| @@ -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 '<release-id>'" | |||||
| } catch { | |||||
| if (Test-Path -LiteralPath $stagingRoot) { | |||||
| Write-Warning "Incomplete staging directory retained for inspection: $stagingRoot" | |||||
| } | |||||
| throw | |||||
| } | |||||
Powered by TurnKey Linux.