Browse Source

Merge safe IIS release deployment pipeline

Live-proven isolated deployment to AspClassicUnifiedFramework on webserver-1.
master
Clawdbot 3 hours ago
parent
commit
1cf7583123
6 changed files with 976 additions and 314 deletions
  1. +8
    -0
      README.md
  2. +38
    -0
      TESTING.md
  3. +70
    -23
      docs/deployment-configuration.md
  4. +123
    -26
      docs/deployment-guide.md
  5. +261
    -265
      scripts/deploy-iis-git.ps1
  6. +476
    -0
      scripts/install-iis-release.ps1

+ 8
- 0
README.md View File

@@ -77,6 +77,14 @@ Move generated file to `app/controllers/`.
- IIS URL Rewrite module - IIS URL Rewrite module
- Microsoft Access Database Engine (for .accdb support) - Microsoft Access Database Engine (for .accdb support)


## Deployment

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.

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 ## Testing


This repo now includes a dev-only `aspunit` harness under `tests/`. It is intentionally separate from the production app rooted at `public/`. This repo now includes a dev-only `aspunit` harness under `tests/`. It is intentionally separate from the production app rooted at `public/`.


+ 38
- 0
TESTING.md View File

@@ -140,6 +140,44 @@ tests\run-tests.cmd http://localhost:8085/run-all.asp
- Re-running the suite produces stable results. - Re-running the suite produces stable results.
- The production site under `public/` still exposes no test runner pages or test routes. - 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 `master` branch, run the local/controller preflight:

```powershell
.\scripts\deploy-iis-git.ps1 -LocalPreflightOnly
```

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):

```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 ## Limitations


- This harness runs only inside IIS/Classic ASP; it is not intended for Linux execution. - This harness runs only inside IIS/Classic ASP; it is not intended for Linux execution.


+ 70
- 23
docs/deployment-configuration.md View File

@@ -1,35 +1,82 @@
# Deployment Configuration # Deployment Configuration


**Date:** 2026-03-11T11:59:39Z
## Dedicated defaults


## Hosting Model
The deployment controller and host installer use the same isolated defaults:


- 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
| Parameter | Default |
| --- | --- |
| `SiteName` | `AspClassicUnifiedFramework` |
| `AppPoolName` | `AspClassicUnifiedFramework` |
| `DeployRoot` | `D:\Deployments\AspClassicUnifiedFramework` |
| `BindingIpAddress` | `100.97.39.23` |
| `BindingPort` | `8085` |
| `HostHeader` | empty |
| `ExpectedBranch` | `master` (controller) |


## Runtime Configuration
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.


Primary deployment configuration is stored in `public/web.config`.
## Configuration ownership


### Important Settings
Repository `public\web.config` is the default template for first deployment. After the package is safely extracted and validated, the host installer initializes:


- `ConnectionString`
- `Environment`
- `FlashMessageTimeout`
- `Error404RedirectSeconds`
- `CacheExpirationYear`
- `EnableErrorLogging`
- `ErrorLogPath`
```text
<DeployRoot>\shared\public.web.config
```


## Deployment Notes
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.


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


## Observed Gaps
## IIS state owned by the pipeline


- No container, CI/CD, or infrastructure-as-code deployment config was detected.
- Deployment is currently documented as a manual IIS-based process.
The pipeline owns only the named dedicated site and app pool. It may:

1. create them, only when both are absent and only after a staged release validates;
2. point the dedicated site at `<DeployRoot>\releases\<release-id>\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.

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.

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.

## Controller parameters

`scripts/deploy-iis-git.ps1` supports:

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

+ 123
- 26
docs/deployment-guide.md View File

@@ -1,37 +1,134 @@
# MVC-Starter - Deployment Guide
# IIS Deployment Guide


**Date:** 2026-03-11T11:59:39Z
## Isolation boundary


## Deployment Model
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.


Single-site Windows IIS deployment with `public/` as the web root.
Defaults (each may be overridden explicitly):


## Deployment Steps
```text
SiteName AspClassicUnifiedFramework
AppPoolName AspClassicUnifiedFramework
DeployRoot D:\Deployments\AspClassicUnifiedFramework
Binding http://100.97.39.23:8085/ (empty host header)
ExpectedBranch master
```


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.
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 `<DeployRoot>\releases`. A partial or mismatched target fails closed; there is no adoption mode. Unrelated sites, pools, and bindings are left unchanged.


## Key Runtime Config
## Release and web-root model


- `ConnectionString`
- `Environment`
- `EnableErrorLogging`
- `ErrorLogPath`
- cache and UI timing settings
```text
D:\Deployments\AspClassicUnifiedFramework\
releases\
<release-id>\
app\
core\
db\
public\ <- only this directory is served by IIS
scripts\
tests\
...
shared\
public.web.config
deployment-state.json
```


