소스 검색

Add transactional remote deployment tooling

master
Open Claw 2 주 전
부모
커밋
f7dde8fedf
4개의 변경된 파일과 739개의 추가작업 그리고 3개의 파일을 삭제
  1. +30
    -3
      README.md
  2. +120
    -0
      tests/Invoke-SelfTest.ps1
  3. +155
    -0
      tools/Deploy-Remote.ps1
  4. +434
    -0
      tools/Invoke-RemoteInstall.ps1

+ 30
- 3
README.md 파일 보기

@@ -13,7 +13,30 @@ Two separate IIS sites sharing only the framework components:
- `logs/`, `test-app/logs/` — each site's own runtime log, written by the app, never served over HTTP.
- `tests/`, `tools/`, `docs/` — test scripts, deployment/registration tooling, and documentation. Never served over HTTP.

## Deploy and register (on the target Windows/IIS host)
## Deploy

### Remote transactional deployment

Run the controller from a Windows checkout with Git, OpenSSH `ssh`/`scp`, and existing key or agent authentication for the target. It never accepts or stores credentials. The remote account must be elevated and the target must have Windows PowerShell 5.1, IIS, Classic ASP, URL Rewrite, and the `WebAdministration` module.

```powershell
powershell -File tools\Deploy-Remote.ps1 `
-RemoteHost Administrator@win2025test `
-RemoteProjectPath C:\Projects\wsc-mvc `
-ProductionSiteName WscMvc -ProductionPoolName WscMvc -ProductionPort 8090 `
-ProductionBaseUrl http://localhost:8090 `
-TestSiteName WscMvcTests -TestPoolName WscMvcTests -TestPort 8091 `
-TestBaseUrl http://localhost:8091 `
-RunTests
```

The controller packages only paths reported by `git ls-files`, so new deployment files must be tracked before using it. `-PullOriginMaster` optionally runs `git pull --ff-only origin master` and refuses a dirty worktree; it is never implicit. The controller reports a SHA-256 digest and the remote installer verifies it before changing the target. The remote preflight also validates the archive, target path, IIS site ownership, app-pool ownership, binding conflicts, and global COM registration ownership. It refuses partial or foreign ProgID/CLSID state rather than overwriting it. It then swaps the complete project tree into place, registers every WSC (including `WscMvc.ViewRenderer`), and reconciles both IIS sites through `Setup-Site.ps1`.

When replacing an existing project tree, the prior tree is retained as `<RemoteProjectPath>.rollback.<UTC timestamp>-<id>`. If registration, IIS reconciliation, or optional tests fail, the installer restores that tree, re-registers its components, restores pre-existing site run states, and removes only sites/app pools created by that invocation. It refuses to adopt mismatched IIS resources and does not alter unrelated sites, pools, or bindings.

With `-RunTests`, deployment succeeds only after the WSH component suite, IIS HTTP suite, and JSON self-test client all pass. Omit it to deploy without those post-cutover checks. Base URLs are used by the optional tests and may differ from the binding ports when local DNS or host headers require it.

### Direct setup on the target host

```powershell
powershell -File tools\Register-Components.ps1
@@ -21,18 +44,22 @@ powershell -File tools\Setup-Site.ps1
powershell -File tools\Setup-Site.ps1 -SiteName WscMvcTests -PoolName WscMvcTests -PhysicalPath <repo>\test-app\public -Port 8091
```

Both `Setup-Site.ps1` invocations are idempotent — safe to re-run after any deploy.
Both `Setup-Site.ps1` invocations are idempotent for project-owned resources.

## Test

```powershell
cscript //nologo tests\Test-Components.vbs # WSH smoke test, no IIS needed
powershell -File tests\Test-Http.ps1 -BaseUrl http://localhost:8090 -TestBaseUrl http://localhost:8091
powershell -File tests\Invoke-SelfTest.ps1 -BaseUrl http://localhost:8091
```

`Invoke-SelfTest.ps1` calls `/self-test`, parses its JSON, prints every reported check as `PASS` or `FAIL`, confirms the test-site root also returns a passing self-test, and confirms direct `Default.asp?route=/hello` access returns 404. It exits nonzero for any request, contract, check, root-mapping, or route-isolation failure.

```bash
./tests/run-self-test.sh http://<host>:8091 # test-app site, plain curl+JSON, any CLI
```

## Status

M0–M3 gated PASS with real test evidence on Windows/IIS; see `docs/TEST-RESULTS.md`. Next milestone: M4 (safe separate HTML templates and encoding) per `IMPLEMENTATION_PLAN.md`.
M0–M4 gated PASS with real test evidence on Windows/IIS; see `docs/TEST-RESULTS.md`. M5 (data) is the next framework milestone; the deployment tooling contributes to the later M6/M7 operational gates but is not live-host verified by this README alone.

+ 120
- 0
tests/Invoke-SelfTest.ps1 파일 보기

@@ -0,0 +1,120 @@
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$BaseUrl
)

$ErrorActionPreference = 'Stop'
Set-StrictMode -Version 2.0
$script:Failures = 0

function Report-Result {
param(
[Parameter(Mandatory = $true)][bool]$Passed,
[Parameter(Mandatory = $true)][string]$Name,
[string]$Detail
)

if ($Passed) {
if ($Detail) { Write-Output "PASS: $Name ($Detail)" } else { Write-Output "PASS: $Name" }
} else {
if ($Detail) { Write-Output "FAIL: $Name ($Detail)" } else { Write-Output "FAIL: $Name" }
$script:Failures++
}
}

function Invoke-HttpRequest {
param([Parameter(Mandatory = $true)][string]$Uri)

$request = [System.Net.HttpWebRequest]::Create($Uri)
$request.Method = 'GET'
$request.AllowAutoRedirect = $false
$response = $null
try {
try {
$response = $request.GetResponse()
} catch [System.Net.WebException] {
if ($_.Exception.Response) {
$response = $_.Exception.Response
} else {
throw
}
}

$reader = New-Object System.IO.StreamReader($response.GetResponseStream())
try {
$body = $reader.ReadToEnd()
} finally {
$reader.Dispose()
}

return [PSCustomObject]@{
StatusCode = [int]$response.StatusCode
ContentType = [string]$response.ContentType
Body = $body
}
} finally {
if ($response) { $response.Dispose() }
}
}

function Read-SelfTestJson {
param(
[Parameter(Mandatory = $true)][string]$Uri,
[Parameter(Mandatory = $true)][string]$Label,
[switch]$PrintChecks
)

try {
$response = Invoke-HttpRequest -Uri $Uri
Report-Result ($response.StatusCode -eq 200) "$Label HTTP status 200" "got $($response.StatusCode)"
if ($response.StatusCode -ne 200) { return }

try {
$document = $response.Body | ConvertFrom-Json
} catch {
Report-Result $false "$Label JSON parses" $_.Exception.Message
return
}

Report-Result $true "$Label JSON parses" $null
if (-not $document.PSObject.Properties['ok'] -or -not $document.PSObject.Properties['checks']) {
Report-Result $false "$Label JSON contract" "expected 'ok' and 'checks'"
return
}
Report-Result $true "$Label JSON contract" $null

if ($PrintChecks) {
foreach ($check in @($document.checks)) {
$name = [string]$check.name
if (-not $name) { $name = '<unnamed check>' }
$detail = [string]$check.detail
Report-Result ([bool]$check.pass) $name $detail
}
}

Report-Result ([bool]$document.ok) "$Label reports overall pass" $null
} catch {
Report-Result $false "$Label request" $_.Exception.Message
}
}

$normalizedBaseUrl = $BaseUrl.TrimEnd('/')
Read-SelfTestJson -Uri "$normalizedBaseUrl/self-test" -Label 'GET /self-test' -PrintChecks
Read-SelfTestJson -Uri "$normalizedBaseUrl/" -Label 'GET / (test root)'

try {
$directHello = Invoke-HttpRequest -Uri "$normalizedBaseUrl/Default.asp?route=/hello"
Report-Result ($directHello.StatusCode -eq 404) 'Direct Default.asp production route is rejected' "got HTTP $($directHello.StatusCode)"
} catch {
Report-Result $false 'Direct Default.asp production route is rejected' $_.Exception.Message
}

Write-Output '---'
if ($script:Failures -eq 0) {
Write-Output 'RESULT: ALL PASS'
exit 0
}

Write-Output "RESULT: $($script:Failures) FAILURE(S)"
exit 1

+ 155
- 0
tools/Deploy-Remote.ps1 파일 보기

@@ -0,0 +1,155 @@
[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
}
}

+ 434
- 0
tools/Invoke-RemoteInstall.ps1 파일 보기

@@ -0,0 +1,434 @@
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)][string]$ArchivePath,
[Parameter(Mandatory = $true)][string]$ProjectPath,
[Parameter(Mandatory = $true)][string]$InvocationId,
[Parameter(Mandatory = $true)]
[ValidatePattern('^[0-9A-Fa-f]{64}$')]
[string]$ExpectedArchiveSha256,
[Parameter(Mandatory = $true)][string]$ProductionSiteName,
[Parameter(Mandatory = $true)][string]$ProductionPoolName,
[Parameter(Mandatory = $true)][int]$ProductionPort,
[Parameter(Mandatory = $true)][string]$ProductionBaseUrl,
[Parameter(Mandatory = $true)][string]$TestSiteName,
[Parameter(Mandatory = $true)][string]$TestPoolName,
[Parameter(Mandatory = $true)][int]$TestPort,
[Parameter(Mandatory = $true)][string]$TestBaseUrl,
[switch]$RunTests
)

$ErrorActionPreference = 'Stop'
Set-StrictMode -Version 2.0

function Quote-PowerShellLiteral {
param([Parameter(Mandatory = $true)][string]$Value)
return "'" + $Value.Replace("'", "''") + "'"
}

function Invoke-WindowsPowerShell {
param([Parameter(Mandatory = $true)][string]$Command)
$encoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($Command))
$process = Start-Process -FilePath 'powershell.exe' -ArgumentList @(
'-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', $encoded
) -Wait -PassThru -NoNewWindow
return $process.ExitCode
}

function Assert-Administrator {
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = New-Object Security.Principal.WindowsPrincipal($identity)
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
throw 'Remote installation requires an elevated Windows account.'
}
}

function Get-SiteSnapshot {
param([string]$Name)
if (-not (Test-Path "IIS:\Sites\$Name")) { return $null }
$site = Get-Website -Name $Name
$parentPaths = (Get-WebConfigurationProperty -PSPath 'MACHINE/WEBROOT/APPHOST' -Location $Name -Filter 'system.webServer/asp' -Name 'enableParentPaths').Value
return [PSCustomObject]@{
Name = $Name
State = [string]$site.State
PhysicalPath = [string]$site.PhysicalPath
ApplicationPool = [string]$site.ApplicationPool
EnableParentPaths = [bool]$parentPaths
}
}

function Assert-SiteTarget {
param(
[string]$Name,
[string]$PoolName,
[string]$PhysicalPath,
[int]$Port
)

$snapshot = Get-SiteSnapshot -Name $Name
if ($snapshot) {
if ([IO.Path]::GetFullPath($snapshot.PhysicalPath).TrimEnd('\') -ne [IO.Path]::GetFullPath($PhysicalPath).TrimEnd('\')) {
throw "Existing site '$Name' has physical path '$($snapshot.PhysicalPath)', expected '$PhysicalPath'. Refusing to adopt or rewrite it."
}
if ($snapshot.ApplicationPool -ne $PoolName) {
throw "Existing site '$Name' uses app pool '$($snapshot.ApplicationPool)', expected '$PoolName'. Refusing to adopt it."
}
$httpBinding = @(Get-WebBinding -Name $Name -Protocol http | Where-Object { $_.bindingInformation -match ":$Port`:" })
if ($httpBinding.Count -ne 1) {
throw "Existing site '$Name' must have exactly one HTTP binding on port $Port."
}
}

