<# 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. # # Remove-Item returning without an error is not proof the directory is actually empty yet - # NTFS can finish a large recursive delete asynchronously, so a Get-ChildItem right after can # still show remnants. If Expand-Archive -Force then runs against those remnants, it queues # them for removal itself and can lose the race against our own delete finishing, failing with # "Cannot find path ... because it does not exist". So after Remove-Item reports success, poll # until the directory is verifiably empty before trusting the wipe is done. function Remove-DirectoryContentsWithRetry { param([string]$DirPath, [int]$MaxAttempts = 5, [int]$DelayMs = 2000) for($attempt = 1; $attempt -le $MaxAttempts; $attempt++){ $problem = $null try { Remove-Item -Recurse -Force (Join-Path $DirPath '*') -ErrorAction Stop } catch { $problem = "delete failed: $($_.Exception.Message)" } if(!$problem){ if(!(Get-ChildItem -Force -LiteralPath $DirPath -ErrorAction SilentlyContinue)){ return } $problem = 'residual files still present after delete (async NTFS delete still finishing?)' } if($attempt -lt $MaxAttempts){ Write-Host "Wipe attempt $attempt failed ($problem) - retrying in $($DelayMs)ms" Start-Sleep -Milliseconds $DelayMs } else { throw "Wipe of $DirPath did not complete after $MaxAttempts attempts - $problem" } } } 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." } if(![string]::IsNullOrWhiteSpace($AdminSiteName)){ 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." } if(![string]::IsNullOrWhiteSpace($AdminAppPool) -and !(Test-Path ('IIS:\AppPools\' + $AdminAppPool))){ throw "Admin app pool '$AdminAppPool' 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 -DirPath $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)){ 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 } } # Start-WebAppPool/Start-Website are no-ops (no error) if already started, so there's no # need to check current state first - and checking first (via Get-WebAppPoolState) is what # was silently killing this whole block: with $ErrorActionPreference = 'Stop', a transient # error from that check (e.g. right after Wait-AppPoolFullyStopped had to force-kill a # lingering worker process) aborted the script before the admin site/pool were ever started. # Each start below is independently wrapped so a problem with one site doesn't prevent the # other from starting, and every failure is reported instead of aborting silently. function Start-SiteAndPool { param([string]$PoolName, [string]$WebsiteName) if(![string]::IsNullOrWhiteSpace($PoolName)){ try { Start-WebAppPool -Name $PoolName -ErrorAction Stop Write-Host "Started app pool $PoolName" } catch { Write-Host "WARNING: failed to start app pool $PoolName : $($_.Exception.Message)" } } if(![string]::IsNullOrWhiteSpace($WebsiteName)){ try { Start-Website -Name $WebsiteName -ErrorAction Stop Write-Host "Started site $WebsiteName" } catch { Write-Host "WARNING: failed to start site $WebsiteName : $($_.Exception.Message)" } } } Start-SiteAndPool -PoolName $AppPool -WebsiteName $SiteName Start-SiteAndPool -PoolName $AdminAppPool -WebsiteName $AdminSiteName Write-Host 'Remote apply complete.'