<# .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 }