Переглянути джерело

ci: isolate deployment from existing IIS sites

ci-iis-release-pipeline
Clawdbot 4 години тому
джерело
коміт
4d04bfa099
1 змінених файлів з 169 додано та 114 видалено
  1. +169
    -114
      scripts/deploy-iis-git.ps1

+ 169
- 114
scripts/deploy-iis-git.ps1 Переглянути файл

@@ -1,29 +1,33 @@
<#
.SYNOPSIS
Packages this repository and deploys it to IIS over Tailscale/OpenSSH.
Validates, packages, and deploys this repository to its dedicated IIS site.

.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()]
param(
[Parameter(Mandatory = $true)]
[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',
[ValidateRange(1, 65535)]
[int]$RemotePort = 22,
[string]$SourcePath = (Split-Path $PSScriptRoot -Parent),
[string]$DeployRoot = '',
[string]$BaseUrl = '',
[string]$ReleaseId = '',
[string]$ExpectedBranch = 'master',
@@ -35,7 +39,9 @@ param(
[switch]$AllowDirty,
[switch]$RunMigrations,
[switch]$SkipSmokeTest,
[switch]$RemotePreflightOnly,
[Alias('HostPreflightOnly', 'RemotePreflightOnly')]
[switch]$PreflightOnly,
[switch]$LocalPreflightOnly,
[switch]$Rollback,
[string]$RollbackTo = '',
[switch]$DryRun,
@@ -45,10 +51,7 @@ param(
Set-StrictMode -Version 2.0
$ErrorActionPreference = 'Stop'

function Write-Step {
param([string]$Message)
Write-Host ('==> ' + $Message)
}
function Write-Step { param([string]$Message) Write-Host ('==> ' + $Message) }

function Assert-Command {
param([string]$Name)
@@ -72,177 +75,232 @@ function Add-RemoteArgument {
$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
)
param([string]$From, [string]$To)

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
$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')
$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 ($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'. Use -AllowAnyBranch only for an intentional exception."
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 ($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. 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()
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 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.'
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 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'))
$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 {
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
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]::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"
} 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.Add('&')
$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) {
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')
} 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 ($SkipSmokeTest) { $remoteArguments.Add('-SkipSmokeTest') }

$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
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"
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
}

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"
$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.' }

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 -and -not $RemotePreflightOnly) {
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.' }
@@ -251,7 +309,6 @@ try {
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)) {
@@ -259,7 +316,5 @@ try {
Copy-Item -LiteralPath $packagePath -Destination $keptPath -Force
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.