<# .SYNOPSIS Installs or rolls back an immutable release in a dedicated IIS site/app pool. .DESCRIPTION The complete repository is retained in each immutable release and IIS serves only \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')] param( [ValidatePattern('^[A-Za-z0-9_. -]+$')] [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')] [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]$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 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-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 { param([string]$ReleasePath) $required = @( 'public\Default.asp', 'public\web.config', 'core\autoload_core.asp', 'app\controllers\autoload_controllers.asp', 'scripts\install-iis-release.ps1' ) foreach ($relativePath in $required) { if (-not (Test-Path -LiteralPath (Join-Path $ReleasePath $relativePath) -PathType Leaf)) { 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 { 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-BindingInformation { return $BindingIpAddress + ':' + $BindingPort + ':' + $HostHeader } 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.' } 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).' } } 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 Test-PathUnderRoot { param([string]$Path, [string]$Root) $normalizedPath = Get-NormalizedPath $Path $prefix = (Get-NormalizedPath $Root) + '\' return $normalizedPath.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase) } 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 } } 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 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) { Write-Step 'Smoke test skipped by explicit request' return } $target = (Get-SmokeUrl).TrimEnd('/') + '/' Write-Step ('Smoke testing ' + $target) $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) { throw "Smoke test returned HTTP $($response.StatusCode)" } Write-Host ('Smoke test returned HTTP ' + $response.StatusCode) } 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 } } 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 ($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 ($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" } 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 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) { Write-Step 'Host preflight passed; no IIS or filesystem changes were made' exit 0 } $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 (-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) if (-not (Test-PathUnderRoot -Path $rollbackRoot -Root $releasesRoot)) { throw 'Rollback target escaped the releases directory.' } Assert-ReleaseLayout -ReleasePath $rollbackRoot try { [xml](Get-Content -LiteralPath $sharedConfig -Raw) | Out-Null } catch { throw "Shared configuration is not valid XML: $($_.Exception.Message)" } try { 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 { Write-Warning 'Rollback failed; restoring the prior dedicated target path and parent-path setting.' Restore-ExistingTarget -TargetState $targetState throw } Write-Step "Rollback complete: $RollbackTo" 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 $stagingRoot = $releaseRoot + '.staging' if ((Test-Path -LiteralPath $releaseRoot) -or (Test-Path -LiteralPath $stagingRoot)) { throw "Release already exists: $ReleaseId" } $createdSite = $false $createdPool = $false $createdSharedConfig = $false $iisMutationStarted = $false try { 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 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 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 migrations by explicit request; IIS rollback cannot undo data changes.' Push-Location $stagingRoot try { & cscript.exe //nologo $migrationScript up 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' $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 } 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 } } 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, $targetState.PhysicalPath) $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): ' + $oldRelease.FullName) } } Write-Step "Deployment complete: $ReleaseId" } catch { $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 } if (Test-Path -LiteralPath $stagingRoot) { Write-Warning "Incomplete staging retained for inspection: $stagingRoot" } throw $failure }