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