| @@ -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. | |||
| @@ -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. | |||
| @@ -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 { | |||
Powered by TurnKey Linux.