[CmdletBinding()] param( [Parameter(Mandatory = $true)][string]$ArchivePath, [Parameter(Mandatory = $true)][string]$ProjectPath, [Parameter(Mandatory = $true)][string]$InvocationId, [Parameter(Mandatory = $true)] [ValidatePattern('^[0-9A-Fa-f]{64}$')] [string]$ExpectedArchiveSha256, [Parameter(Mandatory = $true)][string]$ProductionSiteName, [Parameter(Mandatory = $true)][string]$ProductionPoolName, [Parameter(Mandatory = $true)][int]$ProductionPort, [Parameter(Mandatory = $true)][string]$ProductionBaseUrl, [Parameter(Mandatory = $true)][string]$TestSiteName, [Parameter(Mandatory = $true)][string]$TestPoolName, [Parameter(Mandatory = $true)][int]$TestPort, [Parameter(Mandatory = $true)][string]$TestBaseUrl, [switch]$RunTests ) $ErrorActionPreference = 'Stop' Set-StrictMode -Version 2.0 function Quote-PowerShellLiteral { param([Parameter(Mandatory = $true)][string]$Value) return "'" + $Value.Replace("'", "''") + "'" } function Invoke-WindowsPowerShell { param([Parameter(Mandatory = $true)][string]$Command) $encoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($Command)) $process = Start-Process -FilePath 'powershell.exe' -ArgumentList @( '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', $encoded ) -Wait -PassThru -NoNewWindow return $process.ExitCode } function Assert-Administrator { $identity = [Security.Principal.WindowsIdentity]::GetCurrent() $principal = New-Object Security.Principal.WindowsPrincipal($identity) if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { throw 'Remote installation requires an elevated Windows account.' } } function Get-SiteSnapshot { param([string]$Name) if (-not (Test-Path "IIS:\Sites\$Name")) { return $null } $site = Get-Website -Name $Name $parentPaths = (Get-WebConfigurationProperty -PSPath 'MACHINE/WEBROOT/APPHOST' -Location $Name -Filter 'system.webServer/asp' -Name 'enableParentPaths').Value return [PSCustomObject]@{ Name = $Name State = [string]$site.State PhysicalPath = [string]$site.PhysicalPath ApplicationPool = [string]$site.ApplicationPool EnableParentPaths = [bool]$parentPaths } } function Assert-SiteTarget { param( [string]$Name, [string]$PoolName, [string]$PhysicalPath, [int]$Port ) $snapshot = Get-SiteSnapshot -Name $Name if ($snapshot) { if ([IO.Path]::GetFullPath($snapshot.PhysicalPath).TrimEnd('\') -ne [IO.Path]::GetFullPath($PhysicalPath).TrimEnd('\')) { throw "Existing site '$Name' has physical path '$($snapshot.PhysicalPath)', expected '$PhysicalPath'. Refusing to adopt or rewrite it." } if ($snapshot.ApplicationPool -ne $PoolName) { throw "Existing site '$Name' uses app pool '$($snapshot.ApplicationPool)', expected '$PoolName'. Refusing to adopt it." } $httpBinding = @(Get-WebBinding -Name $Name -Protocol http | Where-Object { $_.bindingInformation -match ":$Port`:" }) if ($httpBinding.Count -ne 1) { throw "Existing site '$Name' must have exactly one HTTP binding on port $Port." } } $conflictingSiteNames = @() foreach ($otherSite in @(Get-Website | Where-Object { $_.Name -ne $Name })) { $matchingBindings = @(Get-WebBinding -Name $otherSite.Name -Protocol http | Where-Object { $_.bindingInformation -match ":$Port`:" }) if ($matchingBindings.Count -gt 0) { $conflictingSiteNames += $otherSite.Name } } if ($conflictingSiteNames.Count -gt 0) { throw "HTTP port $Port is already used by unrelated site(s): $($conflictingSiteNames -join ', ')." } } function Assert-PoolTarget { param([string]$PoolName, [string]$AllowedSiteName) if (-not (Test-Path "IIS:\AppPools\$PoolName")) { return } $otherUsers = @(Get-Website | Where-Object { $_.ApplicationPool -eq $PoolName -and $_.Name -ne $AllowedSiteName }) if ($otherUsers.Count -gt 0) { throw "App pool '$PoolName' is used by unrelated site(s): $($otherUsers.Name -join ', ')." } } function Get-ComponentRegistrationMode { param( [Parameter(Mandatory = $true)][string]$ExistingProjectPath, [Parameter(Mandatory = $true)][object[]]$Components ) $absentCount = 0 $ownedCount = 0 foreach ($component in $Components) { $progIdKey = "HKLM:\SOFTWARE\Classes\$($component.ProgId)" $clsidKey = "HKLM:\SOFTWARE\Classes\CLSID\$($component.ClassId)" $progIdExists = Test-Path -LiteralPath $progIdKey $clsidExists = Test-Path -LiteralPath $clsidKey if (-not $progIdExists -and -not $clsidExists) { $absentCount++ continue } if (-not $progIdExists -or -not $clsidExists) { throw "Component registration is partial for '$($component.ProgId)'. Refusing to overwrite or repair registry state that this invocation does not own." } $registeredClassId = (Get-ItemProperty -LiteralPath (Join-Path $progIdKey 'CLSID')).'(default)' if ($registeredClassId -ne $component.ClassId) { throw "ProgID '$($component.ProgId)' maps to '$registeredClassId', not this project's CLSID '$($component.ClassId)'." } $scriptletKey = Join-Path $clsidKey 'ScriptletURL' if (-not (Test-Path -LiteralPath $scriptletKey)) { throw "CLSID '$($component.ClassId)' has no ScriptletURL. Refusing to overwrite it." } $registeredPath = [string](Get-ItemProperty -LiteralPath $scriptletKey).'(default)' $expectedPath = [IO.Path]::GetFullPath((Join-Path $ExistingProjectPath $component.RelativePath)) $registeredFilePath = $null if (-not [string]::IsNullOrWhiteSpace($registeredPath)) { $registeredUri = $null if ([Uri]::TryCreate($registeredPath, [UriKind]::Absolute, [ref]$registeredUri) -and $registeredUri.IsFile) { $registeredFilePath = [IO.Path]::GetFullPath($registeredUri.LocalPath) } elseif ([IO.Path]::IsPathRooted($registeredPath)) { $registeredFilePath = [IO.Path]::GetFullPath($registeredPath) } } if (-not $registeredFilePath -or $registeredFilePath.TrimEnd('\') -ne $expectedPath.TrimEnd('\')) { throw "CLSID '$($component.ClassId)' is registered from '$registeredPath', not the existing deployment path '$expectedPath'." } $ownedCount++ } if ($absentCount -eq $Components.Count) { return 'Absent' } if ($ownedCount -eq $Components.Count) { return 'Owned' } throw 'Component registrations are a mixture of absent and existing entries. Refusing a deployment that could overwrite or delete unrelated registry state.' } function Restore-SiteState { param($Snapshot) if (-not $Snapshot -or -not (Test-Path "IIS:\Sites\$($Snapshot.Name)")) { return } $parentPathsValue = if ($Snapshot.EnableParentPaths) { 'True' } else { 'False' } $appcmd = "$env:windir\system32\inetsrv\appcmd.exe" & $appcmd set config $Snapshot.Name -section:system.webServer/asp "/enableParentPaths:$parentPathsValue" /commit:apphost | Out-Null if ($LASTEXITCODE -ne 0) { throw "Unable to restore enableParentPaths for site '$($Snapshot.Name)'." } if ($Snapshot.State -eq 'Started') { Start-Website -Name $Snapshot.Name -ErrorAction SilentlyContinue } else { Stop-Website -Name $Snapshot.Name -ErrorAction SilentlyContinue } } function Wait-WebAppPoolState { param( [Parameter(Mandatory = $true)][string]$Name, [Parameter(Mandatory = $true)][string]$DesiredState, [int]$TimeoutSeconds = 30 ) $deadline = (Get-Date).AddSeconds($TimeoutSeconds) do { $current = [string](Get-WebAppPoolState -Name $Name).Value if ($current -eq $DesiredState) { return } Start-Sleep -Milliseconds 250 } while ((Get-Date) -lt $deadline) throw "App pool '$Name' did not reach state '$DesiredState' within $TimeoutSeconds seconds (current: $current)." } function Sync-DirectoryTree { param( [Parameter(Mandatory = $true)][string]$Source, [Parameter(Mandatory = $true)][string]$Destination ) if (-not (Test-Path -LiteralPath $Source -PathType Container)) { throw "Directory sync source does not exist: $Source" } & robocopy.exe $Source $Destination /MIR /COPY:DAT /DCOPY:DAT /R:2 /W:1 /NFL /NDL /NJH /NJS /NP $exitCode = $LASTEXITCODE if ($exitCode -ge 8) { throw "robocopy failed while mirroring '$Source' to '$Destination' (exit code $exitCode)." } } function Restore-PoolState { param([string]$Name, [string]$State) if (-not $State -or -not (Test-Path "IIS:\AppPools\$Name")) { return } $current = [string](Get-WebAppPoolState -Name $Name).Value if ($State -eq 'Started') { if ($current -eq 'Stopping') { Wait-WebAppPoolState -Name $Name -DesiredState 'Stopped' $current = 'Stopped' } if ($current -ne 'Started') { Start-WebAppPool -Name $Name } Wait-WebAppPoolState -Name $Name -DesiredState 'Started' } else { if ($current -eq 'Starting') { Wait-WebAppPoolState -Name $Name -DesiredState 'Started' $current = 'Started' } if ($current -ne 'Stopped') { Stop-WebAppPool -Name $Name } Wait-WebAppPoolState -Name $Name -DesiredState 'Stopped' } } Assert-Administrator Import-Module WebAdministration if (-not (Test-Path -LiteralPath $ArchivePath -PathType Leaf)) { throw "Deployment archive not found: $ArchivePath" } $actualArchiveSha256 = (Get-FileHash -LiteralPath $ArchivePath -Algorithm SHA256).Hash if ($actualArchiveSha256 -ne $ExpectedArchiveSha256) { throw "Deployment archive SHA-256 mismatch (expected $ExpectedArchiveSha256, got $actualArchiveSha256)." } Write-Output "Verified package SHA-256: $($actualArchiveSha256.ToLowerInvariant())" if (-not [IO.Path]::IsPathRooted($ProjectPath)) { throw 'ProjectPath must be an absolute Windows path.' } if ($ProductionSiteName -eq $TestSiteName -or $ProductionPoolName -eq $TestPoolName) { throw 'Production and test IIS names must differ.' } if ($ProductionPort -eq $TestPort) { throw 'Production and test ports must differ.' } $projectFullPath = [IO.Path]::GetFullPath($ProjectPath).TrimEnd('\') $projectParent = Split-Path -Parent $projectFullPath if (-not $projectParent -or $projectFullPath -eq [IO.Path]::GetPathRoot($projectFullPath).TrimEnd('\')) { throw "Unsafe ProjectPath: $ProjectPath" } $projectParentExisted = Test-Path -LiteralPath $projectParent -PathType Container $targetExisted = Test-Path -LiteralPath $projectFullPath if ($targetExisted) { $targetItem = Get-Item -LiteralPath $projectFullPath -Force if (-not $targetItem.PSIsContainer) { throw "ProjectPath exists but is not a directory: $projectFullPath" } if (($targetItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw "ProjectPath may not be a reparse point: $projectFullPath" } if (-not (Test-Path -LiteralPath (Join-Path $projectFullPath 'tools\Register-Components.ps1'))) { throw 'Existing ProjectPath is not a rollback-capable WSC-MVC deployment.' } } $stagingPath = "$projectFullPath.staging.$InvocationId" $backupPath = "$projectFullPath.rollback.$InvocationId" $failedPath = "$projectFullPath.failed.$InvocationId" foreach ($ownedPath in @($stagingPath, $backupPath, $failedPath)) { if (Test-Path -LiteralPath $ownedPath) { throw "Invocation-owned path already exists: $ownedPath" } } $productionPublicPath = Join-Path $projectFullPath 'public' $testPublicPath = Join-Path $projectFullPath 'test-app\public' $components = @( [PSCustomObject]@{ RelativePath = 'Framework\RequestContext.wsc'; ProgId = 'WscMvc.RequestContext'; ClassId = '{1C36FA55-34DF-4974-94B9-D657389362B2}' }, [PSCustomObject]@{ RelativePath = 'Framework\Router.wsc'; ProgId = 'WscMvc.Router'; ClassId = '{C92F9338-B478-4EAD-B865-892FFB1E1C51}' }, [PSCustomObject]@{ RelativePath = 'Framework\ViewRenderer.wsc'; ProgId = 'WscMvc.ViewRenderer'; ClassId = '{4948DF84-5DC6-448A-9F1B-EB596C28842B}' }, [PSCustomObject]@{ RelativePath = 'Framework\Application.wsc'; ProgId = 'WscMvc.Application'; ClassId = '{851C7763-1638-42FE-A166-BF3DD3A96A88}' }, [PSCustomObject]@{ RelativePath = 'Controllers\HomeController.wsc'; ProgId = 'WscMvc.HomeController'; ClassId = '{87488446-60BE-4068-8368-0B709BB68F3F}' }, [PSCustomObject]@{ RelativePath = 'test-app\Controllers\SelfTestController.wsc'; ProgId = 'WscMvc.SelfTestController'; ClassId = '{D2634944-4646-4C55-956E-4C05E7E10904}' } ) # Validate every archive entry before Expand-Archive can write anything. Add-Type -AssemblyName System.IO.Compression.FileSystem $zip = [IO.Compression.ZipFile]::OpenRead($ArchivePath) try { foreach ($entry in $zip.Entries) { $entryName = $entry.FullName.Replace('/', '\') if ([IO.Path]::IsPathRooted($entryName) -or $entryName -match '(^|\\)\.\.(\\|$)') { throw "Unsafe archive entry: $($entry.FullName)" } $entryDestination = [IO.Path]::GetFullPath((Join-Path $stagingPath $entryName)) if (-not $entryDestination.StartsWith($stagingPath + '\', [StringComparison]::OrdinalIgnoreCase)) { throw "Archive entry escapes staging directory: $($entry.FullName)" } } } finally { $zip.Dispose() } # Read-only filesystem and IIS inspection completes before any target, registration, site, or pool mutation. $productionSnapshot = Get-SiteSnapshot -Name $ProductionSiteName $testSnapshot = Get-SiteSnapshot -Name $TestSiteName $productionPoolExisted = Test-Path "IIS:\AppPools\$ProductionPoolName" $testPoolExisted = Test-Path "IIS:\AppPools\$TestPoolName" $productionPoolState = if ($productionPoolExisted) { [string](Get-WebAppPoolState -Name $ProductionPoolName).Value } else { $null } $testPoolState = if ($testPoolExisted) { [string](Get-WebAppPoolState -Name $TestPoolName).Value } else { $null } Assert-SiteTarget -Name $ProductionSiteName -PoolName $ProductionPoolName -PhysicalPath $productionPublicPath -Port $ProductionPort Assert-SiteTarget -Name $TestSiteName -PoolName $TestPoolName -PhysicalPath $testPublicPath -Port $TestPort Assert-PoolTarget -PoolName $ProductionPoolName -AllowedSiteName $ProductionSiteName Assert-PoolTarget -PoolName $TestPoolName -AllowedSiteName $TestSiteName $componentRegistrationMode = Get-ComponentRegistrationMode -ExistingProjectPath $projectFullPath -Components $components $createdProductionSite = -not [bool]$productionSnapshot $createdTestSite = -not [bool]$testSnapshot $createdProductionPool = -not $productionPoolExisted $createdTestPool = -not $testPoolExisted $targetUpdated = $false $backupCreated = $false $installSucceeded = $false try { if (-not $projectParentExisted) { New-Item -ItemType Directory -Path $projectParent | Out-Null } Expand-Archive -LiteralPath $ArchivePath -DestinationPath $stagingPath $requiredRelativePaths = @( 'public\Default.asp', 'test-app\public\Default.asp', 'Framework\ViewRenderer.wsc', 'tools\Register-Components.ps1', 'tools\Setup-Site.ps1', 'tests\Invoke-SelfTest.ps1', 'tests\Test-Http.ps1' ) foreach ($relativePath in $requiredRelativePaths) { if (-not (Test-Path -LiteralPath (Join-Path $stagingPath $relativePath))) { throw "Package is missing required path: $relativePath" } } Get-ChildItem -LiteralPath $stagingPath -Recurse -Force | ForEach-Object { $resolved = [IO.Path]::GetFullPath($_.FullName) if (-not $resolved.StartsWith($stagingPath + '\', [StringComparison]::OrdinalIgnoreCase)) { throw "Archive entry escaped staging directory: $resolved" } if (($_.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw "Archive contains a reparse point: $resolved" } } # Stop only this deployment's two sites and dedicated pools before copying # the release. The live root itself can remain open on IIS hosts even after # its dedicated pools stop, so keep the path stable and mirror a verified # backup/release tree instead of relying on a root-directory rename. foreach ($siteSnapshot in @($productionSnapshot, $testSnapshot)) { if ($siteSnapshot -and $siteSnapshot.State -eq 'Started') { Stop-Website -Name $siteSnapshot.Name } } foreach ($poolName in @($ProductionPoolName, $TestPoolName)) { if (Test-Path "IIS:\AppPools\$poolName") { $poolStateNow = [string](Get-WebAppPoolState -Name $poolName).Value if ($poolStateNow -eq 'Started') { Stop-WebAppPool -Name $poolName } Wait-WebAppPoolState -Name $poolName -DesiredState 'Stopped' } } if ($targetExisted) { Sync-DirectoryTree -Source $projectFullPath -Destination $backupPath $backupCreated = $true } Sync-DirectoryTree -Source $stagingPath -Destination $projectFullPath $targetUpdated = $true Remove-Item -LiteralPath $stagingPath -Recurse -Force & (Join-Path $projectFullPath 'tools\Register-Components.ps1') -ProjectRoot $projectFullPath & (Join-Path $projectFullPath 'tools\Setup-Site.ps1') -SiteName $ProductionSiteName -PoolName $ProductionPoolName -PhysicalPath $productionPublicPath -Port $ProductionPort & (Join-Path $projectFullPath 'tools\Setup-Site.ps1') -SiteName $TestSiteName -PoolName $TestPoolName -PhysicalPath $testPublicPath -Port $TestPort if ($RunTests) { & cscript.exe //nologo (Join-Path $projectFullPath 'tests\Test-Components.vbs') if ($LASTEXITCODE -ne 0) { throw "Test-Components.vbs failed with exit code $LASTEXITCODE" } $httpTestCommand = "& $(Quote-PowerShellLiteral (Join-Path $projectFullPath 'tests\Test-Http.ps1')) -BaseUrl $(Quote-PowerShellLiteral $ProductionBaseUrl) -TestBaseUrl $(Quote-PowerShellLiteral $TestBaseUrl)" $httpTestExitCode = Invoke-WindowsPowerShell -Command $httpTestCommand if ($httpTestExitCode -ne 0) { throw "Test-Http.ps1 failed with exit code $httpTestExitCode" } $apiTestCommand = "& $(Quote-PowerShellLiteral (Join-Path $projectFullPath 'tests\Invoke-SelfTest.ps1')) -BaseUrl $(Quote-PowerShellLiteral $TestBaseUrl)" $apiTestExitCode = Invoke-WindowsPowerShell -Command $apiTestCommand if ($apiTestExitCode -ne 0) { throw "Invoke-SelfTest.ps1 failed with exit code $apiTestExitCode" } } $installSucceeded = $true Write-Output "Deployment succeeded: $projectFullPath" if ($backupCreated) { Write-Output "Timestamped rollback retained at: $backupPath" } else { Write-Output 'No previous project tree existed; no rollback directory was created.' } } catch { $failure = $_ $rollbackErrors = New-Object System.Collections.Generic.List[string] Write-Warning "Deployment failed; rolling back only changes owned by invocation $InvocationId." try { if ($targetUpdated -and (Test-Path -LiteralPath (Join-Path $projectFullPath 'tools\Unregister-Components.ps1'))) { & (Join-Path $projectFullPath 'tools\Unregister-Components.ps1') -ProjectRoot $projectFullPath } } catch { $rollbackErrors.Add("Unregister new components: $($_.Exception.Message)") } try { if ($createdProductionSite -and (Test-Path "IIS:\Sites\$ProductionSiteName")) { Remove-Website -Name $ProductionSiteName } } catch { $rollbackErrors.Add("Remove production site: $($_.Exception.Message)") } try { if ($createdTestSite -and (Test-Path "IIS:\Sites\$TestSiteName")) { Remove-Website -Name $TestSiteName } } catch { $rollbackErrors.Add("Remove test site: $($_.Exception.Message)") } try { if ($createdProductionPool -and (Test-Path "IIS:\AppPools\$ProductionPoolName")) { Remove-WebAppPool -Name $ProductionPoolName } } catch { $rollbackErrors.Add("Remove production pool: $($_.Exception.Message)") } try { if ($createdTestPool -and (Test-Path "IIS:\AppPools\$TestPoolName")) { Remove-WebAppPool -Name $TestPoolName } } catch { $rollbackErrors.Add("Remove test pool: $($_.Exception.Message)") } try { if ($targetUpdated -and (Test-Path -LiteralPath $projectFullPath)) { Sync-DirectoryTree -Source $projectFullPath -Destination $failedPath } } catch { $rollbackErrors.Add("Retain failed release: $($_.Exception.Message)") } try { if ($backupCreated -and (Test-Path -LiteralPath $backupPath)) { Sync-DirectoryTree -Source $backupPath -Destination $projectFullPath } elseif ($targetUpdated -and -not $targetExisted -and (Test-Path -LiteralPath $projectFullPath)) { Remove-Item -LiteralPath $projectFullPath -Recurse -Force } } catch { $rollbackErrors.Add("Restore previous project tree: $($_.Exception.Message)") } try { if ($backupCreated -and $componentRegistrationMode -eq 'Owned' -and (Test-Path -LiteralPath (Join-Path $projectFullPath 'tools\Register-Components.ps1'))) { & (Join-Path $projectFullPath 'tools\Register-Components.ps1') -ProjectRoot $projectFullPath } } catch { $rollbackErrors.Add("Restore previous component registrations: $($_.Exception.Message)") } try { Restore-PoolState -Name $ProductionPoolName -State $productionPoolState } catch { $rollbackErrors.Add("Restore production pool state: $($_.Exception.Message)") } try { Restore-PoolState -Name $TestPoolName -State $testPoolState } catch { $rollbackErrors.Add("Restore test pool state: $($_.Exception.Message)") } try { Restore-SiteState -Snapshot $productionSnapshot } catch { $rollbackErrors.Add("Restore production site state: $($_.Exception.Message)") } try { Restore-SiteState -Snapshot $testSnapshot } catch { $rollbackErrors.Add("Restore test site state: $($_.Exception.Message)") } if ($rollbackErrors.Count -gt 0) { throw "Deployment failed: $($failure.Exception.Message) Rollback also reported: $($rollbackErrors -join ' | ')" } throw $failure } finally { if (-not $installSucceeded -and (Test-Path -LiteralPath $stagingPath)) { # Staging was created by this invocation and never became the live target. Remove-Item -LiteralPath $stagingPath -Recurse -Force } if (-not $installSucceeded -and -not $projectParentExisted -and (Test-Path -LiteralPath $projectParent)) { $remaining = @(Get-ChildItem -LiteralPath $projectParent -Force) if ($remaining.Count -eq 0) { Remove-Item -LiteralPath $projectParent -Force } } if (Test-Path -LiteralPath $ArchivePath) { Remove-Item -LiteralPath $ArchivePath -Force } $selfPath = $MyInvocation.MyCommand.Path if ($selfPath -and $selfPath -like "$env:windir\Temp\*") { Remove-Item -LiteralPath $selfPath -Force -ErrorAction SilentlyContinue } }