Modules/businessdev.ALbuild.Core/Public/Install-ALbuildModuleVersion.ps1
|
function Install-ALbuildModuleVersion { <# .SYNOPSIS Atomically install ONE businessdev.ALbuild version into a module root, serialised across processes. .DESCRIPTION The safe replacement for `Install-Module`, which expands straight into the destination and is NOT atomic - the 2026-08 outage was a file lost when concurrent agents (separate processes, one shared account/module root) ran `Install-Module` into the same folder at once. This: 1. takes a machine-wide mutex keyed by the module root (agents are separate processes, so a per-process lock is useless) - §4.2; 2. re-checks under the lock (the common contended case is that another job already did the work); 3. `Save-Module`s into a private staging folder ON THE SAME VOLUME as the module root, so the final publish is an atomic rename (a reader sees either the old folder or the new one, never a half-written one) - §4.1; 4. structurally verifies the staged copy and writes the '.albuild-complete' marker (the count+hash fingerprint Assert-ALbuildModuleComplete later checks); 5. parks a pre-existing but BROKEN target as '<version>.defekt-<timestamp>' (never deletes it - §4.5/R8) and moves the verified copy into place; 6. prunes old versions to -KeepVersions inside the same lock (§4.5). .PARAMETER Version The exact version to install (e.g. '2.18.2'). .PARAMETER ModuleRoot The module root to install into (…\Modules). Defaults to this edition's CurrentUser module root (WindowsPowerShell vs PowerShell - they differ, which is itself a documented trap). .PARAMETER Repository PowerShellGet repository name. Default 'PSGallery'. .PARAMETER KeepVersions Prune to this many newest complete versions after a successful install (0 = do not prune). '*.defekt-*' folders are always exempt. Default 3. .PARAMETER MutexTimeoutSeconds Max seconds to wait for the install lock. Default 600. .PARAMETER Force Reinstall even if the target already verifies as complete. .OUTPUTS PSCustomObject { Version; Path; Action ('present'|'installed'); ModuleRoot }. #> [CmdletBinding(SupportsShouldProcess)] [OutputType([PSCustomObject])] param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $Version, [string] $ModuleRoot, [string] $Repository = 'PSGallery', [ValidateRange(0, [int]::MaxValue)] [int] $KeepVersions = 3, [ValidateRange(1, 3600)] [int] $MutexTimeoutSeconds = 600, [switch] $Force ) if (-not $ModuleRoot) { $ModuleRoot = Get-ALbuildUserModuleRoot } $moduleDir = Join-Path $ModuleRoot 'businessdev.ALbuild' $target = Join-Path $moduleDir $Version # Machine-wide lock keyed by the module root: agents are separate processes under one account, so only a # 'Global\' mutex serialises them. Keying by root lets a WinPS-5.1 root and a PS7 root install in parallel. $key = 'Global\ALbuild-install-' + ([BitConverter]::ToString( [Security.Cryptography.MD5]::Create().ComputeHash( [Text.Encoding]::UTF8.GetBytes($ModuleRoot.ToLowerInvariant()))) -replace '-') $mutex = New-Object System.Threading.Mutex($false, $key) $held = $false try { try { $held = $mutex.WaitOne([TimeSpan]::FromSeconds($MutexTimeoutSeconds)) } catch [System.Threading.AbandonedMutexException] { $held = $true } # prior holder crashed; we own it if (-not $held) { throw "Timed out after $MutexTimeoutSeconds s waiting for the businessdev.ALbuild install lock ($key)." } # Re-check under the lock: another process may have completed the install while we waited. if (-not $Force -and (Assert-ALbuildModuleComplete -Path $target -PassThru).Complete) { return [PSCustomObject]@{ Version = $Version; Path = $target; Action = 'present'; ModuleRoot = $ModuleRoot } } if (-not $PSCmdlet.ShouldProcess($target, "Install businessdev.ALbuild $Version")) { return } New-Item -ItemType Directory -Force -Path $moduleDir | Out-Null # Stage UNDER the module root (same volume) so [IO.Directory]::Move below is an atomic rename, not a # cross-volume copy (which %TEMP% staging would be). $staging = Join-Path $moduleDir ('.albuild-staging-' + [guid]::NewGuid().ToString('N').Substring(0, 8)) try { New-Item -ItemType Directory -Force -Path $staging | Out-Null Save-Module -Name 'businessdev.ALbuild' -RequiredVersion $Version -Path $staging -Repository $Repository -ErrorAction Stop $source = Join-Path (Join-Path $staging 'businessdev.ALbuild') $Version if (-not (Test-Path -LiteralPath $source -PathType Container)) { throw "Save-Module did not produce '$source'." } # Structural check of the FRESH copy (before the marker exists): manifest parses, nested modules # present. A truncated download fails here and never reaches the module root. $manifest = Join-Path $source 'businessdev.ALbuild.psd1' if (-not (Test-Path -LiteralPath $manifest -PathType Leaf)) { throw 'the downloaded package has no manifest.' } $psd = Import-PowerShellDataFile -Path $manifest -ErrorAction Stop foreach ($nested in @($psd.NestedModules)) { if (-not (Test-Path -LiteralPath (Join-Path $source $nested) -PathType Leaf)) { throw "the downloaded package is missing nested module '$nested'." } } # Write the completeness marker from the fingerprint of the fresh copy. $inv = Get-ALbuildModuleInventory -Path $source $marker = [ordered]@{ version = $Version; fileCount = $inv.FileCount; manifestHash = $inv.ManifestHash; installedUtc = (Get-Date).ToUniversalTime().ToString('o') } Set-Content -LiteralPath (Join-Path $source '.albuild-complete') -Value ($marker | ConvertTo-Json -Compress) -Encoding UTF8 # Publish atomically. If a complete target already exists (race lost, not -Force), keep it. if (Test-Path -LiteralPath $target) { if (-not $Force -and (Assert-ALbuildModuleComplete -Path $target -PassThru).Complete) { return [PSCustomObject]@{ Version = $Version; Path = $target; Action = 'present'; ModuleRoot = $ModuleRoot } } # Present but broken (or -Force): park it for post-mortem, never delete (R8). $defekt = "$target.defekt-$((Get-Date).ToString('yyyyMMddHHmmss'))" try { [System.IO.Directory]::Move($target, $defekt); Write-ALbuildLog -Level Warning "Parked a broken businessdev.ALbuild $Version as '$defekt'." } catch { Remove-Item -LiteralPath $target -Recurse -Force -ErrorAction SilentlyContinue } } [System.IO.Directory]::Move($source, $target) } finally { if (Test-Path -LiteralPath $staging) { Remove-Item -LiteralPath $staging -Recurse -Force -ErrorAction SilentlyContinue } } if ($KeepVersions -gt 0) { [void](Invoke-ALbuildModulePrune -ModuleDir $moduleDir -KeepVersions $KeepVersions) } Write-ALbuildLog -Level Success "Installed businessdev.ALbuild $Version into '$target'." [PSCustomObject]@{ Version = $Version; Path = $target; Action = 'installed'; ModuleRoot = $ModuleRoot } } finally { if ($held) { $mutex.ReleaseMutex() } $mutex.Dispose() } } |