瀏覽代碼

ci: isolate deployment from existing IIS sites

ci-iis-release-pipeline
Clawdbot 4 小時之前
父節點
當前提交
18a1defdf9
共有 1 個檔案被更改,包括 331 行新增256 行删除
  1. +331
    -256
      scripts/install-iis-release.ps1

+ 331
- 256
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 <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')]
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 '<release-id>'"
} 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
}

Loading…
取消
儲存

Powered by TurnKey Linux.