Files
PepApi.Core/scripts/Deploy-PepApi.ps1
AJ Isaacs 2263ad0575 feat(deploy): support running service under user credentials or LocalSystem
- Add parameters: -RunAsCurrentUser, -ServiceCredential, -DomainUser, -Domain, -User, -UseLocalSystem
- Preflight checks for elevation, service logon right, and directory permissions
- Create service with provided credentials or prompt for current user password by default
- Improve start reliability and status reporting
- Update guidance output to reflect new config
2025-10-29 11:07:22 -04:00

326 lines
13 KiB
PowerShell

<#
Deploy PepApi as a Windows Service
Examples:
# Run from repository root:
powershell -ExecutionPolicy Bypass -File scripts/Deploy-PepApi.ps1 -ServiceName PepApi -InstallDir C:\Services\PepApi -Urls "http://*:8085" -OpenFirewall
# Custom installation:
powershell -ExecutionPolicy Bypass -File scripts/Deploy-PepApi.ps1 -ServiceName PepApiService -InstallDir D:\MyServices\PepApi -Urls "http://*:8085" -OpenFirewall
Requires: dotnet SDK/runtime installed and administrative privileges.
#>
Param(
[string]$ServiceName = "PepApi",
[string]$PublishConfiguration = "Release",
[string]$InstallDir = "C:\Services\PepApi",
[string]$Urls = "http://*:8085",
[switch]$OpenFirewall,
[switch]$RunAsCurrentUser,
[PSCredential]$ServiceCredential,
[string]$DomainUser,
[string]$Domain,
[string]$User,
[switch]$UseLocalSystem,
[int]$PublishTimeoutSeconds = 180,
[int]$ServiceStopTimeoutSeconds = 30,
[int]$ServiceStartTimeoutSeconds = 30
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
# Detect repository root
$ScriptDir = Split-Path -Parent $PSCommandPath
$RepoRoot = if ((Split-Path -Leaf $ScriptDir) -eq 'scripts') {
Split-Path -Parent $ScriptDir
} else {
$ScriptDir
}
Write-Host "Repository root: $RepoRoot"
$ProjectPath = Join-Path $RepoRoot 'PepApi.Core\PepApi.Core.csproj'
if (-not (Test-Path -LiteralPath $ProjectPath)) {
throw "Project not found at: $ProjectPath"
}
function Ensure-Dir($path) {
if (-not (Test-Path -LiteralPath $path)) {
New-Item -ItemType Directory -Path $path | Out-Null
}
}
function Resolve-TargetUser() {
if ($UseLocalSystem.IsPresent) { return $null }
if ($ServiceCredential) { return $ServiceCredential.UserName }
if ($DomainUser) { return $DomainUser }
if ($Domain -and $User) { return "$Domain\$User" }
if ($RunAsCurrentUser.IsPresent -or (-not $ServiceCredential -and -not $RunAsCurrentUser.IsPresent -and -not $UseLocalSystem.IsPresent)) {
return "$env:USERDOMAIN\$env:USERNAME"
}
return $null
}
function Preflight-Checks() {
# Require elevation
$isAdmin = ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
if (-not $isAdmin) {
throw "Please run this script in an elevated PowerShell (Run as Administrator)."
}
$targetUser = Resolve-TargetUser
if ($null -ne $targetUser) {
# Best-effort check: Log on as a service right
try {
$tmp = [System.IO.Path]::GetTempFileName()
secedit /export /cfg $tmp | Out-Null
$cfg = Get-Content -LiteralPath $tmp -ErrorAction Stop
Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue
$sid = ([System.Security.Principal.NTAccount]$targetUser).Translate([System.Security.Principal.SecurityIdentifier]).Value
$line = $cfg | Where-Object { $_ -match '^SeServiceLogonRight' }
$hasRight = $false
if ($line) {
$vals = ($line -split '=')[1]
if ($vals -match [Regex]::Escape($sid)) { $hasRight = $true }
}
if (-not $hasRight) {
Write-Warning "Account '$targetUser' may not have 'Log on as a service'. If start fails, grant it via Local Security Policy > Local Policies > User Rights Assignment."
}
} catch {
Write-Warning "Could not verify 'Log on as a service' for '$targetUser'. If service start fails, grant the right and redeploy."
}
# Heuristic: verify read access to install directory
try {
Ensure-Dir $InstallDir
$acl = Get-Acl -LiteralPath $InstallDir
$readOk = $false
foreach ($rule in $acl.Access) {
$id = $rule.IdentityReference.Value
if ($id -ieq $targetUser -or $id -match '^(BUILTIN\\Users|NT AUTHORITY\\Authenticated Users)$') {
if ($rule.AccessControlType -eq 'Allow' -and ($rule.FileSystemRights.ToString() -match 'ReadAndExecute|FullControl|Modify|Read')) {
$readOk = $true; break
}
}
}
if (-not $readOk) {
Write-Warning "Account '$targetUser' may not have read access to '$InstallDir'. If start fails, grant permissions, e.g.: icacls \"$InstallDir\" /grant \"$($targetUser):(RX)\" /t"
}
} catch {
Write-Warning "Could not verify NTFS permissions on '$InstallDir' for '$targetUser': $($_.Exception.Message)"
}
}
}
function Stop-And-DeleteService($name) {
$svc = Get-Service -Name $name -ErrorAction SilentlyContinue
if ($null -ne $svc) {
if ($svc.Status -ne 'Stopped') {
Write-Host "Stopping service '$name'..."
Stop-Service -Name $name -Force -ErrorAction SilentlyContinue
try { $svc.WaitForStatus('Stopped',[TimeSpan]::FromSeconds($ServiceStopTimeoutSeconds)) | Out-Null } catch {}
# If still running, kill by PID
$q = & sc.exe queryex $name 2>$null
$pidLine = $q | Where-Object { $_ -match 'PID' }
if ($pidLine -and ($pidLine -match '(\d+)$')) {
$procId = [int]$Matches[1]
if ($procId -gt 0) {
try { Write-Host "Killing service process PID=$procId ..."; Stop-Process -Id $procId -Force } catch {}
}
}
}
Write-Host "Deleting service '$name'..."
sc.exe delete $name | Out-Null
Start-Sleep -Seconds 1
}
}
function Publish-App() {
Write-Host "Publishing PepApi.Core to $InstallDir ..."
Ensure-Dir $InstallDir
# Run dotnet publish directly - output will be visible
& dotnet publish $ProjectPath -c $PublishConfiguration -o $InstallDir
if ($LASTEXITCODE -ne 0) {
throw "dotnet publish failed with exit code $LASTEXITCODE"
}
# Copy appsettings.json if it doesn't exist in install dir (preserve existing config)
$sourceSettings = Join-Path (Split-Path -Parent $ProjectPath) 'appsettings.json'
$targetSettings = Join-Path $InstallDir 'appsettings.json'
Write-Host ""
Write-Host "============================================"
Write-Host "IMPORTANT: Configuration File" -ForegroundColor Yellow
Write-Host "============================================"
Write-Host "Please review and update the configuration file at:" -ForegroundColor Yellow
Write-Host " $targetSettings" -ForegroundColor Cyan
Write-Host ""
Write-Host "Update the following settings:" -ForegroundColor Yellow
Write-Host " - ConnectionStrings:PepDB (SQL Server connection)" -ForegroundColor White
Write-Host " - PepSettings:NestDirectory (network path to nests)" -ForegroundColor White
Write-Host "============================================"
Write-Host ""
}
function Stop-ExeLocks($path) {
$procs = Get-Process -ErrorAction SilentlyContinue | Where-Object {
$_.Path -and ($_.Path -ieq $path)
}
foreach ($p in $procs) {
try { Write-Host "Killing process $($p.Id) $($p.ProcessName) ..."; Stop-Process -Id $p.Id -Force } catch {}
}
# Wait until unlocked
for ($i=0; $i -lt 50; $i++) {
$still = Get-Process -ErrorAction SilentlyContinue | Where-Object { $_.Path -and ($_.Path -ieq $path) }
if (-not $still) { break }
Start-Sleep -Milliseconds 200
}
}
function Create-Service($name, $bin, $urls, [PSCredential]$credential, [bool]$useCurrentUser) {
$binPath = '"' + $bin + '" --urls ' + $urls
Write-Host "Creating service '$name' with binPath: $binPath"
$useUser = $false
$userName = $null
$plainPassword = $null
if ($credential) {
$useUser = $true
$userName = $credential.UserName
$bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($credential.Password)
try {
$plainPassword = [Runtime.InteropServices.Marshal]::PtrToStringAuto($bstr)
} finally {
[Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr)
}
} elseif ($useCurrentUser) {
$useUser = $true
$userName = "$env:USERDOMAIN\$env:USERNAME"
$secure = Read-Host -AsSecureString -Prompt "Enter password for $userName"
$bstr2 = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure)
try {
$plainPassword = [Runtime.InteropServices.Marshal]::PtrToStringAuto($bstr2)
} finally {
[Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr2)
}
}
# Note: space after '=' is required for sc.exe syntax
if ($useUser) {
Write-Host "Configuring service to run as: $userName"
sc.exe create $name binPath= "$binPath" start= auto DisplayName= "$name" obj= "$userName" password= "$plainPassword" | Out-Null
} else {
sc.exe create $name binPath= "$binPath" start= auto DisplayName= "$name" | Out-Null
}
# Set recovery to restart on failure
sc.exe failure $name reset= 86400 actions= restart/60000/restart/60000/restart/60000 | Out-Null
}
function Start-ServiceSafe($name) {
Write-Host "Starting service '$name'..."
try {
Start-Service -Name $name -ErrorAction Stop
} catch {
Write-Warning ("Start-Service failed: {0}" -f $_.Exception.Message)
Write-Host "Retrying start via sc.exe ..."
Start-Sleep -Seconds 2
try { & sc.exe start $name | Out-Null } catch {}
}
try {
$svc = Get-Service -Name $name -ErrorAction Stop
$svc.WaitForStatus('Running',[TimeSpan]::FromSeconds($ServiceStartTimeoutSeconds)) | Out-Null
} catch {
Write-Warning ("Could not confirm service status: {0}" -f $_.Exception.Message)
}
sc.exe query $name | Write-Host
}
if (-not (Get-Command dotnet -ErrorAction SilentlyContinue)) {
throw "dotnet SDK/Runtime not found in PATH. Please install .NET 8+ or add it to PATH."
}
Write-Host "================================================"
Write-Host "PepApi Deployment Script" -ForegroundColor Green
Write-Host "================================================"
Write-Host "Service Name: $ServiceName"
Write-Host "Install Dir: $InstallDir"
Write-Host "URLs: $Urls"
Write-Host "Configuration: $PublishConfiguration"
Write-Host "Open Firewall: $OpenFirewall"
Write-Host "Run As Current: $RunAsCurrentUser"
Write-Host "Credential Given: $([bool]$ServiceCredential)"
if ($DomainUser) { Write-Host "Domain User: $DomainUser" }
elseif ($Domain -or $User) { Write-Host "Domain/User: $Domain/$User" }
Write-Host "Use LocalSystem: $UseLocalSystem"
Write-Host "================================================"
Write-Host ""
Preflight-Checks
Stop-And-DeleteService -name $ServiceName
Stop-ExeLocks -path (Join-Path $InstallDir 'PepApi.Core.exe')
try { Remove-Item -LiteralPath (Join-Path $InstallDir 'PepApi.Core.exe') -Force -ErrorAction SilentlyContinue } catch {}
Publish-App
$exe = Join-Path $InstallDir 'PepApi.Core.exe'
if (-not (Test-Path -LiteralPath $exe)) {
throw "Expected published executable not found: $exe"
}
# If domain credentials were supplied as strings, build a PSCredential
if (-not $ServiceCredential) {
$du = $null
if ($DomainUser) {
$du = $DomainUser
} elseif ($Domain -and $User) {
$du = "$Domain\$User"
}
if ($du) {
$pw = Read-Host -AsSecureString -Prompt "Enter password for $du"
try { $ServiceCredential = [pscredential]::new($du, $pw) } catch {}
}
}
# Default to current user if nothing specified and not forced to LocalSystem
$effectiveUseCurrent = $RunAsCurrentUser.IsPresent
if (-not $ServiceCredential -and -not $effectiveUseCurrent -and -not $UseLocalSystem.IsPresent) {
Write-Host "No credentials provided. Defaulting to run as current user." -ForegroundColor Yellow
$effectiveUseCurrent = $true
}
Create-Service -name $ServiceName -bin $exe -urls $Urls -credential $ServiceCredential -useCurrentUser $effectiveUseCurrent
if ($OpenFirewall) {
$port = ($Urls -split ':')[-1]
if ($port -match '^(\d+)$') {
$ruleName = "$ServiceName HTTP $port"
$existingRule = Get-NetFirewallRule -DisplayName $ruleName -ErrorAction SilentlyContinue
if ($null -eq $existingRule) {
Write-Host "Creating firewall rule for TCP port $port ..."
New-NetFirewallRule -DisplayName $ruleName -Direction Inbound -Protocol TCP -LocalPort $port -Action Allow | Out-Null
} else {
Write-Host "Firewall rule '$ruleName' already exists, skipping creation."
}
}
}
Start-ServiceSafe -name $ServiceName
Write-Host ""
Write-Host "================================================"
Write-Host "Deployment Complete!" -ForegroundColor Green
Write-Host "================================================"
Write-Host "Service '$ServiceName' is running."
Write-Host "API available at: $Urls"
Write-Host "Swagger UI at: $($Urls -replace '\*', 'localhost')/swagger"
Write-Host ""
Write-Host "Next Steps:" -ForegroundColor Yellow
Write-Host " 1. Verify configuration in: $InstallDir\appsettings.json"
Write-Host " 2. Test API endpoint: $($Urls -replace '\*', 'localhost')/swagger"
Write-Host " 3. Check service status: Get-Service -Name $ServiceName"
Write-Host "================================================"