|
- <#
- .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 }
- }
|