diff --git a/scripts/install-iis-release.ps1 b/scripts/install-iis-release.ps1 index eea0ed7..acb599d 100644 --- a/scripts/install-iis-release.ps1 +++ b/scripts/install-iis-release.ps1 @@ -1,34 +1,38 @@ <# .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 - 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 \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( - [Parameter(Mandatory = $true)] [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')] [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, @@ -42,25 +46,7 @@ param( 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 Write-Step { param([string]$Message) Write-Host ('==> ' + $Message) } function Get-NormalizedPath { 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 { param([string]$ReleasePath) - $required = @( 'public\Default.asp', 'public\web.config', 'core\autoload_core.asp', - 'app\controllers\autoload_controllers.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 { - [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) { 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('/') + '/' + $target = (Get-SmokeUrl).TrimEnd('/') + '/' 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) { 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.' +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) { - 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 } -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 (-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) - $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 - $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 { - 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 { - 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 } - - $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 } +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" -} +if ((Test-Path -LiteralPath $releaseRoot) -or (Test-Path -LiteralPath $stagingRoot)) { throw "Release already exists: $ReleaseId" } +$createdSite = $false +$createdPool = $false +$createdSharedConfig = $false +$iisMutationStarted = $false 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 - 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 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.' + 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 - } - } - - 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' - $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 | 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) - } + if ($protectedPaths -notcontains $oldPublic) { Write-Step ('Retention candidate (not deleted): ' + $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" + $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 }