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 # 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 # 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 $result.Complete() return $result } Write-PatchLog "Found $($updates.Count) updates to process" -Type Info # Process each update foreach ($update in $updates) { Write-PatchLog "Processing update for $($update.AppName) ($($update.AppId))" -Type Info # 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) $result.Message = "Patch cycle complete: $($result.Installed) installed, $($result.Failed) failed, $($result.Deferred) deferred" 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 } } |