Bläddra i källkod

ci: add host-side IIS release installer

ci-iis-release-pipeline
Clawdbot 7 timmar sedan
förälder
incheckning
bf01b2d3fb
1 ändrade filer med 402 tillägg och 0 borttagningar
  1. +402
    -0
      scripts/install-iis-release.ps1

+ 402
- 0
scripts/install-iis-release.ps1 Visa fil

@@ -0,0 +1,402 @@
<#
.SYNOPSIS
Installs, validates, switches, or rolls back an immutable IIS release.

.DESCRIPTION
Run this script in an elevated Windows PowerShell 5.1 session on the IIS host.
A release contains the full repository, while IIS is pointed only at its public
directory. The current site's public\web.config is captured once in shared\
and copied into every new release so host-specific values are not overwritten.

No database migration runs unless -RunMigrations is explicitly supplied.
#>

[CmdletBinding(DefaultParameterSetName = 'Deploy')]
param(
[Parameter(Mandatory = $true)]
[ValidatePattern('^[A-Za-z0-9_. -]+$')]
[string]$SiteName,

[Parameter(ParameterSetName = 'Deploy')]
[string]$PackagePath = '',

[Parameter(ParameterSetName = 'Deploy')]
[ValidatePattern('^[A-Za-z0-9._-]+$')]
[string]$ReleaseId = (Get-Date -Format 'yyyyMMdd-HHmmss'),

[Parameter(Mandatory = $true, ParameterSetName = 'Rollback')]
[ValidatePattern('^[A-Za-z0-9._-]+$')]
[string]$RollbackTo,

[string]$DeployRoot = '',
[string]$BaseUrl = '',
[ValidateRange(2, 100)]
[int]$KeepReleases = 5,
[string]$ExpectedSha256 = '',
[switch]$RunMigrations,
[switch]$SkipSmokeTest,
[switch]$PreflightOnly,
[switch]$DryRun
)

Set-StrictMode -Version 2.0
$ErrorActionPreference = 'Stop'

function Write-Step {
param([string]$Message)
Write-Host ('==> ' + $Message)
}

function Invoke-Change {
param(
[string]$Description,
[scriptblock]$Action
)

if ($DryRun) {
Write-Host ('DRY-RUN: ' + $Description)
return
}

Write-Step $Description
& $Action
}

function Get-NormalizedPath {
param([string]$Path)
return [System.IO.Path]::GetFullPath([Environment]::ExpandEnvironmentVariables($Path)).TrimEnd('\')
}

function Assert-Administrator {
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = New-Object Security.Principal.WindowsPrincipal($identity)
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
throw 'An elevated Administrator PowerShell session is required.'
}
}

function Assert-ReleaseLayout {
param([string]$ReleasePath)

$required = @(
'public\Default.asp',
'public\web.config',
'core\autoload_core.asp',
'app\controllers\autoload_controllers.asp'
)

foreach ($relativePath in $required) {
if (-not (Test-Path -LiteralPath (Join-Path $ReleasePath $relativePath) -PathType Leaf)) {
throw "Release is incomplete; missing $relativePath"
}
}

try {
[xml](Get-Content -LiteralPath (Join-Path $ReleasePath 'public\web.config') -Raw) | Out-Null
} catch {
throw "Release public\web.config is not valid XML: $($_.Exception.Message)"
}
}

function Get-LocalBaseUrl {
param($Site)

$binding = $Site.Bindings.Collection |
Where-Object { $_.protocol -eq 'http' } |
Select-Object -First 1

if ($null -eq $binding) {
return ''
}

$parts = $binding.bindingInformation.Split(':')
$port = $parts[1]
if ([string]::IsNullOrWhiteSpace($port)) {
$port = '80'
}

return 'http://127.0.0.1:' + $port
}

