Просмотр исходного кода

Handle locked IIS deployment roots

master
Open Claw 2 недель назад
Родитель
Сommit
779a1e8f9a
3 измененных файлов: 52 добавлений и 13 удалений
  1. +1
    -1
      README.md
  2. +19
    -0
      docs/TEST-RESULTS.md
  3. +32
    -12
      tools/Invoke-RemoteInstall.ps1

+ 1
- 1
README.md Просмотреть файл

@@ -30,7 +30,7 @@ powershell -File tools\Deploy-Remote.ps1 `
-RunTests -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`.
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 stops only the two project-owned sites/pools, mirrors the current tree to a timestamped rollback directory, mirrors the verified release into the stable live path, 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. 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.




+ 19
- 0
docs/TEST-RESULTS.md Просмотреть файл

@@ -419,3 +419,22 @@ curl -o /dev/null -w "%{http_code}" http://localhost:8090/Views/Home.html -> 4
`tests/run-self-test.sh http://localhost:8091`: **NOT RUN locally** — this host's local Python is a Microsoft Store execution-alias stub, so the script's `json.tool` pretty-print step cannot run. The same JSON contract was already verified directly (`curl http://localhost:8091/self-test` → `{"ok":true,...}`, all 5 checks passing) and via `Test-Http.ps1` above; this is a convenience-script limitation on this specific host, not a framework regression — matches the pre-existing NOT RUN note for this same script under M3. `tests/run-self-test.sh http://localhost:8091`: **NOT RUN locally** — this host's local Python is a Microsoft Store execution-alias stub, so the script's `json.tool` pretty-print step cannot run. The same JSON contract was already verified directly (`curl http://localhost:8091/self-test` → `{"ok":true,...}`, all 5 checks passing) and via `Test-Http.ps1` above; this is a convenience-script limitation on this specific host, not a framework regression — matches the pre-existing NOT RUN note for this same script under M3.


M4 gate status: **PASS** for the vertical slice implemented (safe separate template loading from a physically non-served directory; deterministic `{{Key}}` substitution; default HTML-text-context encoding verified against a real escaping case; missing-template and unresolved-placeholder failures verified path-free and safe; `/hello`'s existing exact-body contract verified unchanged end-to-end through IIS). `Views/Layout.html` and a layout convention are deferred (IMPLEMENTATION_PLAN M4 sequences "add layout after renderer works" as a second step) — not yet needed since there is still only one view. Attribute/URL/JS-context encoding remains explicitly out of scope until a view needs it (documented on `ViewRenderer.wsc` and in `docs/ARCHITECTURE.md`). M4 gate status: **PASS** for the vertical slice implemented (safe separate template loading from a physically non-served directory; deterministic `{{Key}}` substitution; default HTML-text-context encoding verified against a real escaping case; missing-template and unresolved-placeholder failures verified path-free and safe; `/hello`'s existing exact-body contract verified unchanged end-to-end through IIS). `Views/Layout.html` and a layout convention are deferred (IMPLEMENTATION_PLAN M4 sequences "add layout after renderer works" as a second step) — not yet needed since there is still only one view. Attribute/URL/JS-context encoding remains explicitly out of scope until a view needs it (documented on `ViewRenderer.wsc` and in `docs/ARCHITECTURE.md`).

## Remote deployment tooling — Windows Server 2025 live proof (2026-09-19)

Host under test: `win2025test`, Windows Server 2025 Standard build 26100, 64-bit; production `WscMvc` on `:8090` and test `WscMvcTests` on `:8091`.

Windows PowerShell 5.1 parser checks passed for `tools/Deploy-Remote.ps1`, `tools/Invoke-RemoteInstall.ps1`, and `tests/Invoke-SelfTest.ps1`. The first live JSON-client run exposed and fixed output suppression caused by assigning the function's output to `$null`; after the fix, the client printed every endpoint and embedded check and ended with `RESULT: ALL PASS`.

The first installer cutover attempt verified the archive SHA-256 and completed read-only preflight, then Windows denied renaming the live IIS root even after both dedicated pools reached `Stopped`. The installer failed before modifying the live tree and restored both sites/pools to `Started`; `/hello` and `/self-test` remained HTTP 200. The implementation was changed to keep the live root stable: mirror it to a timestamped rollback directory, then mirror the staged release into the live path with checked `robocopy` exit codes. This avoids depending on a root-directory rename while preserving rollback ownership.

Successful live invocation:

```text
Invocation: 20260919T215247Z-606dbb12
Package SHA-256: 8ca5b314a9cb183f1ed9f2497118825658c470eb1237dcb4d53df66931a933f7
Deployment succeeded: C:\Projects\wsc-mvc
Timestamped rollback retained at: C:\Projects\wsc-mvc.rollback.20260919T215247Z-606dbb12
```

Post-cutover results: `tests\Test-Components.vbs` **ALL PASS**; `tests\Test-Http.ps1` **ALL PASS**; `tests\Invoke-SelfTest.ps1` **ALL PASS**, including every JSON-reported check. Both IIS sites and dedicated pools returned to `Started`, and the deployment retained the prior tree for rollback.

+ 32
- 12
tools/Invoke-RemoteInstall.ps1 Просмотреть файл

@@ -179,6 +179,23 @@ function Wait-WebAppPoolState {
throw "App pool '$Name' did not reach state '$DesiredState' within $TimeoutSeconds seconds (current: $current)." throw "App pool '$Name' did not reach state '$DesiredState' within $TimeoutSeconds seconds (current: $current)."
} }


