From bf01b2d3fb26b2aa3fe4a42a1e3471d4a2a80963 Mon Sep 17 00:00:00 2001 From: Clawdbot Date: Mon, 14 Sep 2026 21:44:42 +0000 Subject: [PATCH] ci: add host-side IIS release installer --- scripts/install-iis-release.ps1 | 402 ++++++++++++++++++++++++++++++++ 1 file changed, 402 insertions(+) create mode 100644 scripts/install-iis-release.ps1 diff --git a/scripts/install-iis-release.ps1 b/scripts/install-iis-release.ps1 new file mode 100644 index 0000000..eea0ed7 --- /dev/null +++ b/scripts/install-iis-release.ps1 @@ -0,0 +1,402 @@ +<# +.SYNOPSIS + Installs, validates, switches, or rolls back an immutable IIS release. + +.DESCRIPTION + Run this script in an elevated Windows PowerShell 5.1 session on the IIS host. + A release contains the full repository, while IIS is pointed only at its public + directory. The current site's public\web.config is captured once in shared\ + and copied into every new release so host-specific values are not overwritten. + + No database migration runs unless -RunMigrations is explicitly supplied. +#> + +[CmdletBinding(DefaultParameterSetName = 'Deploy')] +param( + [Parameter(Mandatory = $true)] + [ValidatePattern('^[A-Za-z0-9_. -]+$')] + [string]$SiteName, + + [Parameter(ParameterSetName = 'Deploy')] + [string]$PackagePath = '', + + [Parameter(ParameterSetName = 'Deploy')] + [ValidatePattern('^[A-Za-z0-9._-]+$')] + [string]$ReleaseId = (Get-Date -Format 'yyyyMMdd-HHmmss'), + + [Parameter(Mandatory = $true, ParameterSetName = 'Rollback')] + [ValidatePattern('^[A-Za-z0-9._-]+$')] + [string]$RollbackTo, + + [string]$DeployRoot = '', + [string]$BaseUrl = '', + [ValidateRange(2, 100)] + [int]$KeepReleases = 5, + [string]$ExpectedSha256 = '', + [switch]$RunMigrations, + [switch]$SkipSmokeTest, + [switch]$PreflightOnly, + [switch]$DryRun +) + +Set-StrictMode -Version 2.0 +$ErrorActionPreference = 'Stop' + +function Write-Step { + param([string]$Message) + Write-Host ('==> ' + $Message) +} + +function Invoke-Change { + param( + [string]$Description, + [scriptblock]$Action + ) + + if ($DryRun) { + Write-Host ('DRY-RUN: ' + $Description) + return + } + + Write-Step $Description + & $Action +} + +function Get-NormalizedPath { + param([string]$Path) + return [System.IO.Path]::GetFullPath([Environment]::ExpandEnvironmentVariables($Path)).TrimEnd('\') +} + +function Assert-Administrator { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = New-Object Security.Principal.WindowsPrincipal($identity) + if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + throw 'An elevated Administrator PowerShell session is required.' + } +} + +function Assert-ReleaseLayout { + param([string]$ReleasePath) + + $required = @( + 'public\Default.asp', + 'public\web.config', + 'core\autoload_core.asp', + 'app\controllers\autoload_controllers.asp' + ) + + foreach ($relativePath in $required) { + if (-not (Test-Path -LiteralPath (Join-Path $ReleasePath $relativePath) -PathType Leaf)) { + throw "Release is incomplete; missing $relativePath" + } + } + + try { + [xml](Get-Content -LiteralPath (Join-Path $ReleasePath 'public\web.config') -Raw) | Out-Null + } catch { + throw "Release public\web.config is not valid XML: $($_.Exception.Message)" + } +} + +function Get-LocalBaseUrl { + param($Site) + + $binding = $Site.Bindings.Collection | + Where-Object { $_.protocol -eq 'http' } | + Select-Object -First 1 + + if ($null -eq $binding) { + return '' + } + + $parts = $binding.bindingInformation.Split(':') + $port = $parts[1] + if ([string]::IsNullOrWhiteSpace($port)) { + $port = '80' + } + + return 'http://127.0.0.1:' + $port +} + +function Set-IisRelease { + param( + [string]$PhysicalPath, + [string]$PoolName + ) + + Set-ItemProperty -Path ('IIS:\Sites\' + $SiteName) -Name physicalPath -Value $PhysicalPath + + $poolState = (Get-WebAppPoolState -Name $PoolName).Value + if ($poolState -eq 'Started') { + Restart-WebAppPool -Name $PoolName + } else { + Start-WebAppPool -Name $PoolName + } +} + +function Invoke-SmokeTest { + param([string]$Url) + + if ($SkipSmokeTest) { + Write-Step 'Smoke test skipped by explicit request' + return + } + + if ([string]::IsNullOrWhiteSpace($Url)) { + throw 'No HTTP binding was found. Supply -BaseUrl or use -SkipSmokeTest explicitly.' + } + + $target = $Url.TrimEnd('/') + '/' + Write-Step ('Smoke testing ' + $target) + $response = Invoke-WebRequest -UseBasicParsing -Uri $target -TimeoutSec 30 + if ($response.StatusCode -lt 200 -or $response.StatusCode -ge 400) { + throw "Smoke test returned HTTP $($response.StatusCode)" + } + Write-Host ('Smoke test returned HTTP ' + $response.StatusCode) +} + +if ($env:OS -ne 'Windows_NT') { + throw 'This script must run on Windows.' +} + +Assert-Administrator +Import-Module WebAdministration -ErrorAction Stop + +$site = Get-Website -Name $SiteName -ErrorAction Stop +if ($null -eq $site) { + throw "IIS site not found: $SiteName" +} + +$appPool = $site.applicationPool +if ([string]::IsNullOrWhiteSpace($appPool)) { + throw "IIS site $SiteName has no application pool." +} + +if ([string]::IsNullOrWhiteSpace($DeployRoot)) { + $DeployRoot = Join-Path $env:SystemDrive ('inetpub\deployments\' + $SiteName) +} +$DeployRoot = Get-NormalizedPath $DeployRoot +$releasesRoot = Join-Path $DeployRoot 'releases' +$sharedRoot = Join-Path $DeployRoot 'shared' +$sharedConfig = Join-Path $sharedRoot 'public.web.config' +$statePath = Join-Path $DeployRoot 'deployment-state.json' +$currentPhysicalPath = Get-NormalizedPath $site.physicalPath +$currentConfig = Join-Path $currentPhysicalPath 'web.config' + +Write-Step "Site: $SiteName" +Write-Host "App pool: $appPool" +Write-Host "Current physicalPath: $currentPhysicalPath" +Write-Host "Deployment root: $DeployRoot" +Write-Host 'Classic ASP parent paths will be set at the site location in applicationHost.config.' +Write-Host 'Database migrations are disabled unless -RunMigrations is supplied.' + +if ($PSCmdlet.ParameterSetName -eq 'Deploy' -and + (-not $PreflightOnly) -and + (-not $DryRun) -and + [string]::IsNullOrWhiteSpace($PackagePath)) { + throw '-PackagePath is required for a deployment.' +} + +if (-not [string]::IsNullOrWhiteSpace($PackagePath)) { + $PackagePath = Get-NormalizedPath $PackagePath + if (-not (Test-Path -LiteralPath $PackagePath -PathType Leaf)) { + throw "Package not found: $PackagePath" + } + if ([System.IO.Path]::GetExtension($PackagePath) -ne '.zip') { + throw 'PackagePath must name a .zip release package.' + } + + if (-not [string]::IsNullOrWhiteSpace($ExpectedSha256)) { + $actualHash = (Get-FileHash -LiteralPath $PackagePath -Algorithm SHA256).Hash + if ($actualHash -ne $ExpectedSha256) { + throw "Package SHA-256 mismatch. Expected $ExpectedSha256; got $actualHash" + } + Write-Host ('Package SHA-256 verified: ' + $actualHash) + } +} + +if ((-not (Test-Path -LiteralPath $sharedConfig -PathType Leaf)) -and + (-not (Test-Path -LiteralPath $currentConfig -PathType Leaf))) { + throw "Cannot preserve machine configuration: neither $sharedConfig nor $currentConfig exists." +} + +$getWindowsFeature = Get-Command Get-WindowsFeature -ErrorAction SilentlyContinue +$getOptionalFeature = Get-Command Get-WindowsOptionalFeature -ErrorAction SilentlyContinue +if ($null -ne $getWindowsFeature) { + $aspFeature = Get-WindowsFeature -Name Web-ASP + if ($null -eq $aspFeature -or -not $aspFeature.Installed) { + throw 'The IIS Classic ASP feature (Web-ASP) is not installed.' + } +} elseif ($null -ne $getOptionalFeature) { + $aspFeature = Get-WindowsOptionalFeature -Online -FeatureName IIS-ASP -ErrorAction SilentlyContinue + if ($null -ne $aspFeature -and $aspFeature.State -ne 'Enabled') { + throw 'The IIS-ASP Windows feature is not enabled.' + } +} else { + Write-Warning 'No Windows feature-query cmdlet is available; Classic ASP feature state could not be preflighted.' +} + +$rewriteModule = Get-WebGlobalModule -Name RewriteModule -ErrorAction SilentlyContinue +if ($null -eq $rewriteModule) { + throw 'IIS URL Rewrite is not installed (RewriteModule was not found).' +} + +if ($PreflightOnly -or $DryRun) { + Write-Step 'Preflight passed; no IIS or filesystem changes were made' + exit 0 +} + +Invoke-Change "Create deployment directories under $DeployRoot" { + New-Item -ItemType Directory -Force -Path $releasesRoot, $sharedRoot | Out-Null +} + +if (-not (Test-Path -LiteralPath $sharedConfig -PathType Leaf)) { + Invoke-Change "Capture machine-specific configuration from $currentConfig" { + Copy-Item -LiteralPath $currentConfig -Destination $sharedConfig -Force + } +} + +try { + [xml](Get-Content -LiteralPath $sharedConfig -Raw) | Out-Null +} catch { + throw "Preserved configuration is not valid XML: $($_.Exception.Message)" +} + +Invoke-Change 'Enable Classic ASP parent paths explicitly for this IIS site' { + Set-WebConfigurationProperty ` + -PSPath 'MACHINE/WEBROOT/APPHOST' ` + -Location $SiteName ` + -Filter 'system.webServer/asp' ` + -Name 'enableParentPaths' ` + -Value $true +} + +if ($PSCmdlet.ParameterSetName -eq 'Rollback') { + $rollbackRoot = Get-NormalizedPath (Join-Path $releasesRoot $RollbackTo) + $expectedPrefix = $releasesRoot.TrimEnd('\') + '\' + if (-not $rollbackRoot.StartsWith($expectedPrefix, [StringComparison]::OrdinalIgnoreCase)) { + throw 'Rollback target escaped the releases directory.' + } + Assert-ReleaseLayout -ReleasePath $rollbackRoot + $rollbackPublic = Join-Path $rollbackRoot 'public' + + Invoke-Change "Refresh preserved web.config in rollback release $RollbackTo" { + Copy-Item -LiteralPath $sharedConfig -Destination (Join-Path $rollbackPublic 'web.config') -Force + } + + $oldPath = $currentPhysicalPath + try { + Invoke-Change "Switch IIS physicalPath to rollback release $rollbackPublic" { + Set-IisRelease -PhysicalPath $rollbackPublic -PoolName $appPool + } + if ([string]::IsNullOrWhiteSpace($BaseUrl)) { + $BaseUrl = Get-LocalBaseUrl -Site $site + } + Invoke-SmokeTest -Url $BaseUrl + } catch { + Write-Warning "Rollback smoke test failed; restoring $oldPath" + Set-IisRelease -PhysicalPath $oldPath -PoolName $appPool + throw + } + + $rollbackState = [ordered]@{ + siteName = $SiteName + currentRelease = $RollbackTo + currentPhysicalPath = $rollbackPublic + previousPhysicalPath = $oldPath + switchedAtUtc = (Get-Date).ToUniversalTime().ToString('o') + operation = 'rollback' + } + $rollbackState | ConvertTo-Json | Set-Content -LiteralPath $statePath -Encoding UTF8 + Write-Step "Rollback complete: $RollbackTo" + exit 0 +} + +$releaseRoot = Join-Path $releasesRoot $ReleaseId +$stagingRoot = $releaseRoot + '.staging' +if ((Test-Path -LiteralPath $releaseRoot) -or (Test-Path -LiteralPath $stagingRoot)) { + throw "Release already exists: $ReleaseId" +} + +try { + Invoke-Change "Extract package into staging directory $stagingRoot" { + New-Item -ItemType Directory -Force -Path $stagingRoot | Out-Null + Expand-Archive -LiteralPath $PackagePath -DestinationPath $stagingRoot -Force + } + Assert-ReleaseLayout -ReleasePath $stagingRoot + + Invoke-Change 'Overlay the preserved machine-specific public\web.config' { + Copy-Item -LiteralPath $sharedConfig -Destination (Join-Path $stagingRoot 'public\web.config') -Force + } + Assert-ReleaseLayout -ReleasePath $stagingRoot + + if ($RunMigrations) { + $migrationScript = Join-Path $stagingRoot 'scripts\runMigrations.vbs' + if (-not (Test-Path -LiteralPath $migrationScript -PathType Leaf)) { + throw "Migration script not found: $migrationScript" + } + Write-Warning 'Running production migrations by explicit request. IIS rollback will not undo database changes.' + Push-Location $stagingRoot + try { + & cscript.exe //nologo $migrationScript up + if ($LASTEXITCODE -ne 0) { + throw "Migration command exited with code $LASTEXITCODE" + } + } finally { + Pop-Location + } + } + + Invoke-Change "Promote staging directory to immutable release $releaseRoot" { + Move-Item -LiteralPath $stagingRoot -Destination $releaseRoot + } + + $newPublic = Join-Path $releaseRoot 'public' + $oldPath = $currentPhysicalPath + try { + Invoke-Change "Atomically switch IIS physicalPath to $newPublic" { + Set-IisRelease -PhysicalPath $newPublic -PoolName $appPool + } + if ([string]::IsNullOrWhiteSpace($BaseUrl)) { + $BaseUrl = Get-LocalBaseUrl -Site $site + } + Invoke-SmokeTest -Url $BaseUrl + } catch { + Write-Warning "Deployment smoke test failed; restoring $oldPath" + Set-IisRelease -PhysicalPath $oldPath -PoolName $appPool + throw + } + + $state = [ordered]@{ + siteName = $SiteName + currentRelease = $ReleaseId + currentPhysicalPath = $newPublic + previousPhysicalPath = $oldPath + packageSha256 = (Get-FileHash -LiteralPath $PackagePath -Algorithm SHA256).Hash + switchedAtUtc = (Get-Date).ToUniversalTime().ToString('o') + migrationsRun = [bool]$RunMigrations + operation = 'deploy' + } + $state | ConvertTo-Json | Set-Content -LiteralPath $statePath -Encoding UTF8 + + $protectedPaths = @($newPublic, $oldPath) + $oldReleases = Get-ChildItem -LiteralPath $releasesRoot -Directory | + Where-Object { $_.Name -notlike '*.staging' } | + Sort-Object LastWriteTimeUtc -Descending | + Select-Object -Skip $KeepReleases + + foreach ($oldRelease in $oldReleases) { + $oldPublic = Join-Path $oldRelease.FullName 'public' + if ($protectedPaths -notcontains $oldPublic) { + Write-Step ('Retention candidate (not deleted automatically): ' + $oldRelease.FullName) + } + } + + Write-Step "Deployment complete: $ReleaseId" + Write-Host "Rollback command: .\install-iis-release.ps1 -SiteName '$SiteName' -RollbackTo ''" +} catch { + if (Test-Path -LiteralPath $stagingRoot) { + Write-Warning "Incomplete staging directory retained for inspection: $stagingRoot" + } + throw +}