$conflictingSiteNames = @()
foreach ($otherSite in @(Get-Website | Where-Object { $_.Name -ne $Name })) {
$matchingBindings = @(Get-WebBinding -Name $otherSite.Name -Protocol http | Where-Object { $_.bindingInformation -match ":$Port`:" })
if ($matchingBindings.Count -gt 0) { $conflictingSiteNames += $otherSite.Name }
}
if ($conflictingSiteNames.Count -gt 0) {
throw "HTTP port $Port is already used by unrelated site(s): $($conflictingSiteNames -join ', ')."
}
}

function Assert-PoolTarget {
param([string]$PoolName, [string]$AllowedSiteName)
if (-not (Test-Path "IIS:\AppPools\$PoolName")) { return }
$otherUsers = @(Get-Website | Where-Object { $_.ApplicationPool -eq $PoolName -and $_.Name -ne $AllowedSiteName })
if ($otherUsers.Count -gt 0) {
throw "App pool '$PoolName' is used by unrelated site(s): $($otherUsers.Name -join ', ')."
}
}

function Get-ComponentRegistrationMode {
param(
[Parameter(Mandatory = $true)][string]$ExistingProjectPath,
[Parameter(Mandatory = $true)][object[]]$Components
)

$absentCount = 0
$ownedCount = 0
foreach ($component in $Components) {
$progIdKey = "HKLM:\SOFTWARE\Classes\$($component.ProgId)"
$clsidKey = "HKLM:\SOFTWARE\Classes\CLSID\$($component.ClassId)"
$progIdExists = Test-Path -LiteralPath $progIdKey
$clsidExists = Test-Path -LiteralPath $clsidKey

if (-not $progIdExists -and -not $clsidExists) {
$absentCount++
continue
}
if (-not $progIdExists -or -not $clsidExists) {
throw "Component registration is partial for '$($component.ProgId)'. Refusing to overwrite or repair registry state that this invocation does not own."
}

$registeredClassId = (Get-ItemProperty -LiteralPath (Join-Path $progIdKey 'CLSID')).'(default)'
if ($registeredClassId -ne $component.ClassId) {
throw "ProgID '$($component.ProgId)' maps to '$registeredClassId', not this project's CLSID '$($component.ClassId)'."
}

$scriptletKey = Join-Path $clsidKey 'ScriptletURL'
if (-not (Test-Path -LiteralPath $scriptletKey)) {
throw "CLSID '$($component.ClassId)' has no ScriptletURL. Refusing to overwrite it."
}
$registeredPath = [string](Get-ItemProperty -LiteralPath $scriptletKey).'(default)'
$expectedPath = [IO.Path]::GetFullPath((Join-Path $ExistingProjectPath $component.RelativePath))
$registeredFilePath = $null
if (-not [string]::IsNullOrWhiteSpace($registeredPath)) {
$registeredUri = $null
if ([Uri]::TryCreate($registeredPath, [UriKind]::Absolute, [ref]$registeredUri) -and $registeredUri.IsFile) {
$registeredFilePath = [IO.Path]::GetFullPath($registeredUri.LocalPath)
} elseif ([IO.Path]::IsPathRooted($registeredPath)) {
$registeredFilePath = [IO.Path]::GetFullPath($registeredPath)
}
}
if (-not $registeredFilePath -or $registeredFilePath.TrimEnd('\') -ne $expectedPath.TrimEnd('\')) {
throw "CLSID '$($component.ClassId)' is registered from '$registeredPath', not the existing deployment path '$expectedPath'."
}
$ownedCount++
}

if ($absentCount -eq $Components.Count) { return 'Absent' }
if ($ownedCount -eq $Components.Count) { return 'Owned' }
throw 'Component registrations are a mixture of absent and existing entries. Refusing a deployment that could overwrite or delete unrelated registry state.'
}

function Restore-SiteState {
param($Snapshot)
if (-not $Snapshot -or -not (Test-Path "IIS:\Sites\$($Snapshot.Name)")) { return }
$parentPathsValue = if ($Snapshot.EnableParentPaths) { 'True' } else { 'False' }
$appcmd = "$env:windir\system32\inetsrv\appcmd.exe"
& $appcmd set config $Snapshot.Name -section:system.webServer/asp "/enableParentPaths:$parentPathsValue" /commit:apphost | Out-Null
if ($LASTEXITCODE -ne 0) { throw "Unable to restore enableParentPaths for site '$($Snapshot.Name)'." }
if ($Snapshot.State -eq 'Started') {
Start-Website -Name $Snapshot.Name -ErrorAction SilentlyContinue
} else {
Stop-Website -Name $Snapshot.Name -ErrorAction SilentlyContinue
}
}

function Wait-WebAppPoolState {
param(
[Parameter(Mandatory = $true)][string]$Name,
[Parameter(Mandatory = $true)][string]$DesiredState,
[int]$TimeoutSeconds = 30
)
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
do {
$current = [string](Get-WebAppPoolState -Name $Name).Value
if ($current -eq $DesiredState) { return }
Start-Sleep -Milliseconds 250
} while ((Get-Date) -lt $deadline)
throw "App pool '$Name' did not reach state '$DesiredState' within $TimeoutSeconds seconds (current: $current)."
}

function Restore-PoolState {
param([string]$Name, [string]$State)
if (-not $State -or -not (Test-Path "IIS:\AppPools\$Name")) { return }
$current = [string](Get-WebAppPoolState -Name $Name).Value
if ($State -eq 'Started') {
if ($current -eq 'Stopping') {
Wait-WebAppPoolState -Name $Name -DesiredState 'Stopped'
$current = 'Stopped'
}
if ($current -ne 'Started') { Start-WebAppPool -Name $Name }
Wait-WebAppPoolState -Name $Name -DesiredState 'Started'
} else {
if ($current -eq 'Starting') {
Wait-WebAppPoolState -Name $Name -DesiredState 'Started'
$current = 'Started'
}
if ($current -ne 'Stopped') { Stop-WebAppPool -Name $Name }
Wait-WebAppPoolState -Name $Name -DesiredState 'Stopped'
}
}

Assert-Administrator
Import-Module WebAdministration

if (-not (Test-Path -LiteralPath $ArchivePath -PathType Leaf)) { throw "Deployment archive not found: $ArchivePath" }
$actualArchiveSha256 = (Get-FileHash -LiteralPath $ArchivePath -Algorithm SHA256).Hash
if ($actualArchiveSha256 -ne $ExpectedArchiveSha256) {
throw "Deployment archive SHA-256 mismatch (expected $ExpectedArchiveSha256, got $actualArchiveSha256)."
}
Write-Output "Verified package SHA-256: $($actualArchiveSha256.ToLowerInvariant())"
if (-not [IO.Path]::IsPathRooted($ProjectPath)) { throw 'ProjectPath must be an absolute Windows path.' }
if ($ProductionSiteName -eq $TestSiteName -or $ProductionPoolName -eq $TestPoolName) { throw 'Production and test IIS names must differ.' }
if ($ProductionPort -eq $TestPort) { throw 'Production and test ports must differ.' }

$projectFullPath = [IO.Path]::GetFullPath($ProjectPath).TrimEnd('\')
$projectParent = Split-Path -Parent $projectFullPath
if (-not $projectParent -or $projectFullPath -eq [IO.Path]::GetPathRoot($projectFullPath).TrimEnd('\')) {
throw "Unsafe ProjectPath: $ProjectPath"
}
$projectParentExisted = Test-Path -LiteralPath $projectParent -PathType Container
$targetExisted = Test-Path -LiteralPath $projectFullPath
if ($targetExisted) {
$targetItem = Get-Item -LiteralPath $projectFullPath -Force
if (-not $targetItem.PSIsContainer) { throw "ProjectPath exists but is not a directory: $projectFullPath" }
if (($targetItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw "ProjectPath may not be a reparse point: $projectFullPath" }
if (-not (Test-Path -LiteralPath (Join-Path $projectFullPath 'tools\Register-Components.ps1'))) {
throw 'Existing ProjectPath is not a rollback-capable WSC-MVC deployment.'
}
}

$stagingPath = "$projectFullPath.staging.$InvocationId"
$backupPath = "$projectFullPath.rollback.$InvocationId"
$failedPath = "$projectFullPath.failed.$InvocationId"
foreach ($ownedPath in @($stagingPath, $backupPath, $failedPath)) {
if (Test-Path -LiteralPath $ownedPath) { throw "Invocation-owned path already exists: $ownedPath" }
}

$productionPublicPath = Join-Path $projectFullPath 'public'
$testPublicPath = Join-Path $projectFullPath 'test-app\public'
$components = @(
[PSCustomObject]@{ RelativePath = 'Framework\RequestContext.wsc'; ProgId = 'WscMvc.RequestContext'; ClassId = '{1C36FA55-34DF-4974-94B9-D657389362B2}' },
[PSCustomObject]@{ RelativePath = 'Framework\Router.wsc'; ProgId = 'WscMvc.Router'; ClassId = '{C92F9338-B478-4EAD-B865-892FFB1E1C51}' },
[PSCustomObject]@{ RelativePath = 'Framework\ViewRenderer.wsc'; ProgId = 'WscMvc.ViewRenderer'; ClassId = '{4948DF84-5DC6-448A-9F1B-EB596C28842B}' },
[PSCustomObject]@{ RelativePath = 'Framework\Application.wsc'; ProgId = 'WscMvc.Application'; ClassId = '{851C7763-1638-42FE-A166-BF3DD3A96A88}' },
[PSCustomObject]@{ RelativePath = 'Controllers\HomeController.wsc'; ProgId = 'WscMvc.HomeController'; ClassId = '{87488446-60BE-4068-8368-0B709BB68F3F}' },
[PSCustomObject]@{ RelativePath = 'test-app\Controllers\SelfTestController.wsc'; ProgId = 'WscMvc.SelfTestController'; ClassId = '{D2634944-4646-4C55-956E-4C05E7E10904}' }
)

# Validate every archive entry before Expand-Archive can write anything.
Add-Type -AssemblyName System.IO.Compression.FileSystem
$zip = [IO.Compression.ZipFile]::OpenRead($ArchivePath)
try {
foreach ($entry in $zip.Entries) {
$entryName = $entry.FullName.Replace('/', '\')
if ([IO.Path]::IsPathRooted($entryName) -or $entryName -match '(^|\\)\.\.(\\|$)') {
throw "Unsafe archive entry: $($entry.FullName)"
}
$entryDestination = [IO.Path]::GetFullPath((Join-Path $stagingPath $entryName))
if (-not $entryDestination.StartsWith($stagingPath + '\', [StringComparison]::OrdinalIgnoreCase)) {
throw "Archive entry escapes staging directory: $($entry.FullName)"
}
}
} finally {
$zip.Dispose()
}

# Read-only filesystem and IIS inspection completes before any target, registration, site, or pool mutation.
$productionSnapshot = Get-SiteSnapshot -Name $ProductionSiteName
$testSnapshot = Get-SiteSnapshot -Name $TestSiteName
$productionPoolExisted = Test-Path "IIS:\AppPools\$ProductionPoolName"
$testPoolExisted = Test-Path "IIS:\AppPools\$TestPoolName"
$productionPoolState = if ($productionPoolExisted) { [string](Get-WebAppPoolState -Name $ProductionPoolName).Value } else { $null }
$testPoolState = if ($testPoolExisted) { [string](Get-WebAppPoolState -Name $TestPoolName).Value } else { $null }
Assert-SiteTarget -Name $ProductionSiteName -PoolName $ProductionPoolName -PhysicalPath $productionPublicPath -Port $ProductionPort
Assert-SiteTarget -Name $TestSiteName -PoolName $TestPoolName -PhysicalPath $testPublicPath -Port $TestPort
Assert-PoolTarget -PoolName $ProductionPoolName -AllowedSiteName $ProductionSiteName
Assert-PoolTarget -PoolName $TestPoolName -AllowedSiteName $TestSiteName
$componentRegistrationMode = Get-ComponentRegistrationMode -ExistingProjectPath $projectFullPath -Components $components

$createdProductionSite = -not [bool]$productionSnapshot
$createdTestSite = -not [bool]$testSnapshot
$createdProductionPool = -not $productionPoolExisted
$createdTestPool = -not $testPoolExisted
$targetMoved = $false
$backupCreated = $false
$installSucceeded = $false

try {
if (-not $projectParentExisted) {
New-Item -ItemType Directory -Path $projectParent | Out-Null
}
Expand-Archive -LiteralPath $ArchivePath -DestinationPath $stagingPath

$requiredRelativePaths = @(
'public\Default.asp',
'test-app\public\Default.asp',
'Framework\ViewRenderer.wsc',
'tools\Register-Components.ps1',
'tools\Setup-Site.ps1',
'tests\Invoke-SelfTest.ps1',
'tests\Test-Http.ps1'
)
foreach ($relativePath in $requiredRelativePaths) {
if (-not (Test-Path -LiteralPath (Join-Path $stagingPath $relativePath))) {
throw "Package is missing required path: $relativePath"
}
}

Get-ChildItem -LiteralPath $stagingPath -Recurse -Force | ForEach-Object {
$resolved = [IO.Path]::GetFullPath($_.FullName)
if (-not $resolved.StartsWith($stagingPath + '\', [StringComparison]::OrdinalIgnoreCase)) {
throw "Archive entry escaped staging directory: $resolved"
}
if (($_.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
throw "Archive contains a reparse point: $resolved"
}
}

# Stop only this deployment's two sites and dedicated pools before the
# same-volume directory renames. WSC/COM files remain locked while their
# worker processes are alive. Directory.Move is used instead of Move-Item
# so a lock failure cannot partially split a directory tree.
foreach ($siteSnapshot in @($productionSnapshot, $testSnapshot)) {
if ($siteSnapshot -and $siteSnapshot.State -eq 'Started') {
Stop-Website -Name $siteSnapshot.Name
}
}
foreach ($poolName in @($ProductionPoolName, $TestPoolName)) {
if (Test-Path "IIS:\AppPools\$poolName") {
$poolStateNow = [string](Get-WebAppPoolState -Name $poolName).Value
if ($poolStateNow -eq 'Started') { Stop-WebAppPool -Name $poolName }
Wait-WebAppPoolState -Name $poolName -DesiredState 'Stopped'
}
}

if ($targetExisted) {
[IO.Directory]::Move($projectFullPath, $backupPath)
$backupCreated = $true
}
[IO.Directory]::Move($stagingPath, $projectFullPath)
$targetMoved = $true

& (Join-Path $projectFullPath 'tools\Register-Components.ps1') -ProjectRoot $projectFullPath
& (Join-Path $projectFullPath 'tools\Setup-Site.ps1') -SiteName $ProductionSiteName -PoolName $ProductionPoolName -PhysicalPath $productionPublicPath -Port $ProductionPort
& (Join-Path $projectFullPath 'tools\Setup-Site.ps1') -SiteName $TestSiteName -PoolName $TestPoolName -PhysicalPath $testPublicPath -Port $TestPort

if ($RunTests) {
& cscript.exe //nologo (Join-Path $projectFullPath 'tests\Test-Components.vbs')
if ($LASTEXITCODE -ne 0) { throw "Test-Components.vbs failed with exit code $LASTEXITCODE" }

$httpTestCommand = "& $(Quote-PowerShellLiteral (Join-Path $projectFullPath 'tests\Test-Http.ps1')) -BaseUrl $(Quote-PowerShellLiteral $ProductionBaseUrl) -TestBaseUrl $(Quote-PowerShellLiteral $TestBaseUrl)"
$httpTestExitCode = Invoke-WindowsPowerShell -Command $httpTestCommand
if ($httpTestExitCode -ne 0) { throw "Test-Http.ps1 failed with exit code $httpTestExitCode" }

$apiTestCommand = "& $(Quote-PowerShellLiteral (Join-Path $projectFullPath 'tests\Invoke-SelfTest.ps1')) -BaseUrl $(Quote-PowerShellLiteral $TestBaseUrl)"
$apiTestExitCode = Invoke-WindowsPowerShell -Command $apiTestCommand
if ($apiTestExitCode -ne 0) { throw "Invoke-SelfTest.ps1 failed with exit code $apiTestExitCode" }
}

$installSucceeded = $true
Write-Output "Deployment succeeded: $projectFullPath"
if ($backupCreated) {
Write-Output "Timestamped rollback retained at: $backupPath"
} else {
Write-Output 'No previous project tree existed; no rollback directory was created.'
}
} catch {
$failure = $_
$rollbackErrors = New-Object System.Collections.Generic.List[string]
Write-Warning "Deployment failed; rolling back only changes owned by invocation $InvocationId."

try {
if ($targetMoved -and (Test-Path -LiteralPath (Join-Path $projectFullPath 'tools\Unregister-Components.ps1'))) {
& (Join-Path $projectFullPath 'tools\Unregister-Components.ps1') -ProjectRoot $projectFullPath
}
} catch { $rollbackErrors.Add("Unregister new components: $($_.Exception.Message)") }

try {
if ($createdProductionSite -and (Test-Path "IIS:\Sites\$ProductionSiteName")) { Remove-Website -Name $ProductionSiteName }
} catch { $rollbackErrors.Add("Remove production site: $($_.Exception.Message)") }
try {
if ($createdTestSite -and (Test-Path "IIS:\Sites\$TestSiteName")) { Remove-Website -Name $TestSiteName }
} catch { $rollbackErrors.Add("Remove test site: $($_.Exception.Message)") }
try {
if ($createdProductionPool -and (Test-Path "IIS:\AppPools\$ProductionPoolName")) { Remove-WebAppPool -Name $ProductionPoolName }
} catch { $rollbackErrors.Add("Remove production pool: $($_.Exception.Message)") }
try {
if ($createdTestPool -and (Test-Path "IIS:\AppPools\$TestPoolName")) { Remove-WebAppPool -Name $TestPoolName }
} catch { $rollbackErrors.Add("Remove test pool: $($_.Exception.Message)") }

try {
if ($targetMoved -and (Test-Path -LiteralPath $projectFullPath)) {
[IO.Directory]::Move($projectFullPath, $failedPath)
}
} catch { $rollbackErrors.Add("Retain failed release: $($_.Exception.Message)") }
try {
if ($backupCreated -and (Test-Path -LiteralPath $backupPath)) {
[IO.Directory]::Move($backupPath, $projectFullPath)
}
} catch { $rollbackErrors.Add("Restore previous project tree: $($_.Exception.Message)") }
try {
if ($backupCreated -and $componentRegistrationMode -eq 'Owned' -and
(Test-Path -LiteralPath (Join-Path $projectFullPath 'tools\Register-Components.ps1'))) {
& (Join-Path $projectFullPath 'tools\Register-Components.ps1') -ProjectRoot $projectFullPath
}
} catch { $rollbackErrors.Add("Restore previous component registrations: $($_.Exception.Message)") }

try { Restore-PoolState -Name $ProductionPoolName -State $productionPoolState } catch { $rollbackErrors.Add("Restore production pool state: $($_.Exception.Message)") }
try { Restore-PoolState -Name $TestPoolName -State $testPoolState } catch { $rollbackErrors.Add("Restore test pool state: $($_.Exception.Message)") }
try { Restore-SiteState -Snapshot $productionSnapshot } catch { $rollbackErrors.Add("Restore production site state: $($_.Exception.Message)") }
try { Restore-SiteState -Snapshot $testSnapshot } catch { $rollbackErrors.Add("Restore test site state: $($_.Exception.Message)") }

if ($rollbackErrors.Count -gt 0) {
throw "Deployment failed: $($failure.Exception.Message) Rollback also reported: $($rollbackErrors -join ' | ')"
}
throw $failure
} finally {
if (-not $installSucceeded -and (Test-Path -LiteralPath $stagingPath)) {
# Staging was created by this invocation and never became the live target.
Remove-Item -LiteralPath $stagingPath -Recurse -Force
}
if (-not $installSucceeded -and -not $projectParentExisted -and (Test-Path -LiteralPath $projectParent)) {
$remaining = @(Get-ChildItem -LiteralPath $projectParent -Force)
if ($remaining.Count -eq 0) { Remove-Item -LiteralPath $projectParent -Force }
}
if (Test-Path -LiteralPath $ArchivePath) {
Remove-Item -LiteralPath $ArchivePath -Force
}
$selfPath = $MyInvocation.MyCommand.Path
if ($selfPath -and $selfPath -like "$env:windir\Temp\*") {
Remove-Item -LiteralPath $selfPath -Force -ErrorAction SilentlyContinue
}
}

불러오는 중...
취소
저장

Powered by TurnKey Linux.