## Deployment Risks
The complete repository is packaged because `public/Default.asp` uses parent includes from sibling directories. Releases are extracted into `<release-id>.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 `<release>\public`.


- Incorrect `ConnectionString` path for `.accdb`
- Missing IIS URL Rewrite module
- Missing Classic ASP support
- File permission issues for logs or database access
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.


## What Was Not Found
Classic ASP parent paths are enabled with a location-scoped `system.webServer/asp` setting for this dedicated site only.


- No Docker, Kubernetes, or container deployment setup
- No CI/CD pipeline config
- No infrastructure-as-code deployment definition
## Scripts

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

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.

## Preflight sequence

### 1. Local/controller preflight (no network)

Run from a clean standalone `master` checkout:

```powershell
.\scripts\deploy-iis-git.ps1 -LocalPreflightOnly
```

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.

`-DryRun` also avoids network access but prints the remote operation that would be performed.

### 2. Full preflight (connects, read-only)

```powershell
.\scripts\deploy-iis-git.ps1 -PreflightOnly -RemoteTarget 'webserver-1'
```

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.

## First deployment

The installer creates the dedicated app pool and site only after the staged release and selected initial `web.config` validate. Example using all defaults:

```powershell
.\scripts\deploy-iis-git.ps1 -RemoteTarget 'webserver-1'
```

Example with explicit overrides:

```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'
```

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.

## Existing target and cutover

For later deployments, the host preflight requires the dedicated site to use:

- exactly `AppPoolName`;
- exactly one HTTP binding matching IP, port, and host header;
- a physical path ending in `\public` under `<DeployRoot>\releases`.

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

## Failure recovery

The installer records whether this invocation created the target site or pool and snapshots the prior state of an existing valid target.

- 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

```powershell
.\scripts\deploy-iis-git.ps1 `
-Rollback `
-RollbackTo '20260914-171500-a1b2c3d4e5f6' `
-RemoteTarget 'webserver-1'
```

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.

## Database and writable data

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.

## Windows verification gates

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.

+ 261
- 265
scripts/deploy-iis-git.ps1 View File

@@ -1,324 +1,320 @@
<# <#
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/<Branch>
- Points IIS at <WorkDir>\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
Validates, packages, and deploys this repository to its dedicated IIS site.

.DESCRIPTION
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( 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 = '',

[switch]$RunMigrations = $true,
[switch]$SkipLegacyIsBusinessMigration,
[string]$LegacyMigrationScript = 'scripts\migrate_isbusiness_to_households.vbs',

[switch]$UseRemoteSsh,
[string]$RemoteTarget = '',
[ValidatePattern('^[A-Za-z0-9_. -]+$')]
[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, [int]$RemotePort = 22,
[string]$SourcePath = (Split-Path $PSScriptRoot -Parent),
[string]$BaseUrl = '',
[string]$ReleaseId = '',
[string]$ExpectedBranch = 'master',
[ValidateRange(2, 100)]
[int]$KeepReleases = 5,
[string]$SshExe = 'ssh', [string]$SshExe = 'ssh',
[string]$ScpExe = 'scp', [string]$ScpExe = 'scp',
[switch]$RunRemoteCore
[switch]$AllowAnyBranch,
[switch]$AllowDirty,
[switch]$RunMigrations,
[switch]$SkipSmokeTest,
[Alias('HostPreflightOnly', 'RemotePreflightOnly')]
[switch]$PreflightOnly,
[switch]$LocalPreflightOnly,
[switch]$Rollback,
[string]$RollbackTo = '',
[switch]$DryRun,
[switch]$KeepPackage
) )


Set-StrictMode -Version 2.0
$ErrorActionPreference = 'Stop' $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) 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 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 ConvertTo-SingleQuotedPowerShell {
param([string]$Value)
return "'" + $Value.Replace("'", "''") + "'"
} }


function Set-DataSourceInConfig {
function Add-RemoteArgument {
param( param(
[string]$ConfigPath,
[string]$EffectiveDbPath
[System.Collections.Generic.List[string]]$Arguments,
[string]$Name,
[string]$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
)

if($updated -ne $raw){
Set-Content -Path $ConfigPath -Value $updated -Encoding UTF8
Write-Host "Updated ConnectionString Data Source to $EffectiveDbPath"
}
}

function Get-BaseUrlFromSite {
param($Site)

$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)
}

return 'http://127.0.0.1'
$Arguments.Add($Name)
$Arguments.Add((ConvertTo-SingleQuotedPowerShell $Value))
} }