function Sync-DirectoryTree {
param(
[Parameter(Mandatory = $true)][string]$Source,
[Parameter(Mandatory = $true)][string]$Destination
)

if (-not (Test-Path -LiteralPath $Source -PathType Container)) {
throw "Directory sync source does not exist: $Source"
}

& robocopy.exe $Source $Destination /MIR /COPY:DAT /DCOPY:DAT /R:2 /W:1 /NFL /NDL /NJH /NJS /NP
$exitCode = $LASTEXITCODE
if ($exitCode -ge 8) {
throw "robocopy failed while mirroring '$Source' to '$Destination' (exit code $exitCode)."
}
}

function Restore-PoolState { function Restore-PoolState {
param([string]$Name, [string]$State) param([string]$Name, [string]$State)
if (-not $State -or -not (Test-Path "IIS:\AppPools\$Name")) { return } if (-not $State -or -not (Test-Path "IIS:\AppPools\$Name")) { return }
@@ -282,7 +299,7 @@ $createdProductionSite = -not [bool]$productionSnapshot
$createdTestSite = -not [bool]$testSnapshot $createdTestSite = -not [bool]$testSnapshot
$createdProductionPool = -not $productionPoolExisted $createdProductionPool = -not $productionPoolExisted
$createdTestPool = -not $testPoolExisted $createdTestPool = -not $testPoolExisted
$targetMoved = $false
$targetUpdated = $false
$backupCreated = $false $backupCreated = $false
$installSucceeded = $false $installSucceeded = $false


@@ -317,10 +334,10 @@ try {
} }
} }


# 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.
# Stop only this deployment's two sites and dedicated pools before copying
# the release. The live root itself can remain open on IIS hosts even after
# its dedicated pools stop, so keep the path stable and mirror a verified
# backup/release tree instead of relying on a root-directory rename.
foreach ($siteSnapshot in @($productionSnapshot, $testSnapshot)) { foreach ($siteSnapshot in @($productionSnapshot, $testSnapshot)) {
if ($siteSnapshot -and $siteSnapshot.State -eq 'Started') { if ($siteSnapshot -and $siteSnapshot.State -eq 'Started') {
Stop-Website -Name $siteSnapshot.Name Stop-Website -Name $siteSnapshot.Name
@@ -335,11 +352,12 @@ try {
} }


if ($targetExisted) { if ($targetExisted) {
[IO.Directory]::Move($projectFullPath, $backupPath)
Sync-DirectoryTree -Source $projectFullPath -Destination $backupPath
$backupCreated = $true $backupCreated = $true
} }
[IO.Directory]::Move($stagingPath, $projectFullPath)
$targetMoved = $true
Sync-DirectoryTree -Source $stagingPath -Destination $projectFullPath
$targetUpdated = $true
Remove-Item -LiteralPath $stagingPath -Recurse -Force


& (Join-Path $projectFullPath 'tools\Register-Components.ps1') -ProjectRoot $projectFullPath & (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 $ProductionSiteName -PoolName $ProductionPoolName -PhysicalPath $productionPublicPath -Port $ProductionPort
@@ -371,7 +389,7 @@ try {
Write-Warning "Deployment failed; rolling back only changes owned by invocation $InvocationId." Write-Warning "Deployment failed; rolling back only changes owned by invocation $InvocationId."


try { try {
if ($targetMoved -and (Test-Path -LiteralPath (Join-Path $projectFullPath 'tools\Unregister-Components.ps1'))) {
if ($targetUpdated -and (Test-Path -LiteralPath (Join-Path $projectFullPath 'tools\Unregister-Components.ps1'))) {
& (Join-Path $projectFullPath 'tools\Unregister-Components.ps1') -ProjectRoot $projectFullPath & (Join-Path $projectFullPath 'tools\Unregister-Components.ps1') -ProjectRoot $projectFullPath
} }
} catch { $rollbackErrors.Add("Unregister new components: $($_.Exception.Message)") } } catch { $rollbackErrors.Add("Unregister new components: $($_.Exception.Message)") }
@@ -390,13 +408,15 @@ try {
} catch { $rollbackErrors.Add("Remove test pool: $($_.Exception.Message)") } } catch { $rollbackErrors.Add("Remove test pool: $($_.Exception.Message)") }


try { try {
if ($targetMoved -and (Test-Path -LiteralPath $projectFullPath)) {
[IO.Directory]::Move($projectFullPath, $failedPath)
if ($targetUpdated -and (Test-Path -LiteralPath $projectFullPath)) {
Sync-DirectoryTree -Source $projectFullPath -Destination $failedPath
} }
} catch { $rollbackErrors.Add("Retain failed release: $($_.Exception.Message)") } } catch { $rollbackErrors.Add("Retain failed release: $($_.Exception.Message)") }
try { try {
if ($backupCreated -and (Test-Path -LiteralPath $backupPath)) { if ($backupCreated -and (Test-Path -LiteralPath $backupPath)) {
[IO.Directory]::Move($backupPath, $projectFullPath)
Sync-DirectoryTree -Source $backupPath -Destination $projectFullPath
} elseif ($targetUpdated -and -not $targetExisted -and (Test-Path -LiteralPath $projectFullPath)) {
Remove-Item -LiteralPath $projectFullPath -Recurse -Force
} }
} catch { $rollbackErrors.Add("Restore previous project tree: $($_.Exception.Message)") } } catch { $rollbackErrors.Add("Restore previous project tree: $($_.Exception.Message)") }
try { try {


Загрузка…
Отмена
Сохранить

Powered by TurnKey Linux.