Public/Start-PatchCycle.ps1
|
function Start-PatchCycle { <# .SYNOPSIS Starts a patch cycle to install available updates .DESCRIPTION Orchestrates the installation of pending updates with deferral handling, notifications, and logging. This is the main entry point for update installation. Can also install missing applications from the catalog. .PARAMETER Interactive Run in interactive mode with user notifications .PARAMETER Force Force installation without deferral options .PARAMETER NoReboot Suppress automatic reboots .PARAMETER Priority Only install updates of specified priority or higher .PARAMETER AppId Only update specific application(s) .PARAMETER InstallMissing Install applications from catalog that are not currently installed .EXAMPLE Start-PatchCycle Runs a full patch cycle for all managed applications .EXAMPLE Start-PatchCycle -Interactive Runs with user notifications and deferral dialogs .EXAMPLE Start-PatchCycle -InstallMissing Installs missing applications and updates existing ones .EXAMPLE spc -Force -NoReboot Uses alias to force updates without rebooting #> [CmdletBinding()] [Alias('spc')] param( [Parameter()] [switch]$Interactive, [Parameter()] [switch]$Force, [Parameter()] [switch]$NoReboot, [Parameter()] [ValidateSet('Critical', 'High', 'Normal', 'Low')] [string]$Priority, [Parameter()] [string[]]$AppId, [Parameter()] [switch]$InstallMissing ) $result = [PatchCycleResult]::new() Write-PatchLog "Starting patch cycle (CorrelationId: $($result.CorrelationId))" -Type Info try { # Ensure winget is available if (-not (Test-WingetAvailable -AutoInstall)) { $result.Success = $false $result.Message = "Winget not available" Write-PatchLog $result.Message -Type Error $result.Complete() return $result } # Get configuration and managed apps $config = Get-PatchMyPCConfig $managedApps = Get-ManagedApplicationsInternal # Track DriverManagement outcomes for accurate summary messaging $dmWorkItemRan = $false $dmWorkItemUpdatesAppliedKnown = $false $dmWorkItemUpdatesApplied = 0 # Handle missing applications first if requested if ($InstallMissing) { Write-PatchLog "Checking for missing applications to install" -Type Info $missingApps = Get-MissingApplication # Filter by priority if specified if ($Priority) { $priorityLevel = [UpdatePriority]$Priority $missingApps = $missingApps | Where-Object { $_.Priority -ge $priorityLevel } } # Filter by AppId if specified if ($AppId) { $missingApps = $missingApps | Where-Object { $AppId -contains $_.AppId } } foreach ($app in $missingApps) { Write-PatchLog "Processing missing application: $($app.AppName) ($($app.AppId))" -Type Info $appConfig = $app.AppConfig # Check if deferral is configured for initial install if ($appConfig.DeferInitialInstall -and -not $Force) { if ($Interactive) { # Create a pseudo PatchStatus for the deferral dialog $pseudoStatus = [PatchStatus]::new() $pseudoStatus.AppId = $app.AppId $pseudoStatus.AppName = $app.AppName $pseudoStatus.InstalledVersion = 'Not Installed' $pseudoStatus.AvailableVersion = $app.TargetVersion $pseudoStatus.UpdateAvailable = $true $pseudoStatus.Priority = $app.Priority $userChoice = Show-DeferralDialogFull -Updates @($pseudoStatus) -Config $config -Timeout 60 if ($userChoice -eq 'Defer') { $installResult = [InstallationResult]::new() $installResult.AppId = $app.AppId $installResult.AppName = $app.AppName $installResult.Status = [InstallationStatus]::Deferred $installResult.Message = "Initial installation deferred by user" $installResult.ExitCode = 1602 $result.Results += $installResult $result.Deferred++ $result.TotalUpdates++ Write-PatchLog "Initial installation deferred for $($app.AppName)" -Type Info continue } } else { # Non-interactive with deferral enabled - skip Write-PatchLog "Skipping $($app.AppName) - initial install deferral enabled (use -Force or -Interactive)" -Type Info continue } } # Install the missing application $installResult = Install-MissingApplication -AppId $app.AppId -AppConfig $appConfig -Force:$Force $result.Results += $installResult $result.TotalUpdates++ if ($installResult.Status -eq [InstallationStatus]::Success) { $result.Installed++ if ($installResult.RebootRequired) { $result.RebootRequired = $true } } elseif ($installResult.Status -eq [InstallationStatus]::Deferred) { $result.Deferred++ } else { $result.Failed++ } } } # Get available updates $updates = Get-PatchStatus -ManagedOnly # DriverManagement integration (pseudo work item) $dmCfg = $config.DriverManagement $dmEnabled = $false if ($null -ne $dmCfg -and $null -ne $dmCfg.Enabled) { # Support bool or 'true'/'false' strings $dmEnabled = ($dmCfg.Enabled -eq $true -or ([string]$dmCfg.Enabled).ToLowerInvariant() -eq 'true') } if ($dmEnabled) { $dmWorkItem = Get-DriverManagementWorkItemInternal -Config $config if ($dmWorkItem) { $updates = @($updates) + @($dmWorkItem) } } # Filter by priority if specified if ($Priority) { $priorityLevel = [UpdatePriority]$Priority $updates = $updates | Where-Object { $_.Priority -ge $priorityLevel } } # Filter by AppId if specified if ($AppId) { $updates = $updates | Where-Object { $AppId -contains $_.AppId } } $result.TotalUpdates += $updates.Count if ($updates.Count -eq 0 -and $result.TotalUpdates -eq 0) { $result.Success = $true if ($InstallMissing) { $result.Message = "No updates available and no missing applications to install" } else { $result.Message = "No updates available (use -InstallMissing to install missing catalog apps)" } Write-PatchLog $result.Message -Type Info # In interactive mode, show a notification even when there are no updates if ($Interactive) { try { Show-ToastNotification -Title "Software Updates" -Message "All applications are up to date. No updates required." -Duration 5 Write-PatchLog "Displayed 'no updates' notification in interactive mode" -Type Info } catch { Write-PatchLog "Failed to show notification: $_" -Type Warning } } $result.Complete() return $result } Write-PatchLog "Found $($updates.Count) items to process" -Type Info # Process each update foreach ($update in $updates) { Write-PatchLog "Processing update for $($update.AppName) ($($update.AppId))" -Type Info # DriverManagement pseudo work item if ($update.AppId -eq 'PSDriverManagement.DriverManagement') { $targetVersion = 'Latest' $dmOverride = $null if ($dmCfg -and $dmCfg.DeferralOverride) { $dmOverride = $dmCfg.DeferralOverride } $deferralState = Initialize-DeferralState -AppId $update.AppId -TargetVersion $targetVersion -Config $config -DeferralOverride $dmOverride $deferralState.Phase = Get-DeferralPhaseInternal -Deadline $deferralState.DeadlineDate -Config $config Set-StateToRegistry -State $deferralState if (-not $Force -and $deferralState.CanDefer()) { if ($Interactive) { $timeout = 60 if ($dmCfg -and $dmCfg.UiTimeoutSeconds) { $timeout = [int]$dmCfg.UiTimeoutSeconds } $userChoice = Show-DeferralDialogFull -Updates @($update) -Config $config -Timeout $timeout if ($userChoice -eq 'Defer') { $deferralState.DeferralCount++ $deferralState.LastDeferral = [datetime]::UtcNow Set-StateToRegistry -State $deferralState $installResult = [InstallationResult]::new() $installResult.AppId = $update.AppId $installResult.AppName = $update.AppName $installResult.Status = [InstallationStatus]::Deferred $installResult.Message = "Deferred by user ($($deferralState.GetRemainingDeferrals()) remaining)" $installResult.ExitCode = 1602 $result.Results += $installResult $result.Deferred++ Write-PatchLog "Driver management deferred by user" -Type Info continue } } } # Run DriverManagement engine $installResult = [InstallationResult]::new() $installResult.AppId = $update.AppId $installResult.AppName = $update.AppName $installResult.Status = [InstallationStatus]::InProgress $startTime = Get-Date try { Import-Module DriverManagement -Force -ErrorAction Stop $includeWU = $true if ($dmCfg -and $null -ne $dmCfg.IncludeWindowsUpdates) { $includeWU = ($dmCfg.IncludeWindowsUpdates -eq $true -or ([string]$dmCfg.IncludeWindowsUpdates).ToLowerInvariant() -eq 'true') } $dmParams = @{} if ($includeWU) { $dmParams.IncludeWindowsUpdates = $true } # Option A: keep NoReboot (we handle reboot via PsPatchMyPC UI) $dmParams.NoReboot = $true # Suppress nested verbose spam (e.g., PSWindowsUpdate "Found [0] Updates...") that can confuse # operators when DriverManagement applies driver updates but Windows Update finds none. $oldVerbosePreference = $VerbosePreference $VerbosePreference = 'SilentlyContinue' try { $dmResult = Invoke-DriverManagement @dmParams -Verbose:$false } finally { $VerbosePreference = $oldVerbosePreference } $installResult.Status = if ($dmResult.Success) { [InstallationStatus]::Success } else { [InstallationStatus]::Failed } $installResult.ExitCode = $dmResult.ExitCode $installResult.Message = $dmResult.Message $installResult.RebootRequired = [bool]$dmResult.RebootRequired # DriverManagement returns a DriverUpdateResult which includes UpdatesApplied. # Avoid counting "success with 0 updates applied" as an installed update. $dmUpdatesAppliedKnown = $false $dmUpdatesApplied = 0 if ($null -ne $dmResult -and $null -ne $dmResult.PSObject.Properties['UpdatesApplied'] -and $null -ne $dmResult.UpdatesApplied) { $dmUpdatesAppliedKnown = $true $dmUpdatesApplied = [int]$dmResult.UpdatesApplied } # Capture for summary messaging $dmWorkItemRan = $true $dmWorkItemUpdatesAppliedKnown = $dmUpdatesAppliedKnown $dmWorkItemUpdatesApplied = $dmUpdatesApplied if ($dmUpdatesAppliedKnown) { Write-PatchLog "DriverManagement finished: UpdatesApplied=$dmUpdatesApplied, RebootRequired=$($installResult.RebootRequired), Success=$($dmResult.Success)" -Type Info } else { Write-PatchLog "DriverManagement finished: RebootRequired=$($installResult.RebootRequired), Success=$($dmResult.Success)" -Type Info } if ($dmResult.Success) { if ($dmUpdatesAppliedKnown) { if ($dmUpdatesApplied -gt 0) { $result.Installed++ } else { # DriverManagement ran successfully but nothing was applicable. # Don't inflate TotalUpdates/Installed for "0 updates found/applied" runs. if ($result.TotalUpdates -gt 0) { $result.TotalUpdates-- } } } else { # Back-compat: older DriverManagement builds may not expose UpdatesApplied. $result.Installed++ } Remove-StateFromRegistry -AppId $update.AppId } else { $result.Failed++ } if ($installResult.RebootRequired) { $result.RebootRequired = $true # Option A: handle reboot explicitly via PsPatchMyPC UI (Restart now / Later) # This does not force reboot; it prompts and lets the user choose. if ($Interactive) { $timeout = 300 if ($config.Notifications.DialogTimeoutSeconds) { $timeout = [int]$config.Notifications.DialogTimeoutSeconds } $choice = Show-RebootPrompt -Config $config -Timeout $timeout if ($choice -eq 'RestartNow') { Write-PatchLog "User chose to restart now" -Type Warning Restart-Computer -Force } else { Write-PatchLog "User chose to restart later" -Type Info } } } } catch { $installResult.Status = [InstallationStatus]::Failed $installResult.ExitCode = 1 $installResult.Message = "Driver management failed: $($_.Exception.Message)" $result.Failed++ Write-PatchLog $installResult.Message -Type Error } finally { $installResult.Duration = (Get-Date) - $startTime $result.Results += $installResult } continue } # Get or initialize deferral state $targetVersion = [string]$update.AvailableVersion if ([string]::IsNullOrWhiteSpace($targetVersion)) { $targetVersion = 'Latest' } $deferralState = Initialize-DeferralState -AppId $update.AppId -TargetVersion $targetVersion -Config $config # Update deferral phase based on time $deferralState.Phase = Get-DeferralPhaseInternal -Deadline $deferralState.DeadlineDate -Config $config Set-StateToRegistry -State $deferralState # Check if deferral is allowed (unless Force) if (-not $Force -and $deferralState.CanDefer()) { # In interactive mode, show notification/dialog if ($Interactive) { $userChoice = Show-DeferralDialogFull -Updates @($update) -Config $config -Timeout 60 if ($userChoice -eq 'Defer') { # Record deferral $deferralState.DeferralCount++ $deferralState.LastDeferral = [datetime]::UtcNow Set-StateToRegistry -State $deferralState $installResult = [InstallationResult]::new() $installResult.AppId = $update.AppId $installResult.AppName = $update.AppName $installResult.Status = [InstallationStatus]::Deferred $installResult.Message = "Deferred by user ($($deferralState.GetRemainingDeferrals()) remaining)" $installResult.ExitCode = 1602 $result.Results += $installResult $result.Deferred++ Write-PatchLog "Update deferred for $($update.AppName)" -Type Info continue } } elseif (-not $Force) { # Non-interactive mode with deferrals still available - check processes if ($update.ProcessesRunning) { $installResult = [InstallationResult]::new() $installResult.AppId = $update.AppId $installResult.AppName = $update.AppName $installResult.Status = [InstallationStatus]::Deferred $installResult.Message = "Conflicting processes running" $installResult.ExitCode = 1602 $result.Results += $installResult $result.Deferred++ Write-PatchLog "Skipping $($update.AppName) - conflicting processes running" -Type Info continue } } } # Get app configuration $appConfig = $managedApps | Where-Object { $_.Id -eq $update.AppId } | Select-Object -First 1 # Install the update $installResult = Install-ApplicationUpdate -AppId $update.AppId -AppConfig $appConfig -Force:$Force $result.Results += $installResult if ($installResult.Status -eq [InstallationStatus]::Success) { $result.Installed++ # Clear deferral state on success Remove-StateFromRegistry -AppId $update.AppId if ($installResult.RebootRequired) { $result.RebootRequired = $true } } elseif ($installResult.Status -eq [InstallationStatus]::Deferred) { $result.Deferred++ } else { $result.Failed++ } } # Determine overall success $result.Success = ($result.Failed -eq 0) # When DriverManagement is enabled, it may run successfully with 0 updates applied. # In that case we adjust counters above (and TotalUpdates may become 0). Mirror the same # "no updates" messaging/UX used by the early-return path. if ($result.TotalUpdates -eq 0 -and $result.Installed -eq 0 -and $result.Failed -eq 0 -and $result.Deferred -eq 0) { if ($InstallMissing) { $result.Message = "No updates available and no missing applications to install" } else { $result.Message = "No updates available (use -InstallMissing to install missing catalog apps)" } if ($dmWorkItemRan -and $dmWorkItemUpdatesAppliedKnown -and $dmWorkItemUpdatesApplied -eq 0) { $result.Message += " (DriverManagement: no updates required)" } if ($Interactive) { try { Show-ToastNotification -Title "Software Updates" -Message "All applications are up to date. No updates required." -Duration 5 Write-PatchLog "Displayed 'no updates' notification in interactive mode" -Type Info } catch { Write-PatchLog "Failed to show notification: $_" -Type Warning } } } else { # Use TotalUpdates in the message so it always matches the returned object properties. # Note: TotalUpdates counts PsPatchMyPC work items (apps + DriverManagement pseudo-item), not OS/WU update count. $result.Message = "Patch cycle complete: $($result.TotalUpdates) total, $($result.Installed) installed, $($result.Failed) failed, $($result.Deferred) deferred" if ($dmWorkItemRan -and $dmWorkItemUpdatesAppliedKnown) { $result.Message += " (DriverManagement applied $dmWorkItemUpdatesApplied update(s))" } } Write-PatchLog $result.Message -Type $(if ($result.Success) { 'Info' } else { 'Warning' }) # Handle reboot if required and not suppressed if ($result.RebootRequired -and -not $NoReboot) { Write-PatchLog "Reboot required - scheduling restart" -Type Warning # Note: In production, this would schedule a reboot with user notification } } catch { $result.Success = $false $result.Message = "Patch cycle failed: $_" Write-PatchLog $result.Message -Type Error } finally { $result.Complete() # Write compliance status for MDM systems Export-ComplianceStatus -Result $result } return $result } function Get-DeferralPhaseInternal { <# .SYNOPSIS Determines the current deferral phase based on deadline #> [CmdletBinding()] param( [Parameter(Mandatory)] [datetime]$Deadline, [Parameter(Mandatory)] [PsPatchMyPCConfig]$Config ) $hoursRemaining = ($Deadline - [datetime]::UtcNow).TotalHours if ($hoursRemaining -le 0) { return [DeferralPhase]::Elapsed } if ($hoursRemaining -le $Config.Deferrals.ImminentWindowHours) { return [DeferralPhase]::Imminent } if ($hoursRemaining -le $Config.Deferrals.ApproachingWindowHours) { return [DeferralPhase]::Approaching } return [DeferralPhase]::Initial } function Show-DeferralDialogInternal { <# .SYNOPSIS Internal function to show deferral dialog (placeholder for full WPF implementation) #> [CmdletBinding()] param( [Parameter(Mandatory)] [PatchStatus]$Update, [Parameter(Mandatory)] [DeferralState]$DeferralState, [Parameter(Mandatory)] [PsPatchMyPCConfig]$Config ) # This is a simplified implementation - full WPF dialog is in Show-PatchNotification.ps1 # In aggressive mode (Elapsed phase), return 'Install' if ($DeferralState.Phase -eq [DeferralPhase]::Elapsed) { return 'Install' } # Default to defer in non-WPF scenarios (actual WPF shows dialog) return 'Defer' } function Export-ComplianceStatus { <# .SYNOPSIS Exports compliance status for MDM integration #> [CmdletBinding()] param( [Parameter(Mandatory)] [PatchCycleResult]$Result ) try { $config = Get-ModuleConfiguration # Write to state file for FleetDM/Intune $statusPath = Join-Path $config.StatePath 'compliance.json' $status = @{ Timestamp = $Result.EndTime.ToString('o') CorrelationId = $Result.CorrelationId Success = $Result.Success Installed = $Result.Installed Failed = $Result.Failed Deferred = $Result.Deferred Reboot = $Result.RebootRequired Duration = $Result.Duration.TotalSeconds } try { $status | ConvertTo-Json | Out-File -FilePath $statusPath -Encoding UTF8 -Force } catch { # If ProgramData isn't writable (non-elevated), fall back to TEMP so reporting still works. $fallbackDir = Join-Path $env:TEMP 'PsPatchMyPC\State' if (-not (Test-Path $fallbackDir)) { New-Item -Path $fallbackDir -ItemType Directory -Force | Out-Null } $fallbackPath = Join-Path $fallbackDir 'compliance.json' $status | ConvertTo-Json | Out-File -FilePath $fallbackPath -Encoding UTF8 -Force } # Also write FleetDM-specific status if configured $fleetPath = "C:\ProgramData\FleetDM\patch_status.json" if (Test-Path (Split-Path $fleetPath -Parent)) { $status | ConvertTo-Json | Out-File -FilePath $fleetPath -Encoding UTF8 -Force } } catch { Write-PatchLog "Failed to export compliance status: $_" -Type Warning } } |