Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.

324 строки
14KB

  1. <#
  2. .SYNOPSIS
  3. Validates, packages, and deploys this repository to its dedicated IIS site.
  4. .DESCRIPTION
  5. The controller packages the complete repository, computes SHA-256, transfers
  6. the package and host installer over OpenSSH, and invokes the installer.
  7. -LocalPreflightOnly validates source provenance, XML, package layout, and
  8. archive safety without connecting. -PreflightOnly additionally streams the
  9. installer to the host and runs its read-only IIS preflight without writing a
  10. remote installer or package.
  11. #>
  12. [CmdletBinding()]
  13. param(
  14. [ValidatePattern('^[A-Za-z0-9_. -]+$')]
  15. [string]$SiteName = 'postcardmojo.com',
  16. [ValidatePattern('^[A-Za-z0-9_. -]+$')]
  17. [string]$AppPoolName = 'postcardmojo.com',
  18. [string]$DeployRoot = 'C:\inetpub\postcardmojo',
  19. [string]$BindingIpAddress = '192.168.1.115',
  20. [ValidateRange(1, 65535)]
  21. [int]$BindingPort = 80,
  22. [AllowEmptyString()]
  23. [string]$HostHeader = 'postcardmojo.com',
  24. [string]$InitialWebConfigPath = '',
  25. [string]$RemoteTarget = 'Administrator@100.65.129.203',
  26. [ValidateRange(1, 65535)]
  27. [int]$RemotePort = 22,
  28. [string]$SourcePath = (Split-Path $PSScriptRoot -Parent),
  29. [string]$BaseUrl = '',
  30. [string]$ReleaseId = '',
  31. [string]$ExpectedBranch = 'master',
  32. [ValidateRange(2, 100)]
  33. [int]$KeepReleases = 5,
  34. [string]$SshExe = 'ssh',
  35. [string]$ScpExe = 'scp',
  36. [switch]$AllowAnyBranch,
  37. [switch]$AllowDirty,
  38. [switch]$RunMigrations,
  39. [switch]$AdoptExisting,
  40. [switch]$SkipSmokeTest,
  41. [Alias('HostPreflightOnly', 'RemotePreflightOnly')]
  42. [switch]$PreflightOnly,
  43. [switch]$LocalPreflightOnly,
  44. [switch]$Rollback,
  45. [string]$RollbackTo = '',
  46. [switch]$DryRun,
  47. [switch]$KeepPackage
  48. )
  49. Set-StrictMode -Version 2.0
  50. $ErrorActionPreference = 'Stop'
  51. function Write-Step { param([string]$Message) Write-Host ('==> ' + $Message) }
  52. function Assert-Command {
  53. param([string]$Name)
  54. if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) {
  55. throw "$Name was not found on PATH."
  56. }
  57. }
  58. function ConvertTo-SingleQuotedPowerShell {
  59. param([string]$Value)
  60. return "'" + $Value.Replace("'", "''") + "'"
  61. }
  62. function Add-RemoteArgument {
  63. param(
  64. [System.Collections.Generic.List[string]]$Arguments,
  65. [string]$Name,
  66. [string]$Value
  67. )
  68. $Arguments.Add($Name)
  69. $Arguments.Add((ConvertTo-SingleQuotedPowerShell $Value))
  70. }
  71. function Assert-SafeNameValue {
  72. param([string]$Name, [string]$Value)
  73. if ([string]::IsNullOrWhiteSpace($Value) -or $Value -notmatch '^[A-Za-z0-9_. -]+$') {
  74. throw "$Name contains unsupported characters."
  75. }
  76. if ($Value -match '(?i)schedulicious') {
  77. throw "$Name must not identify a Schedulicious resource."
  78. }
  79. }
  80. function Assert-SafeDeploymentValues {
  81. Assert-SafeNameValue -Name 'SiteName' -Value $SiteName
  82. Assert-SafeNameValue -Name 'AppPoolName' -Value $AppPoolName
  83. if ($DeployRoot -match '(?i)schedulicious') {
  84. throw 'DeployRoot must not reference Schedulicious.'
  85. }
  86. if ([string]::IsNullOrWhiteSpace($BindingIpAddress)) {
  87. throw 'BindingIpAddress must not be empty.'
  88. }
  89. $parsedAddress = $null
  90. if (-not [System.Net.IPAddress]::TryParse($BindingIpAddress, [ref]$parsedAddress)) {
  91. throw "BindingIpAddress is not a valid IP address: $BindingIpAddress"
  92. }
  93. if ($HostHeader -match '[:/\\]') {
  94. throw 'HostHeader must be empty or a DNS host name without a scheme, port, slash, or backslash.'
  95. }
  96. if ($HostHeader -match '(?i)schedulicious') {
  97. throw 'HostHeader must not reference Schedulicious.'
  98. }
  99. }
  100. function Copy-ReleaseSource {
  101. param([string]$From, [string]$To)
  102. $excludedNames = @('.git', '.deployment', 'releases')
  103. $packageRoots = @(Get-ChildItem -LiteralPath $From -Force | Where-Object { $excludedNames -notcontains $_.Name })
  104. $unsafeSourceItem = $packageRoots |
  105. ForEach-Object {
  106. if (($_.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { $_ }
  107. elseif ($_.PSIsContainer) { Get-ChildItem -LiteralPath $_.FullName -Recurse -Force }
  108. } |
  109. Where-Object { ($_.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 } |
  110. Select-Object -First 1
  111. if ($null -ne $unsafeSourceItem) {
  112. throw "Source contains a reparse point/symbolic link, which is not package-safe: $($unsafeSourceItem.FullName)"
  113. }
  114. New-Item -ItemType Directory -Force -Path $To | Out-Null
  115. $packageRoots | ForEach-Object {
  116. if ($excludedNames -contains $_.Name) { return }
  117. if (($_.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
  118. throw "Source contains a reparse point/symbolic link, which is not package-safe: $($_.FullName)"
  119. }
  120. Copy-Item -LiteralPath $_.FullName -Destination $To -Recurse -Force
  121. }
  122. $unsafeEntry = Get-ChildItem -LiteralPath $To -Recurse -Force |
  123. Where-Object { ($_.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 } |
  124. Select-Object -First 1
  125. if ($null -ne $unsafeEntry) {
  126. throw "Package staging contains a reparse point/symbolic link: $($unsafeEntry.FullName)"
  127. }
  128. }
  129. function Assert-ReleaseLayout {
  130. param([string]$Root)
  131. $required = @(
  132. 'public\Default.asp',
  133. 'public\web.config',
  134. 'core\autoload_core.asp',
  135. 'app\controllers\autoload_controllers.asp',
  136. 'scripts\install-iis-release.ps1'
  137. )
  138. foreach ($relativePath in $required) {
  139. if (-not (Test-Path -LiteralPath (Join-Path $Root $relativePath) -PathType Leaf)) {
  140. throw "Package/source tree is incomplete; missing $relativePath"
  141. }
  142. }
  143. foreach ($xmlFile in Get-ChildItem -LiteralPath $Root -Recurse -Force -Filter 'web.config') {
  144. try {
  145. [xml](Get-Content -LiteralPath $xmlFile.FullName -Raw) | Out-Null
  146. } catch {
  147. throw "$($xmlFile.FullName) is not valid XML: $($_.Exception.Message)"
  148. }
  149. }
  150. }
  151. Assert-SafeDeploymentValues
  152. $SourcePath = [System.IO.Path]::GetFullPath($SourcePath)
  153. $installerPath = Join-Path $PSScriptRoot 'install-iis-release.ps1'
  154. Assert-ReleaseLayout -Root $SourcePath
  155. $directGitRoot = Test-Path -LiteralPath (Join-Path $SourcePath '.git')
  156. $commit = 'nogit'
  157. if ($directGitRoot) {
  158. Assert-Command 'git'
  159. $branch = (& git -C $SourcePath branch --show-current).Trim()
  160. if ($LASTEXITCODE -ne 0) { throw 'Could not determine the Git branch.' }
  161. if ((-not $AllowAnyBranch) -and $branch -ne $ExpectedBranch) {
  162. throw "Refusing to deploy branch '$branch'; expected '$ExpectedBranch'."
  163. }
  164. $dirty = & git -C $SourcePath status --porcelain
  165. if ($LASTEXITCODE -ne 0) { throw 'Could not inspect the Git worktree.' }
  166. if ((-not $AllowDirty) -and $null -ne $dirty -and @($dirty).Count -gt 0) {
  167. throw 'Refusing to deploy a dirty worktree. Use -AllowDirty only for a reviewed exception.'
  168. }
  169. $commit = (& git -C $SourcePath rev-parse --short=12 HEAD).Trim()
  170. if ($LASTEXITCODE -ne 0) { throw 'Could not determine the Git commit.' }
  171. Write-Host "Source branch: $branch"
  172. Write-Host "Source commit: $commit"
  173. } else {
  174. Write-Warning 'SourcePath is not a standalone Git checkout; branch and dirty-worktree checks cannot be enforced.'
  175. if (-not $AllowAnyBranch) {
  176. throw 'Use a standalone checkout, or pass -AllowAnyBranch for a reviewed non-Git source tree.'
  177. }
  178. }
  179. if ([string]::IsNullOrWhiteSpace($ReleaseId)) {
  180. $ReleaseId = (Get-Date -Format 'yyyyMMdd-HHmmss') + '-' + $commit
  181. }
  182. if ($ReleaseId -notmatch '^[A-Za-z0-9._-]+$') { throw 'ReleaseId contains unsupported characters.' }
  183. if ($Rollback -and [string]::IsNullOrWhiteSpace($RollbackTo)) { throw '-Rollback requires -RollbackTo.' }
  184. if ((-not $Rollback) -and -not [string]::IsNullOrWhiteSpace($RollbackTo)) { throw '-RollbackTo requires -Rollback.' }
  185. if ($RunMigrations -and $Rollback) { throw '-RunMigrations is not valid during rollback.' }
  186. if ($AdoptExisting -and $Rollback) { throw '-AdoptExisting is not valid during rollback.' }
  187. if (($PreflightOnly -or $LocalPreflightOnly) -and $Rollback) {
  188. throw 'Preflight modes cannot be combined with -Rollback.'
  189. }
  190. if ($PreflightOnly -and $LocalPreflightOnly) {
  191. throw '-PreflightOnly and -LocalPreflightOnly are mutually exclusive.'
  192. }
  193. $workRoot = Join-Path ([System.IO.Path]::GetTempPath()) ('asp-iis-deploy-' + [Guid]::NewGuid().ToString('N'))
  194. $packageStage = Join-Path $workRoot 'package'
  195. $packageExtract = Join-Path $workRoot 'verify'
  196. $packagePath = Join-Path $workRoot ($ReleaseId + '.zip')
  197. $remoteDirectory = 'C:\Windows\Temp\asp-iis-deploy-' + $ReleaseId
  198. $remotePackage = $remoteDirectory + '\' + $ReleaseId + '.zip'
  199. $remoteInstaller = $remoteDirectory + '\install-iis-release.ps1'
  200. try {
  201. $sha256 = ''
  202. if (-not $Rollback) {
  203. Write-Step 'Staging the complete repository for packaging'
  204. Copy-ReleaseSource -From $SourcePath -To $packageStage
  205. Assert-ReleaseLayout -Root $packageStage
  206. Add-Type -AssemblyName System.IO.Compression.FileSystem
  207. [System.IO.Compression.ZipFile]::CreateFromDirectory($packageStage, $packagePath, [System.IO.Compression.CompressionLevel]::Optimal, $false)
  208. [System.IO.Compression.ZipFile]::ExtractToDirectory($packagePath, $packageExtract)
  209. Assert-ReleaseLayout -Root $packageExtract
  210. $sha256 = (Get-FileHash -LiteralPath $packagePath -Algorithm SHA256).Hash
  211. Write-Host "Package: $packagePath"
  212. Write-Host "SHA-256: $sha256"
  213. }
  214. if ($LocalPreflightOnly) {
  215. Write-Step 'Local/controller preflight passed; no network connection or host change was made'
  216. exit 0
  217. }
  218. $remoteArguments = New-Object 'System.Collections.Generic.List[string]'
  219. $remoteArguments.Add('&')
  220. $remoteArguments.Add((ConvertTo-SingleQuotedPowerShell $remoteInstaller))
  221. Add-RemoteArgument $remoteArguments '-SiteName' $SiteName
  222. Add-RemoteArgument $remoteArguments '-AppPoolName' $AppPoolName
  223. Add-RemoteArgument $remoteArguments '-DeployRoot' $DeployRoot
  224. Add-RemoteArgument $remoteArguments '-BindingIpAddress' $BindingIpAddress
  225. Add-RemoteArgument $remoteArguments '-BindingPort' $BindingPort.ToString()
  226. Add-RemoteArgument $remoteArguments '-HostHeader' $HostHeader
  227. Add-RemoteArgument $remoteArguments '-KeepReleases' $KeepReleases.ToString()
  228. if (-not [string]::IsNullOrWhiteSpace($InitialWebConfigPath)) { Add-RemoteArgument $remoteArguments '-InitialWebConfigPath' $InitialWebConfigPath }
  229. if (-not [string]::IsNullOrWhiteSpace($BaseUrl)) { Add-RemoteArgument $remoteArguments '-BaseUrl' $BaseUrl }
  230. if ($Rollback) {
  231. Add-RemoteArgument $remoteArguments '-RollbackTo' $RollbackTo
  232. } elseif ($PreflightOnly) {
  233. $remoteArguments.Add('-PreflightOnly')
  234. } else {
  235. Add-RemoteArgument $remoteArguments '-PackagePath' $remotePackage
  236. Add-RemoteArgument $remoteArguments '-ReleaseId' $ReleaseId
  237. Add-RemoteArgument $remoteArguments '-ExpectedSha256' $sha256
  238. }
  239. if ($RunMigrations) { $remoteArguments.Add('-RunMigrations') }
  240. if ($AdoptExisting) { $remoteArguments.Add('-AdoptExisting') }
  241. if ($SkipSmokeTest) { $remoteArguments.Add('-SkipSmokeTest') }
  242. $remoteScript = $remoteArguments -join ' '
  243. $remoteEncodedCommand = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($remoteScript))
  244. $remoteCommand = 'powershell.exe -NoProfile -NonInteractive -ExecutionPolicy RemoteSigned -EncodedCommand ' + $remoteEncodedCommand
  245. if ($DryRun) {
  246. Write-Step 'Dry-run complete; no network connection was made'
  247. Write-Host "Would use dedicated site '$SiteName' and app pool '$AppPoolName'."
  248. Write-Host "Would use binding ${BindingIpAddress}:$BindingPort with host header '$HostHeader'."
  249. if ($PreflightOnly) {
  250. Write-Host 'Would stream the installer over SSH for a read-only host preflight; no remote file would be written.'
  251. } else {
  252. Write-Host "Would create remote directory: $remoteDirectory"
  253. Write-Host "Would copy installer: $installerPath"
  254. if (-not $Rollback) { Write-Host "Would copy package: $packagePath" }
  255. Write-Host ('Would execute host script: ' + $remoteScript)
  256. }
  257. exit 0
  258. }
  259. Assert-Command $SshExe
  260. if ($PreflightOnly) {
  261. Write-Step 'Streaming the installer for read-only host preflight'
  262. $installerSource = Get-Content -LiteralPath $installerPath -Raw
  263. $argumentTail = @($remoteArguments | Select-Object -Skip 2) -join ' '
  264. $stdinScript = "& {`r`n" + $installerSource + "`r`n} " + $argumentTail
  265. $stdinScript | & $SshExe -p $RemotePort $RemoteTarget 'powershell.exe -NoProfile -NonInteractive -ExecutionPolicy RemoteSigned -Command -'
  266. if ($LASTEXITCODE -ne 0) { throw "Remote preflight failed with exit code $LASTEXITCODE." }
  267. Write-Step 'Remote host preflight completed successfully without persistent host changes'
  268. exit 0
  269. }
  270. Assert-Command $ScpExe
  271. Write-Step 'Creating remote temporary directory'
  272. $mkdirScript = "New-Item -ItemType Directory -Force -Path $(ConvertTo-SingleQuotedPowerShell $remoteDirectory) | Out-Null"
  273. $mkdirEncoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($mkdirScript))
  274. & $SshExe -p $RemotePort $RemoteTarget ('powershell.exe -NoProfile -NonInteractive -EncodedCommand ' + $mkdirEncoded)
  275. if ($LASTEXITCODE -ne 0) { throw 'Remote directory creation failed.' }
  276. Write-Step 'Copying the host installer'
  277. & $ScpExe -P $RemotePort $installerPath ($RemoteTarget + ':' + $remoteDirectory.Replace('\', '/') + '/install-iis-release.ps1')
  278. if ($LASTEXITCODE -ne 0) { throw 'Installer copy failed.' }
  279. if (-not $Rollback) {
  280. Write-Step 'Copying the release package'
  281. & $ScpExe -P $RemotePort $packagePath ($RemoteTarget + ':' + $remoteDirectory.Replace('\', '/') + '/' + $ReleaseId + '.zip')
  282. if ($LASTEXITCODE -ne 0) { throw 'Package copy failed.' }
  283. }
  284. Write-Step 'Invoking the host-side installer'
  285. & $SshExe -p $RemotePort $RemoteTarget $remoteCommand
  286. if ($LASTEXITCODE -ne 0) { throw "Remote installer failed with exit code $LASTEXITCODE." }
  287. Write-Step 'Remote operation completed successfully'
  288. } finally {
  289. if ($KeepPackage -and (Test-Path -LiteralPath $packagePath)) {
  290. $keptPath = Join-Path (Get-Location) ([System.IO.Path]::GetFileName($packagePath))
  291. Copy-Item -LiteralPath $packagePath -Destination $keptPath -Force
  292. Write-Host "Package retained at $keptPath"
  293. }
  294. if (Test-Path -LiteralPath $workRoot) { Remove-Item -LiteralPath $workRoot -Recurse -Force }
  295. }

Powered by TurnKey Linux.