mirror of
https://github.com/rbalsleyMSFT/FFU.git
synced 2026-06-14 02:09:35 -06:00
422bc33da7
Improves the current-run cleanup mechanism by tracking file downloads explicitly. This ensures that files downloaded via BITS or preserving older timestamps are correctly identified and removed during cleanup. Extends the run manifest schema to support file backups, allowing for safe restoration of pre-existing scripts and configuration files modified during a run. Additional cleanup logic now correctly prunes residual empty directories after tracked files are removed.
381 lines
14 KiB
PowerShell
381 lines
14 KiB
PowerShell
<#
|
|
.SYNOPSIS
|
|
Provides core, shared functions for logging, process execution, and resilient file transfers used across the FFU project.
|
|
.DESCRIPTION
|
|
This module is a central component of the FFU project, offering a set of robust, reusable functions.
|
|
It includes a centralized logging mechanism (WriteLog), a wrapper for running external processes with error handling (Invoke-Process),
|
|
a retry-aware BITS transfer function for reliable downloads (Start-BitsTransferWithRetry), and a progress reporting helper.
|
|
This module is designed to be imported by other scripts and modules within the project to ensure consistent behavior for common tasks.
|
|
#>
|
|
# Script-scoped variable for the log file path
|
|
$script:CommonCoreLogFilePath = $null
|
|
# Mutex for log file access
|
|
$script:commonCoreLogMutexName = "Global\FFUCommonCoreLogMutex" # Unique name
|
|
$script:commonCoreLogMutex = New-Object System.Threading.Mutex($false, $script:commonCoreLogMutexName)
|
|
$script:BitsTransferPriority = 'Normal'
|
|
if (-not [string]::IsNullOrWhiteSpace($env:FFU_BITS_PRIORITY)) {
|
|
$script:BitsTransferPriority = $env:FFU_BITS_PRIORITY
|
|
}
|
|
|
|
# Function to set the log file path for this module
|
|
function Set-CommonCoreLogPath {
|
|
[CmdletBinding()]
|
|
param(
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$Path
|
|
)
|
|
$script:CommonCoreLogFilePath = $Path
|
|
if (-not [string]::IsNullOrWhiteSpace($script:CommonCoreLogFilePath)) {
|
|
# This initial WriteLog confirms the path is set and the logger is working.
|
|
WriteLog "CommonCoreLogPath set to: $script:CommonCoreLogFilePath"
|
|
}
|
|
else {
|
|
# This Write-Warning will appear on console if path is bad, but won't go to log file yet.
|
|
Write-Warning "Set-CommonCoreLogPath called with an empty or null path."
|
|
}
|
|
}
|
|
|
|
function Set-BitsTransferPriority {
|
|
[CmdletBinding()]
|
|
param(
|
|
[Parameter(Mandatory = $true)]
|
|
[ValidateSet('Foreground', 'High', 'Normal', 'Low')]
|
|
[string]$Priority
|
|
)
|
|
$script:BitsTransferPriority = $Priority
|
|
try {
|
|
Set-Item -Path Env:FFU_BITS_PRIORITY -Value $Priority -ErrorAction Stop
|
|
}
|
|
catch {
|
|
WriteLog "Failed to set FFU_BITS_PRIORITY environment variable: $($_.Exception.Message)"
|
|
}
|
|
WriteLog "BITS transfer priority set to $Priority."
|
|
}
|
|
|
|
# Centralized WriteLog function
|
|
function WriteLog {
|
|
[CmdletBinding()]
|
|
param(
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$LogText
|
|
)
|
|
|
|
# Check if the log file path has been set
|
|
if ([string]::IsNullOrWhiteSpace($script:CommonCoreLogFilePath)) {
|
|
Write-Warning "CommonCoreLogFilePath not set. Message: $LogText"
|
|
return
|
|
}
|
|
|
|
$logEntry = "$((Get-Date).ToString()) $LogText"
|
|
$streamWriter = $null
|
|
|
|
try {
|
|
$script:commonCoreLogMutex.WaitOne() | Out-Null
|
|
# Ensure directory exists before writing
|
|
$logDir = Split-Path -Path $script:CommonCoreLogFilePath -Parent
|
|
if (-not (Test-Path -Path $logDir -PathType Container)) {
|
|
New-Item -Path $logDir -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
|
|
}
|
|
$streamWriter = New-Object System.IO.StreamWriter($script:CommonCoreLogFilePath, $true, [System.Text.Encoding]::UTF8)
|
|
$streamWriter.WriteLine($logEntry)
|
|
|
|
Write-Verbose $LogText
|
|
}
|
|
catch {
|
|
# Use Write-Host for console visibility as Write-Warning might also try to log
|
|
Write-Host "WARNING: Error writing to log file '$($script:CommonCoreLogFilePath)': $($_.Exception.Message)" -ForegroundColor Yellow
|
|
}
|
|
finally {
|
|
if ($null -ne $streamWriter) {
|
|
$streamWriter.Dispose()
|
|
}
|
|
$script:commonCoreLogMutex.ReleaseMutex()
|
|
}
|
|
}
|
|
|
|
function Invoke-Process {
|
|
[CmdletBinding(SupportsShouldProcess)]
|
|
param
|
|
(
|
|
[Parameter(Mandatory)]
|
|
[ValidateNotNullOrEmpty()]
|
|
[string]$FilePath,
|
|
|
|
[Parameter()]
|
|
[ValidateNotNullOrEmpty()]
|
|
[string[]]$ArgumentList,
|
|
|
|
[Parameter()]
|
|
[ValidateNotNullOrEmpty()]
|
|
[bool]$Wait = $true
|
|
)
|
|
|
|
$ErrorActionPreference = 'Stop'
|
|
|
|
try {
|
|
$stdOutTempFile = "$env:TEMP\$((New-Guid).Guid)"
|
|
$stdErrTempFile = "$env:TEMP\$((New-Guid).Guid)"
|
|
|
|
$startProcessParams = @{
|
|
FilePath = $FilePath
|
|
ArgumentList = $ArgumentList
|
|
RedirectStandardError = $stdErrTempFile
|
|
RedirectStandardOutput = $stdOutTempFile
|
|
Wait = $($Wait);
|
|
PassThru = $true;
|
|
NoNewWindow = $true;
|
|
}
|
|
if ($PSCmdlet.ShouldProcess("Process [$($FilePath)]", "Run with args: [$($ArgumentList)]")) {
|
|
$cmd = Start-Process @startProcessParams
|
|
$cmdOutput = Get-Content -Path $stdOutTempFile -Raw
|
|
$cmdError = Get-Content -Path $stdErrTempFile -Raw
|
|
if ($cmd.ExitCode -ne 0 -and $wait -eq $true) {
|
|
if ($cmdError) {
|
|
throw $cmdError.Trim()
|
|
}
|
|
if ($cmdOutput) {
|
|
throw $cmdOutput.Trim()
|
|
}
|
|
}
|
|
else {
|
|
if ([string]::IsNullOrEmpty($cmdOutput) -eq $false) {
|
|
WriteLog $cmdOutput
|
|
}
|
|
}
|
|
}
|
|
}
|
|
catch {
|
|
#$PSCmdlet.ThrowTerminatingError($_)
|
|
WriteLog $_
|
|
# Write-Host "Script failed - $Logfile for more info"
|
|
throw $_
|
|
|
|
}
|
|
finally {
|
|
Remove-Item -Path $stdOutTempFile, $stdErrTempFile -Force -ErrorAction Ignore
|
|
}
|
|
return $cmd
|
|
}
|
|
|
|
function Get-RunManifestPathForDownloadTarget {
|
|
[CmdletBinding()]
|
|
param(
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$Destination
|
|
)
|
|
|
|
try {
|
|
$currentPath = Split-Path -Path $Destination -Parent
|
|
if ([string]::IsNullOrWhiteSpace($currentPath)) { return $null }
|
|
|
|
while ($currentPath) {
|
|
$manifestPath = Join-Path -Path $currentPath -ChildPath '.session\currentRun.json'
|
|
if (Test-Path -LiteralPath $manifestPath -PathType Leaf) {
|
|
return $manifestPath
|
|
}
|
|
|
|
$parentPath = Split-Path -Path $currentPath -Parent
|
|
if ([string]::IsNullOrWhiteSpace($parentPath) -or $parentPath -eq $currentPath) {
|
|
break
|
|
}
|
|
$currentPath = $parentPath
|
|
}
|
|
}
|
|
catch {
|
|
WriteLog "Get-RunManifestPathForDownloadTarget failed for '$Destination': $($_.Exception.Message)"
|
|
}
|
|
|
|
return $null
|
|
}
|
|
|
|
function Register-CurrentRunDownloadTarget {
|
|
[CmdletBinding()]
|
|
param(
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$Destination
|
|
)
|
|
|
|
if ([string]::IsNullOrWhiteSpace($Destination)) { return }
|
|
|
|
$manifestPath = Get-RunManifestPathForDownloadTarget -Destination $Destination
|
|
if ([string]::IsNullOrWhiteSpace($manifestPath)) { return }
|
|
|
|
$mutexName = 'Global\FFUCurrentRunDownloadTargetsMutex'
|
|
$mutex = New-Object System.Threading.Mutex($false, $mutexName)
|
|
|
|
try {
|
|
$null = $mutex.WaitOne()
|
|
|
|
$manifest = Get-Content -LiteralPath $manifestPath -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop
|
|
if ($null -eq $manifest) { return }
|
|
|
|
if ($null -eq $manifest.PSObject.Properties['DownloadTargets']) {
|
|
Add-Member -InputObject $manifest -MemberType NoteProperty -Name DownloadTargets -Value @()
|
|
}
|
|
|
|
$downloadTargets = @($manifest.DownloadTargets)
|
|
if ($Destination -notin $downloadTargets) {
|
|
$downloadTargets += $Destination
|
|
$manifest.DownloadTargets = $downloadTargets
|
|
$manifest | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $manifestPath -Encoding UTF8
|
|
WriteLog "Registered current-run download target: $Destination"
|
|
}
|
|
}
|
|
catch {
|
|
WriteLog "Register-CurrentRunDownloadTarget failed for '$Destination': $($_.Exception.Message)"
|
|
}
|
|
finally {
|
|
try { $mutex.ReleaseMutex() | Out-Null } catch {}
|
|
$mutex.Dispose()
|
|
}
|
|
}
|
|
|
|
# Function to download a file using BITS with retry and error handling
|
|
function Start-BitsTransferWithRetry {
|
|
param (
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$Source,
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$Destination,
|
|
[int]$Retries = 3,
|
|
[ValidateSet('Foreground','High','Normal','Low')]
|
|
[string]$Priority
|
|
)
|
|
|
|
if ([string]::IsNullOrWhiteSpace($Priority)) {
|
|
if (-not [string]::IsNullOrWhiteSpace($env:FFU_BITS_PRIORITY)) {
|
|
$Priority = $env:FFU_BITS_PRIORITY
|
|
}
|
|
elseif (-not [string]::IsNullOrWhiteSpace($script:BitsTransferPriority)) {
|
|
$Priority = $script:BitsTransferPriority
|
|
}
|
|
else {
|
|
$Priority = 'Normal'
|
|
}
|
|
}
|
|
|
|
# Register destination so cancel cleanup can remove this run's downloaded files
|
|
# even when file timestamps are inherited from the source.
|
|
Register-CurrentRunDownloadTarget -Destination $Destination
|
|
|
|
$attempt = 0
|
|
$lastError = $null
|
|
$notLoggedOnHResult = [int]0x800704dd
|
|
$fallbackTriggered = $false
|
|
|
|
while ($attempt -lt $Retries -and -not $fallbackTriggered) {
|
|
$OriginalVerbosePreference = $VerbosePreference
|
|
$OriginalProgressPreference = $ProgressPreference
|
|
try {
|
|
$VerbosePreference = 'SilentlyContinue'
|
|
$ProgressPreference = 'SilentlyContinue'
|
|
|
|
Start-BitsTransfer -Source $Source -Destination $Destination -Priority $Priority -ErrorAction Stop
|
|
|
|
$ProgressPreference = $OriginalProgressPreference
|
|
$VerbosePreference = $OriginalVerbosePreference
|
|
WriteLog "Successfully transferred $Source to $Destination."
|
|
return
|
|
}
|
|
catch {
|
|
$lastError = $_
|
|
$attempt++
|
|
$errorMessage = $lastError.Exception.Message
|
|
WriteLog "Attempt $attempt of $Retries failed to download $Source. Error: $errorMessage."
|
|
$hResult = $null
|
|
if ($null -ne $lastError.Exception) {
|
|
$hResult = $lastError.Exception.HResult
|
|
}
|
|
$needsHttpFallback = $false
|
|
if ($hResult -eq $notLoggedOnHResult) {
|
|
$needsHttpFallback = $true
|
|
}
|
|
elseif ($errorMessage -match '0x800704DD' -or $errorMessage -match 'not.*logged on to the network') {
|
|
$needsHttpFallback = $true
|
|
}
|
|
if ($needsHttpFallback) {
|
|
WriteLog "BITS cannot download $Source because the current session is not logged on to the network. Falling back to Invoke-WebRequest."
|
|
$fallbackTriggered = $true
|
|
break
|
|
}
|
|
Start-Sleep -Seconds (1 * $attempt)
|
|
}
|
|
finally {
|
|
if (Get-Variable -Name 'OriginalProgressPreference' -ErrorAction SilentlyContinue) {
|
|
$ProgressPreference = $OriginalProgressPreference
|
|
}
|
|
if (Get-Variable -Name 'OriginalVerbosePreference' -ErrorAction SilentlyContinue) {
|
|
$VerbosePreference = $OriginalVerbosePreference
|
|
}
|
|
}
|
|
}
|
|
|
|
if ($fallbackTriggered) {
|
|
$remainingAttempts = $Retries - $attempt
|
|
if ($remainingAttempts -lt 1) {
|
|
$remainingAttempts = 1
|
|
}
|
|
$httpAttempt = 0
|
|
while ($httpAttempt -lt $remainingAttempts) {
|
|
$httpAttempt++
|
|
$OriginalVerbosePreference = $VerbosePreference
|
|
$OriginalProgressPreference = $ProgressPreference
|
|
try {
|
|
$VerbosePreference = 'SilentlyContinue'
|
|
$ProgressPreference = 'SilentlyContinue'
|
|
Invoke-WebRequest -Uri $Source -OutFile $Destination -ErrorAction Stop
|
|
$ProgressPreference = $OriginalProgressPreference
|
|
$VerbosePreference = $OriginalVerbosePreference
|
|
WriteLog "Successfully transferred $Source to $Destination via HTTP fallback."
|
|
return
|
|
}
|
|
catch {
|
|
$lastError = $_
|
|
WriteLog "HTTP fallback attempt $httpAttempt of $remainingAttempts failed to download $Source. Error: $($lastError.Exception.Message)."
|
|
Start-Sleep -Seconds (1 * $httpAttempt)
|
|
}
|
|
finally {
|
|
if (Get-Variable -Name 'OriginalProgressPreference' -ErrorAction SilentlyContinue) {
|
|
$ProgressPreference = $OriginalProgressPreference
|
|
}
|
|
if (Get-Variable -Name 'OriginalVerbosePreference' -ErrorAction SilentlyContinue) {
|
|
$VerbosePreference = $OriginalVerbosePreference
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
WriteLog "Failed to download $Source after $Retries attempts. Last Error: $($lastError.Exception.Message)"
|
|
throw $lastError
|
|
}
|
|
|
|
function Set-Progress {
|
|
[CmdletBinding()]
|
|
param(
|
|
[Parameter(Mandatory)]
|
|
[int]$Percentage,
|
|
[Parameter(Mandatory)]
|
|
[string]$Message
|
|
)
|
|
WriteLog "[PROGRESS] $Percentage | $Message"
|
|
}
|
|
|
|
function ConvertTo-SafeName {
|
|
[CmdletBinding()]
|
|
param(
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$Name
|
|
)
|
|
# Replace invalid Windows filename characters (<>:"/\|?* and control chars) with a dash
|
|
$sanitized = $Name -replace '[<>:\"/\\|?*\x00-\x1F]', '-'
|
|
# Collapse multiple consecutive dashes
|
|
$sanitized = $sanitized -replace '-{2,}', '-'
|
|
# Trim leading/trailing spaces, periods, and dashes
|
|
$sanitized = $sanitized.Trim(' ', '.', '-')
|
|
if ([string]::IsNullOrWhiteSpace($sanitized)) {
|
|
$sanitized = 'Unnamed'
|
|
}
|
|
return $sanitized
|
|
}
|
|
|
|
Export-ModuleMember -Function * |