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