Support/Package/Lifecycle/Eigenverft.Manifested.Sandbox.Package.Remove.ps1
|
<#
Eigenverft.Manifested.Sandbox.Package.Remove - DesiredState Removed orchestration. Dot-sourced from Eigenverft.Manifested.Sandbox.psm1 after Package.CommandFlow.ps1. Removal safety: other inventory rows are scanned by loading each row's definition and matching dependencies against the target definition (repositoryId + definitionId). Persisted dependencyInstallSlotIds on inventory rows (see Update-PackageInventoryRecord) documents direct dependency slots for operators; blocking is driven by definition metadata so stale slot lists cannot bypass the check. #> function Get-PackageInventoryDependentBlockingRecords { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [psobject]$PackageConfig, [Parameter(Mandatory = $true)] [string]$ExcludeInstallSlotId, [Parameter(Mandatory = $true)] [string]$TargetRepositoryId, [Parameter(Mandatory = $true)] [string]$TargetDefinitionId ) $targetKey = Get-PackageDependencyReferenceKey -RepositoryId $TargetRepositoryId -DefinitionId $TargetDefinitionId $index = Get-PackageInventory -PackageConfig $PackageConfig $blockers = New-Object System.Collections.Generic.List[object] foreach ($record in @($index.Records)) { $slot = [string]$record.installSlotId if ([string]::Equals($slot, $ExcludeInstallSlotId, [System.StringComparison]::OrdinalIgnoreCase)) { continue } $parentRepositoryId = if ($record.PSObject.Properties['definitionRepositoryId'] -and -not [string]::IsNullOrWhiteSpace([string]$record.definitionRepositoryId)) { [string]$record.definitionRepositoryId } else { Get-PackageDefaultRepositoryId } $parentDefinitionId = [string]$record.definitionId try { $definitionReference = Resolve-PackageDefinitionReference -RepositoryId $parentRepositoryId -DefinitionId $parentDefinitionId $definitionDocumentInfo = Read-PackageJsonDocument -Path $definitionReference.DefinitionPath Assert-PackageDefinitionSchema -DefinitionDocumentInfo $definitionDocumentInfo -DefinitionId $parentDefinitionId -DefinitionRepositoryId $parentRepositoryId } catch { throw "Package removal dependency scan failed while reading definition '$parentRepositoryId/$parentDefinitionId' for inventory installSlotId '$slot': $($_.Exception.Message)" } $definition = $definitionDocumentInfo.Document if (-not $definition.PSObject.Properties['dependencies'] -or $null -eq $definition.dependencies) { continue } foreach ($dependency in @($definition.dependencies)) { if ($null -eq $dependency) { continue } $depDefinitionId = if ($dependency.PSObject.Properties['definitionId']) { [string]$dependency.definitionId } else { $null } if ([string]::IsNullOrWhiteSpace($depDefinitionId)) { continue } $depRepositoryId = if ($dependency.PSObject.Properties['repositoryId'] -and -not [string]::IsNullOrWhiteSpace([string]$dependency.repositoryId)) { [string]$dependency.repositoryId } else { $parentRepositoryId } $depKey = Get-PackageDependencyReferenceKey -RepositoryId $depRepositoryId -DefinitionId $depDefinitionId if ([string]::Equals($depKey, $targetKey, [System.StringComparison]::OrdinalIgnoreCase)) { $blockers.Add([pscustomobject]@{ DependentInstallSlotId = $slot DependentDefinitionId = $parentDefinitionId DependentRepositoryId = $parentRepositoryId }) | Out-Null break } } } return @($blockers.ToArray()) } function Assert-PackageRemovalDependencyDependents { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [psobject]$PackageResult ) if ($PackageResult.PSObject.Properties['InventoryRemovalSkipped'] -and [bool]$PackageResult.InventoryRemovalSkipped) { return $PackageResult } $targetRepositoryId = Get-PackageResultRepositoryId -PackageResult $PackageResult $targetDefinitionId = [string]$PackageResult.DefinitionId $excludeSlotId = Get-PackageInstallSlotId -PackageResult $PackageResult $blockers = @(Get-PackageInventoryDependentBlockingRecords -PackageConfig $PackageResult.PackageConfig -ExcludeInstallSlotId $excludeSlotId -TargetRepositoryId $targetRepositoryId -TargetDefinitionId $targetDefinitionId) if ($blockers.Count -gt 0) { $summaries = @( foreach ($b in @($blockers)) { "'$($b.DependentRepositoryId)/$($b.DependentDefinitionId)' (installSlotId=$($b.DependentInstallSlotId))" } ) throw ("Package removal blocked: '{0}/{1}' is still declared as a dependency by installed package(s): {2}. Remove those packages first (or implement removeDependencies)." -f $targetRepositoryId, $targetDefinitionId, ($summaries -join '; ')) } return $PackageResult } function Get-PackageExistingInstallSearchLocationById { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [psobject]$Definition, [Parameter(Mandatory = $true)] [string]$SearchLocationId ) $discovery = $Definition.existingInstallDiscovery if (-not $discovery -or -not $discovery.PSObject.Properties['searchLocations']) { throw "Package definition '$($Definition.id)' is missing existingInstallDiscovery.searchLocations required for removal uninstall discovery." } foreach ($searchLocation in @(Get-PackageExistingInstallSearchLocations -SearchLocations @($discovery.searchLocations))) { if ($searchLocation.PSObject.Properties['id'] -and [string]::Equals([string]$searchLocation.id, $SearchLocationId, [System.StringComparison]::OrdinalIgnoreCase)) { return $searchLocation } } throw "Package definition '$($Definition.id)' has no existingInstallDiscovery.searchLocations entry with id '$SearchLocationId'." } function Get-PackageUninstallExecutableAndArgumentTail { [CmdletBinding()] param( [AllowNull()] [string]$RawText ) if ([string]::IsNullOrWhiteSpace($RawText)) { return [pscustomobject]@{ Executable = $null ArgumentTokens = @() } } $expanded = [Environment]::ExpandEnvironmentVariables(([string]$RawText).Trim()) $exe = Get-WindowsRegistryExecutablePathFromText -Text $expanded if ([string]::IsNullOrWhiteSpace($exe)) { return [pscustomobject]@{ Executable = $null ArgumentTokens = @() } } $idx = $expanded.IndexOf($exe, [System.StringComparison]::OrdinalIgnoreCase) $after = if ($idx -lt 0) { '' } else { $expanded.Substring($idx + $exe.Length).Trim() } $tokens = if ([string]::IsNullOrWhiteSpace($after)) { @() } else { @($after -split '\s+' | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) } return [pscustomobject]@{ Executable = $exe ArgumentTokens = @($tokens) } } function ConvertFrom-PackageInventoryPathRegistrationRecord { [CmdletBinding()] param( [AllowNull()] [psobject]$PathRegistrationRecord ) if ($null -eq $PathRegistrationRecord) { return $null } $sourceValues = if ($PathRegistrationRecord.PSObject.Properties['sourceValues'] -and $null -ne $PathRegistrationRecord.sourceValues) { @($PathRegistrationRecord.sourceValues | ForEach-Object { [string]$_ }) } else { @() } $cleanupDirectories = if ($PathRegistrationRecord.PSObject.Properties['cleanupDirectories'] -and $null -ne $PathRegistrationRecord.cleanupDirectories) { @($PathRegistrationRecord.cleanupDirectories | ForEach-Object { [string]$_ }) } else { @() } return [pscustomobject]@{ Status = if ($PathRegistrationRecord.PSObject.Properties['status']) { [string]$PathRegistrationRecord.status } else { $null } Mode = if ($PathRegistrationRecord.PSObject.Properties['mode']) { [string]$PathRegistrationRecord.mode } else { $null } SourceKind = if ($PathRegistrationRecord.PSObject.Properties['sourceKind']) { [string]$PathRegistrationRecord.sourceKind } else { $null } SourceValue = if ($PathRegistrationRecord.PSObject.Properties['sourceValue']) { [string]$PathRegistrationRecord.sourceValue } else { $null } SourceValues = @($sourceValues) SourcePath = if ($PathRegistrationRecord.PSObject.Properties['sourcePath']) { [string]$PathRegistrationRecord.sourcePath } else { $null } RegisteredPath = if ($PathRegistrationRecord.PSObject.Properties['registeredPath']) { [string]$PathRegistrationRecord.registeredPath } else { $null } CleanupDirectories = @($cleanupDirectories) CleanedTargets = @() UpdatedTargets = @() } } function Resolve-PackageRemovalInstallContext { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [psobject]$PackageResult ) $definition = $PackageResult.PackageConfig.Definition $removed = $definition.packageOperations.removed $policy = $removed.policy $index = Get-PackageInventory -PackageConfig $PackageResult.PackageConfig $installSlotId = Get-PackageInstallSlotId -PackageResult $PackageResult $record = $null foreach ($candidate in @($index.Records)) { if ([string]::Equals([string]$candidate.installSlotId, $installSlotId, [System.StringComparison]::OrdinalIgnoreCase)) { $record = $candidate break } } if ($null -eq $record) { if ([string]::Equals([string]$policy.whenNotInInventory, 'fail', [System.StringComparison]::OrdinalIgnoreCase)) { throw "Package removal failed for '$($PackageResult.DefinitionId)': no inventory record for install slot '$installSlotId' and policy.whenNotInInventory is 'fail'." } $PackageResult | Add-Member -Force -MemberType NoteProperty -Name 'InventoryRemovalSkipped' -Value $true Write-PackageExecutionMessage -Message ("[STATE] Removal skipped destructive work: no inventory record for install slot '{0}' (whenNotInInventory='succeed')." -f $installSlotId) return $PackageResult } $PackageResult | Add-Member -Force -MemberType NoteProperty -Name 'InventoryRemovalSkipped' -Value $false if ([string]::IsNullOrWhiteSpace([string]$record.installDirectory)) { throw "Package removal failed for '$($PackageResult.DefinitionId)': inventory record is missing installDirectory." } $normalizedInstallDirectory = [System.IO.Path]::GetFullPath([string]$record.installDirectory) $PackageResult.InstallDirectory = $normalizedInstallDirectory $ownershipKind = if ($record.PSObject.Properties['ownershipKind']) { [string]$record.ownershipKind } else { $null } $installOrigin = switch -Exact ($ownershipKind) { 'PackageInstalled' { 'PackageInstalled'; break } 'PackageApplied' { 'PackageApplied'; break } 'AdoptedExternal' { 'AdoptedExternal'; break } default { $ownershipKind } } $PackageResult.InstallOrigin = $installOrigin $PackageResult.Ownership = [pscustomobject]@{ InventoryPath = $index.Path InstallSlotId = $installSlotId Classification = 'PackageTarget' OwnershipRecord = $record } $PackageResult.ExistingPackage = [pscustomobject]@{ SearchKind = 'packageTargetInstallPath' CandidatePath = $normalizedInstallDirectory InstallDirectory = $normalizedInstallDirectory Decision = 'Pending' Readiness = $null Classification = 'PackageTarget' OwnershipRecord = $record } $pathRegistration = if ($record.PSObject.Properties['pathRegistration'] -and $null -ne $record.pathRegistration) { ConvertFrom-PackageInventoryPathRegistrationRecord -PathRegistrationRecord $record.pathRegistration } else { $null } if ($pathRegistration) { if ($PackageResult.PSObject.Properties['PathRegistration']) { $PackageResult.PathRegistration = $pathRegistration } else { $PackageResult | Add-Member -MemberType NoteProperty -Name PathRegistration -Value $pathRegistration } } Write-PackageExecutionMessage -Message ("[STATE] Removal inventory context: installSlotId='{0}', installDirectory='{1}', ownershipKind='{2}'." -f $installSlotId, $normalizedInstallDirectory, $ownershipKind) return $PackageResult } function Assert-PackageRemovalPolicy { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [psobject]$PackageResult ) $definition = $PackageResult.PackageConfig.Definition $policy = $definition.packageOperations.removed.policy if ($policy.removeDependencies -is [bool] -and [bool]$policy.removeDependencies) { throw "Package removal for '$($PackageResult.DefinitionId)' requested policy.removeDependencies=true, which is not implemented yet. Set removeDependencies to false for v1 removal." } if ($PackageResult.PSObject.Properties['InventoryRemovalSkipped'] -and [bool]$PackageResult.InventoryRemovalSkipped) { return $PackageResult } $record = $PackageResult.Ownership.OwnershipRecord $allowedKinds = @($policy.allowedInventoryOwnershipKinds | ForEach-Object { [string]$_ }) $kind = if ($record -and $record.PSObject.Properties['ownershipKind']) { [string]$record.ownershipKind } else { $null } if ([string]::IsNullOrWhiteSpace($kind)) { throw "Package removal failed for '$($PackageResult.DefinitionId)': inventory record is missing ownershipKind." } $allowed = $false foreach ($allowedKind in @($allowedKinds)) { if ([string]::Equals($allowedKind, $kind, [System.StringComparison]::OrdinalIgnoreCase)) { $allowed = $true break } } if (-not $allowed) { throw "Package removal failed for '$($PackageResult.DefinitionId)': inventory ownershipKind '$kind' is not allowed by removed.policy.allowedInventoryOwnershipKinds." } return $PackageResult } function Invoke-PackageRemovedOperation { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [psobject]$PackageResult ) $definition = $PackageResult.PackageConfig.Definition $operation = $definition.packageOperations.removed.operation $kind = [string]$operation.kind if ($PackageResult.PSObject.Properties['InventoryRemovalSkipped'] -and [bool]$PackageResult.InventoryRemovalSkipped) { Write-PackageExecutionMessage -Message '[STATE] Removed operation skipped because no inventory record was found.' return $PackageResult } switch -Exact ($kind) { 'none' { Write-PackageExecutionMessage -Message '[STATE] Removed operation kind is none; nothing to execute.' } 'deleteInstallDirectory' { $target = [string]$PackageResult.InstallDirectory if ([string]::IsNullOrWhiteSpace($target)) { throw "Package removed operation 'deleteInstallDirectory' requires a resolved inventory installDirectory." } if (Test-Path -LiteralPath $target) { Remove-PathIfExists -Path $target | Out-Null Write-PackageExecutionMessage -Message ("[ACTION] Deleted install directory '{0}' for removed operation." -f $target) } else { Write-PackageExecutionMessage -Message ("[STATE] deleteInstallDirectory skipped; path does not exist: '{0}'." -f $target) } $ceiling = Get-EmptyParentPruneCeilingDirectory -InstallLeafPath $target -PreferredInstallRootDirectory ([string]$PackageResult.PackageConfig.PreferredTargetInstallRootDirectory) if (-not [string]::IsNullOrWhiteSpace($ceiling)) { Remove-EmptyParentDirectoryChain -DeletedLeafPath $target -AncestorCeilingDirectory $ceiling Write-PackageExecutionMessage -Message '[ACTION] Pruned empty parent directories after deleteInstallDirectory (up to Inst root when under Inst, otherwise up to volume or share root).' } else { Write-PackageExecutionMessage -Level 'WRN' -Message '[WARN] Could not resolve empty-parent prune ceiling; skipping prune after deleteInstallDirectory.' } } { $_ -in @('nsisUninstaller', 'innoSetupUninstaller') } { $searchLocation = Get-PackageExistingInstallSearchLocationById -Definition $definition -SearchLocationId ([string]$operation.commandSource.searchLocationId) $resolved = Resolve-PackageExistingUninstallRegistryCandidate -SearchLocation $searchLocation if (-not $resolved -or -not $resolved.RegistryEntry) { throw "Package $kind removal could not resolve a Windows uninstall registry entry for searchLocationId '$($operation.commandSource.searchLocationId)'." } $entry = $resolved.RegistryEntry $chosenText = $null foreach ($registryValueName in @($operation.commandSource.registryValueOrder)) { $prop = if ([string]::Equals([string]$registryValueName, 'QuietUninstallString', [System.StringComparison]::OrdinalIgnoreCase)) { 'QuietUninstallString' } else { 'UninstallString' } if (-not $entry.PSObject.Properties[$prop]) { continue } $text = [string]$entry.$prop if (-not [string]::IsNullOrWhiteSpace($text)) { $chosenText = $text break } } if ([string]::IsNullOrWhiteSpace($chosenText)) { throw "Package $kind removal did not find a usable QuietUninstallString or UninstallString in the resolved registry entry." } $parsed = Get-PackageUninstallExecutableAndArgumentTail -RawText $chosenText if ([string]::IsNullOrWhiteSpace($parsed.Executable) -or -not (Test-Path -LiteralPath $parsed.Executable -PathType Leaf)) { throw "Package $kind removal resolved uninstall executable '$($parsed.Executable)' but it does not exist." } $commandArguments = New-Object System.Collections.Generic.List[string] foreach ($token in @($parsed.ArgumentTokens)) { $commandArguments.Add((Format-PackageProcessArgument -Value $token)) | Out-Null } foreach ($argument in @($operation.commandArguments)) { $resolvedArgument = Resolve-PackageTemplateText -Text ([string]$argument) -PackageConfig $PackageResult.PackageConfig -Package $PackageResult.Package -ExtraTokens @{ packageFilePath = $PackageResult.PackageFilePath installDirectory = $PackageResult.InstallDirectory packageFileStagingDirectory = $PackageResult.PackageFileStagingDirectory packageInstallStageDirectory = $PackageResult.PackageInstallStageDirectory downloadDirectory = $PackageResult.PackageFileStagingDirectory } $commandArguments.Add((Format-PackageProcessArgument -Value $resolvedArgument)) | Out-Null } $timeoutSec = [int]$operation.timeoutSec $successExitCodes = @($operation.successExitCodes | ForEach-Object { [int]$_ }) $restartExitCodes = @($operation.restartExitCodes | ForEach-Object { [int]$_ }) $uiMode = [string]$operation.uiMode $workingDirectory = if (-not [string]::IsNullOrWhiteSpace([string]$PackageResult.InstallDirectory) -and (Test-Path -LiteralPath $PackageResult.InstallDirectory -PathType Container)) { [string]$PackageResult.InstallDirectory } elseif (-not [string]::IsNullOrWhiteSpace([string]$PackageResult.PackageInstallStageDirectory)) { [string]$PackageResult.PackageInstallStageDirectory } else { [System.IO.Path]::GetTempPath() } $elevationMode = if ($operation.PSObject.Properties['elevation'] -and -not [string]::IsNullOrWhiteSpace([string]$operation.elevation)) { [string]$operation.elevation } else { $null } $installerKind = if ([string]::Equals($kind, 'innoSetupUninstaller', [System.StringComparison]::OrdinalIgnoreCase)) { 'innoSetup' } else { 'nsis' } $null = Invoke-PackageInstallerCommand -PackageResult $PackageResult -CommandPath $parsed.Executable -CommandArguments @($commandArguments.ToArray()) -WorkingDirectory $workingDirectory -TimeoutSec $timeoutSec -SuccessExitCodes @($successExitCodes) -RestartExitCodes @($restartExitCodes) -TargetKind 'directory' -InstallerKind $installerKind -UiMode $uiMode -LogPath $null -ElevationMode $elevationMode Write-PackageExecutionMessage -Message ("[ACTION] Completed {0} removal operation." -f $kind) } default { throw "Unsupported removed.operation.kind '$kind'." } } return $PackageResult } function Invoke-PackagePostRemoveCleanup { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [psobject]$PackageResult ) $flags = $PackageResult.PackageConfig.Definition.packageOperations.removed.postRemoveCleanup if ($flags.generatedShims) { $null = Remove-PackageCommandShimsForDefinition -PackageResult $PackageResult } else { Write-PackageExecutionMessage -Message '[STATE] postRemoveCleanup.generatedShims is false; skipping shim removal.' } if ($flags.pathEntries) { $null = Unregister-PackagePathForRemoval -PackageResult $PackageResult } else { Write-PackageExecutionMessage -Message '[STATE] postRemoveCleanup.pathEntries is false; skipping PATH cleanup.' } if ($flags.packageInventoryRecord) { if ($PackageResult.PSObject.Properties['InventoryRemovalSkipped'] -and [bool]$PackageResult.InventoryRemovalSkipped) { Write-PackageExecutionMessage -Message '[STATE] postRemoveCleanup.packageInventoryRecord skipped because there was no inventory record.' } else { $null = Remove-PackageInventoryRecordForInstallSlot -PackageResult $PackageResult } } else { Write-PackageExecutionMessage -Message '[STATE] postRemoveCleanup.packageInventoryRecord is false; skipping inventory record removal.' } if ($flags.workDirectories) { $null = Clear-PackageWorkDirectories -PackageResult $PackageResult } else { Write-PackageExecutionMessage -Message '[STATE] postRemoveCleanup.workDirectories is false; skipping staging directory cleanup.' } return $PackageResult } function Invoke-PackageRemovedFlow { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [psobject]$PackageResult ) try { Write-PackageExecutionMessage -Message ("[START] Invoke-Package repository='{0}' definition='{1}' desiredState='{2}'." -f $PackageResult.RepositoryId, $PackageResult.DefinitionId, $PackageResult.DesiredState) $PackageResult.CurrentStep = 'InitializeLocalEnvironment' Write-PackageExecutionMessage -Message '[STEP] Initializing local package environment.' $PackageResult.LocalEnvironment = Initialize-PackageLocalEnvironment -PackageConfig $PackageResult.PackageConfig if ($PackageResult.LocalEnvironment.InitializedNow) { Write-PackageExecutionMessage -Message ("[STATE] Local package environment initialized: created={0}, existing={1}, skippedSources={2}." -f @($PackageResult.LocalEnvironment.CreatedDirectories).Count, @($PackageResult.LocalEnvironment.ExistingDirectories).Count, @($PackageResult.LocalEnvironment.SkippedSources).Count) } else { Write-PackageExecutionMessage -Message '[STATE] Local package environment already initialized.' } $PackageResult.CurrentStep = 'ResolvePackage' Write-PackageExecutionMessage -Message '[STEP] Resolving package selection.' $PackageResult = Resolve-PackagePackage -PackageResult $PackageResult $PackageResult.CurrentStep = 'ResolvePaths' Write-PackageExecutionMessage -Message '[STEP] Resolving package paths.' $PackageResult = Resolve-PackagePaths -PackageResult $PackageResult $PackageResult.CurrentStep = 'ResolveRemovalInstallContext' Write-PackageExecutionMessage -Message '[STEP] Resolving removal inventory context.' $PackageResult = Resolve-PackageRemovalInstallContext -PackageResult $PackageResult $PackageResult.CurrentStep = 'AssertRemovalPolicy' Write-PackageExecutionMessage -Message '[STEP] Evaluating removal policy.' $PackageResult = Assert-PackageRemovalPolicy -PackageResult $PackageResult $PackageResult.CurrentStep = 'AssertRemovalDependencyDependents' Write-PackageExecutionMessage -Message '[STEP] Checking for installed packages that still declare this package as a dependency.' $PackageResult = Assert-PackageRemovalDependencyDependents -PackageResult $PackageResult $PackageResult.CurrentStep = 'ExecuteRemovedOperation' Write-PackageExecutionMessage -Message '[STEP] Executing removed.operation.' $PackageResult = Invoke-PackageRemovedOperation -PackageResult $PackageResult $PackageResult.CurrentStep = 'PostRemoveCleanup' Write-PackageExecutionMessage -Message '[STEP] Running post-remove cleanup.' $PackageResult = Invoke-PackagePostRemoveCleanup -PackageResult $PackageResult $PackageResult.CurrentStep = 'VerifyRemovedAbsence' Write-PackageExecutionMessage -Message '[STEP] Verifying removed absence.' $PackageResult = Test-PackageRemovedAbsence -PackageResult $PackageResult Write-PackageExecutionMessage -Message ("[OK] Package removal completed for definition '{0}'." -f $PackageResult.DefinitionId) } catch { $PackageResult.Status = 'Failed' $PackageResult.ErrorMessage = $_.Exception.Message Write-PackageExecutionMessage -Level 'ERR' -Message ("[FAIL] Step '{0}' failed: {1}" -f $PackageResult.CurrentStep, $_.Exception.Message) $PackageResult.FailureReason = Get-PackageCommandFailureReason -CurrentStep ([string]$PackageResult.CurrentStep) } return $PackageResult } |