- .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 | *.laccdb | ||||
| # Personal Claude Code permission grants for this machine/session - not team config | # 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.