Parameterize FTP deployment with environment profiles

This commit is contained in:
2026-02-08 21:50:58 +01:00
parent d2ab8a676f
commit 368b4877bc
4 changed files with 210 additions and 95 deletions

2
IIS.md
View File

@@ -22,6 +22,8 @@
- Optional: enable stdout logging in `web.config` during troubleshooting only; disable afterward. - 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. - 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 `<meta name="app-base" content="/picknplay">` in `wwwroot/index.html` for production so API calls include the subpath (keep blank for local/root). - Frontend base path: set `<meta name="app-base" content="/picknplay">` 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 ## Permissions
- Grant modify rights to the app pool identity on `App_Data` (DB file + wal). - Grant modify rights to the app pool identity on `App_Data` (DB file + wal).

View File

@@ -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. - `wwwroot/`: static frontend assets.
- `GameList.Tests/`: integration and helper tests. - `GameList.Tests/`: integration and helper tests.
- `scripts/`: deployment scripts. - `scripts/`: deployment scripts.
`scripts/deploy-ftp.ps1` is profile-driven via `scripts/deploy-ftp.profile.sample.psd1`.
## Operations ## Operations

View File

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

View File

@@ -1,157 +1,238 @@
# Hard-coded deploy settings. Fill these in before running. param(
$FtpHost = "xTr1m.com" [string]$ProfilePath = (Join-Path $PSScriptRoot "deploy-ftp.profile.psd1"),
$FtpUser = "xTr1m" [string]$Password,
$Password = $null # prompted at runtime [switch]$SkipRecycle,
$RemoteDir = "/httpdocs/picknplay" [switch]$SkipMigrations
$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
<#! <#
.SYNOPSIS .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 .DESCRIPTION
- Reads environment-specific settings from a PowerShell data file profile.
- Builds with dotnet publish. - Builds with dotnet publish.
- Uses WinSCP (ftp) to mirror publish output into $RemoteDir (deletes extraneous remote files). - Uses WinSCP to mirror publish output into remote directory (deletes extraneous files).
- Optionally recycles the IIS app pool remotely via WinRM (no RDP needed). - Optionally recycles IIS app pool and runs EF migrations remotely over WinRM.
.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).
.EXAMPLE .EXAMPLE
pwsh ./scripts/deploy-ftp.ps1 pwsh ./scripts/deploy-ftp.ps1 -ProfilePath ./scripts/deploy-ftp.profile.psd1
#> #>
Set-StrictMode -Version Latest Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
function Assert-Tool { function Assert-Tool {
param([string]$Name) param([Parameter(Mandatory = $true)][string]$Name)
if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) { 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" function Require-ConfigValue {
Assert-Tool $WinScpPath 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) { if ($Secure) {
$pwd = Read-Host -Prompt $Prompt -AsSecureString $pwd = Read-Host -Prompt $Prompt -AsSecureString
$ptr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($pwd) $ptr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($pwd)
try { return [Runtime.InteropServices.Marshal]::PtrToStringUni($ptr) } try {
return [Runtime.InteropServices.Marshal]::PtrToStringUni($ptr)
}
finally { 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 return Read-Host -Prompt $Prompt
} }
$Password = Read-PlainOrPrompt $Password "Password" $true function Invoke-WinRmScript {
$WinRmAuth = "Basic" # Basic for local admin over HTTPS; use Default/Kerberos if joined to domain 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 Write-Host "1) Publishing..." -ForegroundColor Cyan
if (Test-Path $PublishDir) { Remove-Item $PublishDir -Recurse -Force -ErrorAction SilentlyContinue } if (Test-Path $publishDir) {
New-Item -ItemType Directory -Force -Path $PublishDir | Out-Null Remove-Item $publishDir -Recurse -Force -ErrorAction SilentlyContinue
$publishArgs = @("publish", $ProjectPath, "-c", $Configuration, "-r", $Runtime, "-o", $PublishDir) }
if (-not $SelfContained) { $publishArgs += "--self-contained=false" } 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 dotnet @publishArgs
if ($RecycleAppPool) { if ($recycleAppPool) {
Require-ConfigValue $config "AppPoolName"
Write-Host "2) Stopping IIS app pool via WinRM..." -ForegroundColor Cyan Write-Host "2) Stopping IIS app pool via WinRM..." -ForegroundColor Cyan
$sec = ConvertTo-SecureString $Password -AsPlainText -Force try {
$cred = New-Object pscredential($WinRmCredentialUser, $sec) Invoke-WinRmScript -Config $config -PasswordValue $passwordForWinRm -ScriptBlock {
$invokeParams = @{
ComputerName = $WinRmComputer
Credential = $cred
ScriptBlock = {
Import-Module WebAdministration Import-Module WebAdministration
Stop-WebAppPool -Name $using:AppPoolName -ErrorAction SilentlyContinue Stop-WebAppPool -Name $using:AppPoolName -ErrorAction SilentlyContinue
Get-Process GameList -ErrorAction SilentlyContinue | Stop-Process -Force -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 } catch {
if ($WinRmAuth) { $invokeParams["Authentication"] = $WinRmAuth }
try {
Invoke-Command @invokeParams
} catch {
Write-Warning "WinRM stop failed: $($_.Exception.Message)." Write-Warning "WinRM stop failed: $($_.Exception.Message)."
} }
} }
Write-Host "3) Syncing via WinSCP (FTP mirror with delete)..." -ForegroundColor Cyan Write-Host "3) Syncing via WinSCP..." -ForegroundColor Cyan
$tempScript = New-TemporaryFile $openCommand = if ($useStoredSession) {
@" "open `"$winScpSessionName`""
option batch continue }
option confirm off else {
open ftp://$($FtpUser):$($Password.Replace('`n','').Replace('`r',''))@$FtpHost $ftpUser = [Uri]::EscapeDataString([string]$config.FtpUser)
lcd $PublishDir $ftpPassword = [Uri]::EscapeDataString($passwordForSession.Replace("`n", "").Replace("`r", ""))
cd $RemoteDir $ftpHost = [string]$config.FtpHost
synchronize remote . -delete -filemask="|web.config;App_Data/;logs/;GameList.Tests/" "open ftp://$ftpUser`:$ftpPassword@$ftpHost"
exit }
"@ | Set-Content -Path $tempScript -Encoding UTF8
& $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 Remove-Item $tempScript -ErrorAction SilentlyContinue
if ($RecycleAppPool) { if ($recycleAppPool) {
Write-Host "4) Starting IIS app pool via WinRM..." -ForegroundColor Cyan Write-Host "4) Starting IIS app pool via WinRM..." -ForegroundColor Cyan
$sec = ConvertTo-SecureString $Password -AsPlainText -Force try {
$cred = New-Object pscredential($WinRmCredentialUser, $sec) Invoke-WinRmScript -Config $config -PasswordValue $passwordForWinRm -ScriptBlock {
$invokeParams = @{
ComputerName = $WinRmComputer
Credential = $cred
ScriptBlock = {
Import-Module WebAdministration Import-Module WebAdministration
Start-WebAppPool -Name $using:AppPoolName Start-WebAppPool -Name $using:AppPoolName
} }
} }
if ($UseWinRmHttps) { $invokeParams["UseSSL"] = $true } catch {
if ($WinRmAuth) { $invokeParams["Authentication"] = $WinRmAuth }
try {
Invoke-Command @invokeParams
} catch {
Write-Warning "WinRM start failed: $($_.Exception.Message)." 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 Write-Host "5) Running EF Core migrations on remote site..." -ForegroundColor Cyan
$sec = ConvertTo-SecureString $Password -AsPlainText -Force try {
$cred = New-Object pscredential($WinRmCredentialUser, $sec) Invoke-WinRmScript -Config $config -PasswordValue $passwordForWinRm -ScriptBlock {
$invokeParams = @{
ComputerName = $WinRmComputer
Credential = $cred
ScriptBlock = {
param($sitePath) param($sitePath)
Set-Location $sitePath Set-Location $sitePath
if (-not (Get-Command dotnet ef -ErrorAction SilentlyContinue)) { if (-not (Get-Command dotnet -ErrorAction SilentlyContinue)) {
throw "dotnet ef not available on remote host. Install SDK or set `$RunEfMigrations = $false." throw "dotnet is not available on remote host."
} }
dotnet ef database update --no-build dotnet ef database update --no-build
} -ArgumentList @([string]$config.RemoteSitePath)
} }
ArgumentList = @($RemoteSitePath) catch {
}
if ($UseWinRmHttps) { $invokeParams["UseSSL"] = $true }
if ($WinRmAuth) { $invokeParams["Authentication"] = $WinRmAuth }
try {
Invoke-Command @invokeParams
} catch {
Write-Warning "WinRM migrations failed: $($_.Exception.Message)." Write-Warning "WinRM migrations failed: $($_.Exception.Message)."
} }
} }