瀏覽代碼

Handle locked IIS deployment roots

master
Open Claw 2 週之前
父節點
當前提交
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
```

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.



+ 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.

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)."
}

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 {
param([string]$Name, [string]$State)
if (-not $State -or -not (Test-Path "IIS:\AppPools\$Name")) { return }
@@ -282,7 +299,7 @@ $createdProductionSite = -not [bool]$productionSnapshot
$createdTestSite = -not [bool]$testSnapshot
$createdProductionPool = -not $productionPoolExisted
$createdTestPool = -not $testPoolExisted
$targetMoved = $false
$targetUpdated = $false
$backupCreated = $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)) {
if ($siteSnapshot -and $siteSnapshot.State -eq 'Started') {
Stop-Website -Name $siteSnapshot.Name
@@ -335,11 +352,12 @@ try {
}

if ($targetExisted) {
[IO.Directory]::Move($projectFullPath, $backupPath)
Sync-DirectoryTree -Source $projectFullPath -Destination $backupPath
$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\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."

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
}
} catch { $rollbackErrors.Add("Unregister new components: $($_.Exception.Message)") }
@@ -390,13 +408,15 @@ try {
} catch { $rollbackErrors.Add("Remove test pool: $($_.Exception.Message)") }

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)") }
try {
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)") }
try {


Loading…
取消
儲存

Powered by TurnKey Linux.