<# 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 "================================================"