From f7dde8fedf555c7432a3d26c76137cdc0801a186 Mon Sep 17 00:00:00 2001 From: Open Claw Date: Sat, 19 Sep 2026 17:45:44 -0400 Subject: [PATCH] Add transactional remote deployment tooling --- README.md | 33 ++- tests/Invoke-SelfTest.ps1 | 120 +++++++++ tools/Deploy-Remote.ps1 | 155 ++++++++++++ tools/Invoke-RemoteInstall.ps1 | 434 +++++++++++++++++++++++++++++++++ 4 files changed, 739 insertions(+), 3 deletions(-) create mode 100644 tests/Invoke-SelfTest.ps1 create mode 100644 tools/Deploy-Remote.ps1 create mode 100644 tools/Invoke-RemoteInstall.ps1 diff --git a/README.md b/README.md index 0d15370..adc4052 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,30 @@ Two separate IIS sites sharing only the framework components: - `logs/`, `test-app/logs/` — each site's own runtime log, written by the app, never served over HTTP. - `tests/`, `tools/`, `docs/` — test scripts, deployment/registration tooling, and documentation. Never served over HTTP. -## Deploy and register (on the target Windows/IIS host) +## Deploy + +### Remote transactional deployment + +Run the controller from a Windows checkout with Git, OpenSSH `ssh`/`scp`, and existing key or agent authentication for the target. It never accepts or stores credentials. The remote account must be elevated and the target must have Windows PowerShell 5.1, IIS, Classic ASP, URL Rewrite, and the `WebAdministration` module. + +```powershell +powershell -File tools\Deploy-Remote.ps1 ` + -RemoteHost Administrator@win2025test ` + -RemoteProjectPath C:\Projects\wsc-mvc ` + -ProductionSiteName WscMvc -ProductionPoolName WscMvc -ProductionPort 8090 ` + -ProductionBaseUrl http://localhost:8090 ` + -TestSiteName WscMvcTests -TestPoolName WscMvcTests -TestPort 8091 ` + -TestBaseUrl http://localhost:8091 ` + -RunTests +``` + +The controller packages only paths reported by `git ls-files`, so new deployment files must be tracked before using it. `-PullOriginMaster` optionally runs `git pull --ff-only origin master` and refuses a dirty worktree; it is never implicit. The controller reports a SHA-256 digest and the remote installer verifies it before changing the target. The remote preflight also validates the archive, target path, IIS site ownership, app-pool ownership, binding conflicts, and global COM registration ownership. It refuses partial or foreign ProgID/CLSID state rather than overwriting it. It then swaps the complete project tree into place, registers every WSC (including `WscMvc.ViewRenderer`), and reconciles both IIS sites through `Setup-Site.ps1`. + +When replacing an existing project tree, the prior tree is retained as `.rollback.-`. If registration, IIS reconciliation, or optional tests fail, the installer restores that tree, re-registers its components, restores pre-existing site run states, and removes only sites/app pools created by that invocation. It refuses to adopt mismatched IIS resources and does not alter unrelated sites, pools, or bindings. + +With `-RunTests`, deployment succeeds only after the WSH component suite, IIS HTTP suite, and JSON self-test client all pass. Omit it to deploy without those post-cutover checks. Base URLs are used by the optional tests and may differ from the binding ports when local DNS or host headers require it. + +### Direct setup on the target host ```powershell powershell -File tools\Register-Components.ps1 @@ -21,18 +44,22 @@ powershell -File tools\Setup-Site.ps1 powershell -File tools\Setup-Site.ps1 -SiteName WscMvcTests -PoolName WscMvcTests -PhysicalPath \test-app\public -Port 8091 ``` -Both `Setup-Site.ps1` invocations are idempotent — safe to re-run after any deploy. +Both `Setup-Site.ps1` invocations are idempotent for project-owned resources. ## Test ```powershell cscript //nologo tests\Test-Components.vbs # WSH smoke test, no IIS needed powershell -File tests\Test-Http.ps1 -BaseUrl http://localhost:8090 -TestBaseUrl http://localhost:8091 +powershell -File tests\Invoke-SelfTest.ps1 -BaseUrl http://localhost:8091 ``` + +`Invoke-SelfTest.ps1` calls `/self-test`, parses its JSON, prints every reported check as `PASS` or `FAIL`, confirms the test-site root also returns a passing self-test, and confirms direct `Default.asp?route=/hello` access returns 404. It exits nonzero for any request, contract, check, root-mapping, or route-isolation failure. + ```bash ./tests/run-self-test.sh http://:8091 # test-app site, plain curl+JSON, any CLI ``` ## Status -M0–M3 gated PASS with real test evidence on Windows/IIS; see `docs/TEST-RESULTS.md`. Next milestone: M4 (safe separate HTML templates and encoding) per `IMPLEMENTATION_PLAN.md`. +M0–M4 gated PASS with real test evidence on Windows/IIS; see `docs/TEST-RESULTS.md`. M5 (data) is the next framework milestone; the deployment tooling contributes to the later M6/M7 operational gates but is not live-host verified by this README alone. diff --git a/tests/Invoke-SelfTest.ps1 b/tests/Invoke-SelfTest.ps1 new file mode 100644 index 0000000..dfc8063 --- /dev/null +++ b/tests/Invoke-SelfTest.ps1 @@ -0,0 +1,120 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$BaseUrl +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version 2.0 +$script:Failures = 0 + +function Report-Result { + param( + [Parameter(Mandatory = $true)][bool]$Passed, + [Parameter(Mandatory = $true)][string]$Name, + [string]$Detail + ) + + if ($Passed) { + if ($Detail) { Write-Output "PASS: $Name ($Detail)" } else { Write-Output "PASS: $Name" } + } else { + if ($Detail) { Write-Output "FAIL: $Name ($Detail)" } else { Write-Output "FAIL: $Name" } + $script:Failures++ + } +} + +function Invoke-HttpRequest { + param([Parameter(Mandatory = $true)][string]$Uri) + + $request = [System.Net.HttpWebRequest]::Create($Uri) + $request.Method = 'GET' + $request.AllowAutoRedirect = $false + $response = $null + try { + try { + $response = $request.GetResponse() + } catch [System.Net.WebException] { + if ($_.Exception.Response) { + $response = $_.Exception.Response + } else { + throw + } + } + + $reader = New-Object System.IO.StreamReader($response.GetResponseStream()) + try { + $body = $reader.ReadToEnd() + } finally { + $reader.Dispose() + } + + return [PSCustomObject]@{ + StatusCode = [int]$response.StatusCode + ContentType = [string]$response.ContentType + Body = $body + } + } finally { + if ($response) { $response.Dispose() } + } +} + +function Read-SelfTestJson { + param( + [Parameter(Mandatory = $true)][string]$Uri, + [Parameter(Mandatory = $true)][string]$Label, + [switch]$PrintChecks + ) + + try { + $response = Invoke-HttpRequest -Uri $Uri + Report-Result ($response.StatusCode -eq 200) "$Label HTTP status 200" "got $($response.StatusCode)" + if ($response.StatusCode -ne 200) { return } + + try { + $document = $response.Body | ConvertFrom-Json + } catch { + Report-Result $false "$Label JSON parses" $_.Exception.Message + return + } + + Report-Result $true "$Label JSON parses" $null + if (-not $document.PSObject.Properties['ok'] -or -not $document.PSObject.Properties['checks']) { + Report-Result $false "$Label JSON contract" "expected 'ok' and 'checks'" + return + } + Report-Result $true "$Label JSON contract" $null + + if ($PrintChecks) { + foreach ($check in @($document.checks)) { + $name = [string]$check.name + if (-not $name) { $name = '' } + $detail = [string]$check.detail + Report-Result ([bool]$check.pass) $name $detail + } + } + + Report-Result ([bool]$document.ok) "$Label reports overall pass" $null + } catch { + Report-Result $false "$Label request" $_.Exception.Message + } +} + +$normalizedBaseUrl = $BaseUrl.TrimEnd('/') +Read-SelfTestJson -Uri "$normalizedBaseUrl/self-test" -Label 'GET /self-test' -PrintChecks +Read-SelfTestJson -Uri "$normalizedBaseUrl/" -Label 'GET / (test root)' + +try { + $directHello = Invoke-HttpRequest -Uri "$normalizedBaseUrl/Default.asp?route=/hello" + Report-Result ($directHello.StatusCode -eq 404) 'Direct Default.asp production route is rejected' "got HTTP $($directHello.StatusCode)" +} catch { + Report-Result $false 'Direct Default.asp production route is rejected' $_.Exception.Message +} + +Write-Output '---' +if ($script:Failures -eq 0) { + Write-Output 'RESULT: ALL PASS' + exit 0 +} + +Write-Output "RESULT: $($script:Failures) FAILURE(S)" +exit 1 diff --git a/tools/Deploy-Remote.ps1 b/tools/Deploy-Remote.ps1 new file mode 100644 index 0000000..223b301 --- /dev/null +++ b/tools/Deploy-Remote.ps1 @@ -0,0 +1,155 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$RemoteHost, + + [Parameter(Mandatory = $true)] + [string]$RemoteProjectPath, + + [string]$ProductionSiteName = 'WscMvc', + [string]$ProductionPoolName = 'WscMvc', + [int]$ProductionPort = 8090, + [string]$ProductionBaseUrl = 'http://localhost:8090', + + [string]$TestSiteName = 'WscMvcTests', + [string]$TestPoolName = 'WscMvcTests', + [int]$TestPort = 8091, + [string]$TestBaseUrl = 'http://localhost:8091', + + [string]$RemoteTempRoot = 'C:\Windows\Temp', + [switch]$PullOriginMaster, + [switch]$RunTests +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version 2.0 + +function Invoke-Native { + param( + [Parameter(Mandatory = $true)][string]$FilePath, + [Parameter(Mandatory = $true)][string[]]$ArgumentList + ) + + & $FilePath @ArgumentList + if ($LASTEXITCODE -ne 0) { + throw "$FilePath failed with exit code $LASTEXITCODE" + } +} + +function Quote-PowerShellLiteral { + param([Parameter(Mandatory = $true)][string]$Value) + return "'" + $Value.Replace("'", "''") + "'" +} + +$projectRoot = Split-Path -Parent $PSScriptRoot +$gitRoot = (& git -C $projectRoot rev-parse --show-toplevel 2>$null) +if ($LASTEXITCODE -ne 0 -or -not $gitRoot) { + throw "Project root is not a Git checkout: $projectRoot" +} +$gitRoot = [System.IO.Path]::GetFullPath($gitRoot.Trim()) +if ($gitRoot.TrimEnd('\') -ne ([System.IO.Path]::GetFullPath($projectRoot)).TrimEnd('\')) { + throw "Deploy-Remote.ps1 must run from this repository checkout (expected $projectRoot, Git reported $gitRoot)." +} + +if ($ProductionSiteName -eq $TestSiteName) { throw 'ProductionSiteName and TestSiteName must differ.' } +if ($ProductionPoolName -eq $TestPoolName) { throw 'ProductionPoolName and TestPoolName must differ.' } +if ($ProductionPort -lt 1 -or $ProductionPort -gt 65535) { throw 'ProductionPort must be between 1 and 65535.' } +if ($TestPort -lt 1 -or $TestPort -gt 65535) { throw 'TestPort must be between 1 and 65535.' } +if ($ProductionPort -eq $TestPort) { throw 'ProductionPort and TestPort must differ.' } + +if ($PullOriginMaster) { + $dirty = (& git -C $gitRoot status --porcelain) + if ($LASTEXITCODE -ne 0) { throw 'Unable to inspect Git worktree state.' } + if ($dirty) { + throw 'Refusing to pull with local changes. Commit, stash, or omit -PullOriginMaster.' + } + Invoke-Native -FilePath 'git' -ArgumentList @('-C', $gitRoot, 'pull', '--ff-only', 'origin', 'master') +} + +$trackedFiles = @(& git -C $gitRoot ls-files) +if ($LASTEXITCODE -ne 0 -or $trackedFiles.Count -eq 0) { + throw 'git ls-files did not return the tracked deployment set.' +} + +$required = @( + 'tools/Register-Components.ps1', + 'tools/Setup-Site.ps1', + 'tools/Invoke-RemoteInstall.ps1', + 'tests/Invoke-SelfTest.ps1', + 'tests/Test-Http.ps1', + 'Framework/ViewRenderer.wsc' +) +foreach ($requiredPath in $required) { + if ($trackedFiles -notcontains $requiredPath) { + throw "Required deployment file is not tracked by Git: $requiredPath" + } + if (-not (Test-Path -LiteralPath (Join-Path $gitRoot $requiredPath))) { + throw "Required deployment file is missing: $requiredPath" + } +} + +$invocationId = (Get-Date).ToUniversalTime().ToString('yyyyMMddTHHmmssZ') + '-' + [Guid]::NewGuid().ToString('N').Substring(0, 8) +$localWork = Join-Path ([System.IO.Path]::GetTempPath()) "wsc-mvc-deploy-$invocationId" +$packageRoot = Join-Path $localWork 'package' +$archivePath = Join-Path $localWork 'wsc-mvc.zip' +$remoteArchive = Join-Path $RemoteTempRoot "wsc-mvc-$invocationId.zip" +$remoteInstaller = Join-Path $RemoteTempRoot "wsc-mvc-install-$invocationId.ps1" + +try { + New-Item -ItemType Directory -Path $packageRoot -Force | Out-Null + + foreach ($relativePath in $trackedFiles) { + if ([string]::IsNullOrWhiteSpace($relativePath)) { continue } + if ([System.IO.Path]::IsPathRooted($relativePath) -or $relativePath -match '(^|[\\/])\.\.([\\/]|$)') { + throw "Unsafe tracked path: $relativePath" + } + + $source = Join-Path $gitRoot $relativePath + $item = Get-Item -LiteralPath $source -Force + if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "Refusing to package a reparse point or symbolic link: $relativePath" + } + + $destination = Join-Path $packageRoot $relativePath + $destinationParent = Split-Path -Parent $destination + if (-not (Test-Path -LiteralPath $destinationParent)) { + New-Item -ItemType Directory -Path $destinationParent -Force | Out-Null + } + Copy-Item -LiteralPath $source -Destination $destination -Force + } + + Add-Type -AssemblyName System.IO.Compression.FileSystem + [System.IO.Compression.ZipFile]::CreateFromDirectory($packageRoot, $archivePath, [System.IO.Compression.CompressionLevel]::Optimal, $false) + $packageSha256 = (Get-FileHash -LiteralPath $archivePath -Algorithm SHA256).Hash.ToLowerInvariant() + + Write-Output "Package contains $($trackedFiles.Count) Git-tracked paths." + Write-Output "Package SHA-256: $packageSha256" + Write-Output "Inspecting and deploying to $RemoteHost without supplying credentials..." + + Invoke-Native -FilePath 'scp' -ArgumentList @($archivePath, "$RemoteHost`:$remoteArchive") + Invoke-Native -FilePath 'scp' -ArgumentList @((Join-Path $PSScriptRoot 'Invoke-RemoteInstall.ps1'), "$RemoteHost`:$remoteInstaller") + + $arguments = @( + '-ArchivePath', (Quote-PowerShellLiteral $remoteArchive), + '-ProjectPath', (Quote-PowerShellLiteral $RemoteProjectPath), + '-InvocationId', (Quote-PowerShellLiteral $invocationId), + '-ExpectedArchiveSha256', (Quote-PowerShellLiteral $packageSha256), + '-ProductionSiteName', (Quote-PowerShellLiteral $ProductionSiteName), + '-ProductionPoolName', (Quote-PowerShellLiteral $ProductionPoolName), + '-ProductionPort', $ProductionPort, + '-ProductionBaseUrl', (Quote-PowerShellLiteral $ProductionBaseUrl), + '-TestSiteName', (Quote-PowerShellLiteral $TestSiteName), + '-TestPoolName', (Quote-PowerShellLiteral $TestPoolName), + '-TestPort', $TestPort, + '-TestBaseUrl', (Quote-PowerShellLiteral $TestBaseUrl) + ) + if ($RunTests) { $arguments += '-RunTests' } + + $remoteCommand = "& $(Quote-PowerShellLiteral $remoteInstaller) " + ($arguments -join ' ') + $encodedCommand = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($remoteCommand)) + Invoke-Native -FilePath 'ssh' -ArgumentList @($RemoteHost, "powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand $encodedCommand") +} finally { + if (Test-Path -LiteralPath $localWork) { + Remove-Item -LiteralPath $localWork -Recurse -Force + } +} diff --git a/tools/Invoke-RemoteInstall.ps1 b/tools/Invoke-RemoteInstall.ps1 new file mode 100644 index 0000000..24a6239 --- /dev/null +++ b/tools/Invoke-RemoteInstall.ps1 @@ -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 + } +}