function Invoke-DeployCore {
Ensure-Command git
Import-Module WebAdministration

$site = Get-Website -Name $SiteName
if(!$site){ throw "IIS site not found: $SiteName" }

if([string]::IsNullOrWhiteSpace($AppPool)){
$AppPool = $site.applicationPool
}

if([string]::IsNullOrWhiteSpace($PublicDir)){
$PublicDir = $site.physicalPath
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([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
}
if ($Value -match '(?i)schedulicious') {
throw "$Name must not identify a Schedulicious resource."
} }
}


if([string]::IsNullOrWhiteSpace($BaseUrl)){
$BaseUrl = Get-BaseUrlFromSite -Site $site
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.'
} }

$currentPublicDir = $PublicDir
$currentConfigPath = Join-Path $currentPublicDir 'web.config'
$effectiveDbPath = $DbPath
if([string]::IsNullOrWhiteSpace($effectiveDbPath)){
$effectiveDbPath = Get-DataSourceFromConfig -ConfigPath $currentConfigPath
if ([string]::IsNullOrWhiteSpace($BindingIpAddress)) {
throw 'BindingIpAddress must not be empty.'
} }
if([string]::IsNullOrWhiteSpace($effectiveDbPath)){
throw 'No database path was provided and no existing Data Source could be read from the current web.config'
$parsedAddress = $null
if (-not [System.Net.IPAddress]::TryParse($BindingIpAddress, [ref]$parsedAddress)) {
throw "BindingIpAddress is not a valid IP address: $BindingIpAddress"
} }

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 ($HostHeader -match '[:/\\]') {
throw 'HostHeader must be empty or a DNS host name without a scheme, port, slash, or backslash.'
} }

if(!(Test-Path $WorkDir)){
Write-Host "Cloning $Repo -> $WorkDir"
git clone $Repo $WorkDir
if ($HostHeader -match '(?i)schedulicious') {
throw 'HostHeader must not reference Schedulicious.'
} }
}


Push-Location $WorkDir
try {
Write-Host "Updating to origin/$Branch"
git fetch origin
git checkout $Branch
& git reset --hard ("origin/" + $Branch)
} finally {
Pop-Location
function Copy-ReleaseSource {
param([string]$From, [string]$To)

$excludedNames = @('.git', '.deployment', 'releases')
$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)"
} }


if((Split-Path $WorkDir -Leaf).ToLower() -eq 'public'){
$WorkDir = Split-Path $WorkDir -Parent
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
} }


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

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
}
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)"
} }
} finally {
Pop-Location
} }
}


if((Get-WebAppPoolState -Name $AppPool).Value -eq 'Started'){
Restart-WebAppPool -Name $AppPool
} else {
Start-WebAppPool -Name $AppPool
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 ((-not $AllowAnyBranch) -and $branch -ne $ExpectedBranch) {
throw "Refusing to deploy branch '$branch'; expected '$ExpectedBranch'."
} }
Start-Website $SiteName

Start-Sleep -Seconds 1

$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)
$dirty = & git -C $SourcePath status --porcelain
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. 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.' }
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 checkout, or pass -AllowAnyBranch for a reviewed non-Git source tree.'
} }

Write-Host 'Deploy complete.'
} }


if($UseRemoteSsh -and !$RunRemoteCore -and [string]::IsNullOrWhiteSpace($RemoteTarget)){
$RemoteTarget = Get-DefaultRemoteTargetFromInfo
if ([string]::IsNullOrWhiteSpace($ReleaseId)) {
$ReleaseId = (Get-Date -Format 'yyyyMMdd-HHmmss') + '-' + $commit
}
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 ($PreflightOnly -and $LocalPreflightOnly) {
throw '-PreflightOnly and -LocalPreflightOnly are mutually exclusive.'
} }


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))
$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 {
$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]::ExtractToDirectory($packagePath, $packageExtract)
Assert-ReleaseLayout -Root $packageExtract
$sha256 = (Get-FileHash -LiteralPath $packagePath -Algorithm SHA256).Hash
Write-Host "Package: $packagePath"
Write-Host "SHA-256: $sha256"
} }


if(-not [string]::IsNullOrWhiteSpace($WorkDir)){
[void]$remoteCommand.Add('-WorkDir')
[void]$remoteCommand.Add((ConvertTo-CmdDoubleQuoted $WorkDir))
if ($LocalPreflightOnly) {
Write-Step 'Local/controller preflight passed; no network connection or host change was made'
exit 0
} }


