- .gitattributes + an explicit allow-list in build-release.ps1 assemble a clean release tree (app/, core/, public/, scripts/, db/migrations only) - anything not on the allow-list is deleted from the deploy, so a stray file added later can't ship to production by accident. - public/web.config.production.template holds the production appSettings (DB path outside the deploy dir, Environment=Production, error logging on). - deploy-iis.ps1 builds, zips, and scp's a release to the IIS host, then runs deploy-iis-remote-apply.ps1 there over ssh to swap in the new files, run migrations, and restart the site/app pool - the DB and error log live outside the deploy directory so a redeploy never touches them. - Removed deploy-iis-git.ps1 and migrate_isbusiness_to_households.vbs, leftovers from a different template project that didn't apply here. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>master
| @@ -0,0 +1,15 @@ | |||
| # Paths excluded from `git archive` (used by scripts/build-release.ps1 to assemble the | |||
| # deployable file set). Everything else - app/, core/, public/, db/migrations/, scripts/ - | |||
| # ships to production. | |||
| /.claude export-ignore | |||
| /.gitattributes export-ignore | |||
| /.gitignore export-ignore | |||
| /AGENTS.md export-ignore | |||
| /CLAUDE.md export-ignore | |||
| /README.md export-ignore | |||
| /TESTING.md export-ignore | |||
| /applicationhost.config export-ignore | |||
| /run_site.cmd export-ignore | |||
| /widget export-ignore | |||
| /docs export-ignore | |||
| /tests export-ignore | |||
| @@ -9,4 +9,10 @@ db/webdata.accdb | |||
| *.laccdb | |||
| # Personal Claude Code permission grants for this machine/session - not team config | |||
| .claude/settings.local.json | |||
| .claude/settings.local.json | |||
| # Deploy target info (SSH host/user) - environment-specific, not team config | |||
| scripts/deployinfo.txt | |||
| # Local build/deploy scratch output | |||
| /dist | |||
| @@ -0,0 +1,114 @@ | |||
| <?xml version="1.0" encoding="UTF-8"?> | |||
| <configuration> | |||
| <appSettings> | |||
| <!-- Access connection string. Points at the persistent data folder, outside the | |||
| deploy directory so a redeploy (which wipes and re-extracts the deploy dir) never | |||
| touches it. --> | |||
| <add key="ConnectionString" | |||
| value="Provider=Microsoft.ACE.OLEDB.12.0;Data Source=C:\inetpub\data\webdata.accdb;Persist Security Info=False;" /> | |||
| <!-- Environment flag (Development / Staging / Production) --> | |||
| <add key="Environment" value="Production" /> | |||
| <!-- Flash message display duration in milliseconds --> | |||
| <add key="FlashMessageTimeout" value="3000" /> | |||
| <!-- 404 error page redirect countdown in seconds --> | |||
| <add key="Error404RedirectSeconds" value="5" /> | |||
| <!-- Cache expiration year for static content --> | |||
| <add key="CacheExpirationYear" value="2030" /> | |||
| <!-- Maximum characters to display in table cells before truncating --> | |||
| <add key="TableCellMaxLength" value="90" /> | |||
| <!-- Character threshold for textarea vs input field in forms --> | |||
| <add key="FormTextareaThreshold" value="100" /> | |||
| <!-- Enable error logging to file (true/false) --> | |||
| <add key="EnableErrorLogging" value="true" /> | |||
| <!-- Error log file path - also outside the deploy directory so logs survive a redeploy. --> | |||
| <add key="ErrorLogPath" value="C:\inetpub\data\logs\errors.log" /> | |||
| <!-- Enable cache-busting for URLs and assets (true/false) --> | |||
| <add key="EnableCacheBusting" value="false" /> | |||
| <!-- Cache-bust parameter name (default: "v") --> | |||
| <add key="CacheBustParamName" value="v" /> | |||
| <!-- SMTP settings used by CDOEmail for outbound order emails. --> | |||
| <add key="SmtpServer" value="kentcommunications-com.mail.protection.outlook.com" /> | |||
| <add key="SmtpPort" value="25" /> | |||
| <add key="SmtpUsername" value="" /> | |||
| <add key="SmtpPassword" value="" /> | |||
| <add key="SmtpUseSSL" value="false" /> | |||
| <add key="SmtpFromAddress" value="no-reply@kentcommunications.com" /> | |||
| <!-- Hours before an order continuation link (token) expires --> | |||
| <add key="OrderTokenExpirationHours" value="24" /> | |||
| <!-- | |||
| Jurisdiction number validation (request-order form). The list is essentially static | |||
| reference data, so it's cached in Application scope rather than fetched on every | |||
| submission - see JurisdictionCacheMinutes. | |||
| --> | |||
| <add key="JurisdictionApiUrl" value="http://192.168.1.40:8081/api/jurisdictions" /> | |||
| <add key="JurisdictionCacheMinutes" value="60" /> | |||
| <!-- Cloudflare Turnstile (CAPTCHA) keys. --> | |||
| <add key="TurnstileSiteKey" value="0x4AAAAAAEUPEPHEnA9hPgbw" /> | |||
| <add key="TurnstileSecretKey" value="0x4AAAAAAEUPEFnbhCe6ttr_Mq6yCozxRkg" /> | |||
| </appSettings> | |||
| <system.webServer> | |||
| <!-- Default document for the site root --> | |||
| <defaultDocument> | |||
| <files> | |||
| <clear /> | |||
| <add value="Default.asp" /> | |||
| </files> | |||
| </defaultDocument> | |||
| <!-- URL Rewrite: send everything through Default.asp except static assets --> | |||
| <rewrite> | |||
| <rules> | |||
| <rule name="Rewrite to Default.asp" stopProcessing="true"> | |||
| <match url="^(?!Default\.asp$|css/|js/|images/|aspunit/|favicon\.ico$).*$" /> | |||
| <conditions> | |||
| <add input="{REQUEST_FILENAME}" matchType="IsFile" negate="true" /> | |||
| <add input="{REQUEST_FILENAME}" matchType="IsDirectory" negate="true" /> | |||
| </conditions> | |||
| <action type="Rewrite" url="/Default.asp" /> | |||
| </rule> | |||
| </rules> | |||
| </rewrite> | |||
| <!-- Strip the Server response header (reveals IIS version) - IIS 10+ only --> | |||
| <security> | |||
| <requestFiltering removeServerHeader="true" /> | |||
| </security> | |||
| <!-- Strip framework-identifying headers that IIS/ASP.NET modules may add --> | |||
| <httpProtocol> | |||
| <customHeaders> | |||
| <remove name="X-Powered-By" /> | |||
| <remove name="X-AspNet-Version" /> | |||
| <remove name="X-AspNetMvc-Version" /> | |||
| <!-- URL Rewrite module adds this in IIS Express, leaking the physical file path --> | |||
| <remove name="X-SourceFiles" /> | |||
| </customHeaders> | |||
| </httpProtocol> | |||
| </system.webServer> | |||
| <location path="css"> | |||
| <system.webServer> | |||
| <staticContent> | |||
| <clientCache cacheControlMode="UseMaxAge" cacheControlMaxAge="01:00:00" /> | |||
| </staticContent> | |||
| </system.webServer> | |||
| </location> | |||
| </configuration> | |||
| @@ -0,0 +1,89 @@ | |||
| <# | |||
| Builds a deployable release folder for the Purple Envelope Orders Site. | |||
| "Build" for this Classic ASP app just means: assemble the exact file set that should ship | |||
| to production, with the production web.config swapped in. There's no compile step. | |||
| - Archives the given git ref via `git archive`, which respects .gitattributes | |||
| export-ignore rules (drops docs/, tests/, .claude/, dev-only config, etc. - see | |||
| .gitattributes for the full list). | |||
| - Enforces $KeepTopLevel below as the authoritative allow-list: anything extracted from | |||
| the archive that isn't on this list gets deleted. This is the real safety net - a file | |||
| added to the repo root later and never added to .gitattributes would otherwise ship to | |||
| production by default. Here, it has to be explicitly added to $KeepTopLevel to ship. | |||
| - Overwrites public/web.config in the archive with public/web.config.production.template. | |||
| - Leaves the assembled tree in -OutDir, ready to zip and ship. | |||
| Usage: | |||
| powershell -File scripts\build-release.ps1 | |||
| powershell -File scripts\build-release.ps1 -Ref master -OutDir dist\release | |||
| #> | |||
| param( | |||
| [string]$Ref = 'HEAD', | |||
| [string]$OutDir = '' | |||
| ) | |||
| $ErrorActionPreference = 'Stop' | |||
| # Everything the live site needs to run: app code, framework internals, the IIS root, and | |||
| # the migration runner (+ its generator siblings, harmless to ship alongside it). Only | |||
| # db\migrations is kept under db - never db\webdata.accdb, even if something ever put a | |||
| # stray copy there. | |||
| $KeepTopLevel = @('app', 'core', 'public', 'scripts') | |||
| $KeepUnderDb = @('migrations') | |||
| $repoRoot = Split-Path $PSScriptRoot -Parent | |||
| Push-Location $repoRoot | |||
| try { | |||
| if([string]::IsNullOrWhiteSpace($OutDir)){ | |||
| $OutDir = Join-Path $repoRoot ('dist\release_' + (Get-Date -Format 'yyyyMMdd_HHmmss')) | |||
| } | |||
| if(Test-Path $OutDir){ | |||
| Remove-Item -Recurse -Force $OutDir | |||
| } | |||
| New-Item -ItemType Directory -Force -Path $OutDir | Out-Null | |||
| $templatePath = Join-Path $repoRoot 'public\web.config.production.template' | |||
| if(!(Test-Path $templatePath)){ | |||
| throw "public\web.config.production.template not found - can't build a production release without it." | |||
| } | |||
| Write-Host "Archiving $Ref (honoring .gitattributes export-ignore rules)..." | |||
| $archivePath = Join-Path $OutDir '_archive.zip' | |||
| git archive --format=zip --output $archivePath $Ref | |||
| if($LASTEXITCODE -ne 0){ throw 'git archive failed' } | |||
| Expand-Archive -Path $archivePath -DestinationPath $OutDir -Force | |||
| Remove-Item $archivePath | |||
| Write-Host "Pruning to the allow-list: $($KeepTopLevel -join ', '), db\$($KeepUnderDb -join ', db\')" | |||
| Get-ChildItem -Path $OutDir -Force | ForEach-Object { | |||
| if($_.Name -notin $KeepTopLevel){ | |||
| Write-Host " removing (not on allow-list): $($_.Name)" | |||
| Remove-Item -Recurse -Force $_.FullName | |||
| } | |||
| } | |||
| $dbDir = Join-Path $OutDir 'db' | |||
| if(Test-Path $dbDir){ | |||
| Get-ChildItem -Path $dbDir -Force | ForEach-Object { | |||
| if($_.Name -notin $KeepUnderDb){ | |||
| Write-Host " removing (not on allow-list): db\$($_.Name)" | |||
| Remove-Item -Recurse -Force $_.FullName | |||
| } | |||
| } | |||
| } | |||
| if(!(Test-Path (Join-Path $OutDir 'public'))){ | |||
| throw "Release has no public\ folder after pruning - check `$KeepTopLevel in this script" | |||
| } | |||
| $liveConfig = Join-Path $OutDir 'public\web.config' | |||
| Copy-Item $templatePath $liveConfig -Force | |||
| Remove-Item (Join-Path $OutDir 'public\web.config.production.template') -ErrorAction SilentlyContinue | |||
| Write-Host "Release built at $OutDir" | |||
| Write-Host " public\web.config swapped in from web.config.production.template" | |||
| } finally { | |||
| Pop-Location | |||
| } | |||
| @@ -1,324 +0,0 @@ | |||
| <# | |||
| 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 | |||
| #> | |||
| 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 = '', | |||
| [int]$RemotePort = 22, | |||
| [string]$SshExe = 'ssh', | |||
| [string]$ScpExe = 'scp', | |||
| [switch]$RunRemoteCore | |||
| ) | |||
| $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 Ensure-Command { | |||
| param([string]$Name) | |||
| if(!(Get-Command $Name -ErrorAction SilentlyContinue)){ | |||
| throw "$Name 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 Set-DataSourceInConfig { | |||
| param( | |||
| [string]$ConfigPath, | |||
| [string]$EffectiveDbPath | |||
| ) | |||
| 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' | |||
| } | |||
| 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 | |||
| } | |||
| 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([string]::IsNullOrWhiteSpace($BaseUrl)){ | |||
| $BaseUrl = Get-BaseUrlFromSite -Site $site | |||
| } | |||
| $currentPublicDir = $PublicDir | |||
| $currentConfigPath = Join-Path $currentPublicDir 'web.config' | |||
| $effectiveDbPath = $DbPath | |||
| if([string]::IsNullOrWhiteSpace($effectiveDbPath)){ | |||
| $effectiveDbPath = Get-DataSourceFromConfig -ConfigPath $currentConfigPath | |||
| } | |||
| 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 "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(!(Test-Path $WorkDir)){ | |||
| Write-Host "Cloning $Repo -> $WorkDir" | |||
| git clone $Repo $WorkDir | |||
| } | |||
| Push-Location $WorkDir | |||
| try { | |||
| Write-Host "Updating to origin/$Branch" | |||
| git fetch origin | |||
| git checkout $Branch | |||
| & git reset --hard ("origin/" + $Branch) | |||
| } finally { | |||
| Pop-Location | |||
| } | |||
| if((Split-Path $WorkDir -Leaf).ToLower() -eq 'public'){ | |||
| $WorkDir = Split-Path $WorkDir -Parent | |||
| } | |||
| $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 | |||
| } | |||
| 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 | |||
| } | |||
| } finally { | |||
| Pop-Location | |||
| } | |||
| if((Get-WebAppPoolState -Name $AppPool).Value -eq 'Started'){ | |||
| Restart-WebAppPool -Name $AppPool | |||
| } else { | |||
| Start-WebAppPool -Name $AppPool | |||
| } | |||
| 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) | |||
| } | |||
| 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 [string]::IsNullOrWhiteSpace($WorkDir)){ | |||
| [void]$remoteCommand.Add('-WorkDir') | |||
| [void]$remoteCommand.Add((ConvertTo-CmdDoubleQuoted $WorkDir)) | |||
| } | |||
| if(-not [string]::IsNullOrWhiteSpace($PublicDir)){ | |||
| [void]$remoteCommand.Add('-PublicDir') | |||
| [void]$remoteCommand.Add((ConvertTo-CmdDoubleQuoted $PublicDir)) | |||
| } | |||
| if(-not [string]::IsNullOrWhiteSpace($BaseUrl)){ | |||
| [void]$remoteCommand.Add('-BaseUrl') | |||
| [void]$remoteCommand.Add((ConvertTo-CmdDoubleQuoted $BaseUrl)) | |||
| } | |||
| 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 | |||
| @@ -0,0 +1,87 @@ | |||
| <# | |||
| Runs ON the IIS server (invoked over SSH by scripts\deploy-iis.ps1). Not meant to be run | |||
| by hand except for troubleshooting a stuck deploy. | |||
| - Stops the site/app pool | |||
| - Wipes RemoteDir and re-extracts the release zip into it (safe: the DB and error log | |||
| live outside RemoteDir, at DbPath/ErrorLogDir, so a full wipe never touches them) | |||
| - Points IIS at RemoteDir\public | |||
| - Ensures the app pool identity has modify rights on the persistent data folder | |||
| - Runs pending migrations (32-bit cscript - ACE OLEDB is x86-only) | |||
| - Restarts the site/app pool | |||
| #> | |||
| param( | |||
| [Parameter(Mandatory = $true)][string]$ZipPath, | |||
| [Parameter(Mandatory = $true)][string]$RemoteDir, | |||
| [Parameter(Mandatory = $true)][string]$SiteName, | |||
| [Parameter(Mandatory = $true)][string]$AppPool, | |||
| [Parameter(Mandatory = $true)][string]$DbPath, | |||
| [switch]$RunMigrations = $true | |||
| ) | |||
| $ErrorActionPreference = 'Stop' | |||
| Import-Module WebAdministration | |||
| if(!(Get-Website -Name $SiteName -ErrorAction SilentlyContinue)){ | |||
| throw "IIS site '$SiteName' does not exist yet. Create the site and app pool once manually (or via a one-time setup script) before running this deploy." | |||
| } | |||
| if(!(Test-Path ('IIS:\AppPools\' + $AppPool))){ | |||
| throw "App pool '$AppPool' does not exist yet. Create it once manually before running this deploy." | |||
| } | |||
| Write-Host "Stopping IIS site $SiteName and app pool $AppPool" | |||
| try { Stop-Website -Name $SiteName } catch { } | |||
| try { Stop-WebAppPool -Name $AppPool } catch { } | |||
| if(Test-Path $RemoteDir){ | |||
| Write-Host "Wiping $RemoteDir" | |||
| Remove-Item -Recurse -Force (Join-Path $RemoteDir '*') -ErrorAction SilentlyContinue | |||
| } else { | |||
| New-Item -ItemType Directory -Force -Path $RemoteDir | Out-Null | |||
| } | |||
| Write-Host "Extracting $ZipPath -> $RemoteDir" | |||
| Expand-Archive -Path $ZipPath -DestinationPath $RemoteDir -Force | |||
| Remove-Item $ZipPath -ErrorAction SilentlyContinue | |||
| $publicDir = Join-Path $RemoteDir 'public' | |||
| if(!(Test-Path $publicDir)){ | |||
| throw "No public\ folder in the extracted release - deploy aborted, site left stopped for inspection." | |||
| } | |||
| # Persistent data folder (DB + error log) - lives outside RemoteDir so it survives the wipe | |||
| # above. Create it and grant the app pool identity modify rights. | |||
| $dataDir = Split-Path $DbPath -Parent | |||
| if(!(Test-Path $dataDir)){ | |||
| New-Item -ItemType Directory -Force -Path $dataDir | Out-Null | |||
| } | |||
| $logDir = Join-Path $dataDir 'logs' | |||
| if(!(Test-Path $logDir)){ | |||
| New-Item -ItemType Directory -Force -Path $logDir | Out-Null | |||
| } | |||
| icacls $dataDir /grant ("IIS AppPool\" + $AppPool + ":(OI)(CI)(M)") /T | Out-Null | |||
| Set-ItemProperty ('IIS:\Sites\' + $SiteName) -Name physicalPath -Value $publicDir | |||
| Set-ItemProperty ('IIS:\Sites\' + $SiteName) -Name applicationPool -Value $AppPool | |||
| if($RunMigrations){ | |||
| Write-Host 'Running pending migrations' | |||
| Push-Location $RemoteDir | |||
| try { | |||
| & C:\Windows\SysWOW64\cscript.exe //nologo scripts\runMigrations.vbs up | |||
| if($LASTEXITCODE -ne 0){ throw 'runMigrations.vbs up failed - see output above' } | |||
| } finally { | |||
| Pop-Location | |||
| } | |||
| } | |||
| Write-Host "Starting IIS site $SiteName and app pool $AppPool" | |||
| if((Get-WebAppPoolState -Name $AppPool).Value -eq 'Started'){ | |||
| Restart-WebAppPool -Name $AppPool | |||
| } else { | |||
| Start-WebAppPool -Name $AppPool | |||
| } | |||
| Start-Website $SiteName | |||
| Write-Host 'Remote apply complete.' | |||
| @@ -0,0 +1,115 @@ | |||
| <# | |||
| Deploys the Purple Envelope Orders Site to the production IIS server. | |||
| Flow: | |||
| 1. Build a release locally (scripts\build-release.ps1) - a clean file tree with the | |||
| production web.config swapped in, and dev-only files excluded via .gitattributes. | |||
| 2. Zip it and scp the zip to the remote host. | |||
| 3. scp scripts\deploy-iis-remote-apply.ps1 to the remote host and run it over ssh - it | |||
| wipes the deploy directory, extracts the new release, points IIS at it, runs | |||
| migrations, and restarts the site/app pool. The database and error log live outside | |||
| the deploy directory, so the wipe never touches them. | |||
| 4. Smoke-test a few routes over HTTPS from this machine. | |||
| Usage: | |||
| powershell -File scripts\deploy-iis.ps1 | |||
| powershell -File scripts\deploy-iis.ps1 -Ref master | |||
| Remote target defaults to the "ssh user@host" line in scripts\deployinfo.txt (gitignored, | |||
| not committed - create it locally, e.g.: ssh daniel_admin@kci-uluto-web). | |||
| #> | |||
| param( | |||
| [string]$Ref = 'HEAD', | |||
| [string]$RemoteTarget = '', | |||
| [int]$RemotePort = 22, | |||
| [string]$RemoteDir = 'C:\inetpub\wwwroot\Purple_Envelop_Order_Site', | |||
| [string]$SiteName = 'PurpleEnvelopes', | |||
| [string]$AppPool = 'PurpleEnvelopes', | |||
| [string]$DbPath = 'C:\inetpub\data\webdata.accdb', | |||
| [string]$BaseUrl = 'https://pe.kentcommunications.com/', | |||
| [switch]$RunMigrations = $true, | |||
| [string]$SshExe = 'ssh', | |||
| [string]$ScpExe = 'scp' | |||
| ) | |||
| $ErrorActionPreference = 'Stop' | |||
| $repoRoot = Split-Path $PSScriptRoot -Parent | |||
| function Ensure-Command { | |||
| param([string]$Name) | |||
| if(!(Get-Command $Name -ErrorAction SilentlyContinue)){ | |||
| throw "$Name not found on PATH" | |||
| } | |||
| } | |||
| function Get-DefaultRemoteTarget { | |||
| $infoPath = Join-Path $PSScriptRoot 'deployinfo.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() | |||
| } | |||
| if([string]::IsNullOrWhiteSpace($RemoteTarget)){ | |||
| $RemoteTarget = Get-DefaultRemoteTarget | |||
| } | |||
| if([string]::IsNullOrWhiteSpace($RemoteTarget)){ | |||
| throw 'No -RemoteTarget given and scripts\deployinfo.txt not found. Create scripts\deployinfo.txt with a line like: ssh user@host' | |||
| } | |||
| Ensure-Command $SshExe | |||
| Ensure-Command $ScpExe | |||
| Ensure-Command git | |||
| # --- 1. Build --- | |||
| $outDir = Join-Path $repoRoot ('dist\release_' + (Get-Date -Format 'yyyyMMdd_HHmmss')) | |||
| & (Join-Path $PSScriptRoot 'build-release.ps1') -Ref $Ref -OutDir $outDir | |||
| if($LASTEXITCODE -ne 0 -and $LASTEXITCODE -ne $null){ throw 'build-release.ps1 failed' } | |||
| # --- 2. Zip and ship it --- | |||
| $zipName = 'release_' + (Get-Date -Format 'yyyyMMdd_HHmmss') + '.zip' | |||
| $localZip = Join-Path (Split-Path $outDir -Parent) $zipName | |||
| Compress-Archive -Path (Join-Path $outDir '*') -DestinationPath $localZip -Force | |||
| $remoteZip = 'C:\Windows\Temp\' + $zipName | |||
| $remoteApplyScript = Join-Path $PSScriptRoot 'deploy-iis-remote-apply.ps1' | |||
| $remoteApplyDest = 'C:\Windows\Temp\deploy-iis-remote-apply.ps1' | |||
| Write-Host "Copying release to $RemoteTarget" | |||
| & $ScpExe -P $RemotePort $localZip "${RemoteTarget}:$remoteZip" | |||
| if($LASTEXITCODE -ne 0){ throw 'scp of release zip failed' } | |||
| & $ScpExe -P $RemotePort $remoteApplyScript "${RemoteTarget}:$remoteApplyDest" | |||
| if($LASTEXITCODE -ne 0){ throw 'scp of remote-apply script failed' } | |||
| # --- 3. Apply on the remote host --- | |||
| $remoteCommandParts = @( | |||
| 'powershell', '-NoProfile', '-ExecutionPolicy', 'Bypass', | |||
| '-File', ('"' + $remoteApplyDest + '"'), | |||
| '-ZipPath', ('"' + $remoteZip + '"'), | |||
| '-RemoteDir', ('"' + $RemoteDir + '"'), | |||
| '-SiteName', ('"' + $SiteName + '"'), | |||
| '-AppPool', ('"' + $AppPool + '"'), | |||
| '-DbPath', ('"' + $DbPath + '"') | |||
| ) | |||
| if($RunMigrations){ $remoteCommandParts += '-RunMigrations' } | |||
| Write-Host "Applying release on $RemoteTarget" | |||
| & $SshExe -p $RemotePort $RemoteTarget ($remoteCommandParts -join ' ') | |||
| if($LASTEXITCODE -ne 0){ throw 'remote apply failed' } | |||
| # --- 4. Local cleanup --- | |||
| Remove-Item $localZip -ErrorAction SilentlyContinue | |||
| Remove-Item -Recurse -Force $outDir -ErrorAction SilentlyContinue | |||
| # --- 5. Smoke test --- | |||
| Write-Host 'Smoke testing...' | |||
| $paths = @('/', '/request-order', '/404') | |||
| foreach($path in $paths){ | |||
| $url = $BaseUrl.TrimEnd('/') + $path | |||
| $response = Invoke-WebRequest -UseBasicParsing -Uri $url -TimeoutSec 30 | |||
| Write-Host ("OK " + $path + ' -> ' + $response.StatusCode) | |||
| } | |||
| Write-Host 'Deploy complete.' | |||
| @@ -1,90 +0,0 @@ | |||
| ' migrate_isbusiness_to_households.vbs | |||
| ' Moves IsBusiness from HouseholderNames to Households. | |||
| ' | |||
| ' Usage: | |||
| ' cscript //nologo scripts\migrate_isbusiness_to_households.vbs "C:\path\to\myAccessFile.accdb" | |||
| ' | |||
| ' What it does: | |||
| ' 1) Adds Households.IsBusiness (SMALLINT) if missing | |||
| ' 2) Copies data: sets Households.IsBusiness=1 if any related HouseholderNames.IsBusiness<>0 | |||
| ' 3) Sets NULLs to 0 | |||
| ' 4) Drops HouseholderNames.IsBusiness if present | |||
| ' | |||
| Option Explicit | |||
| Dim dbPath | |||
| If WScript.Arguments.Count < 1 Then | |||
| WScript.Echo "ERROR: missing db path." | |||
| WScript.Echo "Usage: cscript //nologo scripts\migrate_isbusiness_to_households.vbs ""C:\path\to\db.accdb""" | |||
| WScript.Quit 1 | |||
| End If | |||
| dbPath = WScript.Arguments(0) | |||
| Dim conn | |||
| Set conn = CreateObject("ADODB.Connection") | |||
| conn.Open "Provider=Microsoft.ACE.OLEDB.12.0;Data Source=" & dbPath & ";Persist Security Info=False;" | |||
| On Error Resume Next | |||
| If Not ColumnExists(conn, "Households", "IsBusiness") Then | |||
| Exec conn, "ALTER TABLE [Households] ADD COLUMN [IsBusiness] SMALLINT" | |||
| If Err.Number <> 0 Then | |||
| WScript.Echo "ERROR adding Households.IsBusiness: " & Err.Description | |||
| WScript.Quit 1 | |||
| End If | |||
| WScript.Echo "Added Households.IsBusiness" | |||
| Else | |||
| WScript.Echo "Households.IsBusiness already exists" | |||
| End If | |||
| ' Copy data (only if the old column exists) | |||
| If ColumnExists(conn, "HouseholderNames", "IsBusiness") Then | |||
| ' Normalize all existing households first so the column is never left NULL. | |||
| Exec conn, "UPDATE [Households] SET [IsBusiness]=0" | |||
| If Err.Number <> 0 Then | |||
| WScript.Echo "ERROR initializing Households.IsBusiness: " & Err.Description | |||
| WScript.Quit 1 | |||
| End If | |||
| ' Promote households to business when any related name was previously marked as a business. | |||
| Exec conn, "UPDATE [Households] SET [IsBusiness]=1 WHERE [Id] IN (SELECT [HouseholdId] FROM [HouseholderNames] WHERE [IsBusiness]<>0)" | |||
| If Err.Number <> 0 Then | |||
| WScript.Echo "ERROR copying IsBusiness to Households: " & Err.Description | |||
| WScript.Quit 1 | |||
| End If | |||
| WScript.Echo "Copied IsBusiness values to Households" | |||
| Exec conn, "ALTER TABLE [HouseholderNames] DROP COLUMN [IsBusiness]" | |||
| If Err.Number <> 0 Then | |||
| WScript.Echo "ERROR dropping HouseholderNames.IsBusiness: " & Err.Description | |||
| WScript.Quit 1 | |||
| End If | |||
| WScript.Echo "Dropped HouseholderNames.IsBusiness" | |||
| Else | |||
| WScript.Echo "HouseholderNames.IsBusiness does not exist; nothing to drop" | |||
| End If | |||
| conn.Close | |||
| Set conn = Nothing | |||
| WScript.Echo "Done." | |||
| ' --- helpers --- | |||
| Sub Exec(c, sql) | |||
| Err.Clear | |||
| c.Execute sql | |||
| End Sub | |||
| Function ColumnExists(c, tableName, colName) | |||
| Dim rs | |||
| ColumnExists = False | |||
| Err.Clear | |||
| Set rs = c.OpenSchema(4, Array(Empty, Empty, tableName, colName)) ' adSchemaColumns=4 | |||
| If Err.Number <> 0 Then | |||
| Err.Clear | |||
| Exit Function | |||
| End If | |||
| If Not rs.EOF Then ColumnExists = True | |||
| rs.Close | |||
| Set rs = Nothing | |||
| End Function | |||
Powered by TurnKey Linux.