Public/Remove-HDTApplication.ps1
|
function Remove-HDTApplication { <# .SYNOPSIS Removes an application from a deployment share. .DESCRIPTION THE OTHER HALF OF Import-HDTApplication, and until it existed an application imported by mistake had to be removed with Explorer - which is how somebody deletes the wrong folder. Deployment Workbench offers Delete on the right-click menu of an application; this is the command behind it. IT DELETES A FOLDER, so the guards are the ones CLAUDE.md demands of anything that does. The id names a folder under Applications\ and is checked TWICE: once as typed - no separators, no spaces, not . or .. - and once as resolved, because what matters is where it ended up rather than how it looked. A target that is not provably inside this share's Applications folder is refused with nothing removed. A FOLDER WITH NO app.yaml IS NOT AN APPLICATION. It is somebody's staging directory that happens to sit under Applications\, and this command must not be the thing that removes it. TWO KINDS OF THING CAN BE USING IT, and they fail differently. UsedBy holds the task sequences whose InstallApplications selection names it: that deployment fails at that step, late. RequiredBy holds the applications that name it as a dependency, and that is worse - Resolve-HDTApplicationOrder refuses a plan with a missing dependency, so removing this one can stop an unrelated application from installing at all. NEITHER IS A REFUSAL. An administrator replacing a package knows more than this command does, and a delete that cannot be done because something refers to it is a delete nobody can ever do. They are reported - including under -WhatIf, so a dialog can name them before anybody agrees to anything. A SELECTION THAT IS A VARIABLE CANNOT BE TRACED. `selection: '%HDTApplications%'` is resolved from the rules at run time, so a sequence that installs whatever the rules say does not appear in UsedBy - there is nothing in the document to read. That is a limit of what is knowable here, not an oversight. ConfirmImpact IS High, so it prompts unless the caller says -Confirm:$false. The console says it, having asked in its own dialog. .PARAMETER Workspace The share's root. .PARAMETER Id The application's id - the folder name under Applications\. .PARAMETER FileSystem An IFileSystem. Defaults to the real one. .INPUTS None. This command does not accept pipeline input. .OUTPUTS System.Management.Automation.PSCustomObject with Id, Path, UsedBy - the ids of the task sequences that named it - and RequiredBy, the ids of the applications that depend on it. .EXAMPLE Remove-HDTApplication -Workspace 'C:\HDTLab\Share' -Id '7Zip-24.09' .EXAMPLE (Remove-HDTApplication -Workspace 'C:\HDTLab\Share' -Id '7Zip-24.09' -WhatIf).RequiredBy What would break, without removing anything. .LINK Import-HDTApplication .LINK Get-HDTApplication #> [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')] [OutputType([pscustomobject])] param( [Parameter(Mandatory = $true, Position = 0)] [ValidateNotNullOrEmpty()] [string] $Workspace, [Parameter(Mandatory = $true, Position = 1)] [ValidateNotNullOrEmpty()] [string] $Id, [Parameter()] [AllowNull()] [object] $FileSystem ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' if ($null -eq $FileSystem) { $FileSystem = New-HDTFileSystem } # -- the id, before it becomes a path ------------------------------------ if ($Id -match '[\\/:*?"<>|]' -or $Id -match '\s' -or $Id -eq '.' -or $Id -eq '..') { $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -TargetObject $Id ` -Message ("'{0}' cannot be an application id. An id is a folder name: no spaces, and none of \ / : * ? `" < > |. This command deletes the folder it names, so an id that is a path is refused rather than resolved." -f $Id))) } $folder = Get-HDTWorkspacePath -Root $Workspace -Kind Applications -ChildPath $Id $document = Join-Path -Path $folder -ChildPath 'app.yaml' # -- and the path, after ------------------------------------------------- # # THE SECOND CHECK IS NOT THE FIRST ONE AGAIN. That one judged what was # typed; this judges what it resolved to, which is the thing about to be # removed. $applicationRoot = Get-HDTWorkspacePath -Root $Workspace -Kind Applications if (-not ([System.IO.Path]::GetFullPath($folder)).StartsWith( ([System.IO.Path]::GetFullPath($applicationRoot)).TrimEnd('\') + '\', [System.StringComparison]::OrdinalIgnoreCase)) { $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -TargetObject $folder ` -Message ("'{0}' resolves to '{1}', which is not inside this workspace's Applications folder. Nothing was removed." -f $Id, $folder))) } # -- is it an application at all? ---------------------------------------- if (-not $FileSystem.TestPath($folder)) { $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -TargetObject $folder -Category ObjectNotFound ` -Message ("this workspace has no application called '{0}'." -f $Id))) } if (-not $FileSystem.TestPath($document)) { $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -TargetObject $folder ` -Message ("'{0}' holds no app.yaml, so it is not an application - it is a folder that happens to sit under Applications. Remove it yourself if that is what you meant." -f $folder))) } # -- who was using it ---------------------------------------------------- # # READ BEFORE THE DELETE, because afterwards the answer is the same and the # package is gone. A document that will not read is skipped rather than # failing the removal: it has its own broken row in the console already. $usedBy = New-Object -TypeName System.Collections.ArrayList $sequenceRoot = Get-HDTWorkspacePath -Root $Workspace -Kind TaskSequences if ($FileSystem.TestPath($sequenceRoot)) { foreach ($child in @($FileSystem.GetChildItem($sequenceRoot))) { $sequencePath = Join-Path -Path ([string] $child) -ChildPath 'sequence.yaml' if (-not $FileSystem.TestPath($sequencePath)) { continue } $named = '' try { $parsed = ConvertFrom-HDTYaml -Yaml ([string] $FileSystem.ReadAllText($sequencePath)) -Path $sequencePath } catch { continue } if (-not (Test-HDTSequenceNamesApplication -Document $parsed -Id $Id)) { continue } try { $named = [string] $parsed['id'] } catch { $named = '' } if ([string]::IsNullOrWhiteSpace($named)) { $named = [System.IO.Path]::GetFileName(([string] $child).TrimEnd('\', '/')) } [void] $usedBy.Add($named) } } # -- and what depends on it ---------------------------------------------- $requiredBy = New-Object -TypeName System.Collections.ArrayList foreach ($child in @($FileSystem.GetChildItem($applicationRoot))) { $otherPath = Join-Path -Path ([string] $child) -ChildPath 'app.yaml' if (-not $FileSystem.TestPath($otherPath)) { continue } if ([string]::Equals($otherPath, $document, [System.StringComparison]::OrdinalIgnoreCase)) { continue } try { $other = ConvertFrom-HDTYaml -Yaml ([string] $FileSystem.ReadAllText($otherPath)) -Path $otherPath } catch { continue } if ($null -eq $other -or -not $other.Contains('dependencies')) { continue } $dependsOnIt = @(@($other['dependencies']) | Where-Object { [string]::Equals(([string] $_).Trim(), $Id, [System.StringComparison]::OrdinalIgnoreCase) }) if (@($dependsOnIt).Count -eq 0) { continue } $otherId = '' try { $otherId = [string] $other['id'] } catch { $otherId = '' } if ([string]::IsNullOrWhiteSpace($otherId)) { $otherId = [System.IO.Path]::GetFileName(([string] $child).TrimEnd('\', '/')) } [void] $requiredBy.Add($otherId) } $answer = [pscustomobject] @{ Id = $Id Path = $folder UsedBy = [string[]] @($usedBy) RequiredBy = [string[]] @($requiredBy) } $said = "Remove application '{0}' and everything in its folder" -f $Id if (@($usedBy).Count -gt 0) { $said = '{0}. It is installed by: {1}' -f $said, (@($usedBy) -join ', ') } if (@($requiredBy).Count -gt 0) { $said = '{0}. It is required by: {1}' -f $said, (@($requiredBy) -join ', ') } if (-not $PSCmdlet.ShouldProcess($folder, $said)) { return $answer } $FileSystem.RemoveItem($folder, $true) return $answer } |