Public/Remove-EvergreenLibraryAppVersion.ps1
|
function Remove-EvergreenLibraryAppVersion { <# .EXTERNALHELP Evergreen-help.xml #> [CmdletBinding(SupportsShouldProcess = $true)] param ( [Parameter( Mandatory = $true, Position = 0, ValueFromPipelineByPropertyName, HelpMessage = "Specify the path to the library.", ParameterSetName = "Path")] [ValidateNotNull()] [System.IO.FileInfo] $Path, [Parameter(Mandatory = $false, Position = 1)] [ValidateRange(1, [System.Int32]::MaxValue)] [System.Int32] $Keep = 3, [Parameter(Mandatory = $false, Position = 2)] [ValidateNotNullOrEmpty()] [System.String[]] $Name ) begin { } process { Write-Verbose -Message "$($MyInvocation.MyCommand): Validating library path '$Path'. Keep: $Keep." if (-not (Test-Path -Path $Path -PathType "Container")) { $Msg = "Cannot use path $Path because it does not exist or is not a directory." throw [System.IO.DirectoryNotFoundException]::New($Msg) } $LibraryFile = Join-Path -Path $Path -ChildPath "EvergreenLibrary.json" if (-not (Test-Path -Path $LibraryFile -PathType "Leaf")) { $Msg = "$Path is not an Evergreen Library. Cannot find EvergreenLibrary.json. Create a library with New-EvergreenLibrary." throw [System.IO.FileNotFoundException]::New($Msg) } Write-Verbose -Message "$($MyInvocation.MyCommand): Reading library manifest '$LibraryFile'." $Library = Get-Content -Path $LibraryFile -ErrorAction "Stop" | ConvertFrom-Json -ErrorAction "Stop" $LibraryRoot = [System.IO.Path]::GetFullPath($Path.FullName) $PathComparison = if ([System.Environment]::OSVersion.Platform -eq [System.PlatformID]::Win32NT) { [System.StringComparison]::OrdinalIgnoreCase } else { [System.StringComparison]::Ordinal } $TargetApplications = $Library.Applications if ($PSBoundParameters.ContainsKey("Name")) { Write-Verbose -Message "$($MyInvocation.MyCommand): Filtering applications by name: $($Name -join ', ')." $TargetApplications = $Library.Applications | Where-Object { $_.Name -in $Name } } Write-Verbose -Message "$($MyInvocation.MyCommand): Selected $(@($TargetApplications).Count) of $(@($Library.Applications).Count) applications." foreach ($Application in $TargetApplications) { if ([System.String]::IsNullOrWhiteSpace($Application.Name) -or [System.IO.Path]::IsPathRooted($Application.Name) -or $Application.Name -match "[\\/]" -or [System.IO.Path]::GetFileName($Application.Name) -ne $Application.Name) { Write-Warning -Message "$($MyInvocation.MyCommand): Skipping application with unsafe library name '$($Application.Name)'." continue } $AppPath = [System.IO.Path]::GetFullPath((Join-Path -Path $LibraryRoot -ChildPath $Application.Name)) $LibraryRootPrefix = $LibraryRoot.TrimEnd([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar) + [System.IO.Path]::DirectorySeparatorChar if (-not $AppPath.StartsWith($LibraryRootPrefix, $PathComparison)) { Write-Warning -Message "$($MyInvocation.MyCommand): Skipping '$($Application.Name)' because its path is outside the library." continue } $AppManifest = Join-Path -Path $AppPath -ChildPath "$($Application.Name).json" Write-Verbose -Message "$($MyInvocation.MyCommand): Processing '$($Application.Name)'. App manifest: '$AppManifest'." if (-not (Test-Path -LiteralPath $AppPath -PathType "Container") -or -not (Test-Path -LiteralPath $AppManifest -PathType "Leaf")) { Write-Verbose -Message "$($MyInvocation.MyCommand): Skipping '$($Application.Name)' because its app manifest is missing." Write-Warning -Message "$($MyInvocation.MyCommand): App manifest missing: $AppManifest" continue } $AppDirectoryItem = Get-Item -LiteralPath $AppPath -Force -ErrorAction "Stop" $ManifestItem = Get-Item -LiteralPath $AppManifest -Force -ErrorAction "Stop" if (($AppDirectoryItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -or ($ManifestItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint)) { Write-Warning -Message "$($MyInvocation.MyCommand): Skipping '$($Application.Name)' because its app directory or manifest is a reparse point." continue } try { Write-Verbose -Message "$($MyInvocation.MyCommand): Reading app manifest '$AppManifest'." [System.Array] $AppVersions = Get-Content -Path $AppManifest -Raw -ErrorAction "Stop" | ConvertFrom-Json -ErrorAction "Stop" } catch { Write-Verbose -Message "$($MyInvocation.MyCommand): Skipping '$($Application.Name)' because its app manifest could not be read." Write-Warning -Message "$($MyInvocation.MyCommand): Failed reading $AppManifest with: $($_.Exception.Message)" continue } Write-Verbose -Message "$($MyInvocation.MyCommand): Found $($AppVersions.Count) version entries for '$($Application.Name)'." if ($AppVersions.Count -le $Keep) { Write-Verbose -Message "$($MyInvocation.MyCommand): No pruning required for '$($Application.Name)': $($AppVersions.Count) entries is within the keep limit of $Keep. Installers and manifest unchanged." Write-Output -InputObject ([PSCustomObject] @{ ApplicationName = $Application.Name Keep = $Keep KeptCount = $AppVersions.Count PrunedCount = 0 RemovedCount = 0 RemovedFileCount = 0 RemovedFiles = @() ManifestPath = $AppManifest }) continue } Write-Verbose -Message "$($MyInvocation.MyCommand): Sorting System.Version-compatible entries by numeric version, then other entries by version text, with manifest index as the final tie-breaker (descending). System.Version-compatible entries take priority." $ItemsWithSortData = for ($i = 0; $i -lt $AppVersions.Count; $i++) { Get-VersionItemSortData -Item $AppVersions[$i] -Index $i } $SortedItems = $ItemsWithSortData | Sort-Object -Property ` @{ Expression = { $_.HasParsedVersion }; Descending = $true }, ` @{ Expression = { $_.ParsedVersion }; Descending = $true }, ` @{ Expression = { $_.VersionText }; Descending = $true }, ` @{ Expression = { $_.Index }; Descending = $true } [System.Array] $RetainedItems = @($SortedItems | Select-Object -First $Keep) [System.Array] $PrunedItems = @($SortedItems | Select-Object -Skip $Keep) Write-Verbose -Message "$($MyInvocation.MyCommand): Retaining $($RetainedItems.Count) entries and pruning $($PrunedItems.Count) entries for '$($Application.Name)'." foreach ($Retained in $RetainedItems) { Write-Verbose -Message "$($MyInvocation.MyCommand): Retaining version '$($Retained.VersionText)' (manifest index $($Retained.Index), parsed version: $($Retained.HasParsedVersion)). Installer: '$($Retained.Item.Path)'." } [System.Array] $RetainedObjects = @($RetainedItems | Select-Object -ExpandProperty "Item") $AppPathPrefix = $AppPath.TrimEnd([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar) + [System.IO.Path]::DirectorySeparatorChar $RetainedInstallerPaths = @( foreach ($Retained in $RetainedObjects) { foreach ($RecordedPath in $Retained.Path) { if (-not [System.String]::IsNullOrWhiteSpace($RecordedPath)) { [System.IO.Path]::GetFullPath([System.IO.Path]::Combine($AppPath, [System.String]$RecordedPath)) } } } ) $ManifestUpdated = $false if ($PSCmdlet.ShouldProcess($AppManifest, "Update retained versions")) { Write-Verbose -Message "$($MyInvocation.MyCommand): Writing $($RetainedObjects.Count) retained entries to '$AppManifest'." $TemporaryManifest = Join-Path -Path $AppPath -ChildPath ".$($Application.Name).$([System.Guid]::NewGuid().ToString('N')).tmp" $BackupManifest = "$TemporaryManifest.bak" try { ConvertTo-Json -InputObject $RetainedObjects -ErrorAction "Stop" | Out-File -FilePath $TemporaryManifest -Encoding "Utf8" -NoNewline -ErrorAction "Stop" [System.IO.File]::Replace($TemporaryManifest, $AppManifest, $BackupManifest) } finally { if (Test-Path -LiteralPath $TemporaryManifest -PathType "Leaf") { Remove-Item -LiteralPath $TemporaryManifest -Force -ErrorAction "Stop" } if (Test-Path -LiteralPath $BackupManifest -PathType "Leaf") { Remove-Item -LiteralPath $BackupManifest -Force -ErrorAction "Stop" } } $ManifestUpdated = $true Write-Verbose -Message "$($MyInvocation.MyCommand): Updated app manifest '$AppManifest'." } else { Write-Verbose -Message "$($MyInvocation.MyCommand): Manifest update not approved by ShouldProcess (WhatIf or confirmation declined): '$AppManifest'." } $RemovedFiles = New-Object -TypeName "System.Collections.ArrayList" if ($ManifestUpdated) { foreach ($Pruned in $PrunedItems) { Write-Verbose -Message "$($MyInvocation.MyCommand): Pruning version '$($Pruned.VersionText)' (manifest index $($Pruned.Index), parsed version: $($Pruned.HasParsedVersion)). Installer: '$($Pruned.Item.Path)'." $RecordedPaths = @($Pruned.Item.Path | Where-Object { -not [System.String]::IsNullOrWhiteSpace($_) }) if ($RecordedPaths.Count -eq 0) { Write-Verbose -Message "$($MyInvocation.MyCommand): Skipping installer removal for version '$($Pruned.VersionText)': no installer path is recorded." continue } foreach ($RecordedPath in $RecordedPaths) { $InstallerPath = [System.IO.Path]::GetFullPath([System.IO.Path]::Combine($AppPath, [System.String]$RecordedPath)) if (-not $InstallerPath.StartsWith($AppPathPrefix, $PathComparison)) { Write-Warning -Message "$($MyInvocation.MyCommand): Skipping installer removal because '$RecordedPath' is outside '$AppPath'." continue } $ReferencedByRetainedEntry = @($RetainedInstallerPaths | Where-Object { [System.String]::Equals($_, $InstallerPath, $PathComparison) }).Count -gt 0 if ($ReferencedByRetainedEntry) { Write-Verbose -Message "$($MyInvocation.MyCommand): Skipping installer removal because '$InstallerPath' is referenced by a retained entry." continue } if (-not (Test-Path -LiteralPath $InstallerPath -PathType "Leaf")) { Write-Verbose -Message "$($MyInvocation.MyCommand): Skipping installer removal: '$InstallerPath' does not exist or is not a file." continue } $CurrentPath = $InstallerPath $UnsafePath = $false while ($CurrentPath.StartsWith($AppPathPrefix, $PathComparison) -or [System.String]::Equals($CurrentPath, $AppPath, $PathComparison)) { $PathItem = Get-Item -LiteralPath $CurrentPath -Force -ErrorAction "Stop" if ($PathItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint) { $UnsafePath = $true break } if ([System.String]::Equals($CurrentPath, $AppPath, $PathComparison)) { break } $CurrentPath = Split-Path -Path $CurrentPath -Parent } if ($UnsafePath) { Write-Warning -Message "$($MyInvocation.MyCommand): Skipping installer removal because '$InstallerPath' traverses a reparse point." continue } if ($PSCmdlet.ShouldProcess($InstallerPath, "Remove old installer")) { Write-Verbose -Message "$($MyInvocation.MyCommand): Removing installer '$InstallerPath'." Remove-Item -LiteralPath $InstallerPath -Force -ErrorAction "Stop" $RemovedFiles.Add($InstallerPath) | Out-Null Write-Verbose -Message "$($MyInvocation.MyCommand): Removed installer '$InstallerPath'." } else { Write-Verbose -Message "$($MyInvocation.MyCommand): Installer removal not approved by ShouldProcess (WhatIf or confirmation declined): '$InstallerPath'." } } } } $RemovedCount = if ($ManifestUpdated) { $PrunedItems.Count } else { 0 } Write-Verbose -Message "$($MyInvocation.MyCommand): Completed '$($Application.Name)': $($RetainedObjects.Count) entries selected for retention, $($PrunedItems.Count) entries selected for pruning, $($RemovedFiles.Count) installer files removed." Write-Output -InputObject ([PSCustomObject] @{ ApplicationName = $Application.Name Keep = $Keep KeptCount = $RetainedObjects.Count PrunedCount = $PrunedItems.Count RemovedCount = $RemovedCount RemovedFileCount = $RemovedFiles.Count RemovedFiles = $RemovedFiles ManifestPath = $AppManifest }) } } } |