function Set-IisRelease {
param(
[string]$PhysicalPath,
[string]$PoolName
)

Set-ItemProperty -Path ('IIS:\Sites\' + $SiteName) -Name physicalPath -Value $PhysicalPath

$poolState = (Get-WebAppPoolState -Name $PoolName).Value
if ($poolState -eq 'Started') {
Restart-WebAppPool -Name $PoolName
} else {
Start-WebAppPool -Name $PoolName
}
}

function Invoke-SmokeTest {
param([string]$Url)

if ($SkipSmokeTest) {
Write-Step 'Smoke test skipped by explicit request'
return
}

if ([string]::IsNullOrWhiteSpace($Url)) {
throw 'No HTTP binding was found. Supply -BaseUrl or use -SkipSmokeTest explicitly.'
}

$target = $Url.TrimEnd('/') + '/'
Write-Step ('Smoke testing ' + $target)
$response = Invoke-WebRequest -UseBasicParsing -Uri $target -TimeoutSec 30
if ($response.StatusCode -lt 200 -or $response.StatusCode -ge 400) {
throw "Smoke test returned HTTP $($response.StatusCode)"
}
Write-Host ('Smoke test returned HTTP ' + $response.StatusCode)
}

if ($env:OS -ne 'Windows_NT') {
throw 'This script must run on Windows.'
}

Assert-Administrator
Import-Module WebAdministration -ErrorAction Stop

$site = Get-Website -Name $SiteName -ErrorAction Stop
if ($null -eq $site) {
throw "IIS site not found: $SiteName"
}

$appPool = $site.applicationPool
if ([string]::IsNullOrWhiteSpace($appPool)) {
throw "IIS site $SiteName has no application pool."
}

if ([string]::IsNullOrWhiteSpace($DeployRoot)) {
$DeployRoot = Join-Path $env:SystemDrive ('inetpub\deployments\' + $SiteName)
}
$DeployRoot = Get-NormalizedPath $DeployRoot
$releasesRoot = Join-Path $DeployRoot 'releases'
$sharedRoot = Join-Path $DeployRoot 'shared'
$sharedConfig = Join-Path $sharedRoot 'public.web.config'
$statePath = Join-Path $DeployRoot 'deployment-state.json'
$currentPhysicalPath = Get-NormalizedPath $site.physicalPath
$currentConfig = Join-Path $currentPhysicalPath 'web.config'

Write-Step "Site: $SiteName"
Write-Host "App pool: $appPool"
Write-Host "Current physicalPath: $currentPhysicalPath"
Write-Host "Deployment root: $DeployRoot"
Write-Host 'Classic ASP parent paths will be set at the site location in applicationHost.config.'
Write-Host 'Database migrations are disabled unless -RunMigrations is supplied.'

if ($PSCmdlet.ParameterSetName -eq 'Deploy' -and
(-not $PreflightOnly) -and
(-not $DryRun) -and
[string]::IsNullOrWhiteSpace($PackagePath)) {
throw '-PackagePath is required for a deployment.'
}

if (-not [string]::IsNullOrWhiteSpace($PackagePath)) {
$PackagePath = Get-NormalizedPath $PackagePath
if (-not (Test-Path -LiteralPath $PackagePath -PathType Leaf)) {
throw "Package not found: $PackagePath"
}
if ([System.IO.Path]::GetExtension($PackagePath) -ne '.zip') {
throw 'PackagePath must name a .zip release package.'
}

if (-not [string]::IsNullOrWhiteSpace($ExpectedSha256)) {
$actualHash = (Get-FileHash -LiteralPath $PackagePath -Algorithm SHA256).Hash
if ($actualHash -ne $ExpectedSha256) {
throw "Package SHA-256 mismatch. Expected $ExpectedSha256; got $actualHash"
}
Write-Host ('Package SHA-256 verified: ' + $actualHash)
}
}

if ((-not (Test-Path -LiteralPath $sharedConfig -PathType Leaf)) -and
(-not (Test-Path -LiteralPath $currentConfig -PathType Leaf))) {
throw "Cannot preserve machine configuration: neither $sharedConfig nor $currentConfig exists."
}

$getWindowsFeature = Get-Command Get-WindowsFeature -ErrorAction SilentlyContinue
$getOptionalFeature = Get-Command Get-WindowsOptionalFeature -ErrorAction SilentlyContinue
if ($null -ne $getWindowsFeature) {
$aspFeature = Get-WindowsFeature -Name Web-ASP
if ($null -eq $aspFeature -or -not $aspFeature.Installed) {
throw 'The IIS Classic ASP feature (Web-ASP) is not installed.'
}
} elseif ($null -ne $getOptionalFeature) {
$aspFeature = Get-WindowsOptionalFeature -Online -FeatureName IIS-ASP -ErrorAction SilentlyContinue
if ($null -ne $aspFeature -and $aspFeature.State -ne 'Enabled') {
throw 'The IIS-ASP Windows feature is not enabled.'
}
} else {
Write-Warning 'No Windows feature-query cmdlet is available; Classic ASP feature state could not be preflighted.'
}

$rewriteModule = Get-WebGlobalModule -Name RewriteModule -ErrorAction SilentlyContinue
if ($null -eq $rewriteModule) {
throw 'IIS URL Rewrite is not installed (RewriteModule was not found).'
}

if ($PreflightOnly -or $DryRun) {
Write-Step 'Preflight passed; no IIS or filesystem changes were made'
exit 0
}

Invoke-Change "Create deployment directories under $DeployRoot" {
New-Item -ItemType Directory -Force -Path $releasesRoot, $sharedRoot | Out-Null
}

if (-not (Test-Path -LiteralPath $sharedConfig -PathType Leaf)) {
Invoke-Change "Capture machine-specific configuration from $currentConfig" {
Copy-Item -LiteralPath $currentConfig -Destination $sharedConfig -Force
}
}

try {
[xml](Get-Content -LiteralPath $sharedConfig -Raw) | Out-Null
} catch {
throw "Preserved configuration is not valid XML: $($_.Exception.Message)"
}

Invoke-Change 'Enable Classic ASP parent paths explicitly for this IIS site' {
Set-WebConfigurationProperty `
-PSPath 'MACHINE/WEBROOT/APPHOST' `
-Location $SiteName `
-Filter 'system.webServer/asp' `
-Name 'enableParentPaths' `
-Value $true
}

if ($PSCmdlet.ParameterSetName -eq 'Rollback') {
$rollbackRoot = Get-NormalizedPath (Join-Path $releasesRoot $RollbackTo)
$expectedPrefix = $releasesRoot.TrimEnd('\') + '\'
if (-not $rollbackRoot.StartsWith($expectedPrefix, [StringComparison]::OrdinalIgnoreCase)) {
throw 'Rollback target escaped the releases directory.'
}
Assert-ReleaseLayout -ReleasePath $rollbackRoot
$rollbackPublic = Join-Path $rollbackRoot 'public'

Invoke-Change "Refresh preserved web.config in rollback release $RollbackTo" {
Copy-Item -LiteralPath $sharedConfig -Destination (Join-Path $rollbackPublic 'web.config') -Force
}

$oldPath = $currentPhysicalPath
try {
Invoke-Change "Switch IIS physicalPath to rollback release $rollbackPublic" {
Set-IisRelease -PhysicalPath $rollbackPublic -PoolName $appPool
}
if ([string]::IsNullOrWhiteSpace($BaseUrl)) {
$BaseUrl = Get-LocalBaseUrl -Site $site
}
Invoke-SmokeTest -Url $BaseUrl
} catch {
Write-Warning "Rollback smoke test failed; restoring $oldPath"
Set-IisRelease -PhysicalPath $oldPath -PoolName $appPool
throw
}

$rollbackState = [ordered]@{
siteName = $SiteName
currentRelease = $RollbackTo
currentPhysicalPath = $rollbackPublic
previousPhysicalPath = $oldPath
switchedAtUtc = (Get-Date).ToUniversalTime().ToString('o')
operation = 'rollback'
}
$rollbackState | ConvertTo-Json | Set-Content -LiteralPath $statePath -Encoding UTF8
Write-Step "Rollback complete: $RollbackTo"
exit 0
}

$releaseRoot = Join-Path $releasesRoot $ReleaseId
$stagingRoot = $releaseRoot + '.staging'
if ((Test-Path -LiteralPath $releaseRoot) -or (Test-Path -LiteralPath $stagingRoot)) {
throw "Release already exists: $ReleaseId"
}

try {
Invoke-Change "Extract package into staging directory $stagingRoot" {
New-Item -ItemType Directory -Force -Path $stagingRoot | Out-Null
Expand-Archive -LiteralPath $PackagePath -DestinationPath $stagingRoot -Force
}
Assert-ReleaseLayout -ReleasePath $stagingRoot

Invoke-Change 'Overlay the preserved machine-specific public\web.config' {
Copy-Item -LiteralPath $sharedConfig -Destination (Join-Path $stagingRoot 'public\web.config') -Force
}
Assert-ReleaseLayout -ReleasePath $stagingRoot

if ($RunMigrations) {
$migrationScript = Join-Path $stagingRoot 'scripts\runMigrations.vbs'
if (-not (Test-Path -LiteralPath $migrationScript -PathType Leaf)) {
throw "Migration script not found: $migrationScript"
}
Write-Warning 'Running production migrations by explicit request. IIS rollback will not undo database changes.'
Push-Location $stagingRoot
try {
& cscript.exe //nologo $migrationScript up
if ($LASTEXITCODE -ne 0) {
throw "Migration command exited with code $LASTEXITCODE"
}
} finally {
Pop-Location
}
}

Invoke-Change "Promote staging directory to immutable release $releaseRoot" {
Move-Item -LiteralPath $stagingRoot -Destination $releaseRoot
}

$newPublic = Join-Path $releaseRoot 'public'
$oldPath = $currentPhysicalPath
try {
Invoke-Change "Atomically switch IIS physicalPath to $newPublic" {
Set-IisRelease -PhysicalPath $newPublic -PoolName $appPool
}
if ([string]::IsNullOrWhiteSpace($BaseUrl)) {
$BaseUrl = Get-LocalBaseUrl -Site $site
}
Invoke-SmokeTest -Url $BaseUrl
} catch {
Write-Warning "Deployment smoke test failed; restoring $oldPath"
Set-IisRelease -PhysicalPath $oldPath -PoolName $appPool
throw
}

$state = [ordered]@{
siteName = $SiteName
currentRelease = $ReleaseId
currentPhysicalPath = $newPublic
previousPhysicalPath = $oldPath
packageSha256 = (Get-FileHash -LiteralPath $PackagePath -Algorithm SHA256).Hash
switchedAtUtc = (Get-Date).ToUniversalTime().ToString('o')
migrationsRun = [bool]$RunMigrations
operation = 'deploy'
}
$state | ConvertTo-Json | Set-Content -LiteralPath $statePath -Encoding UTF8

$protectedPaths = @($newPublic, $oldPath)
$oldReleases = Get-ChildItem -LiteralPath $releasesRoot -Directory |
Where-Object { $_.Name -notlike '*.staging' } |
Sort-Object LastWriteTimeUtc -Descending |
Select-Object -Skip $KeepReleases

foreach ($oldRelease in $oldReleases) {
$oldPublic = Join-Path $oldRelease.FullName 'public'
if ($protectedPaths -notcontains $oldPublic) {
Write-Step ('Retention candidate (not deleted automatically): ' + $oldRelease.FullName)
}
}

Write-Step "Deployment complete: $ReleaseId"
Write-Host "Rollback command: .\install-iis-release.ps1 -SiteName '$SiteName' -RollbackTo '<release-id>'"
} catch {
if (Test-Path -LiteralPath $stagingRoot) {
Write-Warning "Incomplete staging directory retained for inspection: $stagingRoot"
}
throw
}

Laddar…
Avbryt
Spara

Powered by TurnKey Linux.