| @@ -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.