if(-not [string]::IsNullOrWhiteSpace($PublicDir)){
[void]$remoteCommand.Add('-PublicDir')
[void]$remoteCommand.Add((ConvertTo-CmdDoubleQuoted $PublicDir))
$remoteArguments = New-Object 'System.Collections.Generic.List[string]'
$remoteArguments.Add('&')
$remoteArguments.Add((ConvertTo-SingleQuotedPowerShell $remoteInstaller))
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 $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($BaseUrl)){
[void]$remoteCommand.Add('-BaseUrl')
[void]$remoteCommand.Add((ConvertTo-CmdDoubleQuoted $BaseUrl))
if ($RunMigrations) { $remoteArguments.Add('-RunMigrations') }
if ($SkipSmokeTest) { $remoteArguments.Add('-SkipSmokeTest') }

$remoteScript = $remoteArguments -join ' '
$remoteEncodedCommand = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($remoteScript))
$remoteCommand = 'powershell.exe -NoProfile -NonInteractive -ExecutionPolicy RemoteSigned -EncodedCommand ' + $remoteEncodedCommand

if ($DryRun) {
Write-Step 'Dry-run complete; no network connection was made'
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)
}
exit 0
} }


if(-not [string]::IsNullOrWhiteSpace($DbPath)){
[void]$remoteCommand.Add('-DbPath')
[void]$remoteCommand.Add((ConvertTo-CmdDoubleQuoted $DbPath))
Assert-Command $SshExe
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
} }


if(-not [string]::IsNullOrWhiteSpace($LegacyMigrationScript)){
[void]$remoteCommand.Add('-LegacyMigrationScript')
[void]$remoteCommand.Add((ConvertTo-CmdDoubleQuoted $LegacyMigrationScript))
Assert-Command $ScpExe
Write-Step 'Creating remote temporary directory'
$mkdirScript = "New-Item -ItemType Directory -Force -Path $(ConvertTo-SingleQuotedPowerShell $remoteDirectory) | Out-Null"
$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) {
Write-Step 'Copying the release package'
& $ScpExe -P $RemotePort $packagePath ($RemoteTarget + ':' + $remoteDirectory.Replace('\', '/') + '/' + $ReleaseId + '.zip')
if ($LASTEXITCODE -ne 0) { throw 'Package copy failed.' }
} }


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
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)) {
$keptPath = Join-Path (Get-Location) ([System.IO.Path]::GetFileName($packagePath))
Copy-Item -LiteralPath $packagePath -Destination $keptPath -Force
Write-Host "Package retained at $keptPath"
}
if (Test-Path -LiteralPath $workRoot) { Remove-Item -LiteralPath $workRoot -Recurse -Force }
} }

Invoke-DeployCore

+ 476
- 0
scripts/install-iis-release.ps1 View File

@@ -0,0 +1,476 @@
<#
.SYNOPSIS
Installs or rolls back an immutable release in a dedicated IIS site/app pool.

.DESCRIPTION
The complete repository is retained in each immutable release and IIS serves
only <release>\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(
[ValidatePattern('^[A-Za-z0-9_. -]+$')]
[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]$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 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-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',
'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 {
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-BindingInformation {
return $BindingIpAddress + ':' + $BindingPort + ':' + $HostHeader
}

function Assert-HostCapabilities {
# 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.'
}
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).'
}
}

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 Test-PathUnderRoot {
param([string]$Path, [string]$Root)
$normalizedPath = Get-NormalizedPath $Path
$prefix = (Get-NormalizedPath $Root) + '\'
return $normalizedPath.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase)
}

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
}
}

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 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
}
$target = (Get-SmokeUrl).TrimEnd('/') + '/'
Write-Step ('Smoke testing ' + $target)
$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)
}

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 }
}

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 ($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 ($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"
}
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

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 'Host preflight passed; no IIS or filesystem changes were made'
exit 0
}

$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)
if (-not (Test-PathUnderRoot -Path $rollbackRoot -Root $releasesRoot)) { throw 'Rollback target escaped the releases directory.' }
Assert-ReleaseLayout -ReleasePath $rollbackRoot
try { [xml](Get-Content -LiteralPath $sharedConfig -Raw) | Out-Null }
catch { throw "Shared configuration is not valid XML: $($_.Exception.Message)" }
try {
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 failed; restoring the prior dedicated target path and parent-path setting.'
Restore-ExistingTarget -TargetState $targetState
throw
}
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" }

$createdSite = $false
$createdPool = $false
$createdSharedConfig = $false
$iisMutationStarted = $false
try {
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

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 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 }
}

Move-Item -LiteralPath $stagingRoot -Destination $releaseRoot
$newPublic = Join-Path $releaseRoot 'public'

$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
}

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 }
}
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, $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): ' + $oldRelease.FullName) }
}
Write-Step "Deployment complete: $ReleaseId"
} catch {
$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
}
if (Test-Path -LiteralPath $stagingRoot) { Write-Warning "Incomplete staging retained for inspection: $stagingRoot" }
throw $failure
}

Loading…
Cancel
Save

Powered by TurnKey Linux.