Private/Sync-OSDAppCache.ps1
|
function Sync-OSDAppCache { [CmdletBinding(SupportsShouldProcess, DefaultParameterSetName = 'Uri')] param( [Parameter(Mandatory, ParameterSetName = 'Uri')] [uri]$CatalogUri, [Parameter(Mandatory, ParameterSetName = 'Path')] [string]$CatalogPath, [Parameter(Mandatory)] [string]$CachePath, [string[]]$Name, [switch]$Prune, [string[]]$PinnedId ) $logPath = Join-Path $CachePath 'Logs\\Client.log' if ($Prune -and $Name) { throw 'Prune requires a complete catalog synchronization. Do not combine Prune with Name.' } if ($PinnedId -and -not $Prune) { throw 'PinnedId is only valid together with Prune.' } if ($PSCmdlet.ParameterSetName -eq 'Uri') { $sourceCatalog = Get-OSDAppCatalogPackages -CatalogUri $CatalogUri -Name $Name $catalogSourceDescription = $CatalogUri.AbsoluteUri } else { $resolvedCatalogPath = (Resolve-Path -LiteralPath $CatalogPath -ErrorAction Stop).Path $sourceCatalog = Get-OSDAppCatalogPackages -CatalogPath $resolvedCatalogPath -Name $Name $catalogSourceDescription = $resolvedCatalogPath } $hostArchitecture = Get-OSDAppHostArchitecture $allPackages = @($sourceCatalog.Packages) $packages = @(Resolve-OSDAppCatalogPackageVariant -Package $allPackages -Architecture $hostArchitecture) $packagesRoot = Join-Path $CachePath 'Packages' $cacheCatalogPath = Join-Path $CachePath 'CacheCatalog.json' $stagingRoot = Join-Path $CachePath '.staging' # Validate ALL entries before touching the persistent cache. # IDs are directory names, so arbitrary paths and traversal are forbidden. foreach ($package in $packages) { if (-not $package.Id -or -not $package.Archive -or -not $package.Archive.Sha256) { throw "Package '$($package.Id)' has an incomplete Archive definition." } if ([string]$package.Id -notmatch '^[A-Za-z0-9][A-Za-z0-9._-]*$' -or [string]$package.Id -match '\.\.') { throw "Invalid package Id '$($package.Id)' in repository catalog." } if ($package.Archive.FileName -and $package.Archive.FileName -ne 'Package.zip') { throw "Package '$($package.Id)' must use the fixed archive name Package.zip." } if ([string]$package.Archive.Sha256 -notmatch '^[A-Fa-f0-9]{64}$') { throw "Package '$($package.Id)' has an invalid SHA-256 hash." } foreach ($aliasValue in @($package.Aliases)) { $alias = [string]$aliasValue if ($alias -notmatch '^[A-Za-z0-9][A-Za-z0-9._-]*$' -or $alias -match '\.\.') { throw "Package '$($package.Id)' has invalid alias '$alias'." } } } $desiredIds = @($packages.Id) $aliasIds = @($packages | ForEach-Object { @($_.Aliases) } | Select-Object -Unique) $pins = @($PinnedId | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique) foreach ($pin in $pins) { if ([string]$pin -notmatch '^[A-Za-z0-9][A-Za-z0-9._-]*$' -or [string]$pin -match '\.\.') { throw "Invalid pinned cache Id '$pin'." } if ($pin -in $aliasIds) { throw "Pinned cache Id '$pin' is a declared alias. Pin the canonical Id instead." } } # Never discard existing cache entries because a catalog is unreadable. $existingPackages = @() if (Test-Path -LiteralPath $cacheCatalogPath -PathType Leaf) { try { $existingCatalog = Get-OSDAppManifest -Path $cacheCatalogPath if ($null -eq $existingCatalog -or -not ($existingCatalog.PSObject.Properties.Name -contains 'Packages')) { throw 'Cache catalog is missing Packages.' } $existingPackages = @($existingCatalog.Packages) } catch { throw "Existing cache catalog '$cacheCatalogPath' cannot be read safely: $($_.Exception.Message)" } } $existingIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($package in $existingPackages) { $existingId = [string]$package.Id if ([string]::IsNullOrWhiteSpace($existingId) -or $existingId -notmatch '^[A-Za-z0-9][A-Za-z0-9._-]*$' -or $existingId -match '\.\.') { throw "Existing cache catalog contains invalid package Id '$existingId'." } if (-not $existingIds.Add($existingId)) { throw "Existing cache catalog contains duplicate package Id '$existingId'." } if (-not $package.Archive -or [string]$package.Archive.Sha256 -notmatch '^[A-Fa-f0-9]{64}$') { throw "Existing cache catalog package '$existingId' has an invalid SHA-256 archive definition." } } $existingAliasOwners = @{} foreach ($package in $existingPackages) { $existingId = [string]$package.Id $existingAliases = if ($package.PSObject.Properties.Name -contains 'Aliases') { @($package.Aliases) } else { @() } foreach ($aliasValue in $existingAliases) { $alias = ([string]$aliasValue).Trim() if ([string]::IsNullOrWhiteSpace($alias) -or $alias -notmatch '^[A-Za-z0-9][A-Za-z0-9._-]*$' -or $alias -match '\.\.') { throw "Existing cache catalog package '$existingId' has invalid alias '$alias'." } if ($existingIds.Contains($alias)) { throw "Existing cache catalog alias '$alias' for '$existingId' conflicts with a canonical package Id." } $aliasKey = $alias.ToLowerInvariant() if ($existingAliasOwners.ContainsKey($aliasKey)) { throw "Existing cache catalog alias '$alias' is assigned to both '$($existingAliasOwners[$aliasKey])' and '$existingId'." } $existingAliasOwners[$aliasKey] = $existingId } } if ($Prune) { $knownIds = @($desiredIds) + @($existingPackages.Id) $unknownPins = @($pins | Where-Object { $_ -notin $knownIds }) if ($unknownPins.Count -gt 0) { throw "Pinned cache Id(s) are not present in desired or existing cache state: $($unknownPins -join ', ')." } foreach ($pin in @($pins | Where-Object { $_ -notin $desiredIds })) { $pinnedPackage = @($existingPackages | Where-Object { $_.Id -ieq $pin })[0] $pinnedArchive = Join-Path $packagesRoot (Join-Path $pinnedPackage.Id 'Package.zip') if (-not (Test-OSDAppFileHash -Path $pinnedArchive -ExpectedSha256 $pinnedPackage.Archive.Sha256)) { throw "Pinned cache package '$pin' is missing or corrupt. Refresh it or remove the pin before pruning." } $pinnedAliases = if ($pinnedPackage.PSObject.Properties.Name -contains 'Aliases') { @($pinnedPackage.Aliases) } else { @() } $identityConflicts = @( $pinnedAliases | Where-Object { $_ -in $desiredIds -or $_ -in $aliasIds } ) if ($identityConflicts.Count -gt 0) { throw "Pinned cache package '$pin' has alias '$($identityConflicts[0])' that conflicts with desired catalog identity." } } } $updatedIds = @($packages.Id) $retainedExistingPackages = [System.Collections.Generic.List[object]]::new() foreach ($existingPackage in @($existingPackages | Where-Object { $_.Id -notin $updatedIds })) { $existingAliases = if ($existingPackage.PSObject.Properties.Name -contains 'Aliases') { @($existingPackage.Aliases) } else { @() } $conflictingIdentity = @( @([string]$existingPackage.Id) + $existingAliases | Where-Object { $_ -in $aliasIds -or $_ -in $desiredIds } ) | Select-Object -First 1 if ($conflictingIdentity) { Write-Verbose "Retiring cached metadata for '$($existingPackage.Id)' because identity '$conflictingIdentity' is owned by the desired catalog. Physical content is retained unless Prune is used." continue } $retainedExistingPackages.Add($existingPackage) } $mergedPackages = if ($Prune) { @( @($retainedExistingPackages | Where-Object { $_.Id -in $pins }) + @($packages) ) } else { @( @($retainedExistingPackages) + @($packages) ) } $selectedCatalog = [ordered]@{ SchemaVersion = $sourceCatalog.SchemaVersion GeneratedAt = $sourceCatalog.GeneratedAt SourceCatalog = $catalogSourceDescription Packages = @($mergedPackages) } $toAcquire = [System.Collections.Generic.List[object]]::new() foreach ($package in $packages) { $targetRoot = Join-Path $packagesRoot $package.Id $targetArchive = Join-Path $targetRoot 'Package.zip' if (Test-OSDAppFileHash -Path $targetArchive -ExpectedSha256 $package.Archive.Sha256) { Write-Verbose "$($package.Id) is current." } else { $toAcquire.Add([pscustomobject]@{ Package = $package TargetRoot = $targetRoot }) } } $toRemove = [System.Collections.Generic.List[object]]::new() if ($Prune -and (Test-Path -LiteralPath $packagesRoot -PathType Container)) { foreach ($directory in @(Get-ChildItem -LiteralPath $packagesRoot -Directory -ErrorAction SilentlyContinue)) { if ($directory.Name -in $desiredIds -or $directory.Name -in $pins) { continue } $classification = if ($directory.Name -in $aliasIds) { 'Renamed' } elseif ($directory.Name -in @($existingPackages.Id)) { 'Obsolete' } else { 'Orphaned' } $toRemove.Add([pscustomobject]@{ Id = $directory.Name Path = $directory.FullName Classification = $classification }) } } $action = if ($Prune) { "Reconcile $($packages.Count) desired application(s) and prune $($toRemove.Count) stale cache folder(s) as a single transaction" } else { "Synchronize $($packages.Count) application(s) as a single cache transaction" } if (-not $PSCmdlet.ShouldProcess($CachePath, $action)) { return } Write-OSDAppLog -LogPath $logPath -Component 'Sync' -Event 'SyncStart' -Message 'Starting transactional repository synchronization.' -Data @{ CachePath = $CachePath; RequestedCount = $packages.Count; AcquireCount = $toAcquire.Count; Prune = [bool]$Prune; PruneCount = $toRemove.Count; PinnedIds = $pins } Write-OSDAppLog -LogPath $logPath -Component 'Sync' -Event 'ArchitectureResolved' -Message 'Resolved package variants.' -Data @{ HostArchitecture = $hostArchitecture; Packages = @($packages | ForEach-Object { "$($_.Id):$($_.Architecture)" }) } # Everything is downloaded and hash-validated BEFORE an existing package is replaced. # All backups live on the same cache volume and are retained if rollback fails. $transactionRoot = Join-Path $stagingRoot ("Sync-{0}" -f [guid]::NewGuid().ToString('N')) $preparedRoot = Join-Path $transactionRoot 'Prepared' $backupRoot = Join-Path $transactionRoot 'Backup' $replacedBackupRoot = Join-Path $backupRoot 'Replaced' $removedBackupRoot = Join-Path $backupRoot 'Removed' $catalogPrepared = Join-Path $transactionRoot 'CacheCatalog.json.new' $catalogBackup = Join-Path $transactionRoot 'CacheCatalog.json.old' $promoted = [System.Collections.Generic.List[object]]::new() $removed = [System.Collections.Generic.List[object]]::new() $keepRecoveryFiles = $false $catalogExisted = Test-Path -LiteralPath $cacheCatalogPath -PathType Leaf $catalogCommitAttempted = $false $committed = $false try { New-Item -ItemType Directory -Path $packagesRoot,$preparedRoot,$replacedBackupRoot,$removedBackupRoot -Force -ErrorAction Stop | Out-Null foreach ($entry in $toAcquire) { $package = $entry.Package $packageTemp = Join-Path $preparedRoot $package.Id New-Item -ItemType Directory -Path $packageTemp -Force -ErrorAction Stop | Out-Null $tempArchive = Join-Path $packageTemp 'Package.zip' if ($package.Archive.Uri) { Write-OSDAppLog -LogPath $logPath -Component 'Sync' -Event 'AcquireStart' -Message 'Downloading package archive.' -Data @{ Id = $package.Id; Source = $package.Archive.Uri } Invoke-WebRequest -Uri $package.Archive.Uri -OutFile $tempArchive -UseBasicParsing -ErrorAction Stop } elseif ($package.Archive.SourcePath) { $sourceArchive = [string]$package.Archive.SourcePath if (-not (Test-Path -LiteralPath $sourceArchive -PathType Leaf)) { throw "Source archive not found: $sourceArchive" } Write-OSDAppLog -LogPath $logPath -Component 'Sync' -Event 'AcquireStart' -Message 'Copying package archive.' -Data @{ Id = $package.Id; Source = $sourceArchive } Copy-Item -LiteralPath $sourceArchive -Destination $tempArchive -Force -ErrorAction Stop } else { throw "Package '$($package.Id)' archive has neither Uri nor SourcePath." } if (-not (Test-OSDAppFileHash -Path $tempArchive -ExpectedSha256 $package.Archive.Sha256)) { throw "SHA-256 validation failed for '$($package.Id)/Package.zip'." } Write-OSDAppLog -LogPath $logPath -Component 'Sync' -Event 'PackagePrepared' -Message 'Package is downloaded and SHA-256 validated.' -Data @{ Id = $package.Id; Version = $package.Version } } # Stage the new catalog and save the old one before any package promotion. $selectedCatalog | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $catalogPrepared -Encoding UTF8 -ErrorAction Stop if ($catalogExisted) { Copy-Item -LiteralPath $cacheCatalogPath -Destination $catalogBackup -Force -ErrorAction Stop } # Stale content is moved, not deleted, until the new catalog commits. foreach ($entry in $toRemove) { $backupPath = Join-Path $removedBackupRoot $entry.Id Move-Item -LiteralPath $entry.Path -Destination $backupPath -ErrorAction Stop $removed.Add([pscustomobject]@{ Id = $entry.Id TargetRoot = $entry.Path BackupPath = $backupPath Classification = $entry.Classification }) } foreach ($entry in $toAcquire) { $id = [string]$entry.Package.Id $targetRoot = [string]$entry.TargetRoot $backupPath = Join-Path $replacedBackupRoot $id $sourcePath = Join-Path $preparedRoot $id $hadPrevious = Test-Path -LiteralPath $targetRoot if ($hadPrevious) { Move-Item -LiteralPath $targetRoot -Destination $backupPath -ErrorAction Stop } # Record before promoting: the second move itself could fail. $promoted.Add([pscustomobject]@{ Id = $id TargetRoot = $targetRoot BackupPath = $backupPath HadPrevious = $hadPrevious }) Move-Item -LiteralPath $sourcePath -Destination $targetRoot -ErrorAction Stop } # This is the commit point: the manifest must describe the new files. $catalogCommitAttempted = $true Publish-OSDAppCacheCatalog -SourcePath $catalogPrepared -DestinationPath $cacheCatalogPath $committed = $true } catch { $syncError = $_.Exception.Message $recoveryErrors = [System.Collections.Generic.List[string]]::new() if (-not $committed) { # Roll back in reverse order, restoring the exact prior directories. for ($i = $promoted.Count - 1; $i -ge 0; $i--) { $entry = $promoted[$i] try { if (Test-Path -LiteralPath $entry.TargetRoot) { Remove-Item -LiteralPath $entry.TargetRoot -Recurse -Force -ErrorAction Stop } if ($entry.HadPrevious) { Move-Item -LiteralPath $entry.BackupPath -Destination $entry.TargetRoot -ErrorAction Stop } } catch { $recoveryErrors.Add("$($entry.Id): $($_.Exception.Message)") } } for ($i = $removed.Count - 1; $i -ge 0; $i--) { $entry = $removed[$i] try { if (Test-Path -LiteralPath $entry.TargetRoot) { Remove-Item -LiteralPath $entry.TargetRoot -Recurse -Force -ErrorAction Stop } Move-Item -LiteralPath $entry.BackupPath -Destination $entry.TargetRoot -ErrorAction Stop } catch { $recoveryErrors.Add("$($entry.Id): $($_.Exception.Message)") } } if ($catalogCommitAttempted) { try { if ($catalogExisted) { Copy-Item -LiteralPath $catalogBackup -Destination $cacheCatalogPath -Force -ErrorAction Stop } elseif (Test-Path -LiteralPath $cacheCatalogPath) { Remove-Item -LiteralPath $cacheCatalogPath -Force -ErrorAction Stop } } catch { $recoveryErrors.Add("CacheCatalog.json: $($_.Exception.Message)") } } } $keepRecoveryFiles = $recoveryErrors.Count -gt 0 Write-OSDAppLog -LogPath $logPath -Component 'Sync' -Event 'SyncFailed' -Level 'Error' -Message 'Cache transaction failed.' -Data @{ Error = $syncError RollbackErrors = ($recoveryErrors -join ' | ') RecoveryPath = if ($keepRecoveryFiles) { $transactionRoot } else { '' } } if ($keepRecoveryFiles) { throw "Cache transaction failed: $syncError. Rollback incomplete ($($recoveryErrors -join '; ')). Preserve recovery data at '$transactionRoot'." } throw "Cache transaction failed: $syncError. Previous cache preserved." } finally { if (-not $keepRecoveryFiles -and (Test-Path -LiteralPath $transactionRoot)) { Remove-Item -LiteralPath $transactionRoot -Recurse -Force -ErrorAction SilentlyContinue } } foreach ($entry in $toAcquire) { Write-OSDAppLog -LogPath $logPath -Component 'Sync' -Event 'PackageUpdated' -Message 'Package transaction committed and SHA-256 validated.' -Data @{ Id = $entry.Package.Id; Version = $entry.Package.Version; Sha256 = $entry.Package.Archive.Sha256 } } foreach ($entry in $toRemove) { Write-OSDAppLog -LogPath $logPath -Component 'Sync' -Event 'PackagePruned' -Message 'Stale cache package was removed after replacement validation and catalog commit.' -Data @{ Id = $entry.Id; Classification = $entry.Classification } } Write-OSDAppLog -LogPath $logPath -Component 'Sync' -Event 'SyncComplete' -Message 'Repository synchronization committed.' -Data @{ Catalog = $cacheCatalogPath SyncedPackageCount = $packages.Count UpdatedPackageCount = $toAcquire.Count CachedPackageCount = @($mergedPackages).Count PrunedPackageCount = $toRemove.Count PinnedPackageCount = $pins.Count } Get-Item -LiteralPath $cacheCatalogPath } |