ps/Modules/Alkami.PowerShell.Services/Public/Install-LegacyMicroservice.ps1

437 lines
19 KiB
PowerShell
Raw Permalink Normal View History

2023-05-30 22:51:22 -07:00
function Install-LegacyMicroservice {
<#
.SYNOPSIS
Installs a legacy Alkami Windows Microservice
.DESCRIPTION
Installs a legacy Alkami Windows Microservice.
Legacy Microservices are considered to be Microservices that implement Alkami.Services.Subscriptions.ParticipatingService.DistributedServiceBase or derivatives.
Legacy Microservices installed via this function are not installed to a Service Fabric or other mesh implementation.
This installer does not create k8s or Lambdas or anything that is not a Windows Service.
If the service is found under the specified name and path already registered, this function will exit early and do nothing.
.PARAMETER ServicePath
The path to the service folder or service file. If to a folder, assumes that the file matches the path name.
Ex: C:\ProgramData\chocolatey\lib\Alkami.Services.Subscriptions.Host as a param would then find the file that matches Alkami.Services.Subscriptions.Host.exe under this path
.PARAMETER AssemblyInfo
The expected name of the service. This overrides the default option of matching the path ID.
.PARAMETER IsDatabaseAccessRequired
Does this need to access a database?
.PARAMETER UseLegacyConfigForServiceName
Look up the legacy service name from the config.ps1 file. This is non-preferred. This is only used if the AssemblyInfo parameter is empty.
.PARAMETER StartOnInstall
Should the program start on install? Defaults to true, must be used to not-start on install
.PARAMETER StartTimeout
The StartTimeout to be passed to Start-AlkamiService. Defaults to 60
.PARAMETER SetNewRelicConfiguration
Should the New Relic configuration be applied to this service?
.PARAMETER StartType
Options are "Manual","Delayed","Automatic","Disabled","AutomaticDelayedStart"
See also the switches: StartDelayed, StartDisabled, StartAutomatically
.EXAMPLE
Install-LegacyMicroservice C:\ProgramData\chocolatey\lib\Alkami.Services.Subscriptions.Host
#>
[CmdletBinding(DefaultParameterSetName = 'StartSpecified')]
[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidDefaultValueSwitchParameter', '', Scope='Function', Justification='Negated-action switch names are non-intuitive. It should take hoops to disable.')]
Param(
[Parameter(Mandatory = $true, Position = 0)]
[Alias("Path")]
[string]$ServicePath,
[Parameter(Mandatory = $false, Position = 1)]
[Alias("Name")]
[string]$AssemblyInfo,
[Alias("UseDBUser")]
[switch]$IsDatabaseAccessRequired,
# Set to true for the dev experience. Expect the pipeline to set to false.
[switch]$StartOnInstall = $true,
[int]$StartTimeout = 60,
[switch]$SetNewRelicConfiguration,
[switch]$UseLegacyConfigForServiceName,
[Parameter(Mandatory = $false, Position = 2)]
[Parameter(ParameterSetName = "StartSpecified")]
[ValidateSet("Manual","Delayed","Automatic","Disabled","AutomaticDelayedStart","")]
[string]$StartType = "",
[Alias("AutoStart")]
[Parameter(ParameterSetName = "StartAutomatically")]
[switch]$StartAutomatically,
[Alias("Disabled")]
[Parameter(ParameterSetName = "StartDisabled")]
[switch]$StartDisabled,
[Alias("Manual")]
[Parameter(ParameterSetName = "StartManual")]
[switch]$StartManual,
[Alias("Delayed")]
[Parameter(ParameterSetName = "StartDelayed")]
[switch]$StartDelayed
)
$delayedParameterConstant = "--delayed"
$delayedConstant = "AutomaticDelayedStart"
$disabledParameterConstant = "--disabled"
$disabledConstant = "Disabled"
$manualParameterConstant = "--manual"
$manualConstant = "Manual"
$automaticParameterConstant = "--autostart"
$automaticConstant = "Automatic"
$tier0Services = Get-ServicesByTier -Tier 0
# Invoke Command With Retry timing.
$icwrTiming = @{
MaxRetries = 5
Milliseconds = 5000
JitterMin = 50
JitterMax = 1000
}
$logLead = (Get-LogLeadName)
$StartOnInstallConfigSetting = (Get-ConfigSetting "Alkami.Installer.StartOnInstall")
if (![string]::IsNullOrWhiteSpace($StartOnInstallConfigSetting) -and ([bool]::TryParse($StartOnInstallConfigSetting,[ref]$StartOnInstallConfigSetting))) {
if ($StartOnInstall -ne $StartOnInstallConfigSetting) {
Write-Warning "$logLead : StartOnInstall flag conflicts with machine config [$StartOnInstallConfigSetting]"
}
$StartOnInstall = $StartOnInstallConfigSetting
}
# Tier 0 is hard coded to Automatic - Delayed -- SRE-17490
if($tier0Services -Contains $AssemblyInfo){
$StartType = $delayedConstant
# Skip these checks for tier0
} else {
if ([string]::IsNullOrWhiteSpace($StartType)) {
$serviceStartupModeAppSettingKey = "Alkami.Installer.ServiceStartupMode"
$defaultInstallerMode = Get-AppSetting $serviceStartupModeAppSettingKey
if (![string]::IsNullOrWhiteSpace($defaultInstallerMode)) {
if ([string]::Equals($defaultInstallerMode,"OFF",[StringComparison]::CurrentCultureIgnoreCase)){
$StartType = $disabledConstant
} elseif ([string]::Equals($defaultInstallerMode,"ON",[StringComparison]::CurrentCultureIgnoreCase)){
$StartType = $manualConstant
} elseif ([string]::Equals($defaultInstallerMode,$automaticConstant,[StringComparison]::CurrentCultureIgnoreCase)){
$StartType = $automaticConstant
} else {
Write-Warning "$logLead : Can not parse appSetting [$serviceStartupModeAppSettingKey] with value [$defaultInstallerMode] (acceptable: on, off, $automaticConstant)"
}
}
}
if ([string]::IsNullOrWhiteSpace($StartType)) {
$StartType = $manualConstant
}
# Allow the user to type either or on AutomaticDelayedStart or Disabled
# The value here is used in this lookup sequence and later with Set-Service -StartupType
if ($StartDelayed -or ($StartType -eq "Delayed")) {
$StartType = $delayedConstant
}
if ($StartAutomatically) {
$StartType = $automaticConstant
}
if ($StartDisabled) {
$StartType = $disabledConstant
}
if ($StartManual) {
$StartType = $manualConstant
}
}
# Hash lookup - quicker than a switch
$serviceInstallType = @{
Manual = $manualParameterConstant;
Disabled = $disabledParameterConstant;
Automatic = $automaticParameterConstant;
AutomaticDelayedStart = $delayedParameterConstant;
}.$StartType
<#
# This is gated behind the pipeline, so we have no need to test right now.
if (!(Test-IsDeveloperMachine) -and (Test-IsWebServer)) {
Write-Warning "$loglead : Can not install microservices on the web tier"
return
}
#>
if (!(Test-Path $ServicePath)) {
Write-Warning "$logLead : Path passed in does not represent a valid path: [$ServicePath]. Can not install something which does not exist."
return
}
# Ensure it doesn't end with exe (yet)
if ($AssemblyInfo.EndsWith(".exe")) {
$AssemblyInfo = $AssemblyInfo.Remove($AssemblyInfo.LastIndexOf(".exe"))
}
$ServicePath = (Resolve-Path $ServicePath)
$folderPath = $ServicePath
$serviceAlreadyExists = $false
$stopWatch = [System.Diagnostics.Stopwatch]::StartNew()
$serviceAccount = (Get-MachineConfigServiceAccount -IsDatabaseAccessRequired:$IsDatabaseAccessRequired)
# If you passed in a value for AssemblyInfo then there is no reason to use the legacy config
# If you did not pass it in, but you did specify the flag, then on we go
# The parameter set names are already a bit lengthy, I'm trying to avoid changing that here
if ([string]::IsNullOrWhiteSpace($AssemblyInfo) -and $UseLegacyConfigForServiceName) {
Write-Verbose "$logLead : Trying to find the `$AssemblyInfo from the legacy config.ps1"
$configPs1s = (Get-ChildItem -Path $ServicePath -Filter "config.ps1" -Recurse)
$serviceId = ""
foreach($config in $configPs1s) {
$configFullName = $config.FullName
$lines = (Get-Content $configFullName)
foreach($line in $lines) {
if ($line.Trim().ToLower().StartsWith('$serviceid')) {
$serviceId = ($line.Split('=')[1]).Replace(';','').Replace('"','').Replace("'","").Trim();
}
}
if (![string]::IsNullOrWhiteSpace($serviceId)) {
# Write out where we got it from in case we need to debug why we got this from a wrong location, etc
Write-Verbose "$logLead : Found [$serviceId] in [$configFullName]]"
break
}
}
# Store the value we got back so we can use it in other places since AssemblyInfo was already empty when we got here
$AssemblyInfo = $serviceId
}
$item = (Get-Item $ServicePath)
if ($item.PSIsContainer) {
# path was a folder
$leafName = (Split-Path -Path $ServicePath -Leaf)
if ($leafName -match 'tools') {
$ServicePath = (Split-Path -Path $ServicePath -Parent)
$leafName = (Split-Path -Path $ServicePath -Leaf)
}
# We still don't have this value, let's pretend it's the folder name then
if ([string]::IsNullOrWhiteSpace($AssemblyInfo)) {
$AssemblyInfo = $leafName
}
$exeName = "$AssemblyInfo.exe"
Write-Host "$logLead : looking for $exeName in $ServicePath"
$exeItems = @()
# Start with @("app","lib","tools") then do the naive scan
foreach($subpath in @("app","lib","tools")) {
if(Test-Path(Join-Path (Join-Path $ServicePath $subpath) $exeName))
{
$exeItems = @(Get-Item -Path (Join-Path (Join-Path $ServicePath $subpath) $exeName))
}
else {
Write-Verbose "Tried to find an item in a subpath ($subpath) that doesn't exist. This is fine."
}
if (!(Test-IsCollectionNullOrEmpty $exeItems)) {
Write-Verbose "Found an exe in $subpath"
break;
}
}
# If we still didn't find anything, fall back to the "naive" pattern
if (Test-IsCollectionNullOrEmpty $exeItems) {
$exeItems = (Get-ChildItem -Path (Join-Path $ServicePath $exeName) -Recurse)
}
# If we are in naive and find the same .exe multiple times, throw
if ($exeItems.Count -gt 1) {
$additionalDetailMessage = ", can not continue as we don't know which to use"
if ($ErrorActionPreference -ne 'Stop') {
$additionalDetailMessage = ", going to try to install the first one."
}
Write-Error "$logLead : More than one EXE available$additionalDetailMessage"
Write-Host "$logLead : $([string].Join(',',$exeItems.FullName))"
}
$exeItem = $exeItems | Select-Object -First 1 # there should only be one exe with the [package name].exe inside the folders
if ($null -eq $exeItem) {
$stopWatch.Stop()
Write-Warning "$logLead : Path passed in does not contain an exe with the filename: [$exeName]. Can not install a non-existent microservice in [$($stopWatch.Elapsed)]."
return
}
$ServicePath = $exeItem.FullName
} else {
# The $item was not a .PSIsContainer so it was a file
# That means we want the folderPath to be the folder of the file
$folderPath = (Split-Path -Path $ServicePath -Parent)
# We still don't have this value, let's pretend it's the folder name then
if ([string]::IsNullOrWhiteSpace($AssemblyInfo)) {
$AssemblyInfo = (Split-Path -Path $ServicePath -Leaf).Replace(".exe","")
}
}
# Check to see if the service is already registered before we try and do extra stuff
# This is where we could unregister it before we continue if we want to force a re-registration
$serviceCandidates = (Get-ServiceInfoByCIMFragment -Fragment $AssemblyInfo)
# If we didn't find anything by the assemblyinfo, let's double check the path in case something was already there
if (Test-IsCollectionNullOrEmpty $serviceCandidates) {
$serviceCandidates = (Get-ServiceInfoByCIMFragment -Fragment $folderPath)
}
# If we found something, log it and quit
if (!(Test-IsCollectionNullOrEmpty $serviceCandidates)) {
# Throw some comments into the console so we know why we didn't do anything
foreach ($serviceCandidate in $serviceCandidates) {
Write-Host "$logLead : Found an already existing service [$($serviceCandidate.Name)] at [$($serviceCandidate.Path)]"
# The path could be complex. The beginning of the paths should match.
# The reason for complex paths has to do with how services are registered in the database
# It's possible to have flags at the end of the path etc.
if ($serviceCandidate.ExePath.ToLower().StartsWith($ServicePath.ToLower())) {
$serviceAlreadyExists = $true
$assemblyinfo = $serviceCandidate.Name
# SRE-13995 - If the service is already registered, and disabled, we keep it disabled.
if ($serviceCandidate.StartMode -eq 'Disabled') {
$serviceInstallType = $disabledParameterConstant
$StartType = $disabledConstant
Write-Warning "$logLead : SRE-18018 ~ Because the service was already disabled, we will be retaining that status, ignoring input flags, and not starting the service on install ~ `$StartOnInstall = `$false"
$StartOnInstall = $false
}
} else {
Write-Warning "$logLead : This service [$($serviceCandidate.Name)] is installed in [$($serviceCandidate.Path)] and doesn't match what's expected. Removing so we can re-register properly, and avoid bad registrations."
# uninstall here
# Ensure it isn't running first
Stop-AlkamiService $serviceCandidate.Name
# making this ICWR due to SRE-16914 which is unexplained. Likely a delay in sc.exe catching up when under heavy system duress.
$icwrSplat = @{
ScriptBlock = {
param ($sb_serviceCandidate)
Invoke-DeleteLegacyMicroserviceFromServiceCandidate $sb_serviceCandidate
}
Arguments = @($serviceCandidate)
}
Invoke-CommandWithRetry @icwrSplat @icwrTiming
}
}
}
$exeName = (Split-Path -Path $ServicePath -Leaf)
$serviceInfo = $null
# If the service already existed we don't have to install it, but we still do the other things
if (!$serviceAlreadyExists) {
# Do the thing here to install the MS.
Write-Host "$logLead : Installing [$exeName] from $ServicePath"
$argumentList = @()
$argumentList += "install"
$argumentList += $serviceInstallType
Invoke-TopshelfPath $ServicePath $argumentList
Write-Host "$logLead : Successfully finished [$ServicePath] registration of service"
# This is done to make sure that Windows has a chance to register the service.
Write-Host "$logLead : Sleeping with retry for [$ServicePath] for flushing..."
$icwrSplat = @{
ScriptBlock = {
param ($sb_AssemblyInfo)
Write-Host "Getting Service for $sb_sAssemblyInfo..."
return Get-Service $sb_AssemblyInfo
}
Arguments = @($AssemblyInfo)
}
$serviceInfo = Invoke-CommandWithRetry @icwrSplat @icwrTiming
} else {
Stop-AlkamiService $AssemblyInfo
# Powershell 5.1 doesn't support using Set-Service to set the start type to Automatic-Delayed. So we do it with SC.exe
if($StartType -eq $delayedConstant){
$params = @("config", $serviceCandidate.Name, "start=delayed-auto")
Invoke-SCExe -Arguments $params
} else {
# Leaving this as is for non-delayed start because it works as is and we don't have unit tests to test a larger change.
Set-Service -ServiceName $Assemblyinfo -StartupType $StartType
}
$serviceInfo = Get-Service $AssemblyInfo
}
if ($null -ne $serviceInfo) {
Write-Host "$logLead : Attempt to Set-ChocolateyPackageNewRelicState"
$packageName = (Get-ChocoPackageFromPath $ServicePath)
if (![string]::IsNullOrWhiteSpace($packageName)) {
Write-Verbose "$logLead : Set-ChocolateyPackageNewRelicState -Name `"$packageName`" -Enabled $SetNewRelicConfiguration"
Set-ChocolateyPackageNewRelicState -Name $packageName -Enabled $SetNewRelicConfiguration
}
} else {
throw "$loglead : Newly registered service could not be found."
}
# We always call this, whether we just installed it or it already existed
# The reason for this is two-fold.
# 1. In case we are correcting a bad install.
# 2. Resets the gMSA "password already rolled" issue that we use "fixlogins" to resolve
# 2a. This wouldn't resolve "the service got installed 3 months ago" need for "fixlogins", just reinstall
Set-WindowsServiceExecutionAccount -ServiceName $AssemblyInfo -ServiceUser $serviceAccount -IsGMSAAccount
Set-ServiceRecoveryOneRestart -ServiceName $AssemblyInfo
# clean up any bad config values
$appConfig = "$ServicePath.config"
# Not every service has to have a config. This is ok
if (Test-Path $appConfig) {
# get the machine app config value
# Yeah, it's the default parameter, but better to just be direct
$machineConfigKeys = @(Get-AllAppSettingKeys -FilePath (Get-DotNetConfigPath -use64Bit $true))
$serviceConfigKeys = @(Get-AllAppSettingKeys -FilePath $appConfig)
# only do the work if we have keys to compare
if (($machineConfigKeys.Count -gt 0) -and ($serviceConfigKeys.Count -gt 0)) {
foreach($key in $serviceConfigKeys) {
if ($machineConfigKeys -contains $key) {
Write-Host "$logLead : Removing [$key] from [$appConfig]"
Remove-AppSetting -Key $key -FilePath $appConfig
}
}
}
}
if ($StartOnInstall) {
try {
Start-AlkamiService -ServiceName $AssemblyInfo -Timeout $StartTimeout
} catch {
Write-Warning "$logLead : Service could not be started. See logs above."
if ($serviceAlreadyExists) {
throw $_
} else {
Write-Warning "$logLead : Continuing with install so files will be present post chocolatey install. This is not reporting as an error due to the way chocolatey works during installs, where if it fails it rolls back, but we've already registered the service with Windows. The alternative is to uninstall on failure and let the package be removed."
}
}
} else {
Write-Host "$logLead : Service not started, flag to start was not present or was not set to true"
}
$stopWatch.Stop()
Write-Host "$logLead : [$assemblyinfo] installed at [$ServicePath] in [$($stopWatch.Elapsed)]"
}