diff --git a/README.md b/README.md index adc4052..fd46979 100644 --- a/README.md +++ b/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 `.rollback.-`. 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. diff --git a/docs/TEST-RESULTS.md b/docs/TEST-RESULTS.md index ecbb839..4e76d57 100644 --- a/docs/TEST-RESULTS.md +++ b/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. diff --git a/tools/Invoke-RemoteInstall.ps1 b/tools/Invoke-RemoteInstall.ps1 index 24a6239..f3d02c0 100644 --- a/tools/Invoke-RemoteInstall.ps1 +++ b/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 {