[CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$RemoteHost, [Parameter(Mandatory = $true)] [string]$RemoteProjectPath, [string]$ProductionSiteName = 'WscMvc', [string]$ProductionPoolName = 'WscMvc', [int]$ProductionPort = 8090, [string]$ProductionBaseUrl = 'http://localhost:8090', [string]$TestSiteName = 'WscMvcTests', [string]$TestPoolName = 'WscMvcTests', [int]$TestPort = 8091, [string]$TestBaseUrl = 'http://localhost:8091', [string]$RemoteTempRoot = 'C:\Windows\Temp', [switch]$PullOriginMaster, [switch]$RunTests ) $ErrorActionPreference = 'Stop' Set-StrictMode -Version 2.0 function Invoke-Native { param( [Parameter(Mandatory = $true)][string]$FilePath, [Parameter(Mandatory = $true)][string[]]$ArgumentList ) & $FilePath @ArgumentList if ($LASTEXITCODE -ne 0) { throw "$FilePath failed with exit code $LASTEXITCODE" } } function Quote-PowerShellLiteral { param([Parameter(Mandatory = $true)][string]$Value) return "'" + $Value.Replace("'", "''") + "'" } $projectRoot = Split-Path -Parent $PSScriptRoot $gitRoot = (& git -C $projectRoot rev-parse --show-toplevel 2>$null) if ($LASTEXITCODE -ne 0 -or -not $gitRoot) { throw "Project root is not a Git checkout: $projectRoot" } $gitRoot = [System.IO.Path]::GetFullPath($gitRoot.Trim()) if ($gitRoot.TrimEnd('\') -ne ([System.IO.Path]::GetFullPath($projectRoot)).TrimEnd('\')) { throw "Deploy-Remote.ps1 must run from this repository checkout (expected $projectRoot, Git reported $gitRoot)." } if ($ProductionSiteName -eq $TestSiteName) { throw 'ProductionSiteName and TestSiteName must differ.' } if ($ProductionPoolName -eq $TestPoolName) { throw 'ProductionPoolName and TestPoolName must differ.' } if ($ProductionPort -lt 1 -or $ProductionPort -gt 65535) { throw 'ProductionPort must be between 1 and 65535.' } if ($TestPort -lt 1 -or $TestPort -gt 65535) { throw 'TestPort must be between 1 and 65535.' } if ($ProductionPort -eq $TestPort) { throw 'ProductionPort and TestPort must differ.' } if ($PullOriginMaster) { $dirty = (& git -C $gitRoot status --porcelain) if ($LASTEXITCODE -ne 0) { throw 'Unable to inspect Git worktree state.' } if ($dirty) { throw 'Refusing to pull with local changes. Commit, stash, or omit -PullOriginMaster.' } Invoke-Native -FilePath 'git' -ArgumentList @('-C', $gitRoot, 'pull', '--ff-only', 'origin', 'master') } $trackedFiles = @(& git -C $gitRoot ls-files) if ($LASTEXITCODE -ne 0 -or $trackedFiles.Count -eq 0) { throw 'git ls-files did not return the tracked deployment set.' } $required = @( 'tools/Register-Components.ps1', 'tools/Setup-Site.ps1', 'tools/Invoke-RemoteInstall.ps1', 'tests/Invoke-SelfTest.ps1', 'tests/Test-Http.ps1', 'Framework/ViewRenderer.wsc' ) foreach ($requiredPath in $required) { if ($trackedFiles -notcontains $requiredPath) { throw "Required deployment file is not tracked by Git: $requiredPath" } if (-not (Test-Path -LiteralPath (Join-Path $gitRoot $requiredPath))) { throw "Required deployment file is missing: $requiredPath" } } $invocationId = (Get-Date).ToUniversalTime().ToString('yyyyMMddTHHmmssZ') + '-' + [Guid]::NewGuid().ToString('N').Substring(0, 8) $localWork = Join-Path ([System.IO.Path]::GetTempPath()) "wsc-mvc-deploy-$invocationId" $packageRoot = Join-Path $localWork 'package' $archivePath = Join-Path $localWork 'wsc-mvc.zip' $remoteArchive = Join-Path $RemoteTempRoot "wsc-mvc-$invocationId.zip" $remoteInstaller = Join-Path $RemoteTempRoot "wsc-mvc-install-$invocationId.ps1" try { New-Item -ItemType Directory -Path $packageRoot -Force | Out-Null foreach ($relativePath in $trackedFiles) { if ([string]::IsNullOrWhiteSpace($relativePath)) { continue } if ([System.IO.Path]::IsPathRooted($relativePath) -or $relativePath -match '(^|[\\/])\.\.([\\/]|$)') { throw "Unsafe tracked path: $relativePath" } $source = Join-Path $gitRoot $relativePath $item = Get-Item -LiteralPath $source -Force if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw "Refusing to package a reparse point or symbolic link: $relativePath" } $destination = Join-Path $packageRoot $relativePath $destinationParent = Split-Path -Parent $destination if (-not (Test-Path -LiteralPath $destinationParent)) { New-Item -ItemType Directory -Path $destinationParent -Force | Out-Null } Copy-Item -LiteralPath $source -Destination $destination -Force } Add-Type -AssemblyName System.IO.Compression.FileSystem [System.IO.Compression.ZipFile]::CreateFromDirectory($packageRoot, $archivePath, [System.IO.Compression.CompressionLevel]::Optimal, $false) $packageSha256 = (Get-FileHash -LiteralPath $archivePath -Algorithm SHA256).Hash.ToLowerInvariant() Write-Output "Package contains $($trackedFiles.Count) Git-tracked paths." Write-Output "Package SHA-256: $packageSha256" Write-Output "Inspecting and deploying to $RemoteHost without supplying credentials..." Invoke-Native -FilePath 'scp' -ArgumentList @($archivePath, "$RemoteHost`:$remoteArchive") Invoke-Native -FilePath 'scp' -ArgumentList @((Join-Path $PSScriptRoot 'Invoke-RemoteInstall.ps1'), "$RemoteHost`:$remoteInstaller") $arguments = @( '-ArchivePath', (Quote-PowerShellLiteral $remoteArchive), '-ProjectPath', (Quote-PowerShellLiteral $RemoteProjectPath), '-InvocationId', (Quote-PowerShellLiteral $invocationId), '-ExpectedArchiveSha256', (Quote-PowerShellLiteral $packageSha256), '-ProductionSiteName', (Quote-PowerShellLiteral $ProductionSiteName), '-ProductionPoolName', (Quote-PowerShellLiteral $ProductionPoolName), '-ProductionPort', $ProductionPort, '-ProductionBaseUrl', (Quote-PowerShellLiteral $ProductionBaseUrl), '-TestSiteName', (Quote-PowerShellLiteral $TestSiteName), '-TestPoolName', (Quote-PowerShellLiteral $TestPoolName), '-TestPort', $TestPort, '-TestBaseUrl', (Quote-PowerShellLiteral $TestBaseUrl) ) if ($RunTests) { $arguments += '-RunTests' } $remoteCommand = "& $(Quote-PowerShellLiteral $remoteInstaller) " + ($arguments -join ' ') $encodedCommand = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($remoteCommand)) Invoke-Native -FilePath 'ssh' -ArgumentList @($RemoteHost, "powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand $encodedCommand") } finally { if (Test-Path -LiteralPath $localWork) { Remove-Item -LiteralPath $localWork -Recurse -Force } }