From 368b4877bc86fbbe59836e288d882de78ed08787 Mon Sep 17 00:00:00 2001 From: Frank Tovar Date: Sun, 8 Feb 2026 21:50:58 +0100 Subject: [PATCH] Parameterize FTP deployment with environment profiles --- IIS.md | 2 + README.md | 1 + scripts/deploy-ftp.profile.sample.psd1 | 31 +++ scripts/deploy-ftp.ps1 | 271 ++++++++++++++++--------- 4 files changed, 210 insertions(+), 95 deletions(-) create mode 100644 scripts/deploy-ftp.profile.sample.psd1 diff --git a/IIS.md b/IIS.md index fb2f536..02b64ed 100644 --- a/IIS.md +++ b/IIS.md @@ -22,6 +22,8 @@ - Optional: enable stdout logging in `web.config` during troubleshooting only; disable afterward. - Data protection keys are persisted to `App_Data/keys`; ensure this folder is deployed and writable so auth cookies stay valid across app pool recycles. - Frontend base path: set `` in `wwwroot/index.html` for production so API calls include the subpath (keep blank for local/root). +- Deployment script: copy `scripts/deploy-ftp.profile.sample.psd1` to `scripts/deploy-ftp.profile.psd1`, fill environment values, then run `pwsh ./scripts/deploy-ftp.ps1 -ProfilePath ./scripts/deploy-ftp.profile.psd1`. +- Prefer `WinScpSessionName` in the deploy profile to avoid embedding FTP credentials in scripted URLs. ## Permissions - Grant modify rights to the app pool identity on `App_Data` (DB file + wal). diff --git a/README.md b/README.md index fcb561c..473aba1 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ Pick'n'Play is a .NET 10 ASP.NET Core Minimal API app with a static HTML/CSS/JS - `wwwroot/`: static frontend assets. - `GameList.Tests/`: integration and helper tests. - `scripts/`: deployment scripts. + `scripts/deploy-ftp.ps1` is profile-driven via `scripts/deploy-ftp.profile.sample.psd1`. ## Operations diff --git a/scripts/deploy-ftp.profile.sample.psd1 b/scripts/deploy-ftp.profile.sample.psd1 new file mode 100644 index 0000000..3b9aa41 --- /dev/null +++ b/scripts/deploy-ftp.profile.sample.psd1 @@ -0,0 +1,31 @@ +@{ + # Required publish settings + ProjectPath = "..\GameList.csproj" + Configuration = "Release" + Runtime = "win-x64" + PublishDir = "$env:TEMP\GameList-publish" + SelfContained = $false + + # Required sync settings + WinScpPath = "C:\Program Files (x86)\WinSCP\WinSCP.com" + RemoteDir = "/httpdocs/picknplay" + + # Preferred: use a named WinSCP stored session (no credential string in script) + WinScpSessionName = "picknplay-prod" + + # Optional FTP URL fallback if no stored session is configured + # FtpHost = "example.com" + # FtpUser = "deploy-user" + + # Optional IIS recycle and WinRM controls + RecycleAppPool = $true + AppPoolName = "picknplay-app-pool" + WinRmComputer = "example.com" + WinRmCredentialUser = "Administrator" + UseWinRmHttps = $true + WinRmAuth = "Basic" + + # Optional remote migration + RunEfMigrations = $false + RemoteSitePath = "C:\Inetpub\vhosts\example.com\httpdocs\picknplay" +} diff --git a/scripts/deploy-ftp.ps1 b/scripts/deploy-ftp.ps1 index 4ae6d78..1c68717 100644 --- a/scripts/deploy-ftp.ps1 +++ b/scripts/deploy-ftp.ps1 @@ -1,157 +1,238 @@ -# Hard-coded deploy settings. Fill these in before running. -$FtpHost = "xTr1m.com" -$FtpUser = "xTr1m" -$Password = $null # prompted at runtime -$RemoteDir = "/httpdocs/picknplay" -$ProjectPath = "..\\GameList.csproj" -$Configuration = "Release" -$Runtime = "win-x64" -$PublishDir = "$env:TEMP\\GameList-publish" -$SelfContained = $false -$WinScpPath = "C:\\Users\\frank\\AppData\\Local\\Programs\\WinSCP\\WinSCP.com" -$RecycleAppPool = $true -$AppPoolName = "xTr1m.com(domain)(4.0)(pool)" -$WinRmComputer = "xTr1m.com" -$WinRmCredentialUser = "Administrator" -$UseWinRmHttps = $true # set false if using HTTP + TrustedHosts -$RemoteSitePath = "C:\Inetpub\vhosts\xTr1m.com\httpdocs\picknplay" -$RunEfMigrations = $false # set to $false to skip remote database update +param( + [string]$ProfilePath = (Join-Path $PSScriptRoot "deploy-ftp.profile.psd1"), + [string]$Password, + [switch]$SkipRecycle, + [switch]$SkipMigrations +) -<#! +<# .SYNOPSIS - Publish the app and mirror the output to an FTP-deployed IIS site. + Publish the app and mirror output to an FTP-deployed IIS site. .DESCRIPTION + - Reads environment-specific settings from a PowerShell data file profile. - Builds with dotnet publish. - - Uses WinSCP (ftp) to mirror publish output into $RemoteDir (deletes extraneous remote files). - - Optionally recycles the IIS app pool remotely via WinRM (no RDP needed). - -.PREREQS - - WinSCP.com available in PATH or set $WinScpPath. - - FTP user must have write/delete rights to $RemoteDir. - - WinRM must be enabled for remote app pool recycle (set $RecycleAppPool = $false otherwise). + - Uses WinSCP to mirror publish output into remote directory (deletes extraneous files). + - Optionally recycles IIS app pool and runs EF migrations remotely over WinRM. .EXAMPLE - pwsh ./scripts/deploy-ftp.ps1 + pwsh ./scripts/deploy-ftp.ps1 -ProfilePath ./scripts/deploy-ftp.profile.psd1 #> Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" function Assert-Tool { - param([string]$Name) + param([Parameter(Mandatory = $true)][string]$Name) if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) { - throw "Required tool '$Name' not found. Install it or update paths." + throw "Required tool '$Name' not found. Install it or update your deploy profile." } } -Assert-Tool "dotnet" -Assert-Tool $WinScpPath +function Require-ConfigValue { + param( + [Parameter(Mandatory = $true)][hashtable]$Config, + [Parameter(Mandatory = $true)][string]$Key + ) + + if (-not $Config.ContainsKey($Key) -or [string]::IsNullOrWhiteSpace([string]$Config[$Key])) { + throw "Missing required deploy profile value '$Key'." + } +} + +function Resolve-ProfilePath { + param( + [Parameter(Mandatory = $true)][string]$BaseDirectory, + [Parameter(Mandatory = $true)][string]$PathValue + ) + + $expanded = [Environment]::ExpandEnvironmentVariables($PathValue) + if ([System.IO.Path]::IsPathRooted($expanded)) { + return $expanded + } + + return [System.IO.Path]::GetFullPath((Join-Path $BaseDirectory $expanded)) +} + +function Read-PlainOrPrompt { + param( + [string]$Value, + [Parameter(Mandatory = $true)][string]$Prompt, + [bool]$Secure = $false + ) + + if (-not [string]::IsNullOrWhiteSpace($Value)) { + return $Value + } -function Read-PlainOrPrompt([object]$Value, [string]$Prompt, [bool]$Secure = $false) { - if ($Value -is [string] -and -not [string]::IsNullOrWhiteSpace($Value)) { return $Value } if ($Secure) { $pwd = Read-Host -Prompt $Prompt -AsSecureString $ptr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($pwd) - try { return [Runtime.InteropServices.Marshal]::PtrToStringUni($ptr) } + try { + return [Runtime.InteropServices.Marshal]::PtrToStringUni($ptr) + } finally { - if ($ptr -ne [IntPtr]::Zero) { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($ptr) } + if ($ptr -ne [IntPtr]::Zero) { + [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($ptr) + } } } + return Read-Host -Prompt $Prompt } -$Password = Read-PlainOrPrompt $Password "Password" $true -$WinRmAuth = "Basic" # Basic for local admin over HTTPS; use Default/Kerberos if joined to domain +function Invoke-WinRmScript { + param( + [Parameter(Mandatory = $true)][hashtable]$Config, + [Parameter(Mandatory = $true)][string]$PasswordValue, + [Parameter(Mandatory = $true)][scriptblock]$ScriptBlock, + [object[]]$ArgumentList = @() + ) + + Require-ConfigValue $Config "WinRmComputer" + Require-ConfigValue $Config "WinRmCredentialUser" + + $secure = ConvertTo-SecureString $PasswordValue -AsPlainText -Force + $cred = New-Object pscredential($Config.WinRmCredentialUser, $secure) + + $invokeParams = @{ + ComputerName = $Config.WinRmComputer + Credential = $cred + ScriptBlock = $ScriptBlock + ArgumentList = $ArgumentList + } + + if ($Config.ContainsKey("UseWinRmHttps") -and [bool]$Config.UseWinRmHttps) { + $invokeParams["UseSSL"] = $true + } + + if ($Config.ContainsKey("WinRmAuth") -and -not [string]::IsNullOrWhiteSpace([string]$Config.WinRmAuth)) { + $invokeParams["Authentication"] = [string]$Config.WinRmAuth + } + + Invoke-Command @invokeParams +} + +if (-not (Test-Path $ProfilePath)) { + throw "Deploy profile not found: $ProfilePath. Copy scripts/deploy-ftp.profile.sample.psd1 and fill environment-specific values." +} + +$resolvedProfilePath = (Resolve-Path $ProfilePath).Path +$profileDirectory = Split-Path -Parent $resolvedProfilePath +$config = Import-PowerShellDataFile -Path $resolvedProfilePath + +Require-ConfigValue $config "ProjectPath" +Require-ConfigValue $config "Configuration" +Require-ConfigValue $config "Runtime" +Require-ConfigValue $config "PublishDir" +Require-ConfigValue $config "WinScpPath" +Require-ConfigValue $config "RemoteDir" + +$winScpSessionName = if ($config.ContainsKey("WinScpSessionName")) { [string]$config.WinScpSessionName } else { "" } +$useStoredSession = -not [string]::IsNullOrWhiteSpace($winScpSessionName) + +if (-not $useStoredSession) { + Require-ConfigValue $config "FtpHost" + Require-ConfigValue $config "FtpUser" +} + +$projectPath = Resolve-ProfilePath $profileDirectory ([string]$config.ProjectPath) +$publishDir = Resolve-ProfilePath $profileDirectory ([string]$config.PublishDir) +$winScpPath = Resolve-ProfilePath $profileDirectory ([string]$config.WinScpPath) +$selfContained = if ($config.ContainsKey("SelfContained")) { [bool]$config.SelfContained } else { $false } +$recycleAppPool = if ($config.ContainsKey("RecycleAppPool")) { [bool]$config.RecycleAppPool } else { $false } +$runEfMigrations = if ($config.ContainsKey("RunEfMigrations")) { [bool]$config.RunEfMigrations } else { $false } +$recycleAppPool = $recycleAppPool -and -not $SkipRecycle +$runEfMigrations = $runEfMigrations -and -not $SkipMigrations + +$passwordFromEnv = $env:PICKNPLAY_FTP_PASSWORD +$passwordForSession = if ($useStoredSession) { "" } else { Read-PlainOrPrompt -Value ($Password ?? $passwordFromEnv) -Prompt "FTP password" -Secure $true } +$passwordForWinRm = if ($recycleAppPool -or $runEfMigrations) { Read-PlainOrPrompt -Value ($Password ?? $passwordFromEnv) -Prompt "WinRM password" -Secure $true } else { "" } + +Assert-Tool "dotnet" +Assert-Tool $winScpPath Write-Host "1) Publishing..." -ForegroundColor Cyan -if (Test-Path $PublishDir) { Remove-Item $PublishDir -Recurse -Force -ErrorAction SilentlyContinue } -New-Item -ItemType Directory -Force -Path $PublishDir | Out-Null -$publishArgs = @("publish", $ProjectPath, "-c", $Configuration, "-r", $Runtime, "-o", $PublishDir) -if (-not $SelfContained) { $publishArgs += "--self-contained=false" } +if (Test-Path $publishDir) { + Remove-Item $publishDir -Recurse -Force -ErrorAction SilentlyContinue +} +New-Item -ItemType Directory -Force -Path $publishDir | Out-Null + +$publishArgs = @("publish", $projectPath, "-c", [string]$config.Configuration, "-r", [string]$config.Runtime, "-o", $publishDir) +if (-not $selfContained) { + $publishArgs += "--self-contained=false" +} dotnet @publishArgs -if ($RecycleAppPool) { +if ($recycleAppPool) { + Require-ConfigValue $config "AppPoolName" Write-Host "2) Stopping IIS app pool via WinRM..." -ForegroundColor Cyan - $sec = ConvertTo-SecureString $Password -AsPlainText -Force - $cred = New-Object pscredential($WinRmCredentialUser, $sec) - $invokeParams = @{ - ComputerName = $WinRmComputer - Credential = $cred - ScriptBlock = { + try { + Invoke-WinRmScript -Config $config -PasswordValue $passwordForWinRm -ScriptBlock { Import-Module WebAdministration Stop-WebAppPool -Name $using:AppPoolName -ErrorAction SilentlyContinue Get-Process GameList -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue - Get-Process dotnet -ErrorAction SilentlyContinue | Where-Object { $_.Path -like "*picknplay*" } | Stop-Process -Force -ErrorAction SilentlyContinue + Get-Process dotnet -ErrorAction SilentlyContinue | Where-Object { $_.Path -like "*picknplay*" } | Stop-Process -Force -ErrorAction SilentlyContinue } } - if ($UseWinRmHttps) { $invokeParams["UseSSL"] = $true } - if ($WinRmAuth) { $invokeParams["Authentication"] = $WinRmAuth } - try { - Invoke-Command @invokeParams - } catch { + catch { Write-Warning "WinRM stop failed: $($_.Exception.Message)." } } -Write-Host "3) Syncing via WinSCP (FTP mirror with delete)..." -ForegroundColor Cyan -$tempScript = New-TemporaryFile -@" -option batch continue -option confirm off -open ftp://$($FtpUser):$($Password.Replace('`n','').Replace('`r',''))@$FtpHost -lcd $PublishDir -cd $RemoteDir -synchronize remote . -delete -filemask="|web.config;App_Data/;logs/;GameList.Tests/" -exit -"@ | Set-Content -Path $tempScript -Encoding UTF8 +Write-Host "3) Syncing via WinSCP..." -ForegroundColor Cyan +$openCommand = if ($useStoredSession) { + "open `"$winScpSessionName`"" +} +else { + $ftpUser = [Uri]::EscapeDataString([string]$config.FtpUser) + $ftpPassword = [Uri]::EscapeDataString($passwordForSession.Replace("`n", "").Replace("`r", "")) + $ftpHost = [string]$config.FtpHost + "open ftp://$ftpUser`:$ftpPassword@$ftpHost" +} -& $WinScpPath "/ini=nul" "/script=$tempScript" +$tempScript = New-TemporaryFile +@( + "option batch continue" + "option confirm off" + $openCommand + "lcd `"$publishDir`"" + "cd $([string]$config.RemoteDir)" + "synchronize remote . -delete -filemask=`"|web.config;App_Data/;logs/;GameList.Tests/`"" + "exit" +) | Set-Content -Path $tempScript -Encoding UTF8 + +& $winScpPath "/ini=nul" "/script=$tempScript" Remove-Item $tempScript -ErrorAction SilentlyContinue -if ($RecycleAppPool) { +if ($recycleAppPool) { Write-Host "4) Starting IIS app pool via WinRM..." -ForegroundColor Cyan - $sec = ConvertTo-SecureString $Password -AsPlainText -Force - $cred = New-Object pscredential($WinRmCredentialUser, $sec) - $invokeParams = @{ - ComputerName = $WinRmComputer - Credential = $cred - ScriptBlock = { + try { + Invoke-WinRmScript -Config $config -PasswordValue $passwordForWinRm -ScriptBlock { Import-Module WebAdministration Start-WebAppPool -Name $using:AppPoolName } } - if ($UseWinRmHttps) { $invokeParams["UseSSL"] = $true } - if ($WinRmAuth) { $invokeParams["Authentication"] = $WinRmAuth } - try { - Invoke-Command @invokeParams - } catch { + catch { Write-Warning "WinRM start failed: $($_.Exception.Message)." } } -if ($RunEfMigrations) { +if ($runEfMigrations) { + Require-ConfigValue $config "RemoteSitePath" Write-Host "5) Running EF Core migrations on remote site..." -ForegroundColor Cyan - $sec = ConvertTo-SecureString $Password -AsPlainText -Force - $cred = New-Object pscredential($WinRmCredentialUser, $sec) - $invokeParams = @{ - ComputerName = $WinRmComputer - Credential = $cred - ScriptBlock = { + try { + Invoke-WinRmScript -Config $config -PasswordValue $passwordForWinRm -ScriptBlock { param($sitePath) Set-Location $sitePath - if (-not (Get-Command dotnet ef -ErrorAction SilentlyContinue)) { - throw "dotnet ef not available on remote host. Install SDK or set `$RunEfMigrations = $false." + if (-not (Get-Command dotnet -ErrorAction SilentlyContinue)) { + throw "dotnet is not available on remote host." } + dotnet ef database update --no-build - } - ArgumentList = @($RemoteSitePath) + } -ArgumentList @([string]$config.RemoteSitePath) } - if ($UseWinRmHttps) { $invokeParams["UseSSL"] = $true } - if ($WinRmAuth) { $invokeParams["Authentication"] = $WinRmAuth } - try { - Invoke-Command @invokeParams - } catch { + catch { Write-Warning "WinRM migrations failed: $($_.Exception.Message)." } }