| @@ -1,34 +1,38 @@ | |||||
| <# | <# | ||||
| .SYNOPSIS | .SYNOPSIS | ||||
| Installs, validates, switches, or rolls back an immutable IIS release. | |||||
| Installs or rolls back an immutable release in a dedicated IIS site/app pool. | |||||
| .DESCRIPTION | .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. | |||||
| The complete repository is retained in each immutable release and IIS serves | |||||
| only <release>\public. The script never discovers or adopts another site. | |||||
| A missing dedicated target is created only after package extraction, layout, | |||||
| and XML validation succeed. Host preflight is read-only and does not require | |||||
| the dedicated target to exist. | |||||
| #> | #> | ||||
| [CmdletBinding(DefaultParameterSetName = 'Deploy')] | [CmdletBinding(DefaultParameterSetName = 'Deploy')] | ||||
| param( | param( | ||||
| [Parameter(Mandatory = $true)] | |||||
| [ValidatePattern('^[A-Za-z0-9_. -]+$')] | [ValidatePattern('^[A-Za-z0-9_. -]+$')] | ||||
| [string]$SiteName, | |||||
| [string]$SiteName = 'AspClassicUnifiedFramework', | |||||
| [ValidatePattern('^[A-Za-z0-9_. -]+$')] | |||||
| [string]$AppPoolName = 'AspClassicUnifiedFramework', | |||||
| [string]$DeployRoot = 'D:\Deployments\AspClassicUnifiedFramework', | |||||
| [string]$BindingIpAddress = '100.97.39.23', | |||||
| [ValidateRange(1, 65535)] | |||||
| [int]$BindingPort = 8085, | |||||
| [AllowEmptyString()] | |||||
| [string]$HostHeader = '', | |||||
| [string]$InitialWebConfigPath = '', | |||||
| [Parameter(ParameterSetName = 'Deploy')] | [Parameter(ParameterSetName = 'Deploy')] | ||||
| [string]$PackagePath = '', | [string]$PackagePath = '', | ||||
| [Parameter(ParameterSetName = 'Deploy')] | [Parameter(ParameterSetName = 'Deploy')] | ||||
| [ValidatePattern('^[A-Za-z0-9._-]+$')] | [ValidatePattern('^[A-Za-z0-9._-]+$')] | ||||
| [string]$ReleaseId = (Get-Date -Format 'yyyyMMdd-HHmmss'), | [string]$ReleaseId = (Get-Date -Format 'yyyyMMdd-HHmmss'), | ||||
| [Parameter(Mandatory = $true, ParameterSetName = 'Rollback')] | [Parameter(Mandatory = $true, ParameterSetName = 'Rollback')] | ||||
| [ValidatePattern('^[A-Za-z0-9._-]+$')] | [ValidatePattern('^[A-Za-z0-9._-]+$')] | ||||
| [string]$RollbackTo, | [string]$RollbackTo, | ||||
| [string]$DeployRoot = '', | |||||
| [string]$BaseUrl = '', | [string]$BaseUrl = '', | ||||
| [ValidateRange(2, 100)] | [ValidateRange(2, 100)] | ||||
| [int]$KeepReleases = 5, | [int]$KeepReleases = 5, | ||||
| @@ -42,25 +46,7 @@ param( | |||||
| Set-StrictMode -Version 2.0 | Set-StrictMode -Version 2.0 | ||||
| $ErrorActionPreference = 'Stop' | $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 Write-Step { param([string]$Message) Write-Host ('==> ' + $Message) } | |||||
| function Get-NormalizedPath { | function Get-NormalizedPath { | ||||
| param([string]$Path) | param([string]$Path) | ||||
| @@ -75,328 +61,417 @@ function Assert-Administrator { | |||||
| } | } | ||||
| } | } | ||||
| function Assert-SafeNameValue { | |||||
| param([string]$Name, [string]$Value) | |||||
| if ([string]::IsNullOrWhiteSpace($Value) -or $Value -notmatch '^[A-Za-z0-9_. -]+$') { | |||||
| throw "$Name contains unsupported characters." | |||||
| } | |||||
| if ($Value -match '(?i)schedulicious') { | |||||
| throw "$Name must not identify a Schedulicious resource." | |||||
| } | |||||
| } | |||||
| function Assert-DeploymentInputs { | |||||
| Assert-SafeNameValue -Name 'SiteName' -Value $SiteName | |||||
| Assert-SafeNameValue -Name 'AppPoolName' -Value $AppPoolName | |||||
| if ($DeployRoot -match '(?i)schedulicious') { throw 'DeployRoot must not reference Schedulicious.' } | |||||
| if ($HostHeader -match '(?i)schedulicious') { throw 'HostHeader must not reference Schedulicious.' } | |||||
| if ($HostHeader -match '[:/\\]') { | |||||
| throw 'HostHeader must be empty or a DNS host name without a scheme, port, slash, or backslash.' | |||||
| } | |||||
| $parsedAddress = $null | |||||
| if (-not [System.Net.IPAddress]::TryParse($BindingIpAddress, [ref]$parsedAddress)) { | |||||
| throw "BindingIpAddress is not a valid IP address: $BindingIpAddress" | |||||
| } | |||||
| } | |||||
| function Assert-ReleaseLayout { | function Assert-ReleaseLayout { | ||||
| param([string]$ReleasePath) | param([string]$ReleasePath) | ||||
| $required = @( | $required = @( | ||||
| 'public\Default.asp', | 'public\Default.asp', | ||||
| 'public\web.config', | 'public\web.config', | ||||
| 'core\autoload_core.asp', | 'core\autoload_core.asp', | ||||
| 'app\controllers\autoload_controllers.asp' | |||||
| 'app\controllers\autoload_controllers.asp', | |||||
| 'scripts\install-iis-release.ps1' | |||||
| ) | ) | ||||
| foreach ($relativePath in $required) { | foreach ($relativePath in $required) { | ||||
| if (-not (Test-Path -LiteralPath (Join-Path $ReleasePath $relativePath) -PathType Leaf)) { | if (-not (Test-Path -LiteralPath (Join-Path $ReleasePath $relativePath) -PathType Leaf)) { | ||||
| throw "Release is incomplete; missing $relativePath" | throw "Release is incomplete; missing $relativePath" | ||||
| } | } | ||||
| } | } | ||||
| foreach ($xmlFile in Get-ChildItem -LiteralPath $ReleasePath -Recurse -Force -Filter 'web.config') { | |||||
| try { | |||||
| [xml](Get-Content -LiteralPath $xmlFile.FullName -Raw) | Out-Null | |||||
| } catch { | |||||
| throw "$($xmlFile.FullName) is not valid XML: $($_.Exception.Message)" | |||||
| } | |||||
| } | |||||
| } | |||||
| function Assert-ArchiveEntries { | |||||
| param([string]$ZipPath) | |||||
| Add-Type -AssemblyName System.IO.Compression.FileSystem | |||||
| $archive = [System.IO.Compression.ZipFile]::OpenRead($ZipPath) | |||||
| try { | 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)" | |||||
| foreach ($entry in $archive.Entries) { | |||||
| $name = $entry.FullName.Replace('/', '\') | |||||
| if ([System.IO.Path]::IsPathRooted($name) -or $name -match '(^|\\)\.\.(\\|$)') { | |||||
| throw "Package contains an unsafe path: $($entry.FullName)" | |||||
| } | |||||
| } | |||||
| } finally { | |||||
| $archive.Dispose() | |||||
| } | } | ||||
| } | } | ||||
| function Get-LocalBaseUrl { | |||||
| param($Site) | |||||
| $binding = $Site.Bindings.Collection | | |||||
| Where-Object { $_.protocol -eq 'http' } | | |||||
| Select-Object -First 1 | |||||
| function Get-BindingInformation { | |||||
| return $BindingIpAddress + ':' + $BindingPort + ':' + $HostHeader | |||||
| } | |||||
| if ($null -eq $binding) { | |||||
| return '' | |||||
| function Assert-HostCapabilities { | |||||
| $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 -eq $aspFeature -or $aspFeature.State -ne 'Enabled') { throw 'The IIS-ASP Windows feature is not enabled.' } | |||||
| } else { | |||||
| throw 'Classic ASP feature state cannot be verified: no supported Windows feature cmdlet is available.' | |||||
| } | } | ||||
| $parts = $binding.bindingInformation.Split(':') | |||||
| $port = $parts[1] | |||||
| if ([string]::IsNullOrWhiteSpace($port)) { | |||||
| $port = '80' | |||||
| if ($null -eq (Get-WebGlobalModule -Name AspModule -ErrorAction SilentlyContinue)) { | |||||
| throw 'The IIS Classic ASP module (AspModule) was not found.' | |||||
| } | |||||
| if ($null -eq (Get-WebGlobalModule -Name RewriteModule -ErrorAction SilentlyContinue)) { | |||||
| throw 'IIS URL Rewrite is not installed (RewriteModule was not found).' | |||||
| } | } | ||||
| } | |||||
| return 'http://127.0.0.1:' + $port | |||||
| function Assert-DeployRoot { | |||||
| param([string]$Path) | |||||
| $root = [System.IO.Path]::GetPathRoot($Path) | |||||
| if ([string]::IsNullOrWhiteSpace($root) -or -not (Test-Path -LiteralPath $root -PathType Container)) { | |||||
| throw "The deployment drive/root is unavailable: $root" | |||||
| } | |||||
| if ($Path.TrimEnd('\') -eq $root.TrimEnd('\')) { throw 'DeployRoot must not be a drive root.' } | |||||
| if ((Test-Path -LiteralPath $Path) -and -not (Test-Path -LiteralPath $Path -PathType Container)) { | |||||
| throw "DeployRoot exists but is not a directory: $Path" | |||||
| } | |||||
| $ancestor = $Path | |||||
| while (-not (Test-Path -LiteralPath $ancestor -PathType Container)) { | |||||
| $parent = Split-Path -Parent $ancestor | |||||
| if ([string]::IsNullOrWhiteSpace($parent) -or $parent -eq $ancestor) { break } | |||||
| $ancestor = $parent | |||||
| } | |||||
| if (-not (Test-Path -LiteralPath $ancestor -PathType Container)) { | |||||
| throw "No accessible ancestor exists for DeployRoot: $Path" | |||||
| } | |||||
| Get-Item -LiteralPath $ancestor -ErrorAction Stop | Out-Null | |||||
| } | } | ||||
| function Set-IisRelease { | |||||
| param( | |||||
| [string]$PhysicalPath, | |||||
| [string]$PoolName | |||||
| ) | |||||
| function Test-PathUnderRoot { | |||||
| param([string]$Path, [string]$Root) | |||||
| $normalizedPath = Get-NormalizedPath $Path | |||||
| $prefix = (Get-NormalizedPath $Root) + '\' | |||||
| return $normalizedPath.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase) | |||||
| } | |||||
| Set-ItemProperty -Path ('IIS:\Sites\' + $SiteName) -Name physicalPath -Value $PhysicalPath | |||||
| function Get-TargetState { | |||||
| $site = Get-Website -Name $SiteName -ErrorAction SilentlyContinue | |||||
| $poolExists = Test-Path -LiteralPath ('IIS:\AppPools\' + $AppPoolName) | |||||
| if (($null -eq $site) -ne (-not $poolExists)) { | |||||
| throw 'Dedicated target is partial: the site and app pool must either both exist or both be absent.' | |||||
| } | |||||
| if ($null -eq $site) { | |||||
| return [pscustomobject]@{ | |||||
| Exists = $false; Site = $null; PhysicalPath = ''; ParentPaths = $null | |||||
| SiteState = ''; PoolState = '' | |||||
| } | |||||
| } | |||||
| if ($site.applicationPool -ne $AppPoolName) { | |||||
| throw "Existing target site uses app pool '$($site.applicationPool)', expected '$AppPoolName'. Refusing adoption." | |||||
| } | |||||
| $otherPoolConsumer = Get-Website | | |||||
| Where-Object { $_.Name -ne $SiteName -and $_.applicationPool -eq $AppPoolName } | | |||||
| Select-Object -First 1 | |||||
| if ($null -ne $otherPoolConsumer) { | |||||
| throw "App pool '$AppPoolName' is also used by site '$($otherPoolConsumer.Name)'. Refusing to alter a shared pool." | |||||
| } | |||||
| $bindings = @($site.Bindings.Collection) | |||||
| $expectedBinding = Get-BindingInformation | |||||
| if ($bindings.Count -ne 1 -or $bindings[0].protocol -ne 'http' -or $bindings[0].bindingInformation -ne $expectedBinding) { | |||||
| throw "Existing target binding does not exactly match http/$expectedBinding. Refusing adoption or binding changes." | |||||
| } | |||||
| $physicalPath = Get-NormalizedPath $site.physicalPath | |||||
| $releasesRoot = Join-Path $DeployRoot 'releases' | |||||
| if (-not (Test-PathUnderRoot -Path $physicalPath -Root $releasesRoot) -or | |||||
| -not $physicalPath.EndsWith('\public', [StringComparison]::OrdinalIgnoreCase)) { | |||||
| throw "Existing target physicalPath is outside this pipeline's release public directories: $physicalPath" | |||||
| } | |||||
| if (-not (Test-Path -LiteralPath $physicalPath -PathType Container)) { | |||||
| throw "Existing target physicalPath does not exist: $physicalPath" | |||||
| } | |||||
| Assert-ReleaseLayout -ReleasePath (Split-Path -Parent $physicalPath) | |||||
| $parentPaths = (Get-WebConfigurationProperty -PSPath 'MACHINE/WEBROOT/APPHOST' -Location $SiteName -Filter 'system.webServer/asp' -Name 'enableParentPaths').Value | |||||
| return [pscustomobject]@{ | |||||
| Exists = $true; Site = $site; PhysicalPath = $physicalPath; ParentPaths = [bool]$parentPaths | |||||
| SiteState = (Get-WebsiteState -Name $SiteName).Value | |||||
| PoolState = (Get-WebAppPoolState -Name $AppPoolName).Value | |||||
| } | |||||
| } | |||||
| $poolState = (Get-WebAppPoolState -Name $PoolName).Value | |||||
| if ($poolState -eq 'Started') { | |||||
| Restart-WebAppPool -Name $PoolName | |||||
| } else { | |||||
| Start-WebAppPool -Name $PoolName | |||||
| function Assert-NoBindingConflict { | |||||
| param($TargetState) | |||||
| $expectedBinding = Get-BindingInformation | |||||
| foreach ($candidate in Get-Website) { | |||||
| if ($TargetState.Exists -and $candidate.Name -eq $SiteName) { continue } | |||||
| foreach ($binding in @($candidate.Bindings.Collection)) { | |||||
| if ($binding.protocol -ne 'http') { continue } | |||||
| if ($binding.bindingInformation -notmatch '^(.*):(\d+):(.*)$') { continue } | |||||
| $candidateIp = $Matches[1] | |||||
| $candidatePort = [int]$Matches[2] | |||||
| $candidateHost = $Matches[3] | |||||
| $ipOverlaps = ($candidateIp -eq '*' -or $candidateIp -eq '0.0.0.0' -or $candidateIp -eq $BindingIpAddress) | |||||
| if ($candidatePort -eq $BindingPort -and $candidateHost -eq $HostHeader -and $ipOverlaps) { | |||||
| throw "Requested binding http/$expectedBinding conflicts with existing site '$($candidate.Name)' binding '$($binding.bindingInformation)'." | |||||
| } | |||||
| } | |||||
| } | } | ||||
| } | } | ||||
| function Invoke-SmokeTest { | |||||
| param([string]$Url) | |||||
| function Get-SmokeUrl { | |||||
| if (-not [string]::IsNullOrWhiteSpace($BaseUrl)) { return $BaseUrl } | |||||
| $hostPart = $BindingIpAddress | |||||
| if ($hostPart.Contains(':')) { $hostPart = '[' + $hostPart + ']' } | |||||
| return 'http://' + $hostPart + ':' + $BindingPort | |||||
| } | |||||
| function Invoke-SmokeTest { | |||||
| if ($SkipSmokeTest) { | if ($SkipSmokeTest) { | ||||
| Write-Step 'Smoke test skipped by explicit request' | Write-Step 'Smoke test skipped by explicit request' | ||||
| return | return | ||||
| } | } | ||||
| if ([string]::IsNullOrWhiteSpace($Url)) { | |||||
| throw 'No HTTP binding was found. Supply -BaseUrl or use -SkipSmokeTest explicitly.' | |||||
| } | |||||
| $target = $Url.TrimEnd('/') + '/' | |||||
| $target = (Get-SmokeUrl).TrimEnd('/') + '/' | |||||
| Write-Step ('Smoke testing ' + $target) | Write-Step ('Smoke testing ' + $target) | ||||
| $response = Invoke-WebRequest -UseBasicParsing -Uri $target -TimeoutSec 30 | |||||
| $headers = @{} | |||||
| if (-not [string]::IsNullOrWhiteSpace($HostHeader)) { $headers['Host'] = $HostHeader } | |||||
| $response = Invoke-WebRequest -UseBasicParsing -Uri $target -Headers $headers -TimeoutSec 30 | |||||
| if ($response.StatusCode -lt 200 -or $response.StatusCode -ge 400) { | if ($response.StatusCode -lt 200 -or $response.StatusCode -ge 400) { | ||||
| throw "Smoke test returned HTTP $($response.StatusCode)" | throw "Smoke test returned HTTP $($response.StatusCode)" | ||||
| } | } | ||||
| Write-Host ('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.' | |||||
| function Set-IisRelease { | |||||
| param([string]$PhysicalPath) | |||||
| Set-ItemProperty -Path ('IIS:\Sites\' + $SiteName) -Name physicalPath -Value $PhysicalPath | |||||
| $poolState = (Get-WebAppPoolState -Name $AppPoolName).Value | |||||
| if ($poolState -eq 'Started') { Restart-WebAppPool -Name $AppPoolName } | |||||
| else { Start-WebAppPool -Name $AppPoolName } | |||||
| } | } | ||||
| 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.' | |||||
| function Restore-ExistingTarget { | |||||
| param($TargetState) | |||||
| if (-not $TargetState.Exists) { return } | |||||
| Set-ItemProperty -Path ('IIS:\Sites\' + $SiteName) -Name physicalPath -Value $TargetState.PhysicalPath | |||||
| Set-WebConfigurationProperty -PSPath 'MACHINE/WEBROOT/APPHOST' -Location $SiteName -Filter 'system.webServer/asp' -Name 'enableParentPaths' -Value $TargetState.ParentPaths | |||||
| if ($TargetState.PoolState -eq 'Started') { | |||||
| if ((Get-WebAppPoolState -Name $AppPoolName).Value -eq 'Started') { Restart-WebAppPool -Name $AppPoolName } | |||||
| else { Start-WebAppPool -Name $AppPoolName } | |||||
| } elseif ((Get-WebAppPoolState -Name $AppPoolName).Value -eq 'Started') { | |||||
| Stop-WebAppPool -Name $AppPoolName | |||||
| } | } | ||||
| 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 ($TargetState.SiteState -eq 'Started') { | |||||
| if ((Get-WebsiteState -Name $SiteName).Value -ne 'Started') { Start-Website -Name $SiteName } | |||||
| } elseif ((Get-WebsiteState -Name $SiteName).Value -eq 'Started') { | |||||
| Stop-Website -Name $SiteName | |||||
| } | } | ||||
| } | } | ||||
| 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.' | |||||
| if ($env:OS -ne 'Windows_NT') { throw 'This script must run on Windows.' } | |||||
| Assert-Administrator | |||||
| Import-Module WebAdministration -ErrorAction Stop | |||||
| Assert-DeploymentInputs | |||||
| $DeployRoot = Get-NormalizedPath $DeployRoot | |||||
| Assert-DeployRoot -Path $DeployRoot | |||||
| if (-not [string]::IsNullOrWhiteSpace($InitialWebConfigPath)) { | |||||
| $preflightInitialConfig = Get-NormalizedPath $InitialWebConfigPath | |||||
| if (-not (Test-Path -LiteralPath $preflightInitialConfig -PathType Leaf)) { | |||||
| throw "InitialWebConfigPath not found: $preflightInitialConfig" | |||||
| } | } | ||||
| } else { | |||||
| Write-Warning 'No Windows feature-query cmdlet is available; Classic ASP feature state could not be preflighted.' | |||||
| try { [xml](Get-Content -LiteralPath $preflightInitialConfig -Raw) | Out-Null } | |||||
| catch { throw "InitialWebConfigPath is not valid XML: $($_.Exception.Message)" } | |||||
| } | } | ||||
| Assert-HostCapabilities | |||||
| $targetState = Get-TargetState | |||||
| Assert-NoBindingConflict -TargetState $targetState | |||||
| $rewriteModule = Get-WebGlobalModule -Name RewriteModule -ErrorAction SilentlyContinue | |||||
| if ($null -eq $rewriteModule) { | |||||
| throw 'IIS URL Rewrite is not installed (RewriteModule was not found).' | |||||
| Write-Step "Dedicated site: $SiteName" | |||||
| Write-Host "Dedicated app pool: $AppPoolName" | |||||
| Write-Host "Binding: http/$(Get-BindingInformation)" | |||||
| Write-Host "Deployment root: $DeployRoot" | |||||
| if ($targetState.Exists) { | |||||
| Write-Host 'Target state: existing and exactly matched' | |||||
| } else { | |||||
| Write-Host 'Target state: absent; eligible for isolated creation after release validation' | |||||
| } | } | ||||
| if ($PreflightOnly -or $DryRun) { | if ($PreflightOnly -or $DryRun) { | ||||
| Write-Step 'Preflight passed; no IIS or filesystem changes were made' | |||||
| Write-Step 'Host preflight passed; no IIS or filesystem changes were made' | |||||
| exit 0 | 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 | |||||
| } | |||||
| $releasesRoot = Join-Path $DeployRoot 'releases' | |||||
| $sharedRoot = Join-Path $DeployRoot 'shared' | |||||
| $sharedConfig = Join-Path $sharedRoot 'public.web.config' | |||||
| $statePath = Join-Path $DeployRoot 'deployment-state.json' | |||||
| if ($PSCmdlet.ParameterSetName -eq 'Rollback') { | if ($PSCmdlet.ParameterSetName -eq 'Rollback') { | ||||
| if (-not $targetState.Exists) { throw 'Rollback requires the dedicated target site and app pool to exist.' } | |||||
| if (-not (Test-Path -LiteralPath $sharedConfig -PathType Leaf)) { throw "Shared configuration is missing: $sharedConfig" } | |||||
| $rollbackRoot = Get-NormalizedPath (Join-Path $releasesRoot $RollbackTo) | $rollbackRoot = Get-NormalizedPath (Join-Path $releasesRoot $RollbackTo) | ||||
| $expectedPrefix = $releasesRoot.TrimEnd('\') + '\' | |||||
| if (-not $rollbackRoot.StartsWith($expectedPrefix, [StringComparison]::OrdinalIgnoreCase)) { | |||||
| throw 'Rollback target escaped the releases directory.' | |||||
| } | |||||
| if (-not (Test-PathUnderRoot -Path $rollbackRoot -Root $releasesRoot)) { throw 'Rollback target escaped the releases directory.' } | |||||
| Assert-ReleaseLayout -ReleasePath $rollbackRoot | 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 { [xml](Get-Content -LiteralPath $sharedConfig -Raw) | Out-Null } | |||||
| catch { throw "Shared configuration is not valid XML: $($_.Exception.Message)" } | |||||
| try { | 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 | |||||
| Set-WebConfigurationProperty -PSPath 'MACHINE/WEBROOT/APPHOST' -Location $SiteName -Filter 'system.webServer/asp' -Name 'enableParentPaths' -Value $true | |||||
| Set-IisRelease -PhysicalPath (Join-Path $rollbackRoot 'public') | |||||
| Invoke-SmokeTest | |||||
| [ordered]@{ | |||||
| siteName = $SiteName; appPoolName = $AppPoolName; currentRelease = $RollbackTo | |||||
| currentPhysicalPath = (Join-Path $rollbackRoot 'public'); previousPhysicalPath = $targetState.PhysicalPath | |||||
| binding = (Get-BindingInformation); switchedAtUtc = (Get-Date).ToUniversalTime().ToString('o'); operation = 'rollback' | |||||
| } | ConvertTo-Json | Set-Content -LiteralPath $statePath -Encoding UTF8 | |||||
| } catch { | } catch { | ||||
| Write-Warning "Rollback smoke test failed; restoring $oldPath" | |||||
| Set-IisRelease -PhysicalPath $oldPath -PoolName $appPool | |||||
| Write-Warning 'Rollback failed; restoring the prior dedicated target path and parent-path setting.' | |||||
| Restore-ExistingTarget -TargetState $targetState | |||||
| throw | 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" | Write-Step "Rollback complete: $RollbackTo" | ||||
| exit 0 | exit 0 | ||||
| } | } | ||||
| if ([string]::IsNullOrWhiteSpace($PackagePath)) { throw '-PackagePath is required for deployment.' } | |||||
| $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 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) | |||||
| } | |||||
| Assert-ArchiveEntries -ZipPath $PackagePath | |||||
| $releaseRoot = Join-Path $releasesRoot $ReleaseId | $releaseRoot = Join-Path $releasesRoot $ReleaseId | ||||
| $stagingRoot = $releaseRoot + '.staging' | $stagingRoot = $releaseRoot + '.staging' | ||||
| if ((Test-Path -LiteralPath $releaseRoot) -or (Test-Path -LiteralPath $stagingRoot)) { | |||||
| throw "Release already exists: $ReleaseId" | |||||
| } | |||||
| if ((Test-Path -LiteralPath $releaseRoot) -or (Test-Path -LiteralPath $stagingRoot)) { throw "Release already exists: $ReleaseId" } | |||||
| $createdSite = $false | |||||
| $createdPool = $false | |||||
| $createdSharedConfig = $false | |||||
| $iisMutationStarted = $false | |||||
| try { | 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 | |||||
| New-Item -ItemType Directory -Force -Path $releasesRoot, $sharedRoot | Out-Null | |||||
| New-Item -ItemType Directory -Force -Path $stagingRoot | Out-Null | |||||
| Expand-Archive -LiteralPath $PackagePath -DestinationPath $stagingRoot -Force | |||||
| $unsafeExtractedItem = Get-ChildItem -LiteralPath $stagingRoot -Recurse -Force | | |||||
| Where-Object { ($_.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 } | | |||||
| Select-Object -First 1 | |||||
| if ($null -ne $unsafeExtractedItem) { | |||||
| throw "Extracted release contains a reparse point: $($unsafeExtractedItem.FullName)" | |||||
| } | } | ||||
| Assert-ReleaseLayout -ReleasePath $stagingRoot | 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 | |||||
| if (-not (Test-Path -LiteralPath $sharedConfig -PathType Leaf)) { | |||||
| $initialConfig = Join-Path $stagingRoot 'public\web.config' | |||||
| if (-not [string]::IsNullOrWhiteSpace($InitialWebConfigPath)) { | |||||
| $initialConfig = Get-NormalizedPath $InitialWebConfigPath | |||||
| if (-not (Test-Path -LiteralPath $initialConfig -PathType Leaf)) { throw "InitialWebConfigPath not found: $initialConfig" } | |||||
| } | |||||
| try { [xml](Get-Content -LiteralPath $initialConfig -Raw) | Out-Null } | |||||
| catch { throw "Initial web.config is not valid XML: $($_.Exception.Message)" } | |||||
| Copy-Item -LiteralPath $initialConfig -Destination $sharedConfig -Force | |||||
| $createdSharedConfig = $true | |||||
| Write-Step "Initialized shared configuration from $initialConfig" | |||||
| } | } | ||||
| try { [xml](Get-Content -LiteralPath $sharedConfig -Raw) | Out-Null } | |||||
| catch { throw "Shared configuration is not valid XML: $($_.Exception.Message)" } | |||||
| Copy-Item -LiteralPath $sharedConfig -Destination (Join-Path $stagingRoot 'public\web.config') -Force | |||||
| Assert-ReleaseLayout -ReleasePath $stagingRoot | Assert-ReleaseLayout -ReleasePath $stagingRoot | ||||
| if ($RunMigrations) { | if ($RunMigrations) { | ||||
| $migrationScript = Join-Path $stagingRoot 'scripts\runMigrations.vbs' | $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.' | |||||
| if (-not (Test-Path -LiteralPath $migrationScript -PathType Leaf)) { throw "Migration script not found: $migrationScript" } | |||||
| Write-Warning 'Running migrations by explicit request; IIS rollback cannot undo data changes.' | |||||
| Push-Location $stagingRoot | Push-Location $stagingRoot | ||||
| try { | try { | ||||
| & cscript.exe //nologo $migrationScript up | & 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 | |||||
| if ($LASTEXITCODE -ne 0) { throw "Migration command exited with code $LASTEXITCODE" } | |||||
| } finally { Pop-Location } | |||||
| } | } | ||||
| Move-Item -LiteralPath $stagingRoot -Destination $releaseRoot | |||||
| $newPublic = Join-Path $releaseRoot 'public' | $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 | |||||
| $iisMutationStarted = $true | |||||
| if (-not $targetState.Exists) { | |||||
| New-WebAppPool -Name $AppPoolName | Out-Null | |||||
| $createdPool = $true | |||||
| Set-ItemProperty -Path ('IIS:\AppPools\' + $AppPoolName) -Name managedRuntimeVersion -Value '' | |||||
| New-Website -Name $SiteName -PhysicalPath $newPublic -ApplicationPool $AppPoolName -IPAddress $BindingIpAddress -Port $BindingPort -HostHeader $HostHeader | Out-Null | |||||
| $createdSite = $true | |||||
| } else { | |||||
| Set-IisRelease -PhysicalPath $newPublic | |||||
| } | } | ||||
| $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' | |||||
| Set-WebConfigurationProperty -PSPath 'MACHINE/WEBROOT/APPHOST' -Location $SiteName -Filter 'system.webServer/asp' -Name 'enableParentPaths' -Value $true | |||||
| if (-not $targetState.Exists) { | |||||
| $poolState = (Get-WebAppPoolState -Name $AppPoolName).Value | |||||
| if ($poolState -ne 'Started') { Start-WebAppPool -Name $AppPoolName } | |||||
| $siteState = (Get-WebsiteState -Name $SiteName).Value | |||||
| if ($siteState -ne 'Started') { Start-Website -Name $SiteName } | |||||
| } | } | ||||
| $state | ConvertTo-Json | Set-Content -LiteralPath $statePath -Encoding UTF8 | |||||
| Invoke-SmokeTest | |||||
| [ordered]@{ | |||||
| siteName = $SiteName; appPoolName = $AppPoolName; currentRelease = $ReleaseId | |||||
| currentPhysicalPath = $newPublic; previousPhysicalPath = $targetState.PhysicalPath | |||||
| binding = (Get-BindingInformation); packageSha256 = (Get-FileHash -LiteralPath $PackagePath -Algorithm SHA256).Hash | |||||
| switchedAtUtc = (Get-Date).ToUniversalTime().ToString('o'); migrationsRun = [bool]$RunMigrations; operation = 'deploy' | |||||
| } | ConvertTo-Json | Set-Content -LiteralPath $statePath -Encoding UTF8 | |||||
| $protectedPaths = @($newPublic, $oldPath) | |||||
| $protectedPaths = @($newPublic, $targetState.PhysicalPath) | |||||
| $oldReleases = Get-ChildItem -LiteralPath $releasesRoot -Directory | | $oldReleases = Get-ChildItem -LiteralPath $releasesRoot -Directory | | ||||
| Where-Object { $_.Name -notlike '*.staging' } | | Where-Object { $_.Name -notlike '*.staging' } | | ||||
| Sort-Object LastWriteTimeUtc -Descending | | Sort-Object LastWriteTimeUtc -Descending | | ||||
| Select-Object -Skip $KeepReleases | Select-Object -Skip $KeepReleases | ||||
| foreach ($oldRelease in $oldReleases) { | foreach ($oldRelease in $oldReleases) { | ||||
| $oldPublic = Join-Path $oldRelease.FullName 'public' | $oldPublic = Join-Path $oldRelease.FullName 'public' | ||||
| if ($protectedPaths -notcontains $oldPublic) { | |||||
| Write-Step ('Retention candidate (not deleted automatically): ' + $oldRelease.FullName) | |||||
| } | |||||
| if ($protectedPaths -notcontains $oldPublic) { Write-Step ('Retention candidate (not deleted): ' + $oldRelease.FullName) } | |||||
| } | } | ||||
| Write-Step "Deployment complete: $ReleaseId" | Write-Step "Deployment complete: $ReleaseId" | ||||
| Write-Host "Rollback command: .\install-iis-release.ps1 -SiteName '$SiteName' -RollbackTo '<release-id>'" | |||||
| } catch { | } catch { | ||||
| if (Test-Path -LiteralPath $stagingRoot) { | |||||
| Write-Warning "Incomplete staging directory retained for inspection: $stagingRoot" | |||||
| $failure = $_ | |||||
| if ($iisMutationStarted) { | |||||
| if ($createdSite -and (Get-Website -Name $SiteName -ErrorAction SilentlyContinue)) { | |||||
| Write-Warning "Removing site created by this invocation: $SiteName" | |||||
| Remove-Website -Name $SiteName | |||||
| } | |||||
| if ($createdPool -and (Test-Path -LiteralPath ('IIS:\AppPools\' + $AppPoolName))) { | |||||
| Write-Warning "Removing app pool created by this invocation: $AppPoolName" | |||||
| Remove-WebAppPool -Name $AppPoolName | |||||
| } | |||||
| if ($targetState.Exists) { | |||||
| Write-Warning 'Restoring the prior dedicated target state.' | |||||
| Restore-ExistingTarget -TargetState $targetState | |||||
| } | |||||
| } | |||||
| if ($createdSharedConfig -and (Test-Path -LiteralPath $sharedConfig -PathType Leaf)) { | |||||
| Write-Warning 'Removing shared configuration initialized by this failed invocation.' | |||||
| Remove-Item -LiteralPath $sharedConfig -Force | |||||
| } | } | ||||
| throw | |||||
| if (Test-Path -LiteralPath $stagingRoot) { Write-Warning "Incomplete staging retained for inspection: $stagingRoot" } | |||||
| throw $failure | |||||
| } | } | ||||
Powered by TurnKey Linux.