<# .SYNOPSIS Validates, packages, and deploys this repository to its dedicated IIS site. .DESCRIPTION The controller packages the complete repository, computes SHA-256, transfers the package and host installer over OpenSSH, and invokes the installer. -LocalPreflightOnly validates source provenance, XML, package layout, and archive safety without connecting. -PreflightOnly additionally streams the installer to the host and runs its read-only IIS preflight without writing a remote installer or package. #> [CmdletBinding()] param( [ValidatePattern('^[A-Za-z0-9_. -]+$')] [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 = '', [string]$RemoteTarget = 'webserver-1', [ValidateRange(1, 65535)] [int]$RemotePort = 22, [string]$SourcePath = (Split-Path $PSScriptRoot -Parent), [string]$BaseUrl = '', [string]$ReleaseId = '', [string]$ExpectedBranch = 'master', [ValidateRange(2, 100)] [int]$KeepReleases = 5, [string]$SshExe = 'ssh', [string]$ScpExe = 'scp', [switch]$AllowAnyBranch, [switch]$AllowDirty, [switch]$RunMigrations, [switch]$SkipSmokeTest, [Alias('HostPreflightOnly', 'RemotePreflightOnly')] [switch]$PreflightOnly, [switch]$LocalPreflightOnly, [switch]$Rollback, [string]$RollbackTo = '', [switch]$DryRun, [switch]$KeepPackage ) Set-StrictMode -Version 2.0 $ErrorActionPreference = 'Stop' function Write-Step { param([string]$Message) Write-Host ('==> ' + $Message) } function Assert-Command { param([string]$Name) if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) { throw "$Name was not found on PATH." } } function ConvertTo-SingleQuotedPowerShell { param([string]$Value) return "'" + $Value.Replace("'", "''") + "'" } function Add-RemoteArgument { param( [System.Collections.Generic.List[string]]$Arguments, [string]$Name, [string]$Value ) $Arguments.Add($Name) $Arguments.Add((ConvertTo-SingleQuotedPowerShell $Value)) } 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-SafeDeploymentValues { Assert-SafeNameValue -Name 'SiteName' -Value $SiteName Assert-SafeNameValue -Name 'AppPoolName' -Value $AppPoolName if ($DeployRoot -match '(?i)schedulicious') { throw 'DeployRoot must not reference Schedulicious.' } if ([string]::IsNullOrWhiteSpace($BindingIpAddress)) { throw 'BindingIpAddress must not be empty.' } $parsedAddress = $null if (-not [System.Net.IPAddress]::TryParse($BindingIpAddress, [ref]$parsedAddress)) { throw "BindingIpAddress is not a valid IP address: $BindingIpAddress" } if ($HostHeader -match '[:/\\]') { throw 'HostHeader must be empty or a DNS host name without a scheme, port, slash, or backslash.' } if ($HostHeader -match '(?i)schedulicious') { throw 'HostHeader must not reference Schedulicious.' } } function Copy-ReleaseSource { param([string]$From, [string]$To) $excludedNames = @('.git', '.deployment', 'releases') $packageRoots = @(Get-ChildItem -LiteralPath $From -Force | Where-Object { $excludedNames -notcontains $_.Name }) $unsafeSourceItem = $packageRoots | ForEach-Object { if (($_.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { $_ } elseif ($_.PSIsContainer) { Get-ChildItem -LiteralPath $_.FullName -Recurse -Force } } | Where-Object { ($_.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 } | Select-Object -First 1 if ($null -ne $unsafeSourceItem) { throw "Source contains a reparse point/symbolic link, which is not package-safe: $($unsafeSourceItem.FullName)" } New-Item -ItemType Directory -Force -Path $To | Out-Null $packageRoots | ForEach-Object { if ($excludedNames -contains $_.Name) { return } if (($_.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw "Source contains a reparse point/symbolic link, which is not package-safe: $($_.FullName)" } Copy-Item -LiteralPath $_.FullName -Destination $To -Recurse -Force } $unsafeEntry = Get-ChildItem -LiteralPath $To -Recurse -Force | Where-Object { ($_.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 } | Select-Object -First 1 if ($null -ne $unsafeEntry) { throw "Package staging contains a reparse point/symbolic link: $($unsafeEntry.FullName)" } } function Assert-ReleaseLayout { param([string]$Root) $required = @( 'public\Default.asp', 'public\web.config', 'core\autoload_core.asp', 'app\controllers\autoload_controllers.asp', 'scripts\install-iis-release.ps1' ) foreach ($relativePath in $required) { if (-not (Test-Path -LiteralPath (Join-Path $Root $relativePath) -PathType Leaf)) { throw "Package/source tree is incomplete; missing $relativePath" } } foreach ($xmlFile in Get-ChildItem -LiteralPath $Root -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)" } } } Assert-SafeDeploymentValues $SourcePath = [System.IO.Path]::GetFullPath($SourcePath) $installerPath = Join-Path $PSScriptRoot 'install-iis-release.ps1' Assert-ReleaseLayout -Root $SourcePath $directGitRoot = Test-Path -LiteralPath (Join-Path $SourcePath '.git') $commit = 'nogit' if ($directGitRoot) { Assert-Command 'git' $branch = (& git -C $SourcePath branch --show-current).Trim() if ($LASTEXITCODE -ne 0) { throw 'Could not determine the Git branch.' } if ((-not $AllowAnyBranch) -and $branch -ne $ExpectedBranch) { throw "Refusing to deploy branch '$branch'; expected '$ExpectedBranch'." } $dirty = & git -C $SourcePath status --porcelain if ($LASTEXITCODE -ne 0) { throw 'Could not inspect the Git worktree.' } if ((-not $AllowDirty) -and $null -ne $dirty -and @($dirty).Count -gt 0) { throw 'Refusing to deploy a dirty worktree. Use -AllowDirty only for a reviewed exception.' } $commit = (& git -C $SourcePath rev-parse --short=12 HEAD).Trim() if ($LASTEXITCODE -ne 0) { throw 'Could not determine the Git commit.' } Write-Host "Source branch: $branch" Write-Host "Source commit: $commit" } else { Write-Warning 'SourcePath is not a standalone Git checkout; branch and dirty-worktree checks cannot be enforced.' if (-not $AllowAnyBranch) { throw 'Use a standalone checkout, or pass -AllowAnyBranch for a reviewed non-Git source tree.' } } if ([string]::IsNullOrWhiteSpace($ReleaseId)) { $ReleaseId = (Get-Date -Format 'yyyyMMdd-HHmmss') + '-' + $commit } if ($ReleaseId -notmatch '^[A-Za-z0-9._-]+$') { throw 'ReleaseId contains unsupported characters.' } if ($Rollback -and [string]::IsNullOrWhiteSpace($RollbackTo)) { throw '-Rollback requires -RollbackTo.' } if ((-not $Rollback) -and -not [string]::IsNullOrWhiteSpace($RollbackTo)) { throw '-RollbackTo requires -Rollback.' } if ($RunMigrations -and $Rollback) { throw '-RunMigrations is not valid during rollback.' } if (($PreflightOnly -or $LocalPreflightOnly) -and $Rollback) { throw 'Preflight modes cannot be combined with -Rollback.' } if ($PreflightOnly -and $LocalPreflightOnly) { throw '-PreflightOnly and -LocalPreflightOnly are mutually exclusive.' } $workRoot = Join-Path ([System.IO.Path]::GetTempPath()) ('asp-iis-deploy-' + [Guid]::NewGuid().ToString('N')) $packageStage = Join-Path $workRoot 'package' $packageExtract = Join-Path $workRoot 'verify' $packagePath = Join-Path $workRoot ($ReleaseId + '.zip') $remoteDirectory = 'C:\Windows\Temp\asp-iis-deploy-' + $ReleaseId $remotePackage = $remoteDirectory + '\' + $ReleaseId + '.zip' $remoteInstaller = $remoteDirectory + '\install-iis-release.ps1' try { $sha256 = '' if (-not $Rollback) { Write-Step 'Staging the complete repository for packaging' Copy-ReleaseSource -From $SourcePath -To $packageStage Assert-ReleaseLayout -Root $packageStage Add-Type -AssemblyName System.IO.Compression.FileSystem [System.IO.Compression.ZipFile]::CreateFromDirectory($packageStage, $packagePath, [System.IO.Compression.CompressionLevel]::Optimal, $false) [System.IO.Compression.ZipFile]::ExtractToDirectory($packagePath, $packageExtract) Assert-ReleaseLayout -Root $packageExtract $sha256 = (Get-FileHash -LiteralPath $packagePath -Algorithm SHA256).Hash Write-Host "Package: $packagePath" Write-Host "SHA-256: $sha256" } if ($LocalPreflightOnly) { Write-Step 'Local/controller preflight passed; no network connection or host change was made' exit 0 } $remoteArguments = New-Object 'System.Collections.Generic.List[string]' $remoteArguments.Add('&') $remoteArguments.Add((ConvertTo-SingleQuotedPowerShell $remoteInstaller)) Add-RemoteArgument $remoteArguments '-SiteName' $SiteName Add-RemoteArgument $remoteArguments '-AppPoolName' $AppPoolName Add-RemoteArgument $remoteArguments '-DeployRoot' $DeployRoot Add-RemoteArgument $remoteArguments '-BindingIpAddress' $BindingIpAddress Add-RemoteArgument $remoteArguments '-BindingPort' $BindingPort.ToString() Add-RemoteArgument $remoteArguments '-HostHeader' $HostHeader Add-RemoteArgument $remoteArguments '-KeepReleases' $KeepReleases.ToString() if (-not [string]::IsNullOrWhiteSpace($InitialWebConfigPath)) { Add-RemoteArgument $remoteArguments '-InitialWebConfigPath' $InitialWebConfigPath } if (-not [string]::IsNullOrWhiteSpace($BaseUrl)) { Add-RemoteArgument $remoteArguments '-BaseUrl' $BaseUrl } if ($Rollback) { Add-RemoteArgument $remoteArguments '-RollbackTo' $RollbackTo } elseif ($PreflightOnly) { $remoteArguments.Add('-PreflightOnly') } else { Add-RemoteArgument $remoteArguments '-PackagePath' $remotePackage Add-RemoteArgument $remoteArguments '-ReleaseId' $ReleaseId Add-RemoteArgument $remoteArguments '-ExpectedSha256' $sha256 } if ($RunMigrations) { $remoteArguments.Add('-RunMigrations') } if ($SkipSmokeTest) { $remoteArguments.Add('-SkipSmokeTest') } $remoteScript = $remoteArguments -join ' ' $remoteEncodedCommand = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($remoteScript)) $remoteCommand = 'powershell.exe -NoProfile -NonInteractive -ExecutionPolicy RemoteSigned -EncodedCommand ' + $remoteEncodedCommand if ($DryRun) { Write-Step 'Dry-run complete; no network connection was made' Write-Host "Would use dedicated site '$SiteName' and app pool '$AppPoolName'." Write-Host "Would use binding ${BindingIpAddress}:$BindingPort with host header '$HostHeader'." if ($PreflightOnly) { Write-Host 'Would stream the installer over SSH for a read-only host preflight; no remote file would be written.' } else { Write-Host "Would create remote directory: $remoteDirectory" Write-Host "Would copy installer: $installerPath" if (-not $Rollback) { Write-Host "Would copy package: $packagePath" } Write-Host ('Would execute host script: ' + $remoteScript) } exit 0 } Assert-Command $SshExe if ($PreflightOnly) { Write-Step 'Streaming the installer for read-only host preflight' $installerSource = Get-Content -LiteralPath $installerPath -Raw $argumentTail = @($remoteArguments | Select-Object -Skip 2) -join ' ' $stdinScript = "& {`r`n" + $installerSource + "`r`n} " + $argumentTail $stdinScript | & $SshExe -p $RemotePort $RemoteTarget 'powershell.exe -NoProfile -NonInteractive -ExecutionPolicy RemoteSigned -Command -' if ($LASTEXITCODE -ne 0) { throw "Remote preflight failed with exit code $LASTEXITCODE." } Write-Step 'Remote host preflight completed successfully without persistent host changes' exit 0 } Assert-Command $ScpExe Write-Step 'Creating remote temporary directory' $mkdirScript = "New-Item -ItemType Directory -Force -Path $(ConvertTo-SingleQuotedPowerShell $remoteDirectory) | Out-Null" $mkdirEncoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($mkdirScript)) & $SshExe -p $RemotePort $RemoteTarget ('powershell.exe -NoProfile -NonInteractive -EncodedCommand ' + $mkdirEncoded) if ($LASTEXITCODE -ne 0) { throw 'Remote directory creation failed.' } Write-Step 'Copying the host installer' & $ScpExe -P $RemotePort $installerPath ($RemoteTarget + ':' + $remoteDirectory.Replace('\', '/') + '/install-iis-release.ps1') if ($LASTEXITCODE -ne 0) { throw 'Installer copy failed.' } if (-not $Rollback) { Write-Step 'Copying the release package' & $ScpExe -P $RemotePort $packagePath ($RemoteTarget + ':' + $remoteDirectory.Replace('\', '/') + '/' + $ReleaseId + '.zip') if ($LASTEXITCODE -ne 0) { throw 'Package copy failed.' } } Write-Step 'Invoking the host-side installer' & $SshExe -p $RemotePort $RemoteTarget $remoteCommand if ($LASTEXITCODE -ne 0) { throw "Remote installer failed with exit code $LASTEXITCODE." } Write-Step 'Remote operation completed successfully' } finally { if ($KeepPackage -and (Test-Path -LiteralPath $packagePath)) { $keptPath = Join-Path (Get-Location) ([System.IO.Path]::GetFileName($packagePath)) Copy-Item -LiteralPath $packagePath -Destination $keptPath -Force Write-Host "Package retained at $keptPath" } if (Test-Path -LiteralPath $workRoot) { Remove-Item -LiteralPath $workRoot -Recurse -Force } }