| @@ -1,29 +1,33 @@ | |||||
| <# | <# | ||||
| .SYNOPSIS | .SYNOPSIS | ||||
| Packages this repository and deploys it to IIS over Tailscale/OpenSSH. | |||||
| Validates, packages, and deploys this repository to its dedicated IIS site. | |||||
| .DESCRIPTION | .DESCRIPTION | ||||
| This is the controller/CI entry point. It creates a ZIP containing the full | |||||
| repository (excluding VCS and local deployment artifacts), computes SHA-256, | |||||
| copies the package and host installer with scp, then invokes the installer on | |||||
| the Windows host through ssh. | |||||
| -DryRun performs local validation and prints the remote operations without | |||||
| connecting. Gitea 1.11.4 does not run this itself; use a trusted external CI | |||||
| worker, a scheduled task, or an operator workstation. | |||||
| 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()] | [CmdletBinding()] | ||||
| param( | param( | ||||
| [Parameter(Mandatory = $true)] | |||||
| [ValidatePattern('^[A-Za-z0-9_. -]+$')] | [ValidatePattern('^[A-Za-z0-9_. -]+$')] | ||||
| [string]$SiteName, | |||||
| [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', | [string]$RemoteTarget = 'webserver-1', | ||||
| [ValidateRange(1, 65535)] | [ValidateRange(1, 65535)] | ||||
| [int]$RemotePort = 22, | [int]$RemotePort = 22, | ||||
| [string]$SourcePath = (Split-Path $PSScriptRoot -Parent), | [string]$SourcePath = (Split-Path $PSScriptRoot -Parent), | ||||
| [string]$DeployRoot = '', | |||||
| [string]$BaseUrl = '', | [string]$BaseUrl = '', | ||||
| [string]$ReleaseId = '', | [string]$ReleaseId = '', | ||||
| [string]$ExpectedBranch = 'master', | [string]$ExpectedBranch = 'master', | ||||
| @@ -35,7 +39,9 @@ param( | |||||
| [switch]$AllowDirty, | [switch]$AllowDirty, | ||||
| [switch]$RunMigrations, | [switch]$RunMigrations, | ||||
| [switch]$SkipSmokeTest, | [switch]$SkipSmokeTest, | ||||
| [switch]$RemotePreflightOnly, | |||||
| [Alias('HostPreflightOnly', 'RemotePreflightOnly')] | |||||
| [switch]$PreflightOnly, | |||||
| [switch]$LocalPreflightOnly, | |||||
| [switch]$Rollback, | [switch]$Rollback, | ||||
| [string]$RollbackTo = '', | [string]$RollbackTo = '', | ||||
| [switch]$DryRun, | [switch]$DryRun, | ||||
| @@ -45,10 +51,7 @@ param( | |||||
| Set-StrictMode -Version 2.0 | Set-StrictMode -Version 2.0 | ||||
| $ErrorActionPreference = 'Stop' | $ErrorActionPreference = 'Stop' | ||||
| function Write-Step { | |||||
| param([string]$Message) | |||||
| Write-Host ('==> ' + $Message) | |||||
| } | |||||
| function Write-Step { param([string]$Message) Write-Host ('==> ' + $Message) } | |||||
| function Assert-Command { | function Assert-Command { | ||||
| param([string]$Name) | param([string]$Name) | ||||
| @@ -72,177 +75,232 @@ function Add-RemoteArgument { | |||||
| $Arguments.Add((ConvertTo-SingleQuotedPowerShell $Value)) | $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 { | function Copy-ReleaseSource { | ||||
| param( | |||||
| [string]$From, | |||||
| [string]$To | |||||
| ) | |||||
| param([string]$From, [string]$To) | |||||
| New-Item -ItemType Directory -Force -Path $To | Out-Null | |||||
| $excludedNames = @('.git', '.deployment', 'releases') | $excludedNames = @('.git', '.deployment', 'releases') | ||||
| Get-ChildItem -LiteralPath $From -Force | ForEach-Object { | |||||
| if ($excludedNames -notcontains $_.Name) { | |||||
| Copy-Item -LiteralPath $_.FullName -Destination $To -Recurse -Force | |||||
| $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 | |||||
| } | } | ||||
| } | |||||
| $SourcePath = [System.IO.Path]::GetFullPath($SourcePath) | |||||
| $installerPath = Join-Path $PSScriptRoot 'install-iis-release.ps1' | |||||
| if (-not (Test-Path -LiteralPath $installerPath -PathType Leaf)) { | |||||
| throw "Host installer is missing: $installerPath" | |||||
| $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)" | |||||
| } | |||||
| } | } | ||||
| $requiredSourceFiles = @( | |||||
| 'public\Default.asp', | |||||
| 'public\web.config', | |||||
| 'core\autoload_core.asp', | |||||
| 'app\controllers\autoload_controllers.asp' | |||||
| ) | |||||
| foreach ($relativePath in $requiredSourceFiles) { | |||||
| if (-not (Test-Path -LiteralPath (Join-Path $SourcePath $relativePath) -PathType Leaf)) { | |||||
| throw "Source tree is incomplete; missing $relativePath" | |||||
| 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)" | |||||
| } | |||||
| } | } | ||||
| } | } | ||||
| try { | |||||
| [xml](Get-Content -LiteralPath (Join-Path $SourcePath 'public\web.config') -Raw) | Out-Null | |||||
| } catch { | |||||
| throw "Source public\web.config 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') | $directGitRoot = Test-Path -LiteralPath (Join-Path $SourcePath '.git') | ||||
| $commit = 'nogit' | $commit = 'nogit' | ||||
| if ($directGitRoot) { | if ($directGitRoot) { | ||||
| Assert-Command 'git' | Assert-Command 'git' | ||||
| $branch = (& git -C $SourcePath branch --show-current).Trim() | $branch = (& git -C $SourcePath branch --show-current).Trim() | ||||
| if ($LASTEXITCODE -ne 0) { | |||||
| throw 'Could not determine the Git branch.' | |||||
| } | |||||
| if ($LASTEXITCODE -ne 0) { throw 'Could not determine the Git branch.' } | |||||
| if ((-not $AllowAnyBranch) -and $branch -ne $ExpectedBranch) { | if ((-not $AllowAnyBranch) -and $branch -ne $ExpectedBranch) { | ||||
| throw "Refusing to deploy branch '$branch'; expected '$ExpectedBranch'. Use -AllowAnyBranch only for an intentional exception." | |||||
| throw "Refusing to deploy branch '$branch'; expected '$ExpectedBranch'." | |||||
| } | } | ||||
| $dirty = & git -C $SourcePath status --porcelain | $dirty = & git -C $SourcePath status --porcelain | ||||
| if ($LASTEXITCODE -ne 0) { | |||||
| throw 'Could not inspect the Git worktree.' | |||||
| } | |||||
| if ($LASTEXITCODE -ne 0) { throw 'Could not inspect the Git worktree.' } | |||||
| if ((-not $AllowDirty) -and $null -ne $dirty -and @($dirty).Count -gt 0) { | if ((-not $AllowDirty) -and $null -ne $dirty -and @($dirty).Count -gt 0) { | ||||
| throw 'Refusing to deploy a dirty worktree. Commit/stash changes or use -AllowDirty for an intentional, auditable exception.' | |||||
| throw 'Refusing to deploy a dirty worktree. Use -AllowDirty only for a reviewed exception.' | |||||
| } | } | ||||
| $commit = (& git -C $SourcePath rev-parse --short=12 HEAD).Trim() | $commit = (& git -C $SourcePath rev-parse --short=12 HEAD).Trim() | ||||
| if ($LASTEXITCODE -ne 0) { | |||||
| throw 'Could not determine the Git commit.' | |||||
| } | |||||
| if ($LASTEXITCODE -ne 0) { throw 'Could not determine the Git commit.' } | |||||
| Write-Host "Source branch: $branch" | Write-Host "Source branch: $branch" | ||||
| Write-Host "Source commit: $commit" | Write-Host "Source commit: $commit" | ||||
| } else { | } else { | ||||
| Write-Warning 'SourcePath is not a standalone Git checkout; branch and dirty-worktree checks cannot be enforced.' | Write-Warning 'SourcePath is not a standalone Git checkout; branch and dirty-worktree checks cannot be enforced.' | ||||
| if (-not $AllowAnyBranch) { | if (-not $AllowAnyBranch) { | ||||
| throw 'Use a standalone CI checkout, or pass -AllowAnyBranch explicitly for a reviewed non-Git source tree.' | |||||
| throw 'Use a standalone checkout, or pass -AllowAnyBranch for a reviewed non-Git source tree.' | |||||
| } | } | ||||
| } | } | ||||
| if ([string]::IsNullOrWhiteSpace($ReleaseId)) { | if ([string]::IsNullOrWhiteSpace($ReleaseId)) { | ||||
| $ReleaseId = (Get-Date -Format 'yyyyMMdd-HHmmss') + '-' + $commit | $ReleaseId = (Get-Date -Format 'yyyyMMdd-HHmmss') + '-' + $commit | ||||
| } | } | ||||
| if ($ReleaseId -notmatch '^[A-Za-z0-9._-]+$') { | |||||
| throw 'ReleaseId may contain only letters, numbers, dot, underscore, and hyphen.' | |||||
| } | |||||
| if ($Rollback -and [string]::IsNullOrWhiteSpace($RollbackTo)) { | |||||
| throw '-Rollback requires -RollbackTo <release-id>.' | |||||
| 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 ((-not $Rollback) -and -not [string]::IsNullOrWhiteSpace($RollbackTo)) { | |||||
| throw '-RollbackTo is only valid with -Rollback.' | |||||
| } | |||||
| if ($RunMigrations -and $Rollback) { | |||||
| throw '-RunMigrations is not valid during 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')) | $workRoot = Join-Path ([System.IO.Path]::GetTempPath()) ('asp-iis-deploy-' + [Guid]::NewGuid().ToString('N')) | ||||
| $packageStage = Join-Path $workRoot 'package' | $packageStage = Join-Path $workRoot 'package' | ||||
| $packageExtract = Join-Path $workRoot 'verify' | |||||
| $packagePath = Join-Path $workRoot ($ReleaseId + '.zip') | $packagePath = Join-Path $workRoot ($ReleaseId + '.zip') | ||||
| $remoteDirectory = 'C:\Windows\Temp\asp-iis-deploy-' + $ReleaseId | $remoteDirectory = 'C:\Windows\Temp\asp-iis-deploy-' + $ReleaseId | ||||
| $remotePackage = $remoteDirectory + '\' + $ReleaseId + '.zip' | $remotePackage = $remoteDirectory + '\' + $ReleaseId + '.zip' | ||||
| $remoteInstaller = $remoteDirectory + '\install-iis-release.ps1' | $remoteInstaller = $remoteDirectory + '\install-iis-release.ps1' | ||||
| try { | try { | ||||
| if (-not $Rollback -and -not $RemotePreflightOnly) { | |||||
| Write-Step 'Staging the full repository for packaging' | |||||
| $sha256 = '' | |||||
| if (-not $Rollback) { | |||||
| Write-Step 'Staging the complete repository for packaging' | |||||
| Copy-ReleaseSource -From $SourcePath -To $packageStage | Copy-ReleaseSource -From $SourcePath -To $packageStage | ||||
| Assert-ReleaseLayout -Root $packageStage | |||||
| Add-Type -AssemblyName System.IO.Compression.FileSystem | Add-Type -AssemblyName System.IO.Compression.FileSystem | ||||
| [System.IO.Compression.ZipFile]::CreateFromDirectory( | |||||
| $packageStage, | |||||
| $packagePath, | |||||
| [System.IO.Compression.CompressionLevel]::Optimal, | |||||
| $false | |||||
| ) | |||||
| [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 | $sha256 = (Get-FileHash -LiteralPath $packagePath -Algorithm SHA256).Hash | ||||
| Write-Host "Package: $packagePath" | Write-Host "Package: $packagePath" | ||||
| Write-Host "SHA-256: $sha256" | Write-Host "SHA-256: $sha256" | ||||
| } else { | |||||
| $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 = New-Object 'System.Collections.Generic.List[string]' | ||||
| $remoteArguments.Add('&') | $remoteArguments.Add('&') | ||||
| $remoteArguments.Add((ConvertTo-SingleQuotedPowerShell $remoteInstaller)) | $remoteArguments.Add((ConvertTo-SingleQuotedPowerShell $remoteInstaller)) | ||||
| Add-RemoteArgument -Arguments $remoteArguments -Name '-SiteName' -Value $SiteName | |||||
| 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) { | if ($Rollback) { | ||||
| Add-RemoteArgument -Arguments $remoteArguments -Name '-RollbackTo' -Value $RollbackTo | |||||
| } elseif (-not $RemotePreflightOnly) { | |||||
| Add-RemoteArgument -Arguments $remoteArguments -Name '-PackagePath' -Value $remotePackage | |||||
| Add-RemoteArgument -Arguments $remoteArguments -Name '-ReleaseId' -Value $ReleaseId | |||||
| Add-RemoteArgument -Arguments $remoteArguments -Name '-ExpectedSha256' -Value $sha256 | |||||
| } else { | |||||
| Add-RemoteArgument $remoteArguments '-RollbackTo' $RollbackTo | |||||
| } elseif ($PreflightOnly) { | |||||
| $remoteArguments.Add('-PreflightOnly') | $remoteArguments.Add('-PreflightOnly') | ||||
| } else { | |||||
| Add-RemoteArgument $remoteArguments '-PackagePath' $remotePackage | |||||
| Add-RemoteArgument $remoteArguments '-ReleaseId' $ReleaseId | |||||
| Add-RemoteArgument $remoteArguments '-ExpectedSha256' $sha256 | |||||
| } | } | ||||
| if (-not [string]::IsNullOrWhiteSpace($DeployRoot)) { | |||||
| Add-RemoteArgument -Arguments $remoteArguments -Name '-DeployRoot' -Value $DeployRoot | |||||
| } | |||||
| if (-not [string]::IsNullOrWhiteSpace($BaseUrl)) { | |||||
| Add-RemoteArgument -Arguments $remoteArguments -Name '-BaseUrl' -Value $BaseUrl | |||||
| } | |||||
| Add-RemoteArgument -Arguments $remoteArguments -Name '-KeepReleases' -Value $KeepReleases.ToString() | |||||
| if ($RunMigrations) { $remoteArguments.Add('-RunMigrations') } | if ($RunMigrations) { $remoteArguments.Add('-RunMigrations') } | ||||
| if ($SkipSmokeTest) { $remoteArguments.Add('-SkipSmokeTest') } | if ($SkipSmokeTest) { $remoteArguments.Add('-SkipSmokeTest') } | ||||
| $remoteScript = $remoteArguments -join ' ' | $remoteScript = $remoteArguments -join ' ' | ||||
| $remoteBytes = [Text.Encoding]::Unicode.GetBytes($remoteScript) | |||||
| $remoteEncodedCommand = [Convert]::ToBase64String($remoteBytes) | |||||
| $remoteEncodedCommand = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($remoteScript)) | |||||
| $remoteCommand = 'powershell.exe -NoProfile -NonInteractive -ExecutionPolicy RemoteSigned -EncodedCommand ' + $remoteEncodedCommand | $remoteCommand = 'powershell.exe -NoProfile -NonInteractive -ExecutionPolicy RemoteSigned -EncodedCommand ' + $remoteEncodedCommand | ||||
| Write-Host "Remote target: $RemoteTarget (Tailscale/OpenSSH port $RemotePort)" | |||||
| if ($DryRun) { | if ($DryRun) { | ||||
| Write-Step 'Dry-run complete; no network connection was made' | Write-Step 'Dry-run complete; no network connection was made' | ||||
| Write-Host "Would create remote directory: $remoteDirectory" | |||||
| Write-Host "Would copy installer: $installerPath" | |||||
| if (-not $Rollback -and -not $RemotePreflightOnly) { | |||||
| Write-Host "Would copy package: $packagePath" | |||||
| 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) | |||||
| } | } | ||||
| Write-Host ('Would execute host script: ' + $remoteScript) | |||||
| Write-Host ('Transport command uses PowerShell -EncodedCommand to avoid remote-shell quoting ambiguity.') | |||||
| exit 0 | exit 0 | ||||
| } | } | ||||
| Assert-Command $SshExe | Assert-Command $SshExe | ||||
| Assert-Command $ScpExe | |||||
| 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 | |||||
| } | |||||
| Write-Step 'Creating remote staging directory over Tailscale/OpenSSH' | |||||
| Assert-Command $ScpExe | |||||
| Write-Step 'Creating remote temporary directory' | |||||
| $mkdirScript = "New-Item -ItemType Directory -Force -Path $(ConvertTo-SingleQuotedPowerShell $remoteDirectory) | Out-Null" | $mkdirScript = "New-Item -ItemType Directory -Force -Path $(ConvertTo-SingleQuotedPowerShell $remoteDirectory) | Out-Null" | ||||
| $mkdirBytes = [Text.Encoding]::Unicode.GetBytes($mkdirScript) | |||||
| $mkdirEncodedCommand = [Convert]::ToBase64String($mkdirBytes) | |||||
| & $SshExe -p $RemotePort $RemoteTarget ('powershell.exe -NoProfile -NonInteractive -EncodedCommand ' + $mkdirEncodedCommand) | |||||
| $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.' } | if ($LASTEXITCODE -ne 0) { throw 'Remote directory creation failed.' } | ||||
| Write-Step 'Copying the host installer' | Write-Step 'Copying the host installer' | ||||
| & $ScpExe -P $RemotePort $installerPath ($RemoteTarget + ':' + $remoteDirectory.Replace('\', '/') + '/install-iis-release.ps1') | & $ScpExe -P $RemotePort $installerPath ($RemoteTarget + ':' + $remoteDirectory.Replace('\', '/') + '/install-iis-release.ps1') | ||||
| if ($LASTEXITCODE -ne 0) { throw 'Installer copy failed.' } | if ($LASTEXITCODE -ne 0) { throw 'Installer copy failed.' } | ||||
| if (-not $Rollback -and -not $RemotePreflightOnly) { | |||||
| if (-not $Rollback) { | |||||
| Write-Step 'Copying the release package' | Write-Step 'Copying the release package' | ||||
| & $ScpExe -P $RemotePort $packagePath ($RemoteTarget + ':' + $remoteDirectory.Replace('\', '/') + '/' + $ReleaseId + '.zip') | & $ScpExe -P $RemotePort $packagePath ($RemoteTarget + ':' + $remoteDirectory.Replace('\', '/') + '/' + $ReleaseId + '.zip') | ||||
| if ($LASTEXITCODE -ne 0) { throw 'Package copy failed.' } | if ($LASTEXITCODE -ne 0) { throw 'Package copy failed.' } | ||||
| @@ -251,7 +309,6 @@ try { | |||||
| Write-Step 'Invoking the host-side installer' | Write-Step 'Invoking the host-side installer' | ||||
| & $SshExe -p $RemotePort $RemoteTarget $remoteCommand | & $SshExe -p $RemotePort $RemoteTarget $remoteCommand | ||||
| if ($LASTEXITCODE -ne 0) { throw "Remote installer failed with exit code $LASTEXITCODE." } | if ($LASTEXITCODE -ne 0) { throw "Remote installer failed with exit code $LASTEXITCODE." } | ||||
| Write-Step 'Remote operation completed successfully' | Write-Step 'Remote operation completed successfully' | ||||
| } finally { | } finally { | ||||
| if ($KeepPackage -and (Test-Path -LiteralPath $packagePath)) { | if ($KeepPackage -and (Test-Path -LiteralPath $packagePath)) { | ||||
| @@ -259,7 +316,5 @@ try { | |||||
| Copy-Item -LiteralPath $packagePath -Destination $keptPath -Force | Copy-Item -LiteralPath $packagePath -Destination $keptPath -Force | ||||
| Write-Host "Package retained at $keptPath" | Write-Host "Package retained at $keptPath" | ||||
| } | } | ||||
| if (Test-Path -LiteralPath $workRoot) { | |||||
| Remove-Item -LiteralPath $workRoot -Recurse -Force | |||||
| } | |||||
| if (Test-Path -LiteralPath $workRoot) { Remove-Item -LiteralPath $workRoot -Recurse -Force } | |||||
| } | } | ||||
Powered by TurnKey Linux.