|
- <#
- Runs ON the IIS server (invoked over SSH by scripts\deploy-iis.ps1). Not meant to be run
- by hand except for troubleshooting a stuck deploy.
-
- - Stops the site/app pool
- - Wipes RemoteDir and re-extracts the release zip into it (safe: the DB and error log
- live outside RemoteDir, at DbPath/ErrorLogDir, so a full wipe never touches them)
- - Points IIS at RemoteDir\public
- - Ensures the app pool identity has modify rights on the persistent data folder
- - Runs pending migrations (32-bit cscript - ACE OLEDB is x86-only)
- - Restarts the site/app pool
- #>
-
- param(
- [Parameter(Mandatory = $true)][string]$ZipPath,
- [Parameter(Mandatory = $true)][string]$RemoteDir,
- [Parameter(Mandatory = $true)][string]$SiteName,
- [Parameter(Mandatory = $true)][string]$AppPool,
- [Parameter(Mandatory = $true)][string]$DbPath,
- [string]$AdminSiteName = '',
- [string]$AdminAppPool = '',
- [switch]$RunMigrations = $true
- )
-
- $ErrorActionPreference = 'Stop'
- Import-Module WebAdministration
-
- # Stop-WebAppPool only requests a stop - the w3wp.exe worker process can keep running for a
- # few seconds afterwards (finishing in-flight requests), still holding the site's files open.
- # Wait for the pool to actually report Stopped, then force-kill any worker process that's
- # still lingering past the timeout so the wipe below doesn't hit "Access is denied".
- function Wait-AppPoolFullyStopped {
- param([string]$PoolName, [int]$TimeoutSec = 30)
-
- if([string]::IsNullOrWhiteSpace($PoolName)){ return }
-
- $deadline = (Get-Date).AddSeconds($TimeoutSec)
- while((Get-Date) -lt $deadline){
- if((Get-WebAppPoolState -Name $PoolName -ErrorAction SilentlyContinue).Value -eq 'Stopped'){
- return
- }
- Start-Sleep -Milliseconds 500
- }
-
- Write-Host "App pool $PoolName did not report Stopped within ${TimeoutSec}s - killing any lingering worker process"
- Get-CimInstance Win32_Process -Filter "Name = 'w3wp.exe'" -ErrorAction SilentlyContinue |
- Where-Object { $_.CommandLine -like ('*' + $PoolName + '*') } |
- ForEach-Object {
- try { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue } catch { }
- }
- }
-
- # Wipes are attempted a few times with a short pause - file handles (IIS or AV scanning
- # the just-stopped worker process's files) can take a moment to release even after the
- # worker process itself is confirmed gone.
- function Remove-DirectoryContentsWithRetry {
- param([string]$Path, [int]$MaxAttempts = 5, [int]$DelayMs = 2000)
-
- for($attempt = 1; $attempt -le $MaxAttempts; $attempt++){
- $failure = $null
- try {
- Remove-Item -Recurse -Force $Path -ErrorAction Stop
- return
- } catch {
- $failure = $_
- if($attempt -lt $MaxAttempts){
- Write-Host "Wipe attempt $attempt failed (files still locked?) - retrying in $($DelayMs)ms"
- Start-Sleep -Milliseconds $DelayMs
- }
- }
- }
- throw $failure
- }
-
- if(!(Get-Website -Name $SiteName -ErrorAction SilentlyContinue)){
- throw "IIS site '$SiteName' does not exist yet. Create the site and app pool once manually (or via a one-time setup script) before running this deploy."
- }
- if(!(Test-Path ('IIS:\AppPools\' + $AppPool))){
- throw "App pool '$AppPool' does not exist yet. Create it once manually before running this deploy."
- }
-
- Write-Host "Stopping IIS site $SiteName and app pool $AppPool"
- try { Stop-Website -Name $SiteName } catch { }
- try { Stop-WebAppPool -Name $AppPool } catch { }
- Wait-AppPoolFullyStopped -PoolName $AppPool
-
- # The admin site's physical path may already point inside RemoteDir (e.g. RemoteDir\public-admin)
- # from a previous deploy - if it's left running, IIS holds those files open and the wipe below
- # fails with "Access is denied". Stop it up front, alongside the public site, not after.
- if(![string]::IsNullOrWhiteSpace($AdminSiteName) -and (Get-Website -Name $AdminSiteName -ErrorAction SilentlyContinue)){
- Write-Host "Stopping IIS admin site $AdminSiteName"
- try { Stop-Website -Name $AdminSiteName } catch { }
- if(![string]::IsNullOrWhiteSpace($AdminAppPool)){
- try { Stop-WebAppPool -Name $AdminAppPool } catch { }
- Wait-AppPoolFullyStopped -PoolName $AdminAppPool
- }
- }
-
- if(Test-Path $RemoteDir){
- Write-Host "Wiping $RemoteDir"
- Remove-DirectoryContentsWithRetry -Path (Join-Path $RemoteDir '*')
- } else {
- New-Item -ItemType Directory -Force -Path $RemoteDir | Out-Null
- }
-
- Write-Host "Extracting $ZipPath -> $RemoteDir"
- Expand-Archive -Path $ZipPath -DestinationPath $RemoteDir -Force
- Remove-Item $ZipPath -ErrorAction SilentlyContinue
-
- $publicDir = Join-Path $RemoteDir 'public'
- if(!(Test-Path $publicDir)){
- throw "No public\ folder in the extracted release - deploy aborted, site left stopped for inspection."
- }
-
- # Persistent data folder (DB + error log) - lives outside RemoteDir so it survives the wipe
- # above. Create it and grant the app pool identity modify rights.
- $dataDir = Split-Path $DbPath -Parent
- if(!(Test-Path $dataDir)){
- New-Item -ItemType Directory -Force -Path $dataDir | Out-Null
- }
- $logDir = Join-Path $dataDir 'logs'
- if(!(Test-Path $logDir)){
- New-Item -ItemType Directory -Force -Path $logDir | Out-Null
- }
- icacls $dataDir /grant ("IIS AppPool\" + $AppPool + ":(OI)(CI)(M)") /T | Out-Null
-
- Set-ItemProperty ('IIS:\Sites\' + $SiteName) -Name physicalPath -Value $publicDir
- Set-ItemProperty ('IIS:\Sites\' + $SiteName) -Name applicationPool -Value $AppPool
-
- # This server has the 64-bit Access Database Engine (ACE OLEDB) installed, unlike the local
- # dev machine, which needs the 32-bit one - so the app pool stays 64-bit here.
- Set-ItemProperty ('IIS:\AppPools\' + $AppPool) -Name enable32BitAppOnWin64 -Value $false
-
- # --- Admin site ---
- $adminPublicDir = Join-Path $RemoteDir 'public-admin'
- if(![string]::IsNullOrWhiteSpace($AdminSiteName) -and (Test-Path $adminPublicDir)){
- if(!(Get-Website -Name $AdminSiteName -ErrorAction SilentlyContinue)){
- throw "IIS admin site '$AdminSiteName' does not exist yet. Create the site and app pool once manually before running this deploy."
- }
- Write-Host "Configuring admin site $AdminSiteName"
- if(![string]::IsNullOrWhiteSpace($AdminAppPool)){
- Set-ItemProperty ('IIS:\Sites\' + $AdminSiteName) -Name applicationPool -Value $AdminAppPool
- Set-ItemProperty ('IIS:\AppPools\' + $AdminAppPool) -Name enable32BitAppOnWin64 -Value $false
- icacls $dataDir /grant ("IIS AppPool\" + $AdminAppPool + ":(OI)(CI)(M)") /T | Out-Null
- }
- Set-ItemProperty ('IIS:\Sites\' + $AdminSiteName) -Name physicalPath -Value $adminPublicDir
- }
-
- if($RunMigrations){
- Write-Host 'Running pending migrations'
- Push-Location $RemoteDir
- try {
- & cscript.exe //nologo scripts\runMigrations.vbs up
- if($LASTEXITCODE -ne 0){ throw 'runMigrations.vbs up failed - see output above' }
- } finally {
- Pop-Location
- }
- }
-
- Write-Host "Starting IIS site $SiteName and app pool $AppPool"
- if((Get-WebAppPoolState -Name $AppPool).Value -eq 'Started'){
- Restart-WebAppPool -Name $AppPool
- } else {
- Start-WebAppPool -Name $AppPool
- }
- Start-Website $SiteName
-
- if(![string]::IsNullOrWhiteSpace($AdminSiteName)){
- if(![string]::IsNullOrWhiteSpace($AdminAppPool)){
- if((Get-WebAppPoolState -Name $AdminAppPool).Value -eq 'Started'){
- Restart-WebAppPool -Name $AdminAppPool
- } else {
- Start-WebAppPool -Name $AdminAppPool
- }
- }
- Start-Website $AdminSiteName
- }
-
- Write-Host 'Remote apply complete.'
|