diff --git a/scripts/deploy-iis-git.ps1 b/scripts/deploy-iis-git.ps1 index 3326f8a..674fe18 100644 --- a/scripts/deploy-iis-git.ps1 +++ b/scripts/deploy-iis-git.ps1 @@ -1,324 +1,265 @@ <# - Deploy asp-territory to an existing IIS site, locally or over SSH. - - Remote mode: - - Copies this script to the remote Windows host with scp - - Executes it remotely via ssh in -RunRemoteCore mode - - Preserves the remote site's current DB path unless -DbPath is passed - - Can run standard migrations and an optional legacy migration script - - Local / remote core behavior: - - Infers IIS site/app pool/work dir from the existing site when possible - - Stops the site/app pool while deploying - - Clones/pulls and hard-resets to origin/ - - Points IIS at \public - - Reapplies the effective DB path in public\web.config - - Grants IIS AppPool rights to the DB folder - - Runs migrations - - Restarts the site/app pool and smoke tests key routes +.SYNOPSIS + Packages this repository and deploys it to IIS over Tailscale/OpenSSH. + +.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. #> +[CmdletBinding()] param( - [string]$Repo = 'git@onefortheroadgit.sytes.net:dcovington/asp-classic-unified-framework.git', - [string]$Branch = 'main', - [string]$SiteName = 'ttasp', - [string]$AppPool = '', - [string]$WorkDir = '', - [string]$PublicDir = '', - [string]$BaseUrl = '', - [string]$DbPath = '', + [Parameter(Mandatory = $true)] + [ValidatePattern('^[A-Za-z0-9_. -]+$')] + [string]$SiteName, - [switch]$RunMigrations = $true, - [switch]$SkipLegacyIsBusinessMigration, - [string]$LegacyMigrationScript = 'scripts\migrate_isbusiness_to_households.vbs', - - [switch]$UseRemoteSsh, - [string]$RemoteTarget = '', + [string]$RemoteTarget = 'webserver-1', + [ValidateRange(1, 65535)] [int]$RemotePort = 22, + [string]$SourcePath = (Split-Path $PSScriptRoot -Parent), + [string]$DeployRoot = '', + [string]$BaseUrl = '', + [string]$ReleaseId = '', + [string]$ExpectedBranch = 'master', + [ValidateRange(2, 100)] + [int]$KeepReleases = 5, [string]$SshExe = 'ssh', [string]$ScpExe = 'scp', - [switch]$RunRemoteCore + [switch]$AllowAnyBranch, + [switch]$AllowDirty, + [switch]$RunMigrations, + [switch]$SkipSmokeTest, + [switch]$RemotePreflightOnly, + [switch]$Rollback, + [string]$RollbackTo = '', + [switch]$DryRun, + [switch]$KeepPackage ) +Set-StrictMode -Version 2.0 $ErrorActionPreference = 'Stop' -function Ensure-Dir { - param([string]$Path) - if([string]::IsNullOrWhiteSpace($Path)){ return } - if(!(Test-Path $Path)){ - New-Item -ItemType Directory -Force -Path $Path | Out-Null - } +function Write-Step { + param([string]$Message) + Write-Host ('==> ' + $Message) } -function Ensure-Command { +function Assert-Command { param([string]$Name) - if(!(Get-Command $Name -ErrorAction SilentlyContinue)){ - throw "$Name not found on PATH" + if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) { + throw "$Name was not found on PATH." } } -function Get-DefaultRemoteTargetFromInfo { - $infoPath = Join-Path $PSScriptRoot 'depolyinfo.txt' - if(!(Test-Path $infoPath)){ return '' } - - $sshLine = Get-Content $infoPath | Where-Object { $_ -match '^\s*ssh\s+' } | Select-Object -First 1 - if([string]::IsNullOrWhiteSpace($sshLine)){ return '' } - - return ($sshLine -replace '^\s*ssh\s+', '').Trim() -} - -function ConvertTo-PowerShellLiteral { - param([AllowNull()][string]$Value) - if($null -eq $Value){ return "''" } - return "'" + ($Value -replace "'", "''") + "'" -} - -function ConvertTo-CmdDoubleQuoted { - param([AllowNull()][string]$Value) - if($null -eq $Value){ return '""' } - return '"' + ($Value -replace '"', '""') + '"' +function ConvertTo-SingleQuotedPowerShell { + param([string]$Value) + return "'" + $Value.Replace("'", "''") + "'" } -function Get-DataSourceFromConfig { - param([string]$ConfigPath) - if(!(Test-Path $ConfigPath)){ return '' } - - $raw = Get-Content $ConfigPath -Raw - $match = [regex]::Match($raw, 'Data Source=([^;]+);', [System.Text.RegularExpressions.RegexOptions]::IgnoreCase) - if($match.Success){ - return $match.Groups[1].Value.Trim() - } - - return '' -} - -function Set-DataSourceInConfig { +function Add-RemoteArgument { param( - [string]$ConfigPath, - [string]$EffectiveDbPath + [System.Collections.Generic.List[string]]$Arguments, + [string]$Name, + [string]$Value ) + $Arguments.Add($Name) + $Arguments.Add((ConvertTo-SingleQuotedPowerShell $Value)) +} - if(!(Test-Path $ConfigPath)){ return } - - $raw = Get-Content $ConfigPath -Raw - $updated = [regex]::Replace( - $raw, - 'Data Source=[^;]*;', - ('Data Source=' + $EffectiveDbPath + ';'), - [System.Text.RegularExpressions.RegexOptions]::IgnoreCase +function Copy-ReleaseSource { + param( + [string]$From, + [string]$To ) - if($updated -ne $raw){ - Set-Content -Path $ConfigPath -Value $updated -Encoding UTF8 - Write-Host "Updated ConnectionString Data Source to $EffectiveDbPath" + New-Item -ItemType Directory -Force -Path $To | Out-Null + $excludedNames = @('.git', '.deployment', 'releases') + Get-ChildItem -LiteralPath $From -Force | ForEach-Object { + if ($excludedNames -notcontains $_.Name) { + Copy-Item -LiteralPath $_.FullName -Destination $To -Recurse -Force + } } } -function Get-BaseUrlFromSite { - param($Site) +$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" +} - $httpBind = $Site.Bindings.Collection | Where-Object { $_.protocol -eq 'http' } | Select-Object -First 1 - if($httpBind){ - $parts = $httpBind.bindingInformation.Split(':') - $port = $parts[1] - if([string]::IsNullOrWhiteSpace($port)){ $port = '80' } - return ('http://127.0.0.1:' + $port) +$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" } - - return 'http://127.0.0.1' } -function Invoke-DeployCore { - Ensure-Command git - Import-Module WebAdministration - - $site = Get-Website -Name $SiteName - if(!$site){ throw "IIS site not found: $SiteName" } +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)" +} - if([string]::IsNullOrWhiteSpace($AppPool)){ - $AppPool = $site.applicationPool +$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([string]::IsNullOrWhiteSpace($PublicDir)){ - $PublicDir = $site.physicalPath + if ((-not $AllowAnyBranch) -and $branch -ne $ExpectedBranch) { + throw "Refusing to deploy branch '$branch'; expected '$ExpectedBranch'. Use -AllowAnyBranch only for an intentional exception." } - if([string]::IsNullOrWhiteSpace($WorkDir)){ - $pd = [Environment]::ExpandEnvironmentVariables($PublicDir) - $pd = $pd.Trim().Trim('"') - $pd = $pd.TrimEnd('\','/') - - if((Split-Path $pd -Leaf).ToLower() -eq 'public'){ - $WorkDir = Split-Path $pd -Parent - } else { - $WorkDir = $pd - } + $dirty = & git -C $SourcePath status --porcelain + if ($LASTEXITCODE -ne 0) { + throw 'Could not inspect the Git worktree.' } - - if([string]::IsNullOrWhiteSpace($BaseUrl)){ - $BaseUrl = Get-BaseUrlFromSite -Site $site + 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.' } - $currentPublicDir = $PublicDir - $currentConfigPath = Join-Path $currentPublicDir 'web.config' - $effectiveDbPath = $DbPath - if([string]::IsNullOrWhiteSpace($effectiveDbPath)){ - $effectiveDbPath = Get-DataSourceFromConfig -ConfigPath $currentConfigPath + $commit = (& git -C $SourcePath rev-parse --short=12 HEAD).Trim() + if ($LASTEXITCODE -ne 0) { + throw 'Could not determine the Git commit.' } - - if([string]::IsNullOrWhiteSpace($effectiveDbPath)){ - throw 'No database path was provided and no existing Data Source could be read from the current web.config' + 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 CI checkout, or pass -AllowAnyBranch explicitly for a reviewed non-Git source tree.' } +} - Write-Host "Stopping IIS site $SiteName and app pool $AppPool" - try { Stop-Website -Name $SiteName } catch { } - try { Stop-WebAppPool -Name $AppPool } catch { } - - Ensure-Dir (Split-Path $WorkDir -Parent) - if((Test-Path $WorkDir) -and !(Test-Path (Join-Path $WorkDir '.git'))){ - $bak = ($WorkDir.TrimEnd('\') + '_pre_git_' + (Get-Date -Format 'yyyyMMdd_HHmmss')) - Write-Host "Existing non-git folder detected. Moving to $bak" - Move-Item -Force $WorkDir $bak - } +if ([string]::IsNullOrWhiteSpace($ReleaseId)) { + $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 .' +} +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(!(Test-Path $WorkDir)){ - Write-Host "Cloning $Repo -> $WorkDir" - git clone $Repo $WorkDir +$workRoot = Join-Path ([System.IO.Path]::GetTempPath()) ('asp-iis-deploy-' + [Guid]::NewGuid().ToString('N')) +$packageStage = Join-Path $workRoot 'package' +$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 { + if (-not $Rollback -and -not $RemotePreflightOnly) { + Write-Step 'Staging the full repository for packaging' + Copy-ReleaseSource -From $SourcePath -To $packageStage + Add-Type -AssemblyName System.IO.Compression.FileSystem + [System.IO.Compression.ZipFile]::CreateFromDirectory( + $packageStage, + $packagePath, + [System.IO.Compression.CompressionLevel]::Optimal, + $false + ) + $sha256 = (Get-FileHash -LiteralPath $packagePath -Algorithm SHA256).Hash + Write-Host "Package: $packagePath" + Write-Host "SHA-256: $sha256" + } else { + $sha256 = '' } - Push-Location $WorkDir - try { - Write-Host "Updating to origin/$Branch" - git fetch origin - git checkout $Branch - & git reset --hard ("origin/" + $Branch) - } finally { - Pop-Location + $remoteArguments = New-Object 'System.Collections.Generic.List[string]' + $remoteArguments.Add('&') + $remoteArguments.Add((ConvertTo-SingleQuotedPowerShell $remoteInstaller)) + Add-RemoteArgument -Arguments $remoteArguments -Name '-SiteName' -Value $SiteName + + 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 { + $remoteArguments.Add('-PreflightOnly') } - if((Split-Path $WorkDir -Leaf).ToLower() -eq 'public'){ - $WorkDir = Split-Path $WorkDir -Parent + if (-not [string]::IsNullOrWhiteSpace($DeployRoot)) { + Add-RemoteArgument -Arguments $remoteArguments -Name '-DeployRoot' -Value $DeployRoot } - - $PublicDir = Join-Path $WorkDir 'public' - $cfg = Join-Path $PublicDir 'web.config' - - Set-ItemProperty ('IIS:\Sites\' + $SiteName) -Name physicalPath -Value $PublicDir - Set-ItemProperty ('IIS:\Sites\' + $SiteName) -Name applicationPool -Value $AppPool - Set-ItemProperty ('IIS:\AppPools\' + $AppPool) -Name processModel.identityType -Value NetworkService - - Set-DataSourceInConfig -ConfigPath $cfg -EffectiveDbPath $effectiveDbPath - - $dbFolder = Split-Path $effectiveDbPath -Parent - if(!(Test-Path $dbFolder)){ - Ensure-Dir $dbFolder + if (-not [string]::IsNullOrWhiteSpace($BaseUrl)) { + Add-RemoteArgument -Arguments $remoteArguments -Name '-BaseUrl' -Value $BaseUrl } - icacls $dbFolder /grant ("IIS AppPool\" + $AppPool + ":(OI)(CI)(M)") /T | Out-Null - - Push-Location $WorkDir - try { - if($RunMigrations){ - Write-Host 'Running standard migrations' - cscript //nologo scripts\runMigrations.vbs up - } - - if(-not $SkipLegacyIsBusinessMigration){ - $legacyPath = Join-Path $WorkDir $LegacyMigrationScript - if(!(Test-Path $legacyPath)){ - throw "Legacy migration script not found: $legacyPath" - } - - Write-Host 'Running legacy IsBusiness migration' - cscript //nologo $legacyPath $effectiveDbPath + Add-RemoteArgument -Arguments $remoteArguments -Name '-KeepReleases' -Value $KeepReleases.ToString() + if ($RunMigrations) { $remoteArguments.Add('-RunMigrations') } + if ($SkipSmokeTest) { $remoteArguments.Add('-SkipSmokeTest') } + + $remoteScript = $remoteArguments -join ' ' + $remoteBytes = [Text.Encoding]::Unicode.GetBytes($remoteScript) + $remoteEncodedCommand = [Convert]::ToBase64String($remoteBytes) + $remoteCommand = 'powershell.exe -NoProfile -NonInteractive -ExecutionPolicy RemoteSigned -EncodedCommand ' + $remoteEncodedCommand + Write-Host "Remote target: $RemoteTarget (Tailscale/OpenSSH port $RemotePort)" + + if ($DryRun) { + 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" } - } finally { - Pop-Location + Write-Host ('Would execute host script: ' + $remoteScript) + Write-Host ('Transport command uses PowerShell -EncodedCommand to avoid remote-shell quoting ambiguity.') + exit 0 } - if((Get-WebAppPoolState -Name $AppPool).Value -eq 'Started'){ - Restart-WebAppPool -Name $AppPool - } else { - Start-WebAppPool -Name $AppPool - } - Start-Website $SiteName + Assert-Command $SshExe + Assert-Command $ScpExe - Start-Sleep -Seconds 1 + Write-Step 'Creating remote staging directory over Tailscale/OpenSSH' + $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) + if ($LASTEXITCODE -ne 0) { throw 'Remote directory creation failed.' } - $paths = @('/','/territories','/households','/householder-names') - foreach($path in $paths){ - $url = $BaseUrl + $path - $response = Invoke-WebRequest -UseBasicParsing -Uri $url -TimeoutSec 30 - Write-Host ("OK " + $path + ' -> ' + $response.StatusCode) - } + 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.' } - Write-Host 'Deploy complete.' -} - -if($UseRemoteSsh -and !$RunRemoteCore -and [string]::IsNullOrWhiteSpace($RemoteTarget)){ - $RemoteTarget = Get-DefaultRemoteTargetFromInfo -} - -if($UseRemoteSsh -and !$RunRemoteCore -and -not [string]::IsNullOrWhiteSpace($RemoteTarget)){ - Ensure-Command $SshExe - Ensure-Command $ScpExe - - $remoteScriptPath = 'C:\Windows\Temp\deploy-test-territory-git.ps1' - $scpDestination = "${RemoteTarget}:C:/Windows/Temp/deploy-test-territory-git.ps1" - - Write-Host "Copying deploy script to $RemoteTarget" - & $ScpExe -P $RemotePort $PSCommandPath $scpDestination - if($LASTEXITCODE -ne 0){ throw 'scp failed' } - - $remoteCommand = New-Object System.Collections.Generic.List[string] - @( - 'powershell', - '-NoProfile', - '-ExecutionPolicy', 'Bypass', - '-File', (ConvertTo-CmdDoubleQuoted $remoteScriptPath), - '-RunRemoteCore', - '-Repo', (ConvertTo-CmdDoubleQuoted $Repo), - '-Branch', (ConvertTo-CmdDoubleQuoted $Branch), - '-SiteName', (ConvertTo-CmdDoubleQuoted $SiteName) - ) | ForEach-Object { [void]$remoteCommand.Add($_) } - - if(-not [string]::IsNullOrWhiteSpace($AppPool)){ - [void]$remoteCommand.Add('-AppPool') - [void]$remoteCommand.Add((ConvertTo-CmdDoubleQuoted $AppPool)) + if (-not $Rollback -and -not $RemotePreflightOnly) { + Write-Step 'Copying the release package' + & $ScpExe -P $RemotePort $packagePath ($RemoteTarget + ':' + $remoteDirectory.Replace('\', '/') + '/' + $ReleaseId + '.zip') + if ($LASTEXITCODE -ne 0) { throw 'Package copy failed.' } } - if(-not [string]::IsNullOrWhiteSpace($WorkDir)){ - [void]$remoteCommand.Add('-WorkDir') - [void]$remoteCommand.Add((ConvertTo-CmdDoubleQuoted $WorkDir)) - } + Write-Step 'Invoking the host-side installer' + & $SshExe -p $RemotePort $RemoteTarget $remoteCommand + if ($LASTEXITCODE -ne 0) { throw "Remote installer failed with exit code $LASTEXITCODE." } - if(-not [string]::IsNullOrWhiteSpace($PublicDir)){ - [void]$remoteCommand.Add('-PublicDir') - [void]$remoteCommand.Add((ConvertTo-CmdDoubleQuoted $PublicDir)) + 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(-not [string]::IsNullOrWhiteSpace($BaseUrl)){ - [void]$remoteCommand.Add('-BaseUrl') - [void]$remoteCommand.Add((ConvertTo-CmdDoubleQuoted $BaseUrl)) + if (Test-Path -LiteralPath $workRoot) { + Remove-Item -LiteralPath $workRoot -Recurse -Force } - - if(-not [string]::IsNullOrWhiteSpace($DbPath)){ - [void]$remoteCommand.Add('-DbPath') - [void]$remoteCommand.Add((ConvertTo-CmdDoubleQuoted $DbPath)) - } - - if(-not [string]::IsNullOrWhiteSpace($LegacyMigrationScript)){ - [void]$remoteCommand.Add('-LegacyMigrationScript') - [void]$remoteCommand.Add((ConvertTo-CmdDoubleQuoted $LegacyMigrationScript)) - } - - if($RunMigrations){ $remoteCommand += '-RunMigrations' } - if($SkipLegacyIsBusinessMigration){ $remoteCommand += '-SkipLegacyIsBusinessMigration' } - - Write-Host "Executing remote deploy on $RemoteTarget" - & $SshExe -p $RemotePort $RemoteTarget ($remoteCommand -join ' ') - if($LASTEXITCODE -ne 0){ throw 'remote deploy failed' } - - exit 0 } - -Invoke-DeployCore