From 3fe93f7bdbc168a37ae13a8dbd3a629b74562847 Mon Sep 17 00:00:00 2001 From: Clawdbot Date: Mon, 14 Sep 2026 21:44:39 +0000 Subject: [PATCH 01/13] ci: add safe IIS release deployment pipeline --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index 279f9f6..0da06ce 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,12 @@ Move generated file to `app/controllers/`. - IIS URL Rewrite module - Microsoft Access Database Engine (for .accdb support) +## Deployment + +Production deployment uses full-repository immutable release directories while IIS points only at each release's `public/` directory. `scripts/deploy-iis-git.ps1` packages and transfers a reviewed checkout to `webserver-1` over Tailscale/OpenSSH; `scripts/install-iis-release.ps1` performs host preflight, preserves the machine-owned `public/web.config`, enables Classic ASP parent paths, switches releases, smoke tests, and supports rollback. Database migrations are disabled by default. + +Gitea 1.11.4 has no Gitea Actions, so run the controller script from a trusted external CI worker or operator workstation. Start with `-DryRun`, which makes no network connection. See [docs/deployment-guide.md](docs/deployment-guide.md) and [docs/deployment-configuration.md](docs/deployment-configuration.md). + ## Testing This repo now includes a dev-only `aspunit` harness under `tests/`. It is intentionally separate from the production app rooted at `public/`. From 6aa1cad89f78ed2dee165a124fa044ad3d45ed4e Mon Sep 17 00:00:00 2001 From: Clawdbot Date: Mon, 14 Sep 2026 21:44:40 +0000 Subject: [PATCH 02/13] ci: update TESTING.md --- TESTING.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/TESTING.md b/TESTING.md index c2f13aa..4e946f3 100644 --- a/TESTING.md +++ b/TESTING.md @@ -140,6 +140,25 @@ tests\run-tests.cmd http://localhost:8085/run-all.asp - Re-running the suite produces stable results. - The production site under `public/` still exposes no test runner pages or test routes. +## Deployment verification gate + +Before a production release: + +1. Run the full ASPUnit suite in the isolated development IIS application. +2. Parse every repository `web.config` as XML. +3. Parse both deployment scripts with the Windows PowerShell parser on a Windows CI worker. +4. From a clean standalone checkout of the approved branch, run: + + ```powershell + .\scripts\deploy-iis-git.ps1 -SiteName 'ttasp' -RemoteTarget 'webserver-1' -DryRun + ``` + + This validates and packages locally but makes no network connection. +5. Run `-RemotePreflightOnly` as a separately approved step when host connectivity is permitted. +6. After deployment, require the installer smoke test and then run any production-safe HTTP checks. Never expose the `tests/` IIS application in production. + +Linux can validate XML and inspect script structure, but cannot prove Windows PowerShell, WebAdministration, Classic ASP, IIS URL Rewrite, Access/ACE, SSH elevation, or live HTTP behavior. Those checks remain Windows/IIS gates. + ## Limitations - This harness runs only inside IIS/Classic ASP; it is not intended for Linux execution. From 3d28a2f1329917a19b262ca92114d5a006906787 Mon Sep 17 00:00:00 2001 From: Clawdbot Date: Mon, 14 Sep 2026 21:44:40 +0000 Subject: [PATCH 03/13] ci: update docs/deployment-guide.md --- docs/deployment-guide.md | 250 +++++++++++++++++++++++++++++++++++---- 1 file changed, 224 insertions(+), 26 deletions(-) diff --git a/docs/deployment-guide.md b/docs/deployment-guide.md index 951813d..b2b15d7 100644 --- a/docs/deployment-guide.md +++ b/docs/deployment-guide.md @@ -1,37 +1,235 @@ -# MVC-Starter - Deployment Guide +# IIS Deployment Guide -**Date:** 2026-03-11T11:59:39Z +## Deployment model -## Deployment Model +Production uses immutable release directories on the Windows host: -Single-site Windows IIS deployment with `public/` as the web root. +```text +C:\inetpub\deployments\\ + releases\ + \ + app\ + core\ + db\ + public\ <- IIS physicalPath points here + scripts\ + ... + shared\ + public.web.config <- machine-owned configuration + deployment-state.json +``` -## Deployment Steps +The complete repository is copied into each release. This is required because +`public/Default.asp` includes files above `public/`, including `../core` and +`../app`. IIS exposes only `\public`; sibling source, scripts, tests, +and database files are not web-root content. -1. Copy the repository to the target Windows host. -2. Configure the IIS site to point to `public/`. -3. Ensure Classic ASP is enabled. -4. Ensure URL Rewrite is installed. -5. Update `public/web.config` for the target environment. -6. Ensure the Access DB file path is valid and accessible. +Switching `physicalPath` from one release's `public` directory to another is the +atomic-ish cutover. The app pool is recycled after the switch. If the smoke test +fails, the installer restores the previous physical path and recycles again. +The script does not stop the site for the normal copy/staging phase. -## Key Runtime Config +## Why Gitea is not the runner -- `ConnectionString` -- `Environment` -- `EnableErrorLogging` -- `ErrorLogPath` -- cache and UI timing settings +The repository server is Gitea 1.11.4, which predates Gitea Actions. Treat +Gitea as the Git source only. Run `scripts/deploy-iis-git.ps1` from one of: -## Deployment Risks +- a trusted external CI worker that checks out this repository; +- a controlled operator workstation; +- a separately managed scheduled task. -- Incorrect `ConnectionString` path for `.accdb` -- Missing IIS URL Rewrite module -- Missing Classic ASP support -- File permission issues for logs or database access +The worker must have Tailscale reachability and OpenSSH key-based access to +`webserver-1`. No password, private key, database path, or connection string +belongs in this repository. -## What Was Not Found +## Files -- No Docker, Kubernetes, or container deployment setup -- No CI/CD pipeline config -- No infrastructure-as-code deployment definition +- `scripts/deploy-iis-git.ps1`: controller/CI entry point. Validates branch and + worktree state, packages the full source tree, hashes it, transfers it with + `scp`, and invokes the host installer with `ssh`. +- `scripts/install-iis-release.ps1`: elevated host-side installer. Performs + preflight, preserves machine configuration, stages/releases, configures + Classic ASP parent paths, switches IIS, smoke tests, and rolls back. + +Both scripts target Windows PowerShell 5.1 syntax. + +## One-time host preparation + +Complete these steps on `webserver-1` before the first live deployment: + +1. Join the host and CI worker to the intended Tailscale tailnet. Confirm ACLs + allow SSH only from the intended deploy identity. +2. Install and configure Windows OpenSSH Server for key-based authentication. + The deploy identity must be able to run an elevated, non-interactive + PowerShell process; choose and audit the elevation mechanism locally. +3. Install IIS, Classic ASP (`IIS-ASP` / `Web-ASP`), and IIS URL Rewrite. +4. Create the IIS site and application pool. The scripts require an existing + site and do not change its bindings, authentication, app-pool identity, or + managed runtime. +5. Ensure the site currently points at a valid `public` directory containing + its machine-specific `web.config`. On first deployment this file is copied + to `shared\public.web.config` and becomes the deployment-owned preserved + copy. +6. Put the production Access database and logs outside release directories. + Grant the existing app-pool identity only the required permissions. The + deployment scripts deliberately do not change app-pool identity or ACLs. +7. Grant the deploy identity modify permission on the chosen deployment root + and permission to update this IIS site's configuration. +8. Back up IIS configuration and production data using the host's normal backup + system before enabling unattended deployment. + +Default deployment root: + +```text +C:\inetpub\deployments\ +``` + +Classic ASP parent paths are set explicitly at the site's location in +`applicationHost.config` by `install-iis-release.ps1`: + +```text +system.webServer/asp enableParentPaths = true +``` + +This is intentional. The ASP section is commonly locked against `web.config` +overrides, so the setting is applied at the host/site level rather than added to +repository `public/web.config`. + +## Safe validation sequence + +From a standalone checkout on a Windows worker, first validate without making a +network connection: + +```powershell +.\scripts\deploy-iis-git.ps1 ` + -SiteName 'ttasp' ` + -RemoteTarget 'webserver-1' ` + -ExpectedBranch 'master' ` + -DryRun +``` + +`-DryRun` validates the local source, branch, dirty state, XML, package creation, +and argument construction. It prints remote operations but does not run `ssh` +or `scp`. + +Next run a remote read-only preflight. This does connect to `webserver-1`, copies +only the installer to a temporary directory, and checks the existing site, +app pool, Classic ASP feature (when its feature cmdlet is available), URL +Rewrite module, and availability of a preservable `web.config`: + +```powershell +.\scripts\deploy-iis-git.ps1 ` + -SiteName 'ttasp' ` + -RemoteTarget 'webserver-1' ` + -RemotePreflightOnly +``` + +For a normal deployment: + +```powershell +.\scripts\deploy-iis-git.ps1 ` + -SiteName 'ttasp' ` + -RemoteTarget 'webserver-1' ` + -ExpectedBranch 'master' ` + -BaseUrl 'http://127.0.0.1/' +``` + +Use a host header or HTTPS URL in `-BaseUrl` when the site's binding requires +one. If no URL is supplied, the installer uses the first HTTP binding's port on +`127.0.0.1`. `-SkipSmokeTest` is an explicit exception and should not be used in +unattended production CI. + +## Branch and artifact policy + +The controller defaults to `-ExpectedBranch master`, matching the repository's +actual default branch. It refuses a different branch or dirty worktree. External CI should: + +1. fetch from Gitea over its configured authenticated channel; +2. check out the exact approved commit on `master`; +3. run repository tests/static checks; +4. call the deployment script from that standalone checkout; +5. retain the commit ID, release ID, package SHA-256, and deployment output. + +Use `-AllowAnyBranch` or `-AllowDirty` only for an intentional, reviewed manual +exception. If `SourcePath` is not itself a Git root, `-AllowAnyBranch` is +required because branch provenance cannot be proved. + +A webhook receiver is not included. Gitea 1.11.4 can emit webhooks, but accepting +and authenticating them safely is infrastructure-specific. Polling or a manually +approved CI job is simpler unless an existing secured webhook runner is +available. + +## Configuration preservation + +The installer copies the current site's entire `public\web.config` to: + +```text +\shared\public.web.config +``` + +It then overwrites each staged release's repository version with that preserved +file before cutover. This preserves all machine-specific values, especially: + +- `ConnectionString` and its Access `Data Source` path; +- `Environment`; +- `ErrorLogPath` and logging flags; +- site-specific rewrite or app settings already present on the host. + +The shared file is never automatically refreshed from repository content. To +change production settings, edit the shared file under the host's normal change +control, validate its XML, and deploy or roll back. Keep a protected backup. +Do not commit production values. + +## Database migration policy + +Deployments do **not** run migrations by default. The sample `db` directory is +part of the complete release but the production connection string should point +to data outside the release tree. + +`-RunMigrations` is available only as an explicit operator choice. Before using +it: + +1. take and verify a database backup; +2. review every pending migration against the target schema; +3. schedule downtime if the Access database requires exclusive access; +4. understand that switching IIS back does not undo a database migration. + +The old script's automatic legacy `migrate_isbusiness_to_households.vbs` path has +been removed from deployment. Run one-off data repair scripts only under a +separate reviewed procedure. + +## Rollback + +List release IDs on the host under `\releases`, or use the value in +`deployment-state.json`. Then run from the worker: + +```powershell +.\scripts\deploy-iis-git.ps1 ` + -SiteName 'ttasp' ` + -RemoteTarget 'webserver-1' ` + -Rollback ` + -RollbackTo '20260914-171500-a1b2c3d4e5f6' ` + -BaseUrl 'http://127.0.0.1/' +``` + +Rollback refreshes the target release's `public\web.config` from the preserved +shared copy, switches `physicalPath`, recycles the app pool, and smoke tests. If +that smoke test fails, it restores the pre-rollback path. + +The installer reports releases older than `-KeepReleases` as retention +candidates but does not delete them automatically. Delete only after confirming +they are neither current nor required for rollback and that backups exist. + +## Failure and recovery behavior + +- Package extraction occurs in `.staging`. +- Layout and `web.config` XML are validated before promotion. +- Promotion is a directory rename within the release volume. +- IIS changes only after the final release directory exists. +- Failed smoke tests restore the prior `physicalPath`. +- Incomplete staging directories are retained for diagnosis. +- Database changes, external file writes, and machine configuration outside the + IIS path switch cannot be undone by release rollback. + +Review Windows Event Log, IIS logs, app logs, the script transcript, and +`deployment-state.json` after any failure. From 44f5674742f46f05541ac79daf694587818eb6f8 Mon Sep 17 00:00:00 2001 From: Clawdbot Date: Mon, 14 Sep 2026 21:44:41 +0000 Subject: [PATCH 04/13] ci: update docs/deployment-configuration.md --- docs/deployment-configuration.md | 88 +++++++++++++++++++++++--------- 1 file changed, 65 insertions(+), 23 deletions(-) diff --git a/docs/deployment-configuration.md b/docs/deployment-configuration.md index fc291fc..e1749cc 100644 --- a/docs/deployment-configuration.md +++ b/docs/deployment-configuration.md @@ -1,35 +1,77 @@ # Deployment Configuration -**Date:** 2026-03-11T11:59:39Z +## Repository configuration versus machine configuration -## Hosting Model +`public/web.config` in the repository is a development/template configuration. +Production values are machine-owned and must not be committed. -- Windows IIS -- `public/` configured as the site root -- `public/Default.asp` as the default document -- URL Rewrite sends non-static requests through the ASP front controller +On the first release deployment, `scripts/install-iis-release.ps1` copies the +currently active site's complete `public\web.config` to: -## Runtime Configuration +```text +\shared\public.web.config +``` -Primary deployment configuration is stored in `public/web.config`. +Every staged release receives that preserved file before IIS is switched. This +protects the production database path and all other machine-specific settings +from replacement by repository defaults. -### Important Settings +Important machine values include: -- `ConnectionString` -- `Environment` -- `FlashMessageTimeout` -- `Error404RedirectSeconds` -- `CacheExpirationYear` -- `EnableErrorLogging` -- `ErrorLogPath` +- `ConnectionString`, especially the Access `Data Source` path; +- `Environment`; +- `EnableErrorLogging` and `ErrorLogPath`; +- cache, timeout, and UI settings; +- any host-specific rewrite behavior. -## Deployment Notes +Change the shared file only through the host's normal configuration-change and +backup process. Validate that it is well-formed XML before deployment. -- The Access DB path must be updated for the target machine. -- `ErrorLogPath` should be writable by the IIS application identity if enabled. -- Static assets are expected under `public/` paths excluded from rewrite rules. +## IIS settings owned by deployment -## Observed Gaps +The host installer changes only: -- No container, CI/CD, or infrastructure-as-code deployment config was detected. -- Deployment is currently documented as a manual IIS-based process. +1. the selected site's `system.webServer/asp enableParentPaths` value, applied + explicitly at the site's location in `applicationHost.config`; +2. the selected site's `physicalPath`, set to `\public`; +3. the existing app pool's running state, by starting or recycling it. + +It does not create the site, alter bindings, change authentication, change the +app-pool identity, grant filesystem permissions, install Windows features, or +install IIS URL Rewrite. + +## Deployment parameters + +Controller (`scripts/deploy-iis-git.ps1`): + +- `SiteName` (required): existing IIS site name. +- `RemoteTarget`: SSH/Tailscale target; defaults to `webserver-1`. +- `RemotePort`: defaults to 22. +- `ExpectedBranch`: defaults to `master`, the repository's current default branch. +- `DeployRoot`: optional host release root override. +- `BaseUrl`: local-to-host smoke-test URL. +- `ReleaseId`: optional immutable release identifier. +- `KeepReleases`: retention reporting threshold; no automatic deletion. +- `RemotePreflightOnly`: connect and perform host checks without IIS/release + changes. +- `DryRun`: local-only validation/package simulation; makes no network call. +- `RunMigrations`: explicit opt-in; never implied. +- `Rollback` plus `RollbackTo`: switch to an existing release. + +Host installer (`scripts/install-iis-release.ps1`): + +- `PackagePath`: ZIP produced by the controller. +- `ExpectedSha256`: transfer integrity check. +- `PreflightOnly` or `DryRun`: checks without host changes. +- `SkipSmokeTest`: explicit exception for sites that cannot be probed locally. + +## Secrets and identity + +No secret parameter is provided. Use OpenSSH configuration, an SSH agent, or a +CI credential store for the private key. Restrict the deploy key and Tailscale +ACL to the specific worker and host. Do not put passwords, keys, production +connection strings, or database files into CI logs or repository files. + +The production database and writable logs should live outside immutable release +directories. Grant the existing IIS app-pool identity only the minimum access +required for those paths. From b3bf38cbd4cbb7ba2396ee6b0d17109dc4584e7c Mon Sep 17 00:00:00 2001 From: Clawdbot Date: Mon, 14 Sep 2026 21:44:42 +0000 Subject: [PATCH 05/13] ci: update scripts/deploy-iis-git.ps1 --- scripts/deploy-iis-git.ps1 | 467 ++++++++++++++++--------------------- 1 file changed, 204 insertions(+), 263 deletions(-) diff --git a/scripts/deploy-iis-git.ps1 b/scripts/deploy-iis-git.ps1 index 3326f8a..674fe18 100644 --- a/scripts/deploy-iis-git.ps1 +++ b/scripts/deploy-iis-git.ps1 @@ -1,324 +1,265 @@ <# - Deploy asp-territory to an existing IIS site, locally or over SSH. - - Remote mode: - - Copies this script to the remote Windows host with scp - - Executes it remotely via ssh in -RunRemoteCore mode - - Preserves the remote site's current DB path unless -DbPath is passed - - Can run standard migrations and an optional legacy migration script - - Local / remote core behavior: - - Infers IIS site/app pool/work dir from the existing site when possible - - Stops the site/app pool while deploying - - Clones/pulls and hard-resets to origin/ - - Points IIS at \public - - Reapplies the effective DB path in public\web.config - - Grants IIS AppPool rights to the DB folder - - Runs migrations - - Restarts the site/app pool and smoke tests key routes +.SYNOPSIS + Packages this repository and deploys it to IIS over Tailscale/OpenSSH. + +.DESCRIPTION + This is the controller/CI entry point. It creates a ZIP containing the full + repository (excluding VCS and local deployment artifacts), computes SHA-256, + copies the package and host installer with scp, then invokes the installer on + the Windows host through ssh. + + -DryRun performs local validation and prints the remote operations without + connecting. Gitea 1.11.4 does not run this itself; use a trusted external CI + worker, a scheduled task, or an operator workstation. #> +[CmdletBinding()] param( - [string]$Repo = 'git@onefortheroadgit.sytes.net:dcovington/asp-classic-unified-framework.git', - [string]$Branch = 'main', - [string]$SiteName = 'ttasp', - [string]$AppPool = '', - [string]$WorkDir = '', - [string]$PublicDir = '', - [string]$BaseUrl = '', - [string]$DbPath = '', + [Parameter(Mandatory = $true)] + [ValidatePattern('^[A-Za-z0-9_. -]+$')] + [string]$SiteName, - [switch]$RunMigrations = $true, - [switch]$SkipLegacyIsBusinessMigration, - [string]$LegacyMigrationScript = 'scripts\migrate_isbusiness_to_households.vbs', - - [switch]$UseRemoteSsh, - [string]$RemoteTarget = '', + [string]$RemoteTarget = 'webserver-1', + [ValidateRange(1, 65535)] [int]$RemotePort = 22, + [string]$SourcePath = (Split-Path $PSScriptRoot -Parent), + [string]$DeployRoot = '', + [string]$BaseUrl = '', + [string]$ReleaseId = '', + [string]$ExpectedBranch = 'master', + [ValidateRange(2, 100)] + [int]$KeepReleases = 5, [string]$SshExe = 'ssh', [string]$ScpExe = 'scp', - [switch]$RunRemoteCore + [switch]$AllowAnyBranch, + [switch]$AllowDirty, + [switch]$RunMigrations, + [switch]$SkipSmokeTest, + [switch]$RemotePreflightOnly, + [switch]$Rollback, + [string]$RollbackTo = '', + [switch]$DryRun, + [switch]$KeepPackage ) +Set-StrictMode -Version 2.0 $ErrorActionPreference = 'Stop' -function Ensure-Dir { - param([string]$Path) - if([string]::IsNullOrWhiteSpace($Path)){ return } - if(!(Test-Path $Path)){ - New-Item -ItemType Directory -Force -Path $Path | Out-Null - } +function Write-Step { + param([string]$Message) + Write-Host ('==> ' + $Message) } -function Ensure-Command { +function Assert-Command { param([string]$Name) - if(!(Get-Command $Name -ErrorAction SilentlyContinue)){ - throw "$Name not found on PATH" + if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) { + throw "$Name was not found on PATH." } } -function Get-DefaultRemoteTargetFromInfo { - $infoPath = Join-Path $PSScriptRoot 'depolyinfo.txt' - if(!(Test-Path $infoPath)){ return '' } - - $sshLine = Get-Content $infoPath | Where-Object { $_ -match '^\s*ssh\s+' } | Select-Object -First 1 - if([string]::IsNullOrWhiteSpace($sshLine)){ return '' } - - return ($sshLine -replace '^\s*ssh\s+', '').Trim() -} - -function ConvertTo-PowerShellLiteral { - param([AllowNull()][string]$Value) - if($null -eq $Value){ return "''" } - return "'" + ($Value -replace "'", "''") + "'" -} - -function ConvertTo-CmdDoubleQuoted { - param([AllowNull()][string]$Value) - if($null -eq $Value){ return '""' } - return '"' + ($Value -replace '"', '""') + '"' +function ConvertTo-SingleQuotedPowerShell { + param([string]$Value) + return "'" + $Value.Replace("'", "''") + "'" } -function Get-DataSourceFromConfig { - param([string]$ConfigPath) - if(!(Test-Path $ConfigPath)){ return '' } - - $raw = Get-Content $ConfigPath -Raw - $match = [regex]::Match($raw, 'Data Source=([^;]+);', [System.Text.RegularExpressions.RegexOptions]::IgnoreCase) - if($match.Success){ - return $match.Groups[1].Value.Trim() - } - - return '' -} - -function Set-DataSourceInConfig { +function Add-RemoteArgument { param( - [string]$ConfigPath, - [string]$EffectiveDbPath + [System.Collections.Generic.List[string]]$Arguments, + [string]$Name, + [string]$Value ) + $Arguments.Add($Name) + $Arguments.Add((ConvertTo-SingleQuotedPowerShell $Value)) +} - if(!(Test-Path $ConfigPath)){ return } - - $raw = Get-Content $ConfigPath -Raw - $updated = [regex]::Replace( - $raw, - 'Data Source=[^;]*;', - ('Data Source=' + $EffectiveDbPath + ';'), - [System.Text.RegularExpressions.RegexOptions]::IgnoreCase +function Copy-ReleaseSource { + param( + [string]$From, + [string]$To ) - if($updated -ne $raw){ - Set-Content -Path $ConfigPath -Value $updated -Encoding UTF8 - Write-Host "Updated ConnectionString Data Source to $EffectiveDbPath" + New-Item -ItemType Directory -Force -Path $To | Out-Null + $excludedNames = @('.git', '.deployment', 'releases') + Get-ChildItem -LiteralPath $From -Force | ForEach-Object { + if ($excludedNames -notcontains $_.Name) { + Copy-Item -LiteralPath $_.FullName -Destination $To -Recurse -Force + } } } -function Get-BaseUrlFromSite { - param($Site) +$SourcePath = [System.IO.Path]::GetFullPath($SourcePath) +$installerPath = Join-Path $PSScriptRoot 'install-iis-release.ps1' +if (-not (Test-Path -LiteralPath $installerPath -PathType Leaf)) { + throw "Host installer is missing: $installerPath" +} - $httpBind = $Site.Bindings.Collection | Where-Object { $_.protocol -eq 'http' } | Select-Object -First 1 - if($httpBind){ - $parts = $httpBind.bindingInformation.Split(':') - $port = $parts[1] - if([string]::IsNullOrWhiteSpace($port)){ $port = '80' } - return ('http://127.0.0.1:' + $port) +$requiredSourceFiles = @( + 'public\Default.asp', + 'public\web.config', + 'core\autoload_core.asp', + 'app\controllers\autoload_controllers.asp' +) +foreach ($relativePath in $requiredSourceFiles) { + if (-not (Test-Path -LiteralPath (Join-Path $SourcePath $relativePath) -PathType Leaf)) { + throw "Source tree is incomplete; missing $relativePath" } - - return 'http://127.0.0.1' } -function Invoke-DeployCore { - Ensure-Command git - Import-Module WebAdministration - - $site = Get-Website -Name $SiteName - if(!$site){ throw "IIS site not found: $SiteName" } +try { + [xml](Get-Content -LiteralPath (Join-Path $SourcePath 'public\web.config') -Raw) | Out-Null +} catch { + throw "Source public\web.config is not valid XML: $($_.Exception.Message)" +} - if([string]::IsNullOrWhiteSpace($AppPool)){ - $AppPool = $site.applicationPool +$directGitRoot = Test-Path -LiteralPath (Join-Path $SourcePath '.git') +$commit = 'nogit' +if ($directGitRoot) { + Assert-Command 'git' + $branch = (& git -C $SourcePath branch --show-current).Trim() + if ($LASTEXITCODE -ne 0) { + throw 'Could not determine the Git branch.' } - - if([string]::IsNullOrWhiteSpace($PublicDir)){ - $PublicDir = $site.physicalPath + if ((-not $AllowAnyBranch) -and $branch -ne $ExpectedBranch) { + throw "Refusing to deploy branch '$branch'; expected '$ExpectedBranch'. Use -AllowAnyBranch only for an intentional exception." } - if([string]::IsNullOrWhiteSpace($WorkDir)){ - $pd = [Environment]::ExpandEnvironmentVariables($PublicDir) - $pd = $pd.Trim().Trim('"') - $pd = $pd.TrimEnd('\','/') - - if((Split-Path $pd -Leaf).ToLower() -eq 'public'){ - $WorkDir = Split-Path $pd -Parent - } else { - $WorkDir = $pd - } + $dirty = & git -C $SourcePath status --porcelain + if ($LASTEXITCODE -ne 0) { + throw 'Could not inspect the Git worktree.' } - - if([string]::IsNullOrWhiteSpace($BaseUrl)){ - $BaseUrl = Get-BaseUrlFromSite -Site $site + if ((-not $AllowDirty) -and $null -ne $dirty -and @($dirty).Count -gt 0) { + throw 'Refusing to deploy a dirty worktree. Commit/stash changes or use -AllowDirty for an intentional, auditable exception.' } - $currentPublicDir = $PublicDir - $currentConfigPath = Join-Path $currentPublicDir 'web.config' - $effectiveDbPath = $DbPath - if([string]::IsNullOrWhiteSpace($effectiveDbPath)){ - $effectiveDbPath = Get-DataSourceFromConfig -ConfigPath $currentConfigPath + $commit = (& git -C $SourcePath rev-parse --short=12 HEAD).Trim() + if ($LASTEXITCODE -ne 0) { + throw 'Could not determine the Git commit.' } - - if([string]::IsNullOrWhiteSpace($effectiveDbPath)){ - throw 'No database path was provided and no existing Data Source could be read from the current web.config' + Write-Host "Source branch: $branch" + Write-Host "Source commit: $commit" +} else { + Write-Warning 'SourcePath is not a standalone Git checkout; branch and dirty-worktree checks cannot be enforced.' + if (-not $AllowAnyBranch) { + throw 'Use a standalone CI checkout, or pass -AllowAnyBranch explicitly for a reviewed non-Git source tree.' } +} - Write-Host "Stopping IIS site $SiteName and app pool $AppPool" - try { Stop-Website -Name $SiteName } catch { } - try { Stop-WebAppPool -Name $AppPool } catch { } - - Ensure-Dir (Split-Path $WorkDir -Parent) - if((Test-Path $WorkDir) -and !(Test-Path (Join-Path $WorkDir '.git'))){ - $bak = ($WorkDir.TrimEnd('\') + '_pre_git_' + (Get-Date -Format 'yyyyMMdd_HHmmss')) - Write-Host "Existing non-git folder detected. Moving to $bak" - Move-Item -Force $WorkDir $bak - } +if ([string]::IsNullOrWhiteSpace($ReleaseId)) { + $ReleaseId = (Get-Date -Format 'yyyyMMdd-HHmmss') + '-' + $commit +} +if ($ReleaseId -notmatch '^[A-Za-z0-9._-]+$') { + throw 'ReleaseId may contain only letters, numbers, dot, underscore, and hyphen.' +} +if ($Rollback -and [string]::IsNullOrWhiteSpace($RollbackTo)) { + throw '-Rollback requires -RollbackTo .' +} +if ((-not $Rollback) -and -not [string]::IsNullOrWhiteSpace($RollbackTo)) { + throw '-RollbackTo is only valid with -Rollback.' +} +if ($RunMigrations -and $Rollback) { + throw '-RunMigrations is not valid during rollback.' +} - if(!(Test-Path $WorkDir)){ - Write-Host "Cloning $Repo -> $WorkDir" - git clone $Repo $WorkDir +$workRoot = Join-Path ([System.IO.Path]::GetTempPath()) ('asp-iis-deploy-' + [Guid]::NewGuid().ToString('N')) +$packageStage = Join-Path $workRoot 'package' +$packagePath = Join-Path $workRoot ($ReleaseId + '.zip') +$remoteDirectory = 'C:\Windows\Temp\asp-iis-deploy-' + $ReleaseId +$remotePackage = $remoteDirectory + '\' + $ReleaseId + '.zip' +$remoteInstaller = $remoteDirectory + '\install-iis-release.ps1' + +try { + if (-not $Rollback -and -not $RemotePreflightOnly) { + Write-Step 'Staging the full repository for packaging' + Copy-ReleaseSource -From $SourcePath -To $packageStage + Add-Type -AssemblyName System.IO.Compression.FileSystem + [System.IO.Compression.ZipFile]::CreateFromDirectory( + $packageStage, + $packagePath, + [System.IO.Compression.CompressionLevel]::Optimal, + $false + ) + $sha256 = (Get-FileHash -LiteralPath $packagePath -Algorithm SHA256).Hash + Write-Host "Package: $packagePath" + Write-Host "SHA-256: $sha256" + } else { + $sha256 = '' } - Push-Location $WorkDir - try { - Write-Host "Updating to origin/$Branch" - git fetch origin - git checkout $Branch - & git reset --hard ("origin/" + $Branch) - } finally { - Pop-Location + $remoteArguments = New-Object 'System.Collections.Generic.List[string]' + $remoteArguments.Add('&') + $remoteArguments.Add((ConvertTo-SingleQuotedPowerShell $remoteInstaller)) + Add-RemoteArgument -Arguments $remoteArguments -Name '-SiteName' -Value $SiteName + + if ($Rollback) { + Add-RemoteArgument -Arguments $remoteArguments -Name '-RollbackTo' -Value $RollbackTo + } elseif (-not $RemotePreflightOnly) { + Add-RemoteArgument -Arguments $remoteArguments -Name '-PackagePath' -Value $remotePackage + Add-RemoteArgument -Arguments $remoteArguments -Name '-ReleaseId' -Value $ReleaseId + Add-RemoteArgument -Arguments $remoteArguments -Name '-ExpectedSha256' -Value $sha256 + } else { + $remoteArguments.Add('-PreflightOnly') } - if((Split-Path $WorkDir -Leaf).ToLower() -eq 'public'){ - $WorkDir = Split-Path $WorkDir -Parent + if (-not [string]::IsNullOrWhiteSpace($DeployRoot)) { + Add-RemoteArgument -Arguments $remoteArguments -Name '-DeployRoot' -Value $DeployRoot } - - $PublicDir = Join-Path $WorkDir 'public' - $cfg = Join-Path $PublicDir 'web.config' - - Set-ItemProperty ('IIS:\Sites\' + $SiteName) -Name physicalPath -Value $PublicDir - Set-ItemProperty ('IIS:\Sites\' + $SiteName) -Name applicationPool -Value $AppPool - Set-ItemProperty ('IIS:\AppPools\' + $AppPool) -Name processModel.identityType -Value NetworkService - - Set-DataSourceInConfig -ConfigPath $cfg -EffectiveDbPath $effectiveDbPath - - $dbFolder = Split-Path $effectiveDbPath -Parent - if(!(Test-Path $dbFolder)){ - Ensure-Dir $dbFolder + if (-not [string]::IsNullOrWhiteSpace($BaseUrl)) { + Add-RemoteArgument -Arguments $remoteArguments -Name '-BaseUrl' -Value $BaseUrl } - icacls $dbFolder /grant ("IIS AppPool\" + $AppPool + ":(OI)(CI)(M)") /T | Out-Null - - Push-Location $WorkDir - try { - if($RunMigrations){ - Write-Host 'Running standard migrations' - cscript //nologo scripts\runMigrations.vbs up - } - - if(-not $SkipLegacyIsBusinessMigration){ - $legacyPath = Join-Path $WorkDir $LegacyMigrationScript - if(!(Test-Path $legacyPath)){ - throw "Legacy migration script not found: $legacyPath" - } - - Write-Host 'Running legacy IsBusiness migration' - cscript //nologo $legacyPath $effectiveDbPath + Add-RemoteArgument -Arguments $remoteArguments -Name '-KeepReleases' -Value $KeepReleases.ToString() + if ($RunMigrations) { $remoteArguments.Add('-RunMigrations') } + if ($SkipSmokeTest) { $remoteArguments.Add('-SkipSmokeTest') } + + $remoteScript = $remoteArguments -join ' ' + $remoteBytes = [Text.Encoding]::Unicode.GetBytes($remoteScript) + $remoteEncodedCommand = [Convert]::ToBase64String($remoteBytes) + $remoteCommand = 'powershell.exe -NoProfile -NonInteractive -ExecutionPolicy RemoteSigned -EncodedCommand ' + $remoteEncodedCommand + Write-Host "Remote target: $RemoteTarget (Tailscale/OpenSSH port $RemotePort)" + + if ($DryRun) { + Write-Step 'Dry-run complete; no network connection was made' + Write-Host "Would create remote directory: $remoteDirectory" + Write-Host "Would copy installer: $installerPath" + if (-not $Rollback -and -not $RemotePreflightOnly) { + Write-Host "Would copy package: $packagePath" } - } finally { - Pop-Location + Write-Host ('Would execute host script: ' + $remoteScript) + Write-Host ('Transport command uses PowerShell -EncodedCommand to avoid remote-shell quoting ambiguity.') + exit 0 } - if((Get-WebAppPoolState -Name $AppPool).Value -eq 'Started'){ - Restart-WebAppPool -Name $AppPool - } else { - Start-WebAppPool -Name $AppPool - } - Start-Website $SiteName + Assert-Command $SshExe + Assert-Command $ScpExe - Start-Sleep -Seconds 1 + Write-Step 'Creating remote staging directory over Tailscale/OpenSSH' + $mkdirScript = "New-Item -ItemType Directory -Force -Path $(ConvertTo-SingleQuotedPowerShell $remoteDirectory) | Out-Null" + $mkdirBytes = [Text.Encoding]::Unicode.GetBytes($mkdirScript) + $mkdirEncodedCommand = [Convert]::ToBase64String($mkdirBytes) + & $SshExe -p $RemotePort $RemoteTarget ('powershell.exe -NoProfile -NonInteractive -EncodedCommand ' + $mkdirEncodedCommand) + if ($LASTEXITCODE -ne 0) { throw 'Remote directory creation failed.' } - $paths = @('/','/territories','/households','/householder-names') - foreach($path in $paths){ - $url = $BaseUrl + $path - $response = Invoke-WebRequest -UseBasicParsing -Uri $url -TimeoutSec 30 - Write-Host ("OK " + $path + ' -> ' + $response.StatusCode) - } + Write-Step 'Copying the host installer' + & $ScpExe -P $RemotePort $installerPath ($RemoteTarget + ':' + $remoteDirectory.Replace('\', '/') + '/install-iis-release.ps1') + if ($LASTEXITCODE -ne 0) { throw 'Installer copy failed.' } - Write-Host 'Deploy complete.' -} - -if($UseRemoteSsh -and !$RunRemoteCore -and [string]::IsNullOrWhiteSpace($RemoteTarget)){ - $RemoteTarget = Get-DefaultRemoteTargetFromInfo -} - -if($UseRemoteSsh -and !$RunRemoteCore -and -not [string]::IsNullOrWhiteSpace($RemoteTarget)){ - Ensure-Command $SshExe - Ensure-Command $ScpExe - - $remoteScriptPath = 'C:\Windows\Temp\deploy-test-territory-git.ps1' - $scpDestination = "${RemoteTarget}:C:/Windows/Temp/deploy-test-territory-git.ps1" - - Write-Host "Copying deploy script to $RemoteTarget" - & $ScpExe -P $RemotePort $PSCommandPath $scpDestination - if($LASTEXITCODE -ne 0){ throw 'scp failed' } - - $remoteCommand = New-Object System.Collections.Generic.List[string] - @( - 'powershell', - '-NoProfile', - '-ExecutionPolicy', 'Bypass', - '-File', (ConvertTo-CmdDoubleQuoted $remoteScriptPath), - '-RunRemoteCore', - '-Repo', (ConvertTo-CmdDoubleQuoted $Repo), - '-Branch', (ConvertTo-CmdDoubleQuoted $Branch), - '-SiteName', (ConvertTo-CmdDoubleQuoted $SiteName) - ) | ForEach-Object { [void]$remoteCommand.Add($_) } - - if(-not [string]::IsNullOrWhiteSpace($AppPool)){ - [void]$remoteCommand.Add('-AppPool') - [void]$remoteCommand.Add((ConvertTo-CmdDoubleQuoted $AppPool)) + if (-not $Rollback -and -not $RemotePreflightOnly) { + Write-Step 'Copying the release package' + & $ScpExe -P $RemotePort $packagePath ($RemoteTarget + ':' + $remoteDirectory.Replace('\', '/') + '/' + $ReleaseId + '.zip') + if ($LASTEXITCODE -ne 0) { throw 'Package copy failed.' } } - if(-not [string]::IsNullOrWhiteSpace($WorkDir)){ - [void]$remoteCommand.Add('-WorkDir') - [void]$remoteCommand.Add((ConvertTo-CmdDoubleQuoted $WorkDir)) - } + Write-Step 'Invoking the host-side installer' + & $SshExe -p $RemotePort $RemoteTarget $remoteCommand + if ($LASTEXITCODE -ne 0) { throw "Remote installer failed with exit code $LASTEXITCODE." } - if(-not [string]::IsNullOrWhiteSpace($PublicDir)){ - [void]$remoteCommand.Add('-PublicDir') - [void]$remoteCommand.Add((ConvertTo-CmdDoubleQuoted $PublicDir)) + Write-Step 'Remote operation completed successfully' +} finally { + if ($KeepPackage -and (Test-Path -LiteralPath $packagePath)) { + $keptPath = Join-Path (Get-Location) ([System.IO.Path]::GetFileName($packagePath)) + Copy-Item -LiteralPath $packagePath -Destination $keptPath -Force + Write-Host "Package retained at $keptPath" } - - if(-not [string]::IsNullOrWhiteSpace($BaseUrl)){ - [void]$remoteCommand.Add('-BaseUrl') - [void]$remoteCommand.Add((ConvertTo-CmdDoubleQuoted $BaseUrl)) + if (Test-Path -LiteralPath $workRoot) { + Remove-Item -LiteralPath $workRoot -Recurse -Force } - - if(-not [string]::IsNullOrWhiteSpace($DbPath)){ - [void]$remoteCommand.Add('-DbPath') - [void]$remoteCommand.Add((ConvertTo-CmdDoubleQuoted $DbPath)) - } - - if(-not [string]::IsNullOrWhiteSpace($LegacyMigrationScript)){ - [void]$remoteCommand.Add('-LegacyMigrationScript') - [void]$remoteCommand.Add((ConvertTo-CmdDoubleQuoted $LegacyMigrationScript)) - } - - if($RunMigrations){ $remoteCommand += '-RunMigrations' } - if($SkipLegacyIsBusinessMigration){ $remoteCommand += '-SkipLegacyIsBusinessMigration' } - - Write-Host "Executing remote deploy on $RemoteTarget" - & $SshExe -p $RemotePort $RemoteTarget ($remoteCommand -join ' ') - if($LASTEXITCODE -ne 0){ throw 'remote deploy failed' } - - exit 0 } - -Invoke-DeployCore From bf01b2d3fb26b2aa3fe4a42a1e3471d4a2a80963 Mon Sep 17 00:00:00 2001 From: Clawdbot Date: Mon, 14 Sep 2026 21:44:42 +0000 Subject: [PATCH 06/13] ci: add host-side IIS release installer --- scripts/install-iis-release.ps1 | 402 ++++++++++++++++++++++++++++++++ 1 file changed, 402 insertions(+) create mode 100644 scripts/install-iis-release.ps1 diff --git a/scripts/install-iis-release.ps1 b/scripts/install-iis-release.ps1 new file mode 100644 index 0000000..eea0ed7 --- /dev/null +++ b/scripts/install-iis-release.ps1 @@ -0,0 +1,402 @@ +<# +.SYNOPSIS + Installs, validates, switches, or rolls back an immutable IIS release. + +.DESCRIPTION + Run this script in an elevated Windows PowerShell 5.1 session on the IIS host. + A release contains the full repository, while IIS is pointed only at its public + directory. The current site's public\web.config is captured once in shared\ + and copied into every new release so host-specific values are not overwritten. + + No database migration runs unless -RunMigrations is explicitly supplied. +#> + +[CmdletBinding(DefaultParameterSetName = 'Deploy')] +param( + [Parameter(Mandatory = $true)] + [ValidatePattern('^[A-Za-z0-9_. -]+$')] + [string]$SiteName, + + [Parameter(ParameterSetName = 'Deploy')] + [string]$PackagePath = '', + + [Parameter(ParameterSetName = 'Deploy')] + [ValidatePattern('^[A-Za-z0-9._-]+$')] + [string]$ReleaseId = (Get-Date -Format 'yyyyMMdd-HHmmss'), + + [Parameter(Mandatory = $true, ParameterSetName = 'Rollback')] + [ValidatePattern('^[A-Za-z0-9._-]+$')] + [string]$RollbackTo, + + [string]$DeployRoot = '', + [string]$BaseUrl = '', + [ValidateRange(2, 100)] + [int]$KeepReleases = 5, + [string]$ExpectedSha256 = '', + [switch]$RunMigrations, + [switch]$SkipSmokeTest, + [switch]$PreflightOnly, + [switch]$DryRun +) + +Set-StrictMode -Version 2.0 +$ErrorActionPreference = 'Stop' + +function Write-Step { + param([string]$Message) + Write-Host ('==> ' + $Message) +} + +function Invoke-Change { + param( + [string]$Description, + [scriptblock]$Action + ) + + if ($DryRun) { + Write-Host ('DRY-RUN: ' + $Description) + return + } + + Write-Step $Description + & $Action +} + +function Get-NormalizedPath { + param([string]$Path) + return [System.IO.Path]::GetFullPath([Environment]::ExpandEnvironmentVariables($Path)).TrimEnd('\') +} + +function Assert-Administrator { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = New-Object Security.Principal.WindowsPrincipal($identity) + if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + throw 'An elevated Administrator PowerShell session is required.' + } +} + +function Assert-ReleaseLayout { + param([string]$ReleasePath) + + $required = @( + 'public\Default.asp', + 'public\web.config', + 'core\autoload_core.asp', + 'app\controllers\autoload_controllers.asp' + ) + + foreach ($relativePath in $required) { + if (-not (Test-Path -LiteralPath (Join-Path $ReleasePath $relativePath) -PathType Leaf)) { + throw "Release is incomplete; missing $relativePath" + } + } + + try { + [xml](Get-Content -LiteralPath (Join-Path $ReleasePath 'public\web.config') -Raw) | Out-Null + } catch { + throw "Release public\web.config is not valid XML: $($_.Exception.Message)" + } +} + +function Get-LocalBaseUrl { + param($Site) + + $binding = $Site.Bindings.Collection | + Where-Object { $_.protocol -eq 'http' } | + Select-Object -First 1 + + if ($null -eq $binding) { + return '' + } + + $parts = $binding.bindingInformation.Split(':') + $port = $parts[1] + if ([string]::IsNullOrWhiteSpace($port)) { + $port = '80' + } + + return 'http://127.0.0.1:' + $port +} + +function Set-IisRelease { + param( + [string]$PhysicalPath, + [string]$PoolName + ) + + Set-ItemProperty -Path ('IIS:\Sites\' + $SiteName) -Name physicalPath -Value $PhysicalPath + + $poolState = (Get-WebAppPoolState -Name $PoolName).Value + if ($poolState -eq 'Started') { + Restart-WebAppPool -Name $PoolName + } else { + Start-WebAppPool -Name $PoolName + } +} + +function Invoke-SmokeTest { + param([string]$Url) + + if ($SkipSmokeTest) { + Write-Step 'Smoke test skipped by explicit request' + return + } + + if ([string]::IsNullOrWhiteSpace($Url)) { + throw 'No HTTP binding was found. Supply -BaseUrl or use -SkipSmokeTest explicitly.' + } + + $target = $Url.TrimEnd('/') + '/' + Write-Step ('Smoke testing ' + $target) + $response = Invoke-WebRequest -UseBasicParsing -Uri $target -TimeoutSec 30 + if ($response.StatusCode -lt 200 -or $response.StatusCode -ge 400) { + throw "Smoke test returned HTTP $($response.StatusCode)" + } + Write-Host ('Smoke test returned HTTP ' + $response.StatusCode) +} + +if ($env:OS -ne 'Windows_NT') { + throw 'This script must run on Windows.' +} + +Assert-Administrator +Import-Module WebAdministration -ErrorAction Stop + +$site = Get-Website -Name $SiteName -ErrorAction Stop +if ($null -eq $site) { + throw "IIS site not found: $SiteName" +} + +$appPool = $site.applicationPool +if ([string]::IsNullOrWhiteSpace($appPool)) { + throw "IIS site $SiteName has no application pool." +} + +if ([string]::IsNullOrWhiteSpace($DeployRoot)) { + $DeployRoot = Join-Path $env:SystemDrive ('inetpub\deployments\' + $SiteName) +} +$DeployRoot = Get-NormalizedPath $DeployRoot +$releasesRoot = Join-Path $DeployRoot 'releases' +$sharedRoot = Join-Path $DeployRoot 'shared' +$sharedConfig = Join-Path $sharedRoot 'public.web.config' +$statePath = Join-Path $DeployRoot 'deployment-state.json' +$currentPhysicalPath = Get-NormalizedPath $site.physicalPath +$currentConfig = Join-Path $currentPhysicalPath 'web.config' + +Write-Step "Site: $SiteName" +Write-Host "App pool: $appPool" +Write-Host "Current physicalPath: $currentPhysicalPath" +Write-Host "Deployment root: $DeployRoot" +Write-Host 'Classic ASP parent paths will be set at the site location in applicationHost.config.' +Write-Host 'Database migrations are disabled unless -RunMigrations is supplied.' + +if ($PSCmdlet.ParameterSetName -eq 'Deploy' -and + (-not $PreflightOnly) -and + (-not $DryRun) -and + [string]::IsNullOrWhiteSpace($PackagePath)) { + throw '-PackagePath is required for a deployment.' +} + +if (-not [string]::IsNullOrWhiteSpace($PackagePath)) { + $PackagePath = Get-NormalizedPath $PackagePath + if (-not (Test-Path -LiteralPath $PackagePath -PathType Leaf)) { + throw "Package not found: $PackagePath" + } + if ([System.IO.Path]::GetExtension($PackagePath) -ne '.zip') { + throw 'PackagePath must name a .zip release package.' + } + + if (-not [string]::IsNullOrWhiteSpace($ExpectedSha256)) { + $actualHash = (Get-FileHash -LiteralPath $PackagePath -Algorithm SHA256).Hash + if ($actualHash -ne $ExpectedSha256) { + throw "Package SHA-256 mismatch. Expected $ExpectedSha256; got $actualHash" + } + Write-Host ('Package SHA-256 verified: ' + $actualHash) + } +} + +if ((-not (Test-Path -LiteralPath $sharedConfig -PathType Leaf)) -and + (-not (Test-Path -LiteralPath $currentConfig -PathType Leaf))) { + throw "Cannot preserve machine configuration: neither $sharedConfig nor $currentConfig exists." +} + +$getWindowsFeature = Get-Command Get-WindowsFeature -ErrorAction SilentlyContinue +$getOptionalFeature = Get-Command Get-WindowsOptionalFeature -ErrorAction SilentlyContinue +if ($null -ne $getWindowsFeature) { + $aspFeature = Get-WindowsFeature -Name Web-ASP + if ($null -eq $aspFeature -or -not $aspFeature.Installed) { + throw 'The IIS Classic ASP feature (Web-ASP) is not installed.' + } +} elseif ($null -ne $getOptionalFeature) { + $aspFeature = Get-WindowsOptionalFeature -Online -FeatureName IIS-ASP -ErrorAction SilentlyContinue + if ($null -ne $aspFeature -and $aspFeature.State -ne 'Enabled') { + throw 'The IIS-ASP Windows feature is not enabled.' + } +} else { + Write-Warning 'No Windows feature-query cmdlet is available; Classic ASP feature state could not be preflighted.' +} + +$rewriteModule = Get-WebGlobalModule -Name RewriteModule -ErrorAction SilentlyContinue +if ($null -eq $rewriteModule) { + throw 'IIS URL Rewrite is not installed (RewriteModule was not found).' +} + +if ($PreflightOnly -or $DryRun) { + Write-Step 'Preflight passed; no IIS or filesystem changes were made' + exit 0 +} + +Invoke-Change "Create deployment directories under $DeployRoot" { + New-Item -ItemType Directory -Force -Path $releasesRoot, $sharedRoot | Out-Null +} + +if (-not (Test-Path -LiteralPath $sharedConfig -PathType Leaf)) { + Invoke-Change "Capture machine-specific configuration from $currentConfig" { + Copy-Item -LiteralPath $currentConfig -Destination $sharedConfig -Force + } +} + +try { + [xml](Get-Content -LiteralPath $sharedConfig -Raw) | Out-Null +} catch { + throw "Preserved configuration is not valid XML: $($_.Exception.Message)" +} + +Invoke-Change 'Enable Classic ASP parent paths explicitly for this IIS site' { + Set-WebConfigurationProperty ` + -PSPath 'MACHINE/WEBROOT/APPHOST' ` + -Location $SiteName ` + -Filter 'system.webServer/asp' ` + -Name 'enableParentPaths' ` + -Value $true +} + +if ($PSCmdlet.ParameterSetName -eq 'Rollback') { + $rollbackRoot = Get-NormalizedPath (Join-Path $releasesRoot $RollbackTo) + $expectedPrefix = $releasesRoot.TrimEnd('\') + '\' + if (-not $rollbackRoot.StartsWith($expectedPrefix, [StringComparison]::OrdinalIgnoreCase)) { + throw 'Rollback target escaped the releases directory.' + } + Assert-ReleaseLayout -ReleasePath $rollbackRoot + $rollbackPublic = Join-Path $rollbackRoot 'public' + + Invoke-Change "Refresh preserved web.config in rollback release $RollbackTo" { + Copy-Item -LiteralPath $sharedConfig -Destination (Join-Path $rollbackPublic 'web.config') -Force + } + + $oldPath = $currentPhysicalPath + try { + Invoke-Change "Switch IIS physicalPath to rollback release $rollbackPublic" { + Set-IisRelease -PhysicalPath $rollbackPublic -PoolName $appPool + } + if ([string]::IsNullOrWhiteSpace($BaseUrl)) { + $BaseUrl = Get-LocalBaseUrl -Site $site + } + Invoke-SmokeTest -Url $BaseUrl + } catch { + Write-Warning "Rollback smoke test failed; restoring $oldPath" + Set-IisRelease -PhysicalPath $oldPath -PoolName $appPool + throw + } + + $rollbackState = [ordered]@{ + siteName = $SiteName + currentRelease = $RollbackTo + currentPhysicalPath = $rollbackPublic + previousPhysicalPath = $oldPath + switchedAtUtc = (Get-Date).ToUniversalTime().ToString('o') + operation = 'rollback' + } + $rollbackState | ConvertTo-Json | Set-Content -LiteralPath $statePath -Encoding UTF8 + Write-Step "Rollback complete: $RollbackTo" + exit 0 +} + +$releaseRoot = Join-Path $releasesRoot $ReleaseId +$stagingRoot = $releaseRoot + '.staging' +if ((Test-Path -LiteralPath $releaseRoot) -or (Test-Path -LiteralPath $stagingRoot)) { + throw "Release already exists: $ReleaseId" +} + +try { + Invoke-Change "Extract package into staging directory $stagingRoot" { + New-Item -ItemType Directory -Force -Path $stagingRoot | Out-Null + Expand-Archive -LiteralPath $PackagePath -DestinationPath $stagingRoot -Force + } + Assert-ReleaseLayout -ReleasePath $stagingRoot + + Invoke-Change 'Overlay the preserved machine-specific public\web.config' { + Copy-Item -LiteralPath $sharedConfig -Destination (Join-Path $stagingRoot 'public\web.config') -Force + } + Assert-ReleaseLayout -ReleasePath $stagingRoot + + if ($RunMigrations) { + $migrationScript = Join-Path $stagingRoot 'scripts\runMigrations.vbs' + if (-not (Test-Path -LiteralPath $migrationScript -PathType Leaf)) { + throw "Migration script not found: $migrationScript" + } + Write-Warning 'Running production migrations by explicit request. IIS rollback will not undo database changes.' + Push-Location $stagingRoot + try { + & cscript.exe //nologo $migrationScript up + if ($LASTEXITCODE -ne 0) { + throw "Migration command exited with code $LASTEXITCODE" + } + } finally { + Pop-Location + } + } + + Invoke-Change "Promote staging directory to immutable release $releaseRoot" { + Move-Item -LiteralPath $stagingRoot -Destination $releaseRoot + } + + $newPublic = Join-Path $releaseRoot 'public' + $oldPath = $currentPhysicalPath + try { + Invoke-Change "Atomically switch IIS physicalPath to $newPublic" { + Set-IisRelease -PhysicalPath $newPublic -PoolName $appPool + } + if ([string]::IsNullOrWhiteSpace($BaseUrl)) { + $BaseUrl = Get-LocalBaseUrl -Site $site + } + Invoke-SmokeTest -Url $BaseUrl + } catch { + Write-Warning "Deployment smoke test failed; restoring $oldPath" + Set-IisRelease -PhysicalPath $oldPath -PoolName $appPool + throw + } + + $state = [ordered]@{ + siteName = $SiteName + currentRelease = $ReleaseId + currentPhysicalPath = $newPublic + previousPhysicalPath = $oldPath + packageSha256 = (Get-FileHash -LiteralPath $PackagePath -Algorithm SHA256).Hash + switchedAtUtc = (Get-Date).ToUniversalTime().ToString('o') + migrationsRun = [bool]$RunMigrations + operation = 'deploy' + } + $state | ConvertTo-Json | Set-Content -LiteralPath $statePath -Encoding UTF8 + + $protectedPaths = @($newPublic, $oldPath) + $oldReleases = Get-ChildItem -LiteralPath $releasesRoot -Directory | + Where-Object { $_.Name -notlike '*.staging' } | + Sort-Object LastWriteTimeUtc -Descending | + Select-Object -Skip $KeepReleases + + foreach ($oldRelease in $oldReleases) { + $oldPublic = Join-Path $oldRelease.FullName 'public' + if ($protectedPaths -notcontains $oldPublic) { + Write-Step ('Retention candidate (not deleted automatically): ' + $oldRelease.FullName) + } + } + + Write-Step "Deployment complete: $ReleaseId" + Write-Host "Rollback command: .\install-iis-release.ps1 -SiteName '$SiteName' -RollbackTo ''" +} catch { + if (Test-Path -LiteralPath $stagingRoot) { + Write-Warning "Incomplete staging directory retained for inspection: $stagingRoot" + } + throw +} From d819a6383a928b116a3d75e7c3843fa270b37436 Mon Sep 17 00:00:00 2001 From: Clawdbot Date: Tue, 15 Sep 2026 01:08:27 +0000 Subject: [PATCH 07/13] ci: isolate deployment from existing IIS sites --- README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 0da06ce..9f06761 100644 --- a/README.md +++ b/README.md @@ -79,9 +79,11 @@ Move generated file to `app/controllers/`. ## Deployment -Production deployment uses full-repository immutable release directories while IIS points only at each release's `public/` directory. `scripts/deploy-iis-git.ps1` packages and transfers a reviewed checkout to `webserver-1` over Tailscale/OpenSSH; `scripts/install-iis-release.ps1` performs host preflight, preserves the machine-owned `public/web.config`, enables Classic ASP parent paths, switches releases, smoke tests, and supports rollback. Database migrations are disabled by default. +Production deployment owns an isolated IIS site and app pool; it never targets, adopts, or copies configuration from another site. Defaults are `SiteName=AspClassicUnifiedFramework`, `AppPoolName=AspClassicUnifiedFramework`, `DeployRoot=D:\Deployments\AspClassicUnifiedFramework`, and HTTP binding `100.97.39.23:8085` with an empty host header. Every value is explicitly overridable, while mismatched existing target state fails closed. -Gitea 1.11.4 has no Gitea Actions, so run the controller script from a trusted external CI worker or operator workstation. Start with `-DryRun`, which makes no network connection. See [docs/deployment-guide.md](docs/deployment-guide.md) and [docs/deployment-configuration.md](docs/deployment-configuration.md). +The complete repository is retained in immutable release directories and IIS serves only each release's `public/` directory. First deployment initializes shared configuration from packaged `public/web.config` unless `-InitialWebConfigPath` is supplied. The target site/app pool is created only after the staged release validates; Classic ASP parent paths are enabled only at that site's location. Failure cleanup is limited to target resources created by the current invocation, or restoration of the prior valid target state. Migrations remain disabled by default. + +Run `scripts/deploy-iis-git.ps1 -LocalPreflightOnly` for local source/XML/package/safety validation with no network access. When host access is permitted, run `-PreflightOnly` for the complete check: it streams the installer without writing remote files and validates IIS features/modules, binding and shared-pool conflicts, drive/path, names, and target state without requiring the target to exist or making IIS/deployment changes. `-HostPreflightOnly` and `-RemotePreflightOnly` remain aliases. Gitea 1.11.4 has no Gitea Actions, so use a trusted external worker or operator workstation. See [docs/deployment-guide.md](docs/deployment-guide.md) and [docs/deployment-configuration.md](docs/deployment-configuration.md). ## Testing From 719c26deb102652585e853589e004803cf80842c Mon Sep 17 00:00:00 2001 From: Clawdbot Date: Tue, 15 Sep 2026 01:09:37 +0000 Subject: [PATCH 08/13] ci: isolate deployment from existing IIS sites --- TESTING.md | 31 +++++++++++++++++++++++++------ 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/TESTING.md b/TESTING.md index 4e946f3..bb217cc 100644 --- a/TESTING.md +++ b/TESTING.md @@ -147,17 +147,36 @@ Before a production release: 1. Run the full ASPUnit suite in the isolated development IIS application. 2. Parse every repository `web.config` as XML. 3. Parse both deployment scripts with the Windows PowerShell parser on a Windows CI worker. -4. From a clean standalone checkout of the approved branch, run: +4. From a clean standalone checkout of the approved `master` branch, run the local/controller preflight: ```powershell - .\scripts\deploy-iis-git.ps1 -SiteName 'ttasp' -RemoteTarget 'webserver-1' -DryRun + .\scripts\deploy-iis-git.ps1 -LocalPreflightOnly ``` - This validates and packages locally but makes no network connection. -5. Run `-RemotePreflightOnly` as a separately approved step when host connectivity is permitted. -6. After deployment, require the installer smoke test and then run any production-safe HTTP checks. Never expose the `tests/` IIS application in production. + This validates source provenance, required files, `public/web.config` XML, package creation/extraction, release layout, and package safety without SSH/SCP or host changes. +5. When host connectivity is separately permitted, run the complete read-only preflight (the dedicated target need not exist): -Linux can validate XML and inspect script structure, but cannot prove Windows PowerShell, WebAdministration, Classic ASP, IIS URL Rewrite, Access/ACE, SSH elevation, or live HTTP behavior. Those checks remain Windows/IIS gates. + ```powershell + .\scripts\deploy-iis-git.ps1 -PreflightOnly -RemoteTarget 'webserver-1' + ``` + + It repeats the local/package checks, streams the installer without writing remote files, and validates IIS/Classic ASP and URL Rewrite, binding and shared-pool conflicts, the deployment drive/path, names, and exact existing-target state without changing IIS or the deployment root. +6. For an isolated Windows test deployment, override names/root/port together rather than reusing any existing site: + + ```powershell + .\scripts\deploy-iis-git.ps1 ` + -SiteName 'AspClassicUnifiedFramework-Test' ` + -AppPoolName 'AspClassicUnifiedFramework-Test' ` + -DeployRoot 'D:\Deployments\AspClassicUnifiedFramework-Test' ` + -BindingIpAddress '100.97.39.23' ` + -BindingPort 8185 ` + -RemoteTarget 'webserver-1' + ``` + +7. Require the installer smoke test. Also induce a smoke-test failure in the isolated test target and verify that only resources created by that invocation are removed, or that an existing valid target's prior path and parent-path value are restored. Verify unrelated sites, pools, and bindings are unchanged. +8. Run production-safe HTTP checks and prove rollback to a prior immutable release. Never expose the `tests/` IIS application in production. + +Linux can validate XML and repository safety/source assertions, but cannot prove Windows PowerShell 5.1 parsing, WebAdministration behavior, installed IIS features/modules, binding behavior on the live host, elevation, app-pool identity/ACLs, Access/ACE, SSH transport, cleanup under real IIS failures, or live HTTP responses. Those remain Windows/IIS gates. ## Limitations From 203b7d76ff13b10abe1aa7a94ce9fe99b19b55c4 Mon Sep 17 00:00:00 2001 From: Clawdbot Date: Tue, 15 Sep 2026 01:10:00 +0000 Subject: [PATCH 09/13] ci: isolate deployment from existing IIS sites --- docs/deployment-guide.md | 251 ++++++++++++--------------------------- 1 file changed, 75 insertions(+), 176 deletions(-) diff --git a/docs/deployment-guide.md b/docs/deployment-guide.md index b2b15d7..1ffd3a3 100644 --- a/docs/deployment-guide.md +++ b/docs/deployment-guide.md @@ -1,235 +1,134 @@ # IIS Deployment Guide -## Deployment model +## Isolation boundary -Production uses immutable release directories on the Windows host: +This pipeline owns one dedicated IIS site and one dedicated application pool. It never discovers, copies configuration from, adopts, or changes another IIS site. In particular, no existing Schedulicious site, app pool, binding, path, or configuration is a deployment source or target. + +Defaults (each may be overridden explicitly): + +```text +SiteName AspClassicUnifiedFramework +AppPoolName AspClassicUnifiedFramework +DeployRoot D:\Deployments\AspClassicUnifiedFramework +Binding http://100.97.39.23:8085/ (empty host header) +ExpectedBranch master +``` + +An existing target is accepted only when its name, app pool, sole HTTP binding, and physical path exactly match this pipeline's expected dedicated state. The physical path must be a `public` directory under `\releases`. A partial or mismatched target fails closed; there is no adoption mode. Unrelated sites, pools, and bindings are left unchanged. + +## Release and web-root model ```text -C:\inetpub\deployments\\ +D:\Deployments\AspClassicUnifiedFramework\ releases\ \ app\ core\ db\ - public\ <- IIS physicalPath points here + public\ <- only this directory is served by IIS scripts\ + tests\ ... shared\ - public.web.config <- machine-owned configuration + public.web.config deployment-state.json ``` -The complete repository is copied into each release. This is required because -`public/Default.asp` includes files above `public/`, including `../core` and -`../app`. IIS exposes only `\public`; sibling source, scripts, tests, -and database files are not web-root content. - -Switching `physicalPath` from one release's `public` directory to another is the -atomic-ish cutover. The app pool is recycled after the switch. If the smoke test -fails, the installer restores the previous physical path and recycles again. -The script does not stop the site for the normal copy/staging phase. - -## Why Gitea is not the runner - -The repository server is Gitea 1.11.4, which predates Gitea Actions. Treat -Gitea as the Git source only. Run `scripts/deploy-iis-git.ps1` from one of: - -- a trusted external CI worker that checks out this repository; -- a controlled operator workstation; -- a separately managed scheduled task. - -The worker must have Tailscale reachability and OpenSSH key-based access to -`webserver-1`. No password, private key, database path, or connection string -belongs in this repository. - -## Files - -- `scripts/deploy-iis-git.ps1`: controller/CI entry point. Validates branch and - worktree state, packages the full source tree, hashes it, transfers it with - `scp`, and invokes the host installer with `ssh`. -- `scripts/install-iis-release.ps1`: elevated host-side installer. Performs - preflight, preserves machine configuration, stages/releases, configures - Classic ASP parent paths, switches IIS, smoke tests, and rolls back. - -Both scripts target Windows PowerShell 5.1 syntax. - -## One-time host preparation - -Complete these steps on `webserver-1` before the first live deployment: - -1. Join the host and CI worker to the intended Tailscale tailnet. Confirm ACLs - allow SSH only from the intended deploy identity. -2. Install and configure Windows OpenSSH Server for key-based authentication. - The deploy identity must be able to run an elevated, non-interactive - PowerShell process; choose and audit the elevation mechanism locally. -3. Install IIS, Classic ASP (`IIS-ASP` / `Web-ASP`), and IIS URL Rewrite. -4. Create the IIS site and application pool. The scripts require an existing - site and do not change its bindings, authentication, app-pool identity, or - managed runtime. -5. Ensure the site currently points at a valid `public` directory containing - its machine-specific `web.config`. On first deployment this file is copied - to `shared\public.web.config` and becomes the deployment-owned preserved - copy. -6. Put the production Access database and logs outside release directories. - Grant the existing app-pool identity only the required permissions. The - deployment scripts deliberately do not change app-pool identity or ACLs. -7. Grant the deploy identity modify permission on the chosen deployment root - and permission to update this IIS site's configuration. -8. Back up IIS configuration and production data using the host's normal backup - system before enabling unattended deployment. - -Default deployment root: - -```text -C:\inetpub\deployments\ -``` +The complete repository is packaged because `public/Default.asp` uses parent includes from sibling directories. Releases are extracted into `.staging`, checked for required files, safe paths/reparse points, and valid `public\web.config` XML, overlaid with shared configuration, and renamed to an immutable release directory. IIS serves only `\public`. -Classic ASP parent paths are set explicitly at the site's location in -`applicationHost.config` by `install-iis-release.ps1`: +On the first deployment, shared configuration is initialized from the packaged release's `public\web.config`. Supply `-InitialWebConfigPath` only when an explicitly managed host-local configuration file should seed the first deployment. The script validates that file as XML. It never copies configuration from any IIS site. Once created, the shared file is reused and is not automatically refreshed. -```text -system.webServer/asp enableParentPaths = true -``` +Classic ASP parent paths are enabled with a location-scoped `system.webServer/asp` setting for this dedicated site only. -This is intentional. The ASP section is commonly locked against `web.config` -overrides, so the setting is applied at the host/site level rather than added to -repository `public/web.config`. +## Scripts -## Safe validation sequence +- `scripts/deploy-iis-git.ps1`: controller entry point; validates source, branch, XML, package structure, and safety; hashes and optionally transfers the package. +- `scripts/install-iis-release.ps1`: elevated Windows PowerShell 5.1 host installer; performs read-only host preflight, stages and validates releases, creates/manages only the dedicated target, switches releases, smoke tests, and rolls back target mutations on failure. -From a standalone checkout on a Windows worker, first validate without making a -network connection: +Gitea 1.11.4 predates Gitea Actions. Run the controller from a trusted external worker or operator workstation with approved Tailscale/OpenSSH access. No credentials or production configuration belong in this repository. -```powershell -.\scripts\deploy-iis-git.ps1 ` - -SiteName 'ttasp' ` - -RemoteTarget 'webserver-1' ` - -ExpectedBranch 'master' ` - -DryRun -``` +## Preflight sequence -`-DryRun` validates the local source, branch, dirty state, XML, package creation, -and argument construction. It prints remote operations but does not run `ssh` -or `scp`. +### 1. Local/controller preflight (no network) -Next run a remote read-only preflight. This does connect to `webserver-1`, copies -only the installer to a temporary directory, and checks the existing site, -app pool, Classic ASP feature (when its feature cmdlet is available), URL -Rewrite module, and availability of a preservable `web.config`: +Run from a clean standalone `master` checkout: ```powershell -.\scripts\deploy-iis-git.ps1 ` - -SiteName 'ttasp' ` - -RemoteTarget 'webserver-1' ` - -RemotePreflightOnly +.\scripts\deploy-iis-git.ps1 -LocalPreflightOnly ``` -For a normal deployment: - -```powershell -.\scripts\deploy-iis-git.ps1 ` - -SiteName 'ttasp' ` - -RemoteTarget 'webserver-1' ` - -ExpectedBranch 'master' ` - -BaseUrl 'http://127.0.0.1/' -``` +This validates the dedicated names/binding/root, source provenance, required source files, repository `public\web.config` XML, package creation, extracted package layout, and reparse-point/symlink safety. It does not call SSH/SCP and makes no host change. `-AllowAnyBranch` is still required when the source is not a standalone Git checkout. -Use a host header or HTTPS URL in `-BaseUrl` when the site's binding requires -one. If no URL is supplied, the installer uses the first HTTP binding's port on -`127.0.0.1`. `-SkipSmokeTest` is an explicit exception and should not be used in -unattended production CI. +`-DryRun` also avoids network access but prints the remote operation that would be performed. -## Branch and artifact policy +### 2. Full preflight (connects, read-only) -The controller defaults to `-ExpectedBranch master`, matching the repository's -actual default branch. It refuses a different branch or dirty worktree. External CI should: +```powershell +.\scripts\deploy-iis-git.ps1 -PreflightOnly -RemoteTarget 'webserver-1' +``` -1. fetch from Gitea over its configured authenticated channel; -2. check out the exact approved commit on `master`; -3. run repository tests/static checks; -4. call the deployment script from that standalone checkout; -5. retain the commit ID, release ID, package SHA-256, and deployment output. +After the local/package checks, the controller streams the installer over SSH and runs it in memory; it does not create a remote transport directory or copy a remote file. The host phase checks elevation, WebAdministration, Classic ASP feature/module, URL Rewrite module, deployment drive/path and resource names, binding conflicts (including wildcard IP overlap), shared-pool conflicts, and existing dedicated target consistency. The dedicated site and pool do not need to exist. No IIS or deployment-root change is made. `-HostPreflightOnly` and `-RemotePreflightOnly` remain aliases. -Use `-AllowAnyBranch` or `-AllowDirty` only for an intentional, reviewed manual -exception. If `SourcePath` is not itself a Git root, `-AllowAnyBranch` is -required because branch provenance cannot be proved. +## First deployment -A webhook receiver is not included. Gitea 1.11.4 can emit webhooks, but accepting -and authenticating them safely is infrastructure-specific. Polling or a manually -approved CI job is simpler unless an existing secured webhook runner is -available. +The installer creates the dedicated app pool and site only after the staged release and selected initial `web.config` validate. Example using all defaults: -## Configuration preservation +```powershell +.\scripts\deploy-iis-git.ps1 -RemoteTarget 'webserver-1' +``` -The installer copies the current site's entire `public\web.config` to: +Example with explicit overrides: -```text -\shared\public.web.config +```powershell +.\scripts\deploy-iis-git.ps1 ` + -SiteName 'AspClassicUnifiedFramework-Test' ` + -AppPoolName 'AspClassicUnifiedFramework-Test' ` + -DeployRoot 'E:\Deployments\AspClassicUnifiedFramework-Test' ` + -BindingIpAddress '100.97.39.23' ` + -BindingPort 8185 ` + -HostHeader 'framework-test.internal' ` + -InitialWebConfigPath 'E:\ManagedConfig\framework-test.web.config' ` + -RemoteTarget 'webserver-1' ` + -ExpectedBranch 'master' ``` -It then overwrites each staged release's repository version with that preserved -file before cutover. This preserves all machine-specific values, especially: +The new app pool uses no managed CLR. Configure any required app-pool identity and ACL policy through separately reviewed host preparation; the deployment does not borrow another pool's identity or permissions. -- `ConnectionString` and its Access `Data Source` path; -- `Environment`; -- `ErrorLogPath` and logging flags; -- site-specific rewrite or app settings already present on the host. +## Existing target and cutover -The shared file is never automatically refreshed from repository content. To -change production settings, edit the shared file under the host's normal change -control, validate its XML, and deploy or roll back. Keep a protected backup. -Do not commit production values. +For later deployments, the host preflight requires the dedicated site to use: -## Database migration policy +- exactly `AppPoolName`; +- exactly one HTTP binding matching IP, port, and host header; +- a physical path ending in `\public` under `\releases`. -Deployments do **not** run migrations by default. The sample `db` directory is -part of the complete release but the production connection string should point -to data outside the release tree. +Any mismatch fails without mutation. After the new release validates, the installer changes only the dedicated site's physical path, applies the site-location parent-path setting, recycles/starts the dedicated pool, and smoke tests the configured binding or explicit `-BaseUrl`. -`-RunMigrations` is available only as an explicit operator choice. Before using -it: +## Failure recovery -1. take and verify a database backup; -2. review every pending migration against the target schema; -3. schedule downtime if the Access database requires exclusive access; -4. understand that switching IIS back does not undo a database migration. +The installer records whether this invocation created the target site or pool and snapshots the prior state of an existing valid target. -The old script's automatic legacy `migrate_isbusiness_to_households.vbs` path has -been removed from deployment. Run one-off data repair scripts only under a -separate reviewed procedure. +- If target creation or smoke testing fails, it removes only the site/app pool created by this invocation. +- If an existing target fails during cutover or smoke testing, it restores its previous physical path and previous site-location parent-path value, then restarts/recycles its pool. +- It never removes or edits an unrelated site, pool, or binding. +- Staging/release files may remain for diagnosis; releases are reported as retention candidates and are never deleted automatically. +- Explicit database migrations cannot be undone by IIS rollback. ## Rollback -List release IDs on the host under `\releases`, or use the value in -`deployment-state.json`. Then run from the worker: - ```powershell .\scripts\deploy-iis-git.ps1 ` - -SiteName 'ttasp' ` - -RemoteTarget 'webserver-1' ` -Rollback ` -RollbackTo '20260914-171500-a1b2c3d4e5f6' ` - -BaseUrl 'http://127.0.0.1/' + -RemoteTarget 'webserver-1' ``` -Rollback refreshes the target release's `public\web.config` from the preserved -shared copy, switches `physicalPath`, recycles the app pool, and smoke tests. If -that smoke test fails, it restores the pre-rollback path. +Rollback requires an already valid dedicated target and an existing immutable release under the configured `DeployRoot`. It switches only that target's physical path and smoke tests; it does not rewrite the prior release. Failure restores the prior target state. -The installer reports releases older than `-KeepReleases` as retention -candidates but does not delete them automatically. Delete only after confirming -they are neither current nor required for rollback and that backups exist. +## Database and writable data -## Failure and recovery behavior +Migrations are disabled by default. `-RunMigrations` is an explicit exception and requires a verified backup; path rollback cannot undo database changes. Keep production databases, logs, and other writable data outside immutable release directories and grant the dedicated app-pool identity only necessary permissions. -- Package extraction occurs in `.staging`. -- Layout and `web.config` XML are validated before promotion. -- Promotion is a directory rename within the release volume. -- IIS changes only after the final release directory exists. -- Failed smoke tests restore the prior `physicalPath`. -- Incomplete staging directories are retained for diagnosis. -- Database changes, external file writes, and machine configuration outside the - IIS path switch cannot be undone by release rollback. +## Windows verification gates -Review Windows Event Log, IIS logs, app logs, the script transcript, and -`deployment-state.json` after any failure. +Before production use, validate both scripts with the Windows PowerShell 5.1 parser, run host preflight on the intended Windows/IIS host, deploy to an isolated test name/binding, verify HTTP and parent includes, induce a smoke failure to prove invocation-scoped cleanup/restoration, then prove rollback. Linux checks cannot validate WebAdministration behavior, installed IIS features/modules, binding semantics on the live host, elevation, ACLs, app-pool identity, Access/ACE, or an actual HTTP response. From 8daa55ee70d843a5e5014b8ccfc37bea884f5a04 Mon Sep 17 00:00:00 2001 From: Clawdbot Date: Tue, 15 Sep 2026 01:11:17 +0000 Subject: [PATCH 10/13] ci: isolate deployment from existing IIS sites --- docs/deployment-configuration.md | 121 ++++++++++++++++--------------- 1 file changed, 63 insertions(+), 58 deletions(-) diff --git a/docs/deployment-configuration.md b/docs/deployment-configuration.md index e1749cc..51186d4 100644 --- a/docs/deployment-configuration.md +++ b/docs/deployment-configuration.md @@ -1,77 +1,82 @@ # Deployment Configuration -## Repository configuration versus machine configuration +## Dedicated defaults -`public/web.config` in the repository is a development/template configuration. -Production values are machine-owned and must not be committed. +The deployment controller and host installer use the same isolated defaults: -On the first release deployment, `scripts/install-iis-release.ps1` copies the -currently active site's complete `public\web.config` to: +| Parameter | Default | +| --- | --- | +| `SiteName` | `AspClassicUnifiedFramework` | +| `AppPoolName` | `AspClassicUnifiedFramework` | +| `DeployRoot` | `D:\Deployments\AspClassicUnifiedFramework` | +| `BindingIpAddress` | `100.97.39.23` | +| `BindingPort` | `8085` | +| `HostHeader` | empty | +| `ExpectedBranch` | `master` (controller) | + +Every value can be explicitly overridden. Site and pool names, deployment root, and host header containing `Schedulicious` are rejected as an additional safety boundary. The scripts do not enumerate another site as a configuration source and provide no adoption switch. + +## Configuration ownership + +Repository `public\web.config` is the default template for first deployment. After the package is safely extracted and validated, the host installer initializes: ```text \shared\public.web.config ``` -Every staged release receives that preserved file before IIS is switched. This -protects the production database path and all other machine-specific settings -from replacement by repository defaults. - -Important machine values include: +from the staged package's `public\web.config`. If `InitialWebConfigPath` is supplied on that first deployment, the validated host-local file at that path is used instead. The parameter does not replace an existing shared configuration. Configuration is never copied from another IIS site. -- `ConnectionString`, especially the Access `Data Source` path; -- `Environment`; -- `EnableErrorLogging` and `ErrorLogPath`; -- cache, timeout, and UI settings; -- any host-specific rewrite behavior. +Every staged release receives the shared file before promotion. Change the shared file only through normal host configuration/change control and validate its XML. Keep secrets and production database paths out of source control. -Change the shared file only through the host's normal configuration-change and -backup process. Validate that it is well-formed XML before deployment. +## IIS state owned by the pipeline -## IIS settings owned by deployment +The pipeline owns only the named dedicated site and app pool. It may: -The host installer changes only: +1. create them, only when both are absent and only after a staged release validates; +2. point the dedicated site at `\releases\\public`; +3. set `system.webServer/asp enableParentPaths=true` at that site's location; +4. start/recycle the dedicated app pool and start a newly created site. -1. the selected site's `system.webServer/asp enableParentPaths` value, applied - explicitly at the site's location in `applicationHost.config`; -2. the selected site's `physicalPath`, set to `\public`; -3. the existing app pool's running state, by starting or recycling it. +It does not alter unrelated sites, app pools, bindings, authentication, identities, ACLs, Windows features, or IIS modules. It does not add bindings to an existing site. -It does not create the site, alter bindings, change authentication, change the -app-pool identity, grant filesystem permissions, install Windows features, or -install IIS URL Rewrite. +When the target already exists, the site and pool must both exist and exactly match the requested app pool, sole HTTP binding, and pipeline-owned release-public path. Any mismatch fails closed. -## Deployment parameters +## Controller parameters -Controller (`scripts/deploy-iis-git.ps1`): +`scripts/deploy-iis-git.ps1` supports: -- `SiteName` (required): existing IIS site name. -- `RemoteTarget`: SSH/Tailscale target; defaults to `webserver-1`. -- `RemotePort`: defaults to 22. -- `ExpectedBranch`: defaults to `master`, the repository's current default branch. -- `DeployRoot`: optional host release root override. -- `BaseUrl`: local-to-host smoke-test URL. +- `SiteName`, `AppPoolName`, `DeployRoot`, `BindingIpAddress`, `BindingPort`, `HostHeader`: dedicated target identity and binding. +- `InitialWebConfigPath`: optional host-local first-deployment seed. +- `RemoteTarget`, `RemotePort`: OpenSSH transport. +- `SourcePath`: standalone checkout to validate/package. +- `ExpectedBranch`: remains `master` by default. - `ReleaseId`: optional immutable release identifier. -- `KeepReleases`: retention reporting threshold; no automatic deletion. -- `RemotePreflightOnly`: connect and perform host checks without IIS/release - changes. -- `DryRun`: local-only validation/package simulation; makes no network call. -- `RunMigrations`: explicit opt-in; never implied. -- `Rollback` plus `RollbackTo`: switch to an existing release. - -Host installer (`scripts/install-iis-release.ps1`): - -- `PackagePath`: ZIP produced by the controller. -- `ExpectedSha256`: transfer integrity check. -- `PreflightOnly` or `DryRun`: checks without host changes. -- `SkipSmokeTest`: explicit exception for sites that cannot be probed locally. - -## Secrets and identity - -No secret parameter is provided. Use OpenSSH configuration, an SSH agent, or a -CI credential store for the private key. Restrict the deploy key and Tailscale -ACL to the specific worker and host. Do not put passwords, keys, production -connection strings, or database files into CI logs or repository files. - -The production database and writable logs should live outside immutable release -directories. Grant the existing IIS app-pool identity only the minimum access -required for those paths. +- `BaseUrl`: explicit host-side smoke-test URL. +- `KeepReleases`: reporting threshold only; no deletion. +- `LocalPreflightOnly`: local source/XML/package/safety validation; no network. +- `PreflightOnly` (`HostPreflightOnly` and `RemotePreflightOnly` aliases): run local/package checks, then stream the installer over SSH for host validation without writing remote files or changing IIS/deployment state. +- `DryRun`: local package validation plus remote command preview; no network. +- `AllowAnyBranch`, `AllowDirty`: reviewed provenance exceptions. +- `RunMigrations`: explicit database migration opt-in. +- `SkipSmokeTest`: explicit smoke-test exception. +- `Rollback` and `RollbackTo`: switch the dedicated target to a validated prior release. + +## Host installer parameters + +`scripts/install-iis-release.ps1` accepts the same target/configuration parameters plus: + +- `PackagePath`, `ReleaseId`, `ExpectedSha256`: deployment artifact inputs. +- `PreflightOnly` or `DryRun`: read-only host checks; the target may be absent. +- `RollbackTo`: validated release ID for rollback. + +Host preflight requires elevation and verifies WebAdministration, Classic ASP feature and `AspModule`, URL Rewrite, requested binding conflicts (including wildcard overlap), shared-pool conflicts, deployment drive/path accessibility, names, and exact existing-target state. It makes no IIS or deployment-root changes. + +## Failure scope + +If creation or smoke testing fails, the installer removes only a site or app pool whose creation flags were set by that invocation. For an existing valid target, it restores the captured physical path and parent-path value. Unrelated IIS resources are never cleanup candidates. + +Package staging/release files can remain as diagnostic evidence. Database migrations and external writes are outside IIS rollback. + +## Secrets, identity, and writable data + +Use OpenSSH configuration, an SSH agent, or a CI secret store. Do not commit passwords, private keys, production connection strings, or database files. Put production databases and logs outside release directories. App-pool identity and filesystem ACL provisioning are explicit host-administration tasks, not inferred from another site. From 4d04bfa0997bbbfa731871bc9418481a9416a9c5 Mon Sep 17 00:00:00 2001 From: Clawdbot Date: Tue, 15 Sep 2026 01:12:34 +0000 Subject: [PATCH 11/13] ci: isolate deployment from existing IIS sites --- scripts/deploy-iis-git.ps1 | 283 ++++++++++++++++++++++--------------- 1 file changed, 169 insertions(+), 114 deletions(-) diff --git a/scripts/deploy-iis-git.ps1 b/scripts/deploy-iis-git.ps1 index 674fe18..71910bd 100644 --- a/scripts/deploy-iis-git.ps1 +++ b/scripts/deploy-iis-git.ps1 @@ -1,29 +1,33 @@ <# .SYNOPSIS - Packages this repository and deploys it to IIS over Tailscale/OpenSSH. + Validates, packages, and deploys this repository to its dedicated IIS site. .DESCRIPTION - This is the controller/CI entry point. It creates a ZIP containing the full - repository (excluding VCS and local deployment artifacts), computes SHA-256, - copies the package and host installer with scp, then invokes the installer on - the Windows host through ssh. - - -DryRun performs local validation and prints the remote operations without - connecting. Gitea 1.11.4 does not run this itself; use a trusted external CI - worker, a scheduled task, or an operator workstation. + The controller packages the complete repository, computes SHA-256, transfers + the package and host installer over OpenSSH, and invokes the installer. + -LocalPreflightOnly validates source provenance, XML, package layout, and + archive safety without connecting. -PreflightOnly additionally streams the + installer to the host and runs its read-only IIS preflight without writing a + remote installer or package. #> [CmdletBinding()] param( - [Parameter(Mandatory = $true)] [ValidatePattern('^[A-Za-z0-9_. -]+$')] - [string]$SiteName, - + [string]$SiteName = 'AspClassicUnifiedFramework', + [ValidatePattern('^[A-Za-z0-9_. -]+$')] + [string]$AppPoolName = 'AspClassicUnifiedFramework', + [string]$DeployRoot = 'D:\Deployments\AspClassicUnifiedFramework', + [string]$BindingIpAddress = '100.97.39.23', + [ValidateRange(1, 65535)] + [int]$BindingPort = 8085, + [AllowEmptyString()] + [string]$HostHeader = '', + [string]$InitialWebConfigPath = '', [string]$RemoteTarget = 'webserver-1', [ValidateRange(1, 65535)] [int]$RemotePort = 22, [string]$SourcePath = (Split-Path $PSScriptRoot -Parent), - [string]$DeployRoot = '', [string]$BaseUrl = '', [string]$ReleaseId = '', [string]$ExpectedBranch = 'master', @@ -35,7 +39,9 @@ param( [switch]$AllowDirty, [switch]$RunMigrations, [switch]$SkipSmokeTest, - [switch]$RemotePreflightOnly, + [Alias('HostPreflightOnly', 'RemotePreflightOnly')] + [switch]$PreflightOnly, + [switch]$LocalPreflightOnly, [switch]$Rollback, [string]$RollbackTo = '', [switch]$DryRun, @@ -45,10 +51,7 @@ param( Set-StrictMode -Version 2.0 $ErrorActionPreference = 'Stop' -function Write-Step { - param([string]$Message) - Write-Host ('==> ' + $Message) -} +function Write-Step { param([string]$Message) Write-Host ('==> ' + $Message) } function Assert-Command { param([string]$Name) @@ -72,177 +75,232 @@ function Add-RemoteArgument { $Arguments.Add((ConvertTo-SingleQuotedPowerShell $Value)) } +function Assert-SafeNameValue { + param([string]$Name, [string]$Value) + if ([string]::IsNullOrWhiteSpace($Value) -or $Value -notmatch '^[A-Za-z0-9_. -]+$') { + throw "$Name contains unsupported characters." + } + if ($Value -match '(?i)schedulicious') { + throw "$Name must not identify a Schedulicious resource." + } +} + +function Assert-SafeDeploymentValues { + Assert-SafeNameValue -Name 'SiteName' -Value $SiteName + Assert-SafeNameValue -Name 'AppPoolName' -Value $AppPoolName + if ($DeployRoot -match '(?i)schedulicious') { + throw 'DeployRoot must not reference Schedulicious.' + } + if ([string]::IsNullOrWhiteSpace($BindingIpAddress)) { + throw 'BindingIpAddress must not be empty.' + } + $parsedAddress = $null + if (-not [System.Net.IPAddress]::TryParse($BindingIpAddress, [ref]$parsedAddress)) { + throw "BindingIpAddress is not a valid IP address: $BindingIpAddress" + } + if ($HostHeader -match '[:/\\]') { + throw 'HostHeader must be empty or a DNS host name without a scheme, port, slash, or backslash.' + } + if ($HostHeader -match '(?i)schedulicious') { + throw 'HostHeader must not reference Schedulicious.' + } +} + function Copy-ReleaseSource { - param( - [string]$From, - [string]$To - ) + param([string]$From, [string]$To) - New-Item -ItemType Directory -Force -Path $To | Out-Null $excludedNames = @('.git', '.deployment', 'releases') - Get-ChildItem -LiteralPath $From -Force | ForEach-Object { - if ($excludedNames -notcontains $_.Name) { - Copy-Item -LiteralPath $_.FullName -Destination $To -Recurse -Force + $packageRoots = @(Get-ChildItem -LiteralPath $From -Force | Where-Object { $excludedNames -notcontains $_.Name }) + $unsafeSourceItem = $packageRoots | + ForEach-Object { + if (($_.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { $_ } + elseif ($_.PSIsContainer) { Get-ChildItem -LiteralPath $_.FullName -Recurse -Force } + } | + Where-Object { ($_.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 } | + Select-Object -First 1 + if ($null -ne $unsafeSourceItem) { + throw "Source contains a reparse point/symbolic link, which is not package-safe: $($unsafeSourceItem.FullName)" + } + + New-Item -ItemType Directory -Force -Path $To | Out-Null + $packageRoots | ForEach-Object { + if ($excludedNames -contains $_.Name) { return } + if (($_.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "Source contains a reparse point/symbolic link, which is not package-safe: $($_.FullName)" } + Copy-Item -LiteralPath $_.FullName -Destination $To -Recurse -Force } -} -$SourcePath = [System.IO.Path]::GetFullPath($SourcePath) -$installerPath = Join-Path $PSScriptRoot 'install-iis-release.ps1' -if (-not (Test-Path -LiteralPath $installerPath -PathType Leaf)) { - throw "Host installer is missing: $installerPath" + $unsafeEntry = Get-ChildItem -LiteralPath $To -Recurse -Force | + Where-Object { ($_.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 } | + Select-Object -First 1 + if ($null -ne $unsafeEntry) { + throw "Package staging contains a reparse point/symbolic link: $($unsafeEntry.FullName)" + } } -$requiredSourceFiles = @( - 'public\Default.asp', - 'public\web.config', - 'core\autoload_core.asp', - 'app\controllers\autoload_controllers.asp' -) -foreach ($relativePath in $requiredSourceFiles) { - if (-not (Test-Path -LiteralPath (Join-Path $SourcePath $relativePath) -PathType Leaf)) { - throw "Source tree is incomplete; missing $relativePath" +function Assert-ReleaseLayout { + param([string]$Root) + $required = @( + 'public\Default.asp', + 'public\web.config', + 'core\autoload_core.asp', + 'app\controllers\autoload_controllers.asp', + 'scripts\install-iis-release.ps1' + ) + foreach ($relativePath in $required) { + if (-not (Test-Path -LiteralPath (Join-Path $Root $relativePath) -PathType Leaf)) { + throw "Package/source tree is incomplete; missing $relativePath" + } + } + foreach ($xmlFile in Get-ChildItem -LiteralPath $Root -Recurse -Force -Filter 'web.config') { + try { + [xml](Get-Content -LiteralPath $xmlFile.FullName -Raw) | Out-Null + } catch { + throw "$($xmlFile.FullName) is not valid XML: $($_.Exception.Message)" + } } } -try { - [xml](Get-Content -LiteralPath (Join-Path $SourcePath 'public\web.config') -Raw) | Out-Null -} catch { - throw "Source public\web.config is not valid XML: $($_.Exception.Message)" -} +Assert-SafeDeploymentValues +$SourcePath = [System.IO.Path]::GetFullPath($SourcePath) +$installerPath = Join-Path $PSScriptRoot 'install-iis-release.ps1' +Assert-ReleaseLayout -Root $SourcePath $directGitRoot = Test-Path -LiteralPath (Join-Path $SourcePath '.git') $commit = 'nogit' if ($directGitRoot) { Assert-Command 'git' $branch = (& git -C $SourcePath branch --show-current).Trim() - if ($LASTEXITCODE -ne 0) { - throw 'Could not determine the Git branch.' - } + if ($LASTEXITCODE -ne 0) { throw 'Could not determine the Git branch.' } if ((-not $AllowAnyBranch) -and $branch -ne $ExpectedBranch) { - throw "Refusing to deploy branch '$branch'; expected '$ExpectedBranch'. Use -AllowAnyBranch only for an intentional exception." + throw "Refusing to deploy branch '$branch'; expected '$ExpectedBranch'." } - $dirty = & git -C $SourcePath status --porcelain - if ($LASTEXITCODE -ne 0) { - throw 'Could not inspect the Git worktree.' - } + if ($LASTEXITCODE -ne 0) { throw 'Could not inspect the Git worktree.' } if ((-not $AllowDirty) -and $null -ne $dirty -and @($dirty).Count -gt 0) { - throw 'Refusing to deploy a dirty worktree. Commit/stash changes or use -AllowDirty for an intentional, auditable exception.' + throw 'Refusing to deploy a dirty worktree. Use -AllowDirty only for a reviewed exception.' } - $commit = (& git -C $SourcePath rev-parse --short=12 HEAD).Trim() - if ($LASTEXITCODE -ne 0) { - throw 'Could not determine the Git commit.' - } + if ($LASTEXITCODE -ne 0) { throw 'Could not determine the Git commit.' } Write-Host "Source branch: $branch" Write-Host "Source commit: $commit" } else { Write-Warning 'SourcePath is not a standalone Git checkout; branch and dirty-worktree checks cannot be enforced.' if (-not $AllowAnyBranch) { - throw 'Use a standalone CI checkout, or pass -AllowAnyBranch explicitly for a reviewed non-Git source tree.' + throw 'Use a standalone checkout, or pass -AllowAnyBranch for a reviewed non-Git source tree.' } } if ([string]::IsNullOrWhiteSpace($ReleaseId)) { $ReleaseId = (Get-Date -Format 'yyyyMMdd-HHmmss') + '-' + $commit } -if ($ReleaseId -notmatch '^[A-Za-z0-9._-]+$') { - throw 'ReleaseId may contain only letters, numbers, dot, underscore, and hyphen.' -} -if ($Rollback -and [string]::IsNullOrWhiteSpace($RollbackTo)) { - throw '-Rollback requires -RollbackTo .' +if ($ReleaseId -notmatch '^[A-Za-z0-9._-]+$') { throw 'ReleaseId contains unsupported characters.' } +if ($Rollback -and [string]::IsNullOrWhiteSpace($RollbackTo)) { throw '-Rollback requires -RollbackTo.' } +if ((-not $Rollback) -and -not [string]::IsNullOrWhiteSpace($RollbackTo)) { throw '-RollbackTo requires -Rollback.' } +if ($RunMigrations -and $Rollback) { throw '-RunMigrations is not valid during rollback.' } +if (($PreflightOnly -or $LocalPreflightOnly) -and $Rollback) { + throw 'Preflight modes cannot be combined with -Rollback.' } -if ((-not $Rollback) -and -not [string]::IsNullOrWhiteSpace($RollbackTo)) { - throw '-RollbackTo is only valid with -Rollback.' -} -if ($RunMigrations -and $Rollback) { - throw '-RunMigrations is not valid during rollback.' +if ($PreflightOnly -and $LocalPreflightOnly) { + throw '-PreflightOnly and -LocalPreflightOnly are mutually exclusive.' } $workRoot = Join-Path ([System.IO.Path]::GetTempPath()) ('asp-iis-deploy-' + [Guid]::NewGuid().ToString('N')) $packageStage = Join-Path $workRoot 'package' +$packageExtract = Join-Path $workRoot 'verify' $packagePath = Join-Path $workRoot ($ReleaseId + '.zip') $remoteDirectory = 'C:\Windows\Temp\asp-iis-deploy-' + $ReleaseId $remotePackage = $remoteDirectory + '\' + $ReleaseId + '.zip' $remoteInstaller = $remoteDirectory + '\install-iis-release.ps1' try { - if (-not $Rollback -and -not $RemotePreflightOnly) { - Write-Step 'Staging the full repository for packaging' + $sha256 = '' + if (-not $Rollback) { + Write-Step 'Staging the complete repository for packaging' Copy-ReleaseSource -From $SourcePath -To $packageStage + Assert-ReleaseLayout -Root $packageStage Add-Type -AssemblyName System.IO.Compression.FileSystem - [System.IO.Compression.ZipFile]::CreateFromDirectory( - $packageStage, - $packagePath, - [System.IO.Compression.CompressionLevel]::Optimal, - $false - ) + [System.IO.Compression.ZipFile]::CreateFromDirectory($packageStage, $packagePath, [System.IO.Compression.CompressionLevel]::Optimal, $false) + [System.IO.Compression.ZipFile]::ExtractToDirectory($packagePath, $packageExtract) + Assert-ReleaseLayout -Root $packageExtract $sha256 = (Get-FileHash -LiteralPath $packagePath -Algorithm SHA256).Hash Write-Host "Package: $packagePath" Write-Host "SHA-256: $sha256" - } else { - $sha256 = '' + } + + if ($LocalPreflightOnly) { + Write-Step 'Local/controller preflight passed; no network connection or host change was made' + exit 0 } $remoteArguments = New-Object 'System.Collections.Generic.List[string]' $remoteArguments.Add('&') $remoteArguments.Add((ConvertTo-SingleQuotedPowerShell $remoteInstaller)) - Add-RemoteArgument -Arguments $remoteArguments -Name '-SiteName' -Value $SiteName + Add-RemoteArgument $remoteArguments '-SiteName' $SiteName + Add-RemoteArgument $remoteArguments '-AppPoolName' $AppPoolName + Add-RemoteArgument $remoteArguments '-DeployRoot' $DeployRoot + Add-RemoteArgument $remoteArguments '-BindingIpAddress' $BindingIpAddress + Add-RemoteArgument $remoteArguments '-BindingPort' $BindingPort.ToString() + Add-RemoteArgument $remoteArguments '-HostHeader' $HostHeader + Add-RemoteArgument $remoteArguments '-KeepReleases' $KeepReleases.ToString() + if (-not [string]::IsNullOrWhiteSpace($InitialWebConfigPath)) { Add-RemoteArgument $remoteArguments '-InitialWebConfigPath' $InitialWebConfigPath } + if (-not [string]::IsNullOrWhiteSpace($BaseUrl)) { Add-RemoteArgument $remoteArguments '-BaseUrl' $BaseUrl } if ($Rollback) { - Add-RemoteArgument -Arguments $remoteArguments -Name '-RollbackTo' -Value $RollbackTo - } elseif (-not $RemotePreflightOnly) { - Add-RemoteArgument -Arguments $remoteArguments -Name '-PackagePath' -Value $remotePackage - Add-RemoteArgument -Arguments $remoteArguments -Name '-ReleaseId' -Value $ReleaseId - Add-RemoteArgument -Arguments $remoteArguments -Name '-ExpectedSha256' -Value $sha256 - } else { + Add-RemoteArgument $remoteArguments '-RollbackTo' $RollbackTo + } elseif ($PreflightOnly) { $remoteArguments.Add('-PreflightOnly') + } else { + Add-RemoteArgument $remoteArguments '-PackagePath' $remotePackage + Add-RemoteArgument $remoteArguments '-ReleaseId' $ReleaseId + Add-RemoteArgument $remoteArguments '-ExpectedSha256' $sha256 } - - if (-not [string]::IsNullOrWhiteSpace($DeployRoot)) { - Add-RemoteArgument -Arguments $remoteArguments -Name '-DeployRoot' -Value $DeployRoot - } - if (-not [string]::IsNullOrWhiteSpace($BaseUrl)) { - Add-RemoteArgument -Arguments $remoteArguments -Name '-BaseUrl' -Value $BaseUrl - } - Add-RemoteArgument -Arguments $remoteArguments -Name '-KeepReleases' -Value $KeepReleases.ToString() if ($RunMigrations) { $remoteArguments.Add('-RunMigrations') } if ($SkipSmokeTest) { $remoteArguments.Add('-SkipSmokeTest') } $remoteScript = $remoteArguments -join ' ' - $remoteBytes = [Text.Encoding]::Unicode.GetBytes($remoteScript) - $remoteEncodedCommand = [Convert]::ToBase64String($remoteBytes) + $remoteEncodedCommand = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($remoteScript)) $remoteCommand = 'powershell.exe -NoProfile -NonInteractive -ExecutionPolicy RemoteSigned -EncodedCommand ' + $remoteEncodedCommand - Write-Host "Remote target: $RemoteTarget (Tailscale/OpenSSH port $RemotePort)" if ($DryRun) { Write-Step 'Dry-run complete; no network connection was made' - Write-Host "Would create remote directory: $remoteDirectory" - Write-Host "Would copy installer: $installerPath" - if (-not $Rollback -and -not $RemotePreflightOnly) { - Write-Host "Would copy package: $packagePath" + Write-Host "Would use dedicated site '$SiteName' and app pool '$AppPoolName'." + Write-Host "Would use binding ${BindingIpAddress}:$BindingPort with host header '$HostHeader'." + if ($PreflightOnly) { + Write-Host 'Would stream the installer over SSH for a read-only host preflight; no remote file would be written.' + } else { + Write-Host "Would create remote directory: $remoteDirectory" + Write-Host "Would copy installer: $installerPath" + if (-not $Rollback) { Write-Host "Would copy package: $packagePath" } + Write-Host ('Would execute host script: ' + $remoteScript) } - Write-Host ('Would execute host script: ' + $remoteScript) - Write-Host ('Transport command uses PowerShell -EncodedCommand to avoid remote-shell quoting ambiguity.') exit 0 } Assert-Command $SshExe - Assert-Command $ScpExe + if ($PreflightOnly) { + Write-Step 'Streaming the installer for read-only host preflight' + $installerSource = Get-Content -LiteralPath $installerPath -Raw + $argumentTail = @($remoteArguments | Select-Object -Skip 2) -join ' ' + $stdinScript = "& {`r`n" + $installerSource + "`r`n} " + $argumentTail + $stdinScript | & $SshExe -p $RemotePort $RemoteTarget 'powershell.exe -NoProfile -NonInteractive -ExecutionPolicy RemoteSigned -Command -' + if ($LASTEXITCODE -ne 0) { throw "Remote preflight failed with exit code $LASTEXITCODE." } + Write-Step 'Remote host preflight completed successfully without persistent host changes' + exit 0 + } - Write-Step 'Creating remote staging directory over Tailscale/OpenSSH' + Assert-Command $ScpExe + Write-Step 'Creating remote temporary directory' $mkdirScript = "New-Item -ItemType Directory -Force -Path $(ConvertTo-SingleQuotedPowerShell $remoteDirectory) | Out-Null" - $mkdirBytes = [Text.Encoding]::Unicode.GetBytes($mkdirScript) - $mkdirEncodedCommand = [Convert]::ToBase64String($mkdirBytes) - & $SshExe -p $RemotePort $RemoteTarget ('powershell.exe -NoProfile -NonInteractive -EncodedCommand ' + $mkdirEncodedCommand) + $mkdirEncoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($mkdirScript)) + & $SshExe -p $RemotePort $RemoteTarget ('powershell.exe -NoProfile -NonInteractive -EncodedCommand ' + $mkdirEncoded) if ($LASTEXITCODE -ne 0) { throw 'Remote directory creation failed.' } Write-Step 'Copying the host installer' & $ScpExe -P $RemotePort $installerPath ($RemoteTarget + ':' + $remoteDirectory.Replace('\', '/') + '/install-iis-release.ps1') if ($LASTEXITCODE -ne 0) { throw 'Installer copy failed.' } - - if (-not $Rollback -and -not $RemotePreflightOnly) { + if (-not $Rollback) { Write-Step 'Copying the release package' & $ScpExe -P $RemotePort $packagePath ($RemoteTarget + ':' + $remoteDirectory.Replace('\', '/') + '/' + $ReleaseId + '.zip') if ($LASTEXITCODE -ne 0) { throw 'Package copy failed.' } @@ -251,7 +309,6 @@ try { Write-Step 'Invoking the host-side installer' & $SshExe -p $RemotePort $RemoteTarget $remoteCommand if ($LASTEXITCODE -ne 0) { throw "Remote installer failed with exit code $LASTEXITCODE." } - Write-Step 'Remote operation completed successfully' } finally { if ($KeepPackage -and (Test-Path -LiteralPath $packagePath)) { @@ -259,7 +316,5 @@ try { Copy-Item -LiteralPath $packagePath -Destination $keptPath -Force Write-Host "Package retained at $keptPath" } - if (Test-Path -LiteralPath $workRoot) { - Remove-Item -LiteralPath $workRoot -Recurse -Force - } + if (Test-Path -LiteralPath $workRoot) { Remove-Item -LiteralPath $workRoot -Recurse -Force } } From 18a1defdf9c296ed54f2bdde624cfcad1fd1e00a Mon Sep 17 00:00:00 2001 From: Clawdbot Date: Tue, 15 Sep 2026 01:12:57 +0000 Subject: [PATCH 12/13] ci: isolate deployment from existing IIS sites --- scripts/install-iis-release.ps1 | 587 ++++++++++++++++++-------------- 1 file changed, 331 insertions(+), 256 deletions(-) diff --git a/scripts/install-iis-release.ps1 b/scripts/install-iis-release.ps1 index eea0ed7..acb599d 100644 --- a/scripts/install-iis-release.ps1 +++ b/scripts/install-iis-release.ps1 @@ -1,34 +1,38 @@ <# .SYNOPSIS - Installs, validates, switches, or rolls back an immutable IIS release. + Installs or rolls back an immutable release in a dedicated IIS site/app pool. .DESCRIPTION - Run this script in an elevated Windows PowerShell 5.1 session on the IIS host. - A release contains the full repository, while IIS is pointed only at its public - directory. The current site's public\web.config is captured once in shared\ - and copied into every new release so host-specific values are not overwritten. - - No database migration runs unless -RunMigrations is explicitly supplied. + The complete repository is retained in each immutable release and IIS serves + only \public. The script never discovers or adopts another site. + A missing dedicated target is created only after package extraction, layout, + and XML validation succeed. Host preflight is read-only and does not require + the dedicated target to exist. #> [CmdletBinding(DefaultParameterSetName = 'Deploy')] param( - [Parameter(Mandatory = $true)] [ValidatePattern('^[A-Za-z0-9_. -]+$')] - [string]$SiteName, + [string]$SiteName = 'AspClassicUnifiedFramework', + [ValidatePattern('^[A-Za-z0-9_. -]+$')] + [string]$AppPoolName = 'AspClassicUnifiedFramework', + [string]$DeployRoot = 'D:\Deployments\AspClassicUnifiedFramework', + [string]$BindingIpAddress = '100.97.39.23', + [ValidateRange(1, 65535)] + [int]$BindingPort = 8085, + [AllowEmptyString()] + [string]$HostHeader = '', + [string]$InitialWebConfigPath = '', [Parameter(ParameterSetName = 'Deploy')] [string]$PackagePath = '', - [Parameter(ParameterSetName = 'Deploy')] [ValidatePattern('^[A-Za-z0-9._-]+$')] [string]$ReleaseId = (Get-Date -Format 'yyyyMMdd-HHmmss'), - [Parameter(Mandatory = $true, ParameterSetName = 'Rollback')] [ValidatePattern('^[A-Za-z0-9._-]+$')] [string]$RollbackTo, - [string]$DeployRoot = '', [string]$BaseUrl = '', [ValidateRange(2, 100)] [int]$KeepReleases = 5, @@ -42,25 +46,7 @@ param( Set-StrictMode -Version 2.0 $ErrorActionPreference = 'Stop' -function Write-Step { - param([string]$Message) - Write-Host ('==> ' + $Message) -} - -function Invoke-Change { - param( - [string]$Description, - [scriptblock]$Action - ) - - if ($DryRun) { - Write-Host ('DRY-RUN: ' + $Description) - return - } - - Write-Step $Description - & $Action -} +function Write-Step { param([string]$Message) Write-Host ('==> ' + $Message) } function Get-NormalizedPath { param([string]$Path) @@ -75,328 +61,417 @@ function Assert-Administrator { } } +function Assert-SafeNameValue { + param([string]$Name, [string]$Value) + if ([string]::IsNullOrWhiteSpace($Value) -or $Value -notmatch '^[A-Za-z0-9_. -]+$') { + throw "$Name contains unsupported characters." + } + if ($Value -match '(?i)schedulicious') { + throw "$Name must not identify a Schedulicious resource." + } +} + +function Assert-DeploymentInputs { + Assert-SafeNameValue -Name 'SiteName' -Value $SiteName + Assert-SafeNameValue -Name 'AppPoolName' -Value $AppPoolName + if ($DeployRoot -match '(?i)schedulicious') { throw 'DeployRoot must not reference Schedulicious.' } + if ($HostHeader -match '(?i)schedulicious') { throw 'HostHeader must not reference Schedulicious.' } + if ($HostHeader -match '[:/\\]') { + throw 'HostHeader must be empty or a DNS host name without a scheme, port, slash, or backslash.' + } + $parsedAddress = $null + if (-not [System.Net.IPAddress]::TryParse($BindingIpAddress, [ref]$parsedAddress)) { + throw "BindingIpAddress is not a valid IP address: $BindingIpAddress" + } +} + function Assert-ReleaseLayout { param([string]$ReleasePath) - $required = @( 'public\Default.asp', 'public\web.config', 'core\autoload_core.asp', - 'app\controllers\autoload_controllers.asp' + 'app\controllers\autoload_controllers.asp', + 'scripts\install-iis-release.ps1' ) - foreach ($relativePath in $required) { if (-not (Test-Path -LiteralPath (Join-Path $ReleasePath $relativePath) -PathType Leaf)) { throw "Release is incomplete; missing $relativePath" } } + foreach ($xmlFile in Get-ChildItem -LiteralPath $ReleasePath -Recurse -Force -Filter 'web.config') { + try { + [xml](Get-Content -LiteralPath $xmlFile.FullName -Raw) | Out-Null + } catch { + throw "$($xmlFile.FullName) is not valid XML: $($_.Exception.Message)" + } + } +} +function Assert-ArchiveEntries { + param([string]$ZipPath) + Add-Type -AssemblyName System.IO.Compression.FileSystem + $archive = [System.IO.Compression.ZipFile]::OpenRead($ZipPath) try { - [xml](Get-Content -LiteralPath (Join-Path $ReleasePath 'public\web.config') -Raw) | Out-Null - } catch { - throw "Release public\web.config is not valid XML: $($_.Exception.Message)" + foreach ($entry in $archive.Entries) { + $name = $entry.FullName.Replace('/', '\') + if ([System.IO.Path]::IsPathRooted($name) -or $name -match '(^|\\)\.\.(\\|$)') { + throw "Package contains an unsafe path: $($entry.FullName)" + } + } + } finally { + $archive.Dispose() } } -function Get-LocalBaseUrl { - param($Site) - - $binding = $Site.Bindings.Collection | - Where-Object { $_.protocol -eq 'http' } | - Select-Object -First 1 +function Get-BindingInformation { + return $BindingIpAddress + ':' + $BindingPort + ':' + $HostHeader +} - if ($null -eq $binding) { - return '' +function Assert-HostCapabilities { + $getWindowsFeature = Get-Command Get-WindowsFeature -ErrorAction SilentlyContinue + $getOptionalFeature = Get-Command Get-WindowsOptionalFeature -ErrorAction SilentlyContinue + if ($null -ne $getWindowsFeature) { + $aspFeature = Get-WindowsFeature -Name Web-ASP + if ($null -eq $aspFeature -or -not $aspFeature.Installed) { throw 'The IIS Classic ASP feature (Web-ASP) is not installed.' } + } elseif ($null -ne $getOptionalFeature) { + $aspFeature = Get-WindowsOptionalFeature -Online -FeatureName IIS-ASP -ErrorAction SilentlyContinue + if ($null -eq $aspFeature -or $aspFeature.State -ne 'Enabled') { throw 'The IIS-ASP Windows feature is not enabled.' } + } else { + throw 'Classic ASP feature state cannot be verified: no supported Windows feature cmdlet is available.' } - - $parts = $binding.bindingInformation.Split(':') - $port = $parts[1] - if ([string]::IsNullOrWhiteSpace($port)) { - $port = '80' + if ($null -eq (Get-WebGlobalModule -Name AspModule -ErrorAction SilentlyContinue)) { + throw 'The IIS Classic ASP module (AspModule) was not found.' + } + if ($null -eq (Get-WebGlobalModule -Name RewriteModule -ErrorAction SilentlyContinue)) { + throw 'IIS URL Rewrite is not installed (RewriteModule was not found).' } +} - return 'http://127.0.0.1:' + $port +function Assert-DeployRoot { + param([string]$Path) + $root = [System.IO.Path]::GetPathRoot($Path) + if ([string]::IsNullOrWhiteSpace($root) -or -not (Test-Path -LiteralPath $root -PathType Container)) { + throw "The deployment drive/root is unavailable: $root" + } + if ($Path.TrimEnd('\') -eq $root.TrimEnd('\')) { throw 'DeployRoot must not be a drive root.' } + if ((Test-Path -LiteralPath $Path) -and -not (Test-Path -LiteralPath $Path -PathType Container)) { + throw "DeployRoot exists but is not a directory: $Path" + } + $ancestor = $Path + while (-not (Test-Path -LiteralPath $ancestor -PathType Container)) { + $parent = Split-Path -Parent $ancestor + if ([string]::IsNullOrWhiteSpace($parent) -or $parent -eq $ancestor) { break } + $ancestor = $parent + } + if (-not (Test-Path -LiteralPath $ancestor -PathType Container)) { + throw "No accessible ancestor exists for DeployRoot: $Path" + } + Get-Item -LiteralPath $ancestor -ErrorAction Stop | Out-Null } -function Set-IisRelease { - param( - [string]$PhysicalPath, - [string]$PoolName - ) +function Test-PathUnderRoot { + param([string]$Path, [string]$Root) + $normalizedPath = Get-NormalizedPath $Path + $prefix = (Get-NormalizedPath $Root) + '\' + return $normalizedPath.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase) +} - Set-ItemProperty -Path ('IIS:\Sites\' + $SiteName) -Name physicalPath -Value $PhysicalPath +function Get-TargetState { + $site = Get-Website -Name $SiteName -ErrorAction SilentlyContinue + $poolExists = Test-Path -LiteralPath ('IIS:\AppPools\' + $AppPoolName) + if (($null -eq $site) -ne (-not $poolExists)) { + throw 'Dedicated target is partial: the site and app pool must either both exist or both be absent.' + } + if ($null -eq $site) { + return [pscustomobject]@{ + Exists = $false; Site = $null; PhysicalPath = ''; ParentPaths = $null + SiteState = ''; PoolState = '' + } + } + if ($site.applicationPool -ne $AppPoolName) { + throw "Existing target site uses app pool '$($site.applicationPool)', expected '$AppPoolName'. Refusing adoption." + } + $otherPoolConsumer = Get-Website | + Where-Object { $_.Name -ne $SiteName -and $_.applicationPool -eq $AppPoolName } | + Select-Object -First 1 + if ($null -ne $otherPoolConsumer) { + throw "App pool '$AppPoolName' is also used by site '$($otherPoolConsumer.Name)'. Refusing to alter a shared pool." + } + $bindings = @($site.Bindings.Collection) + $expectedBinding = Get-BindingInformation + if ($bindings.Count -ne 1 -or $bindings[0].protocol -ne 'http' -or $bindings[0].bindingInformation -ne $expectedBinding) { + throw "Existing target binding does not exactly match http/$expectedBinding. Refusing adoption or binding changes." + } + $physicalPath = Get-NormalizedPath $site.physicalPath + $releasesRoot = Join-Path $DeployRoot 'releases' + if (-not (Test-PathUnderRoot -Path $physicalPath -Root $releasesRoot) -or + -not $physicalPath.EndsWith('\public', [StringComparison]::OrdinalIgnoreCase)) { + throw "Existing target physicalPath is outside this pipeline's release public directories: $physicalPath" + } + if (-not (Test-Path -LiteralPath $physicalPath -PathType Container)) { + throw "Existing target physicalPath does not exist: $physicalPath" + } + Assert-ReleaseLayout -ReleasePath (Split-Path -Parent $physicalPath) + $parentPaths = (Get-WebConfigurationProperty -PSPath 'MACHINE/WEBROOT/APPHOST' -Location $SiteName -Filter 'system.webServer/asp' -Name 'enableParentPaths').Value + return [pscustomobject]@{ + Exists = $true; Site = $site; PhysicalPath = $physicalPath; ParentPaths = [bool]$parentPaths + SiteState = (Get-WebsiteState -Name $SiteName).Value + PoolState = (Get-WebAppPoolState -Name $AppPoolName).Value + } +} - $poolState = (Get-WebAppPoolState -Name $PoolName).Value - if ($poolState -eq 'Started') { - Restart-WebAppPool -Name $PoolName - } else { - Start-WebAppPool -Name $PoolName +function Assert-NoBindingConflict { + param($TargetState) + $expectedBinding = Get-BindingInformation + foreach ($candidate in Get-Website) { + if ($TargetState.Exists -and $candidate.Name -eq $SiteName) { continue } + foreach ($binding in @($candidate.Bindings.Collection)) { + if ($binding.protocol -ne 'http') { continue } + if ($binding.bindingInformation -notmatch '^(.*):(\d+):(.*)$') { continue } + $candidateIp = $Matches[1] + $candidatePort = [int]$Matches[2] + $candidateHost = $Matches[3] + $ipOverlaps = ($candidateIp -eq '*' -or $candidateIp -eq '0.0.0.0' -or $candidateIp -eq $BindingIpAddress) + if ($candidatePort -eq $BindingPort -and $candidateHost -eq $HostHeader -and $ipOverlaps) { + throw "Requested binding http/$expectedBinding conflicts with existing site '$($candidate.Name)' binding '$($binding.bindingInformation)'." + } + } } } -function Invoke-SmokeTest { - param([string]$Url) +function Get-SmokeUrl { + if (-not [string]::IsNullOrWhiteSpace($BaseUrl)) { return $BaseUrl } + $hostPart = $BindingIpAddress + if ($hostPart.Contains(':')) { $hostPart = '[' + $hostPart + ']' } + return 'http://' + $hostPart + ':' + $BindingPort +} +function Invoke-SmokeTest { if ($SkipSmokeTest) { Write-Step 'Smoke test skipped by explicit request' return } - - if ([string]::IsNullOrWhiteSpace($Url)) { - throw 'No HTTP binding was found. Supply -BaseUrl or use -SkipSmokeTest explicitly.' - } - - $target = $Url.TrimEnd('/') + '/' + $target = (Get-SmokeUrl).TrimEnd('/') + '/' Write-Step ('Smoke testing ' + $target) - $response = Invoke-WebRequest -UseBasicParsing -Uri $target -TimeoutSec 30 + $headers = @{} + if (-not [string]::IsNullOrWhiteSpace($HostHeader)) { $headers['Host'] = $HostHeader } + $response = Invoke-WebRequest -UseBasicParsing -Uri $target -Headers $headers -TimeoutSec 30 if ($response.StatusCode -lt 200 -or $response.StatusCode -ge 400) { throw "Smoke test returned HTTP $($response.StatusCode)" } Write-Host ('Smoke test returned HTTP ' + $response.StatusCode) } -if ($env:OS -ne 'Windows_NT') { - throw 'This script must run on Windows.' -} - -Assert-Administrator -Import-Module WebAdministration -ErrorAction Stop - -$site = Get-Website -Name $SiteName -ErrorAction Stop -if ($null -eq $site) { - throw "IIS site not found: $SiteName" -} - -$appPool = $site.applicationPool -if ([string]::IsNullOrWhiteSpace($appPool)) { - throw "IIS site $SiteName has no application pool." -} - -if ([string]::IsNullOrWhiteSpace($DeployRoot)) { - $DeployRoot = Join-Path $env:SystemDrive ('inetpub\deployments\' + $SiteName) -} -$DeployRoot = Get-NormalizedPath $DeployRoot -$releasesRoot = Join-Path $DeployRoot 'releases' -$sharedRoot = Join-Path $DeployRoot 'shared' -$sharedConfig = Join-Path $sharedRoot 'public.web.config' -$statePath = Join-Path $DeployRoot 'deployment-state.json' -$currentPhysicalPath = Get-NormalizedPath $site.physicalPath -$currentConfig = Join-Path $currentPhysicalPath 'web.config' - -Write-Step "Site: $SiteName" -Write-Host "App pool: $appPool" -Write-Host "Current physicalPath: $currentPhysicalPath" -Write-Host "Deployment root: $DeployRoot" -Write-Host 'Classic ASP parent paths will be set at the site location in applicationHost.config.' -Write-Host 'Database migrations are disabled unless -RunMigrations is supplied.' - -if ($PSCmdlet.ParameterSetName -eq 'Deploy' -and - (-not $PreflightOnly) -and - (-not $DryRun) -and - [string]::IsNullOrWhiteSpace($PackagePath)) { - throw '-PackagePath is required for a deployment.' +function Set-IisRelease { + param([string]$PhysicalPath) + Set-ItemProperty -Path ('IIS:\Sites\' + $SiteName) -Name physicalPath -Value $PhysicalPath + $poolState = (Get-WebAppPoolState -Name $AppPoolName).Value + if ($poolState -eq 'Started') { Restart-WebAppPool -Name $AppPoolName } + else { Start-WebAppPool -Name $AppPoolName } } -if (-not [string]::IsNullOrWhiteSpace($PackagePath)) { - $PackagePath = Get-NormalizedPath $PackagePath - if (-not (Test-Path -LiteralPath $PackagePath -PathType Leaf)) { - throw "Package not found: $PackagePath" - } - if ([System.IO.Path]::GetExtension($PackagePath) -ne '.zip') { - throw 'PackagePath must name a .zip release package.' +function Restore-ExistingTarget { + param($TargetState) + if (-not $TargetState.Exists) { return } + Set-ItemProperty -Path ('IIS:\Sites\' + $SiteName) -Name physicalPath -Value $TargetState.PhysicalPath + Set-WebConfigurationProperty -PSPath 'MACHINE/WEBROOT/APPHOST' -Location $SiteName -Filter 'system.webServer/asp' -Name 'enableParentPaths' -Value $TargetState.ParentPaths + if ($TargetState.PoolState -eq 'Started') { + if ((Get-WebAppPoolState -Name $AppPoolName).Value -eq 'Started') { Restart-WebAppPool -Name $AppPoolName } + else { Start-WebAppPool -Name $AppPoolName } + } elseif ((Get-WebAppPoolState -Name $AppPoolName).Value -eq 'Started') { + Stop-WebAppPool -Name $AppPoolName } - - if (-not [string]::IsNullOrWhiteSpace($ExpectedSha256)) { - $actualHash = (Get-FileHash -LiteralPath $PackagePath -Algorithm SHA256).Hash - if ($actualHash -ne $ExpectedSha256) { - throw "Package SHA-256 mismatch. Expected $ExpectedSha256; got $actualHash" - } - Write-Host ('Package SHA-256 verified: ' + $actualHash) + if ($TargetState.SiteState -eq 'Started') { + if ((Get-WebsiteState -Name $SiteName).Value -ne 'Started') { Start-Website -Name $SiteName } + } elseif ((Get-WebsiteState -Name $SiteName).Value -eq 'Started') { + Stop-Website -Name $SiteName } } -if ((-not (Test-Path -LiteralPath $sharedConfig -PathType Leaf)) -and - (-not (Test-Path -LiteralPath $currentConfig -PathType Leaf))) { - throw "Cannot preserve machine configuration: neither $sharedConfig nor $currentConfig exists." -} - -$getWindowsFeature = Get-Command Get-WindowsFeature -ErrorAction SilentlyContinue -$getOptionalFeature = Get-Command Get-WindowsOptionalFeature -ErrorAction SilentlyContinue -if ($null -ne $getWindowsFeature) { - $aspFeature = Get-WindowsFeature -Name Web-ASP - if ($null -eq $aspFeature -or -not $aspFeature.Installed) { - throw 'The IIS Classic ASP feature (Web-ASP) is not installed.' - } -} elseif ($null -ne $getOptionalFeature) { - $aspFeature = Get-WindowsOptionalFeature -Online -FeatureName IIS-ASP -ErrorAction SilentlyContinue - if ($null -ne $aspFeature -and $aspFeature.State -ne 'Enabled') { - throw 'The IIS-ASP Windows feature is not enabled.' +if ($env:OS -ne 'Windows_NT') { throw 'This script must run on Windows.' } +Assert-Administrator +Import-Module WebAdministration -ErrorAction Stop +Assert-DeploymentInputs +$DeployRoot = Get-NormalizedPath $DeployRoot +Assert-DeployRoot -Path $DeployRoot +if (-not [string]::IsNullOrWhiteSpace($InitialWebConfigPath)) { + $preflightInitialConfig = Get-NormalizedPath $InitialWebConfigPath + if (-not (Test-Path -LiteralPath $preflightInitialConfig -PathType Leaf)) { + throw "InitialWebConfigPath not found: $preflightInitialConfig" } -} else { - Write-Warning 'No Windows feature-query cmdlet is available; Classic ASP feature state could not be preflighted.' + try { [xml](Get-Content -LiteralPath $preflightInitialConfig -Raw) | Out-Null } + catch { throw "InitialWebConfigPath is not valid XML: $($_.Exception.Message)" } } +Assert-HostCapabilities +$targetState = Get-TargetState +Assert-NoBindingConflict -TargetState $targetState -$rewriteModule = Get-WebGlobalModule -Name RewriteModule -ErrorAction SilentlyContinue -if ($null -eq $rewriteModule) { - throw 'IIS URL Rewrite is not installed (RewriteModule was not found).' +Write-Step "Dedicated site: $SiteName" +Write-Host "Dedicated app pool: $AppPoolName" +Write-Host "Binding: http/$(Get-BindingInformation)" +Write-Host "Deployment root: $DeployRoot" +if ($targetState.Exists) { + Write-Host 'Target state: existing and exactly matched' +} else { + Write-Host 'Target state: absent; eligible for isolated creation after release validation' } if ($PreflightOnly -or $DryRun) { - Write-Step 'Preflight passed; no IIS or filesystem changes were made' + Write-Step 'Host preflight passed; no IIS or filesystem changes were made' exit 0 } -Invoke-Change "Create deployment directories under $DeployRoot" { - New-Item -ItemType Directory -Force -Path $releasesRoot, $sharedRoot | Out-Null -} - -if (-not (Test-Path -LiteralPath $sharedConfig -PathType Leaf)) { - Invoke-Change "Capture machine-specific configuration from $currentConfig" { - Copy-Item -LiteralPath $currentConfig -Destination $sharedConfig -Force - } -} - -try { - [xml](Get-Content -LiteralPath $sharedConfig -Raw) | Out-Null -} catch { - throw "Preserved configuration is not valid XML: $($_.Exception.Message)" -} - -Invoke-Change 'Enable Classic ASP parent paths explicitly for this IIS site' { - Set-WebConfigurationProperty ` - -PSPath 'MACHINE/WEBROOT/APPHOST' ` - -Location $SiteName ` - -Filter 'system.webServer/asp' ` - -Name 'enableParentPaths' ` - -Value $true -} +$releasesRoot = Join-Path $DeployRoot 'releases' +$sharedRoot = Join-Path $DeployRoot 'shared' +$sharedConfig = Join-Path $sharedRoot 'public.web.config' +$statePath = Join-Path $DeployRoot 'deployment-state.json' if ($PSCmdlet.ParameterSetName -eq 'Rollback') { + if (-not $targetState.Exists) { throw 'Rollback requires the dedicated target site and app pool to exist.' } + if (-not (Test-Path -LiteralPath $sharedConfig -PathType Leaf)) { throw "Shared configuration is missing: $sharedConfig" } $rollbackRoot = Get-NormalizedPath (Join-Path $releasesRoot $RollbackTo) - $expectedPrefix = $releasesRoot.TrimEnd('\') + '\' - if (-not $rollbackRoot.StartsWith($expectedPrefix, [StringComparison]::OrdinalIgnoreCase)) { - throw 'Rollback target escaped the releases directory.' - } + if (-not (Test-PathUnderRoot -Path $rollbackRoot -Root $releasesRoot)) { throw 'Rollback target escaped the releases directory.' } Assert-ReleaseLayout -ReleasePath $rollbackRoot - $rollbackPublic = Join-Path $rollbackRoot 'public' - - Invoke-Change "Refresh preserved web.config in rollback release $RollbackTo" { - Copy-Item -LiteralPath $sharedConfig -Destination (Join-Path $rollbackPublic 'web.config') -Force - } - - $oldPath = $currentPhysicalPath + try { [xml](Get-Content -LiteralPath $sharedConfig -Raw) | Out-Null } + catch { throw "Shared configuration is not valid XML: $($_.Exception.Message)" } try { - Invoke-Change "Switch IIS physicalPath to rollback release $rollbackPublic" { - Set-IisRelease -PhysicalPath $rollbackPublic -PoolName $appPool - } - if ([string]::IsNullOrWhiteSpace($BaseUrl)) { - $BaseUrl = Get-LocalBaseUrl -Site $site - } - Invoke-SmokeTest -Url $BaseUrl + Set-WebConfigurationProperty -PSPath 'MACHINE/WEBROOT/APPHOST' -Location $SiteName -Filter 'system.webServer/asp' -Name 'enableParentPaths' -Value $true + Set-IisRelease -PhysicalPath (Join-Path $rollbackRoot 'public') + Invoke-SmokeTest + [ordered]@{ + siteName = $SiteName; appPoolName = $AppPoolName; currentRelease = $RollbackTo + currentPhysicalPath = (Join-Path $rollbackRoot 'public'); previousPhysicalPath = $targetState.PhysicalPath + binding = (Get-BindingInformation); switchedAtUtc = (Get-Date).ToUniversalTime().ToString('o'); operation = 'rollback' + } | ConvertTo-Json | Set-Content -LiteralPath $statePath -Encoding UTF8 } catch { - Write-Warning "Rollback smoke test failed; restoring $oldPath" - Set-IisRelease -PhysicalPath $oldPath -PoolName $appPool + Write-Warning 'Rollback failed; restoring the prior dedicated target path and parent-path setting.' + Restore-ExistingTarget -TargetState $targetState throw } - - $rollbackState = [ordered]@{ - siteName = $SiteName - currentRelease = $RollbackTo - currentPhysicalPath = $rollbackPublic - previousPhysicalPath = $oldPath - switchedAtUtc = (Get-Date).ToUniversalTime().ToString('o') - operation = 'rollback' - } - $rollbackState | ConvertTo-Json | Set-Content -LiteralPath $statePath -Encoding UTF8 Write-Step "Rollback complete: $RollbackTo" exit 0 } +if ([string]::IsNullOrWhiteSpace($PackagePath)) { throw '-PackagePath is required for deployment.' } +$PackagePath = Get-NormalizedPath $PackagePath +if (-not (Test-Path -LiteralPath $PackagePath -PathType Leaf)) { throw "Package not found: $PackagePath" } +if ([System.IO.Path]::GetExtension($PackagePath) -ne '.zip') { throw 'PackagePath must name a .zip package.' } +if (-not [string]::IsNullOrWhiteSpace($ExpectedSha256)) { + $actualHash = (Get-FileHash -LiteralPath $PackagePath -Algorithm SHA256).Hash + if ($actualHash -ne $ExpectedSha256) { throw "Package SHA-256 mismatch. Expected $ExpectedSha256; got $actualHash" } + Write-Host ('Package SHA-256 verified: ' + $actualHash) +} +Assert-ArchiveEntries -ZipPath $PackagePath + $releaseRoot = Join-Path $releasesRoot $ReleaseId $stagingRoot = $releaseRoot + '.staging' -if ((Test-Path -LiteralPath $releaseRoot) -or (Test-Path -LiteralPath $stagingRoot)) { - throw "Release already exists: $ReleaseId" -} +if ((Test-Path -LiteralPath $releaseRoot) -or (Test-Path -LiteralPath $stagingRoot)) { throw "Release already exists: $ReleaseId" } +$createdSite = $false +$createdPool = $false +$createdSharedConfig = $false +$iisMutationStarted = $false try { - Invoke-Change "Extract package into staging directory $stagingRoot" { - New-Item -ItemType Directory -Force -Path $stagingRoot | Out-Null - Expand-Archive -LiteralPath $PackagePath -DestinationPath $stagingRoot -Force + New-Item -ItemType Directory -Force -Path $releasesRoot, $sharedRoot | Out-Null + New-Item -ItemType Directory -Force -Path $stagingRoot | Out-Null + Expand-Archive -LiteralPath $PackagePath -DestinationPath $stagingRoot -Force + $unsafeExtractedItem = Get-ChildItem -LiteralPath $stagingRoot -Recurse -Force | + Where-Object { ($_.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 } | + Select-Object -First 1 + if ($null -ne $unsafeExtractedItem) { + throw "Extracted release contains a reparse point: $($unsafeExtractedItem.FullName)" } Assert-ReleaseLayout -ReleasePath $stagingRoot - Invoke-Change 'Overlay the preserved machine-specific public\web.config' { - Copy-Item -LiteralPath $sharedConfig -Destination (Join-Path $stagingRoot 'public\web.config') -Force + if (-not (Test-Path -LiteralPath $sharedConfig -PathType Leaf)) { + $initialConfig = Join-Path $stagingRoot 'public\web.config' + if (-not [string]::IsNullOrWhiteSpace($InitialWebConfigPath)) { + $initialConfig = Get-NormalizedPath $InitialWebConfigPath + if (-not (Test-Path -LiteralPath $initialConfig -PathType Leaf)) { throw "InitialWebConfigPath not found: $initialConfig" } + } + try { [xml](Get-Content -LiteralPath $initialConfig -Raw) | Out-Null } + catch { throw "Initial web.config is not valid XML: $($_.Exception.Message)" } + Copy-Item -LiteralPath $initialConfig -Destination $sharedConfig -Force + $createdSharedConfig = $true + Write-Step "Initialized shared configuration from $initialConfig" } + try { [xml](Get-Content -LiteralPath $sharedConfig -Raw) | Out-Null } + catch { throw "Shared configuration is not valid XML: $($_.Exception.Message)" } + Copy-Item -LiteralPath $sharedConfig -Destination (Join-Path $stagingRoot 'public\web.config') -Force Assert-ReleaseLayout -ReleasePath $stagingRoot if ($RunMigrations) { $migrationScript = Join-Path $stagingRoot 'scripts\runMigrations.vbs' - if (-not (Test-Path -LiteralPath $migrationScript -PathType Leaf)) { - throw "Migration script not found: $migrationScript" - } - Write-Warning 'Running production migrations by explicit request. IIS rollback will not undo database changes.' + if (-not (Test-Path -LiteralPath $migrationScript -PathType Leaf)) { throw "Migration script not found: $migrationScript" } + Write-Warning 'Running migrations by explicit request; IIS rollback cannot undo data changes.' Push-Location $stagingRoot try { & cscript.exe //nologo $migrationScript up - if ($LASTEXITCODE -ne 0) { - throw "Migration command exited with code $LASTEXITCODE" - } - } finally { - Pop-Location - } - } - - Invoke-Change "Promote staging directory to immutable release $releaseRoot" { - Move-Item -LiteralPath $stagingRoot -Destination $releaseRoot + if ($LASTEXITCODE -ne 0) { throw "Migration command exited with code $LASTEXITCODE" } + } finally { Pop-Location } } + Move-Item -LiteralPath $stagingRoot -Destination $releaseRoot $newPublic = Join-Path $releaseRoot 'public' - $oldPath = $currentPhysicalPath - try { - Invoke-Change "Atomically switch IIS physicalPath to $newPublic" { - Set-IisRelease -PhysicalPath $newPublic -PoolName $appPool - } - if ([string]::IsNullOrWhiteSpace($BaseUrl)) { - $BaseUrl = Get-LocalBaseUrl -Site $site - } - Invoke-SmokeTest -Url $BaseUrl - } catch { - Write-Warning "Deployment smoke test failed; restoring $oldPath" - Set-IisRelease -PhysicalPath $oldPath -PoolName $appPool - throw + + $iisMutationStarted = $true + if (-not $targetState.Exists) { + New-WebAppPool -Name $AppPoolName | Out-Null + $createdPool = $true + Set-ItemProperty -Path ('IIS:\AppPools\' + $AppPoolName) -Name managedRuntimeVersion -Value '' + New-Website -Name $SiteName -PhysicalPath $newPublic -ApplicationPool $AppPoolName -IPAddress $BindingIpAddress -Port $BindingPort -HostHeader $HostHeader | Out-Null + $createdSite = $true + } else { + Set-IisRelease -PhysicalPath $newPublic } - $state = [ordered]@{ - siteName = $SiteName - currentRelease = $ReleaseId - currentPhysicalPath = $newPublic - previousPhysicalPath = $oldPath - packageSha256 = (Get-FileHash -LiteralPath $PackagePath -Algorithm SHA256).Hash - switchedAtUtc = (Get-Date).ToUniversalTime().ToString('o') - migrationsRun = [bool]$RunMigrations - operation = 'deploy' + Set-WebConfigurationProperty -PSPath 'MACHINE/WEBROOT/APPHOST' -Location $SiteName -Filter 'system.webServer/asp' -Name 'enableParentPaths' -Value $true + if (-not $targetState.Exists) { + $poolState = (Get-WebAppPoolState -Name $AppPoolName).Value + if ($poolState -ne 'Started') { Start-WebAppPool -Name $AppPoolName } + $siteState = (Get-WebsiteState -Name $SiteName).Value + if ($siteState -ne 'Started') { Start-Website -Name $SiteName } } - $state | ConvertTo-Json | Set-Content -LiteralPath $statePath -Encoding UTF8 + Invoke-SmokeTest + + [ordered]@{ + siteName = $SiteName; appPoolName = $AppPoolName; currentRelease = $ReleaseId + currentPhysicalPath = $newPublic; previousPhysicalPath = $targetState.PhysicalPath + binding = (Get-BindingInformation); packageSha256 = (Get-FileHash -LiteralPath $PackagePath -Algorithm SHA256).Hash + switchedAtUtc = (Get-Date).ToUniversalTime().ToString('o'); migrationsRun = [bool]$RunMigrations; operation = 'deploy' + } | ConvertTo-Json | Set-Content -LiteralPath $statePath -Encoding UTF8 - $protectedPaths = @($newPublic, $oldPath) + $protectedPaths = @($newPublic, $targetState.PhysicalPath) $oldReleases = Get-ChildItem -LiteralPath $releasesRoot -Directory | Where-Object { $_.Name -notlike '*.staging' } | Sort-Object LastWriteTimeUtc -Descending | Select-Object -Skip $KeepReleases - foreach ($oldRelease in $oldReleases) { $oldPublic = Join-Path $oldRelease.FullName 'public' - if ($protectedPaths -notcontains $oldPublic) { - Write-Step ('Retention candidate (not deleted automatically): ' + $oldRelease.FullName) - } + if ($protectedPaths -notcontains $oldPublic) { Write-Step ('Retention candidate (not deleted): ' + $oldRelease.FullName) } } - Write-Step "Deployment complete: $ReleaseId" - Write-Host "Rollback command: .\install-iis-release.ps1 -SiteName '$SiteName' -RollbackTo ''" } catch { - if (Test-Path -LiteralPath $stagingRoot) { - Write-Warning "Incomplete staging directory retained for inspection: $stagingRoot" + $failure = $_ + if ($iisMutationStarted) { + if ($createdSite -and (Get-Website -Name $SiteName -ErrorAction SilentlyContinue)) { + Write-Warning "Removing site created by this invocation: $SiteName" + Remove-Website -Name $SiteName + } + if ($createdPool -and (Test-Path -LiteralPath ('IIS:\AppPools\' + $AppPoolName))) { + Write-Warning "Removing app pool created by this invocation: $AppPoolName" + Remove-WebAppPool -Name $AppPoolName + } + if ($targetState.Exists) { + Write-Warning 'Restoring the prior dedicated target state.' + Restore-ExistingTarget -TargetState $targetState + } + } + if ($createdSharedConfig -and (Test-Path -LiteralPath $sharedConfig -PathType Leaf)) { + Write-Warning 'Removing shared configuration initialized by this failed invocation.' + Remove-Item -LiteralPath $sharedConfig -Force } - throw + if (Test-Path -LiteralPath $stagingRoot) { Write-Warning "Incomplete staging retained for inspection: $stagingRoot" } + throw $failure } From dbcf2f44b09c34e91f41941a9d68e3873fb4b704 Mon Sep 17 00:00:00 2001 From: Clawdbot Date: Tue, 15 Sep 2026 01:45:51 +0000 Subject: [PATCH 13/13] ci: prove isolated IIS deployment on webserver-1 --- scripts/install-iis-release.ps1 | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/scripts/install-iis-release.ps1 b/scripts/install-iis-release.ps1 index acb599d..77bbef9 100644 --- a/scripts/install-iis-release.ps1 +++ b/scripts/install-iis-release.ps1 @@ -129,19 +129,18 @@ function Get-BindingInformation { } function Assert-HostCapabilities { - $getWindowsFeature = Get-Command Get-WindowsFeature -ErrorAction SilentlyContinue - $getOptionalFeature = Get-Command Get-WindowsOptionalFeature -ErrorAction SilentlyContinue - if ($null -ne $getWindowsFeature) { - $aspFeature = Get-WindowsFeature -Name Web-ASP - if ($null -eq $aspFeature -or -not $aspFeature.Installed) { throw 'The IIS Classic ASP feature (Web-ASP) is not installed.' } - } elseif ($null -ne $getOptionalFeature) { - $aspFeature = Get-WindowsOptionalFeature -Online -FeatureName IIS-ASP -ErrorAction SilentlyContinue - if ($null -eq $aspFeature -or $aspFeature.State -ne 'Enabled') { throw 'The IIS-ASP Windows feature is not enabled.' } - } else { - throw 'Classic ASP feature state cannot be verified: no supported Windows feature cmdlet is available.' + # Query IIS directly instead of Get-WindowsFeature/Get-WindowsOptionalFeature: + # those feature cmdlets can stall while collecting server-manager state. + $aspHandler = (Get-WebConfiguration -PSPath 'MACHINE/WEBROOT/APPHOST' -Filter 'system.webServer/handlers').Collection | + Where-Object { $_.path -eq '*.asp' -and $_.modules -match '(^|,)IsapiModule(,|$)' -and $_.scriptProcessor -match '(?i)asp\.dll$' } | + Select-Object -First 1 + if ($null -eq $aspHandler) { + throw 'The IIS Classic ASP handler (*.asp through asp.dll) was not found.' } - if ($null -eq (Get-WebGlobalModule -Name AspModule -ErrorAction SilentlyContinue)) { - throw 'The IIS Classic ASP module (AspModule) was not found.' + try { + Get-WebConfigurationProperty -PSPath 'MACHINE/WEBROOT/APPHOST' -Filter 'system.webServer/asp' -Name 'enableParentPaths' -ErrorAction Stop | Out-Null + } catch { + throw "The IIS Classic ASP configuration section is unavailable: $($_.Exception.Message)" } if ($null -eq (Get-WebGlobalModule -Name RewriteModule -ErrorAction SilentlyContinue)) { throw 'IIS URL Rewrite is not installed (RewriteModule was not found).'