|
|
@@ -0,0 +1,434 @@ |
|
|
|
|
|
[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 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 |
|
|
|
|
|
$targetMoved = $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 the |
|
|
|
|
|
# same-volume directory renames. WSC/COM files remain locked while their |
|
|
|
|
|
# worker processes are alive. Directory.Move is used instead of Move-Item |
|
|
|
|
|
# so a lock failure cannot partially split a directory tree. |
|
|
|
|
|
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) { |
|
|
|
|
|
[IO.Directory]::Move($projectFullPath, $backupPath) |
|
|
|
|
|
$backupCreated = $true |
|
|
|
|
|
} |
|
|
|
|
|
[IO.Directory]::Move($stagingPath, $projectFullPath) |
|
|
|
|
|
$targetMoved = $true |
|
|
|
|
|
|
|
|
|
|
|
& (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 ($targetMoved -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 ($targetMoved -and (Test-Path -LiteralPath $projectFullPath)) { |
|
|
|
|
|
[IO.Directory]::Move($projectFullPath, $failedPath) |
|
|
|
|
|
} |
|
|
|
|
|
} catch { $rollbackErrors.Add("Retain failed release: $($_.Exception.Message)") } |
|
|
|
|
|
try { |
|
|
|
|
|
if ($backupCreated -and (Test-Path -LiteralPath $backupPath)) { |
|
|
|
|
|
[IO.Directory]::Move($backupPath, $projectFullPath) |
|
|
|
|
|
} |
|
|
|
|
|
} 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 |
|
|
|
|
|
} |
|
|
|
|
|
} |