diff --git a/Modules/.build/Clear-BadPesterUserAccounts.ps1 b/Modules/.build/Clear-BadPesterUserAccounts.ps1
new file mode 100644
index 0000000..043d2f0
--- /dev/null
+++ b/Modules/.build/Clear-BadPesterUserAccounts.ps1
@@ -0,0 +1,183 @@
+Function Clear-BadPesterUserAccounts {
+<#
+.SYNOPSIS
+ Cleans up all incorrectly created user accounts on the system from Pester.
+
+.EXAMPLE
+ Clear-BadPesterUserAccounts
+
+.OUTPUTS
+ Returns the list of folder names (from C:\Users) that are "invalid".
+ If the return list is empty then there were none to remove.
+ This is useful for post-facto additional resource cleanup. In the case of a non-WhatIf, this will be an ephemeral response, so catch it while you can.
+
+.PARAMETER AllPossibleDomainsToSearch
+ What domains do we want to search? Defaults to [fh, corp]
+
+.PARAMETER WhatIf
+ Used to test before removing for validation
+#>
+ param(
+ $AllPossibleDomainsToSearch = @("fh","corp"),
+ [switch]$WhatIf
+ )
+
+ $logLead = (Get-LogLeadName)
+
+ # This means we are on Windows
+ # At the very least, we can't get the CIM Instances to remove users if they exist
+ if ($null -eq (Get-Command Get-CimInstance -ErrorAction SilentlyContinue)) {
+ Write-Host "$logLead : Can't CimInstance on this host, nothing to do"
+ return
+ }
+
+ Write-Host "$logLead : Cleaning bad pester user accounts [Dry run? $WhatIf]"
+
+ # Get local user accounts
+ # Get IIS user accounts or IIS app pool names (it defaults the identity to the name if no identity specified)
+ # Get all domain GMSA accounts
+ # Get all domain user accounts
+ #
+ # Get all c:\users folders
+ # Diff the names
+ # Remove the users
+
+ # Annoying bug...
+ if ($null -ne (Get-Module Carbon)) {
+ # Carbon stomps all over our Get-ADDomainController amongst other things
+ Remove-Module Carbon -Force
+ }
+
+ $userAccountsToExclude = @()
+ $foldersToKeep = @()
+ $foldersToRemove = @()
+
+ # The system can't determine about some of these, they are special folders
+ # This is where any such folders go
+ # Try to make them dynamic before adding them here.
+ # Public is a very special child unrelated to anything else.
+ $userAccountsToExclude += "Public"
+
+ if ($null -ne (Get-Module -ListAvailable -Name WebAdministration)) {
+ Get-Module WebAdministration | Remove-Module -Force
+ Import-Module WebAdministration
+ $appPools = @(Get-ChildItem IIS:\AppPools)
+ foreach($appPool in $appPools) {
+ if (![string]::IsNullOrWhiteSpace($appPool.processModel.userName)) {
+ $userAccountsToExclude += $appPool.processModel.userName
+ } else {
+ $userAccountsToExclude += $appPool.Name
+ }
+ }
+ }
+
+ $localUsers = @(Get-LocalUser)
+ foreach($localUser in $localUsers) {
+ $userAccountsToExclude += $localUser.Name
+ }
+
+ if ($null -eq (Get-Module -ListAvailable -Name ActiveDirectory)) {
+ # This is truly an impossible condition to hit. So if we hit it, hit it hard.
+ throw 'how in the name of sanity is this server not setup to query the domain???'
+ }
+
+ Import-Module ActiveDirectory
+ foreach($domainName in $AllPossibleDomainsToSearch) {
+ $dc = Get-ADDomainController -DomainName $domainName -Discover -NextClosestSite
+
+ $dcHostname = $dc.Hostname[0]
+
+ if ([string]::IsNullOrWhiteSpace($dcHostname)) {
+ Write-Warning "$logLead : Could not find a hostname for $domainName - Can you access that domain from here?"
+ continue
+ }
+
+ $domainServiceAccounts = @(Get-ADServiceAccount -Filter "*" -Server $dcHostname)
+ foreach($domainServiceAccount in $domainServiceAccounts) {
+ $userAccountsToExclude += $domainServiceAccount.SamAccountName
+ }
+
+ $domainUserAccounts = @(Get-ADUser -Filter "*" -Server $dcHostname)
+ foreach($domainUserAccount in $domainUserAccounts) {
+ $userAccountsToExclude += $domainUserAccount.SamAccountName
+ }
+ }
+
+ $userFolders = @(Get-ChildItem -Directory -Path C:\Users)
+ foreach($folder in $userFolders) {
+ if ($userAccountsToExclude -contains $folder.Name) {
+ $foldersToKeep += $folder.Name
+ } else {
+ $foldersToRemove += $folder.Name
+ }
+ }
+
+ $allCimAccounts = @(Get-CimInstance -ClassName Win32_UserProfile)
+
+ $teamCity = (Test-IsTeamCityProcess)
+
+ if ($teamCity) {
+ Write-Host "##teamcity[blockOpened name='Display accounts for followup']"
+ } else {
+ Write-Host "================================="
+ }
+
+ if (!(Test-IsCollectionNullOrEmpty $foldersToKeep)) {
+ $tcBlurb = "Keep these accounts"
+ if ($teamCity) {
+ Write-Host "##teamcity[blockOpened name='$tcBlurb']"
+ } else {
+ Write-Host "$logLead : Found these folders to KEEP"
+ }
+
+ foreach($folder in $foldersToKeep) {
+ if ($teamCity) {
+ Write-Host "$logLead : KEEP C:\Users\$folder"
+ } else {
+ Write-Verbose "$logLead : KEEP C:\Users\$folder"
+ }
+ }
+
+ if ($teamCity) {
+ Write-Host "##teamcity[blockClosed name='$tcBlurb']"
+ } else {
+ Write-Host "================================="
+ }
+ }
+
+ if (!(Test-IsCollectionNullOrEmpty $foldersToRemove)) {
+ $tcBlurb = "Remove these accounts"
+ if ($teamCity) {
+ Write-Host "##teamcity[blockOpened name='$tcBlurb']"
+ } else {
+ Write-Host "$logLead : Found these folders to REMOVE"
+ }
+
+ foreach($folder in $foldersToRemove) {
+ Write-Host "$logLead : REMOVE C:\Users\$folder"
+ $matchingCimInstances = @($allCimAccounts.Where({$_.LocalPath -eq "C:\Users\$folder"}))
+
+ foreach($instance in $matchingCimInstances) {
+ if ($instance.Sid.StartsWith("S-1-5-82")) {
+ Write-Host "$logLead : $folder is an IIS-AppPool SID"
+ }
+ }
+
+ $matchingCimInstances | Remove-CimInstance -WhatIf:$WhatIf
+ }
+
+ if ($teamCity) {
+ Write-Host "##teamcity[blockClosed name='$tcBlurb']"
+ } else {
+ Write-Host ""
+ }
+ }
+
+ if ($teamCity) {
+ Write-Host "##teamcity[blockClosed name='Display accounts for followup']"
+ } else {
+ Write-Host "================================="
+ }
+
+ return $foldersToRemove
+}
\ No newline at end of file
diff --git a/Modules/.build/Get-Aliases.ps1 b/Modules/.build/Get-Aliases.ps1
new file mode 100644
index 0000000..82fc201
--- /dev/null
+++ b/Modules/.build/Get-Aliases.ps1
@@ -0,0 +1,42 @@
+Function Get-Aliases {
+<#
+.SYNOPSIS
+ Collects all alias names from files in a folder. Assumes Test-FunctionNames properly ran and validated target function names.
+
+.EXAMPLE
+ Get-Aliases .\Alkami.PowerShell.IIS\Public
+
+.PARAMETER FolderPath
+ The name of the folder to examine all files under.
+#>
+ [CmdletBinding()]
+ Param (
+ [String]$FolderPath
+ )
+ process {
+ $aliases = @()
+ $verbs = Get-Verb | Select-Object -ExpandProperty Verb
+
+ $files = (Get-ChildItem -Path $FolderPath *.ps1)
+ foreach($file in $files) {
+ if ($file.BaseName.ToLower().EndsWith(".tests") -or $file.BaseName.ToLower().EndsWith(".test")) {
+ Write-Verbose "skipping function names of test $file"
+ } else {
+ $lines = (Get-Content $file.FullName)
+ foreach($line in $lines) {
+ if (($line.Trim().ToLower().StartsWith("set-alias")) -or ($line.Trim().ToLower().StartsWith("new-alias"))) {
+ $candidateLine = $line -replace ';','' -replace '-name','' -replace '-value','' -replace '-force','' -replace '-scope:global','' -replace '-Scope Global',''
+ $splits = ($candidateLine.Trim() -split '\s+')
+ if ($splits.length -ne 3) {
+ Write-Warning "Could not parse line [$line] for Set-Alias, found [$splits]"
+ } else {
+ $aliases += $splits[1]
+ }
+ }
+ }
+ }
+ }
+
+ return $aliases
+ }
+}
\ No newline at end of file
diff --git a/Modules/.build/Get-BuildConfigs.ps1 b/Modules/.build/Get-BuildConfigs.ps1
new file mode 100644
index 0000000..a7d1630
--- /dev/null
+++ b/Modules/.build/Get-BuildConfigs.ps1
@@ -0,0 +1,32 @@
+Function Get-BuildConfigs {
+<#
+.SYNOPSIS
+ Get the build configs from a single csproj
+#>
+ [CmdletBinding()]
+ Param(
+ $csprojPath
+ )
+ process {
+ if (!(Test-Path $csprojPath)) {
+ Write-Warning "can't test on an empty path [$csprojPath]"
+ return
+ }
+
+ $returnValues = @()
+
+ $xml = [Xml](Get-Content $csprojPath)
+
+ $propertyGroups = @($xml.Project.PropertyGroup)
+ foreach($propertyGroup in $propertyGroups) {
+ if ($null -ne $propertyGroup.Condition) {
+ $value = $propertyGroup.Condition.Replace("'",'').Split('==')[-1].Split('|')[0].Trim()
+ if (!([string]::IsNullOrWhiteSpace($value))) {
+ $returnValues += $value
+ }
+ }
+ }
+
+ return $returnValues
+ }
+}
diff --git a/Modules/.build/Get-ContentFromFilesInPath.ps1 b/Modules/.build/Get-ContentFromFilesInPath.ps1
new file mode 100644
index 0000000..e90dc36
--- /dev/null
+++ b/Modules/.build/Get-ContentFromFilesInPath.ps1
@@ -0,0 +1,58 @@
+Function Get-ContentFromFilesInPath {
+<#
+.SYNOPSIS
+ Collects all content from valid files for inclusion into the PSM1
+
+.DESCRIPTION
+ Collects all content from valid files for inclusion into the PSM1
+ * Skips test files
+ * Adds a header line saying where the file came from during compile
+ The reason for these files to match their names is about process, not a functional concern.
+
+.EXAMPLE
+ Get-ContentFromFilesInPath .\Alkami.PowerShell.IIS\
+
+.PARAMETER FolderPath
+ The name of the folder to examine all files under.
+#>
+ [CmdletBinding()]
+ Param (
+ [String]$FolderPath
+ )
+ process {
+ $functionLines = @()
+
+ $prefixPath = (Split-Path $FolderPath -leaf)
+
+ $files = (Get-ChildItem (Join-Path $FolderPath "*.ps1"))
+
+ foreach($file in $files) {
+ if ($file.BaseName.ToLower().EndsWith(".tests") -or $file.BaseName.ToLower().EndsWith(".test")) {
+ Write-Verbose "skipping function names of test $file"
+ }
+ elseif ($file.BaseName.ToLower() -eq "variabledeclarations") {
+
+ $content = Get-Content $file.FullName
+ $functionLines += @("## Function from $file")
+
+ if ($null -ne $content -and $content[0] -match "SuppressMessageAttribute" -and $content[1] -match "param\(\)") {
+
+ Write-Host "Skipping PSScriptAnalyzer and Param Declaration from $($file.FullName)" -ForegroundColor Green
+ $functionLines += ($content | Select-Object -Skip 2)
+ } else {
+
+ $functionLines += $content
+ }
+ }
+ else {
+ $functionLines += @("## Function from $file")
+
+ $functionLines += (Get-Content $file.FullName)
+ }
+
+ $functionLines += ""
+ }
+
+ return $functionLines
+ }
+}
\ No newline at end of file
diff --git a/Modules/.build/Get-ContentFromFormatFilesInPath.ps1 b/Modules/.build/Get-ContentFromFormatFilesInPath.ps1
new file mode 100644
index 0000000..a04020d
--- /dev/null
+++ b/Modules/.build/Get-ContentFromFormatFilesInPath.ps1
@@ -0,0 +1,57 @@
+Function Get-ContentFromFormatFilesInPath {
+<#
+.SYNOPSIS
+ Collects all content from valid files for inclusion into the PSM1
+
+.DESCRIPTION
+ Collects all content from valid files for inclusion into the PSM1
+ * Skips test files
+ * Adds a header line saying where the file came from during compile
+ The reason for these files to match their names is about process, not a functional concern.
+
+.EXAMPLE
+ Get-ContentFromFilesInPath .\Alkami.PowerShell.IIS\
+
+.PARAMETER FolderPath
+ The name of the folder to examine all files under.
+#>
+ [CmdletBinding()]
+ Param (
+ [String]$FolderPath
+ )
+ process {
+ $functionLines = @()
+
+ $prefixPath = (Split-Path $FolderPath -leaf)
+
+ $files = (Get-ChildItem (Join-Path $FolderPath "*.ps1xml"))
+
+ [Xml]$defaultXml = [Xml]@"
+
+
+
+"@
+ $defaultXml.DocumentElement.AppendChild($defaultXml.CreateElement("SelectionSets")) | Out-Null
+ $defaultXml.DocumentElement.AppendChild($defaultXml.CreateElement("ViewDefinitions")) | Out-Null
+ $defaultXml.DocumentElement.AppendChild($defaultXml.CreateElement("Controls")) | Out-Null
+ $defaultXml.DocumentElement.AppendChild($defaultXml.CreateElement("DefaultSettings")) | Out-Null
+
+ foreach($file in $files) {
+ [Xml]$xml = [Xml](Get-Content $file.FullName)
+ foreach($node in $xml.DocumentElement.SelectionSets.ChildNodes) {
+ $defaultXml.DocumentElement.SelectSingleNode("SelectionSets").AppendChild($defaultXml.ImportNode($node,$true)) | Out-Null
+ }
+ foreach($node in $xml.DocumentElement.Controls.ChildNodes) {
+ $defaultXml.DocumentElement.SelectSingleNode("Controls").AppendChild($defaultXml.ImportNode($node,$true)) | Out-Null
+ }
+ foreach($node in $xml.DocumentElement.ViewDefinitions.ChildNodes) {
+ $defaultXml.DocumentElement.SelectSingleNode("ViewDefinitions").AppendChild($defaultXml.ImportNode($node,$true)) | Out-Null
+ }
+ foreach($node in $xml.DocumentElement.DefaultSettings.ChildNodes) {
+ $defaultXml.DocumentElement.SelectSingleNode("DefaultSettings").AppendChild($defaultXml.ImportNode($node,$true)) | Out-Null
+ }
+ }
+
+ return $defaultXml
+ }
+}
\ No newline at end of file
diff --git a/Modules/.build/Get-FunctionNames.ps1 b/Modules/.build/Get-FunctionNames.ps1
new file mode 100644
index 0000000..3a9f452
--- /dev/null
+++ b/Modules/.build/Get-FunctionNames.ps1
@@ -0,0 +1,31 @@
+Function Get-FunctionNames {
+<#
+.SYNOPSIS
+ Collects all function names from filenames. Assumes Test-FunctionNames properly ran.
+
+.EXAMPLE
+ Get-FunctionNames .\Alkami.PowerShell.IIS\Public
+
+.PARAMETER FolderPath
+ The name of the folder to examine all files under.
+#>
+ [CmdletBinding()]
+ Param (
+ [String]$FolderPath
+ )
+ process {
+ $functionNames = @()
+ $verbs = Get-Verb | Select-Object -ExpandProperty Verb
+
+ $files = (Get-ChildItem -Path $FolderPath *.ps1)
+ foreach($file in $files) {
+ if ($file.BaseName.ToLower().EndsWith(".tests") -or $file.BaseName.ToLower().EndsWith(".test")) {
+ Write-Verbose "skipping function names of test $file"
+ } else {
+ $functionNames += $file.BaseName
+ }
+ }
+
+ return $functionNames
+ }
+}
\ No newline at end of file
diff --git a/Modules/.build/Get-MSBuildPath.ps1 b/Modules/.build/Get-MSBuildPath.ps1
new file mode 100644
index 0000000..1f033f5
--- /dev/null
+++ b/Modules/.build/Get-MSBuildPath.ps1
@@ -0,0 +1,41 @@
+Function Get-MSBuildPath {
+ <#
+.SYNOPSIS
+ Get the local machine MSBuild path in a way that it can be easily consumed
+
+.EXAMPLE
+ Get-MSBuildPath
+
+C:\Program Files (x86)\Microsoft Visual Studio\2017\BuildTools\MSBuild\15.0\Bin\MSBuild.exe
+#>
+ [CmdletBinding()]
+ Param()
+ process {
+ if ($script:isSet_GetMSBuildPath) {
+ return $script:value_GetMSBuildPath
+ }
+ $msBuildPath = ""
+ if (Test-Path "C:\Program Files (x86)\Microsoft Visual Studio\2019\Professional\MSBuild\Current\Bin\MSBuild.exe") {
+ $msBuildPath = "C:\Program Files (x86)\Microsoft Visual Studio\2019\Professional\MSBuild\Current\Bin\MSBuild.exe"
+ } elseif (Test-Path "C:\Program Files (x86)\Microsoft Visual Studio\2017\BuildTools\MSBuild\15.0\Bin\MSBuild.exe") {
+ $msBuildPath = "C:\Program Files (x86)\Microsoft Visual Studio\2017\BuildTools\MSBuild\15.0\Bin\MSBuild.exe"
+ } elseif (Test-Path "C:\Program Files (x86)\Microsoft Visual Studio\2017\Professional\MSBuild\15.0\Bin\MSBuild.exe") {
+ $msBuildPath = "C:\Program Files (x86)\Microsoft Visual Studio\2017\Professional\MSBuild\15.0\Bin\MSBuild.exe"
+ } elseif (Test-Path "C:\Program Files (x86)\Microsoft Visual Studio\2019\BuildTools\MSBuild\Current\Bin\MSBuild.exe") {
+ $msBuildPath = "C:\Program Files (x86)\Microsoft Visual Studio\2019\BuildTools\MSBuild\Current\Bin\MSBuild.exe"
+ } elseif (Test-Path "C:\Windows\Microsoft.NET\Framework64\v4.0.30319\MSBuild.exe") {
+ $msBuildPath = "C:\Windows\Microsoft.NET\Framework64\v4.0.30319\MSBuild.exe"
+ } else {
+ $buildVersion = (Get-ChildItem HKLM:\SOFTWARE\Microsoft\MSBuild\ToolsVersions -Name | Measure -Maximum).Maximum
+ $MSBuildVersionRegEntry = (Get-ItemProperty -LiteralPath "HKLM:\SOFTWARE\Microsoft\MSBuild\ToolsVersions\$($buildVersion).0\" -Name "msbuild.exe")
+ if ($null -ne $MSBuildVersionRegEntry) {
+ $msBuildPath = $MSBuildVersionRegEntry."msbuild.exe"
+ }
+ }
+ if (!([string]::IsNullOrEmpty($msBuildPath))) {
+ $script:isSet_GetMSBuildPath = $true
+ $script:value_GetMSBuildPath = $msBuildPath
+ }
+ return $msBuildPath
+ }
+}
diff --git a/Modules/.build/Join-PS1XMLFromFiles.ps1 b/Modules/.build/Join-PS1XMLFromFiles.ps1
new file mode 100644
index 0000000..09794c1
--- /dev/null
+++ b/Modules/.build/Join-PS1XMLFromFiles.ps1
@@ -0,0 +1,29 @@
+Function Join-PS1XMLFromFiles {
+<#
+.SYNOPSIS
+ Uses Get-ContentFromFormatFilesInPath to join the file contents appropriately
+
+.DESCRIPTION
+ Collects all content from valid files for inclusion into the PS1XML file
+
+.EXAMPLE
+ Join-PS1XMLFromFiles .\Alkami.PowerShell.IIS\Public\ .\Alkami.PowerShell.IIS\Alkami.PowerShell.IIS.ps1xml
+
+.PARAMETER PublicPath
+ The name of the folder to examine all files under.
+
+.PARAMETER Ps1xmlFilePath
+ The file to overwrite with the contents of the build process.
+#>
+ [CmdletBinding()]
+ Param (
+ [String]$PublicPath,
+ $Ps1xmlFilePath
+ )
+ process {
+ # Get all of the xml nodes from the format files
+ $nodes = @(Get-ContentFromFormatFilesInPath $PublicPath)
+
+ Set-Content -Path $Ps1xmlFilePath -Value $nodes.OuterXml -Force
+ }
+}
\ No newline at end of file
diff --git a/Modules/.build/Join-PSM1FromFiles.ps1 b/Modules/.build/Join-PSM1FromFiles.ps1
new file mode 100644
index 0000000..f0f87b2
--- /dev/null
+++ b/Modules/.build/Join-PSM1FromFiles.ps1
@@ -0,0 +1,41 @@
+Function Join-PSM1FromFiles {
+<#
+.SYNOPSIS
+ Uses Get-ContentFromFilesInPath to join the file contents appropriately
+
+.DESCRIPTION
+ Collects all content from valid files for inclusion into the PSM1
+ * Skips test files
+ * Adds a header line saying where the file came from during compile
+ The reason for these files to match their names is about process, not a functional concern.
+
+.EXAMPLE
+ Join-PSM1FromFiles .\Alkami.PowerShell.IIS\Public\ .\Alkami.PowerShell.IIS\Private\ .\Alkami.PowerShell.IIS\Alkami.PowerShell.IIS.psm1
+
+.PARAMETER PublicPath
+ The name of the folder to examine all files under.
+
+.PARAMETER PrivatePath
+ The name of the folder to examine all files under. Tested for existence, may not be valid.
+
+.PARAMETER Psm1FilePath
+ The file to overwrite with the contents of the build process.
+#>
+ [CmdletBinding()]
+ Param (
+ [String]$PublicPath,
+ [String]$PrivatePath,
+ $Psm1FilePath
+ )
+ process {
+ ## Put all of the content from the public files in a long array with a file-header
+ $functionLines = @(Get-ContentFromFilesInPath $PublicPath)
+
+ if (Test-Path $PrivatePath) {
+ ## Put all of the content from the private files in the array with a file-header, after the public functions
+ $functionLines += @(Get-ContentFromFilesInPath $PrivatePath)
+ }
+
+ Set-Content -Path $Psm1FilePath -Value $functionLines -Force
+ }
+}
\ No newline at end of file
diff --git a/Modules/.build/Load-Includes.ps1 b/Modules/.build/Load-Includes.ps1
new file mode 100644
index 0000000..f94e0d4
--- /dev/null
+++ b/Modules/.build/Load-Includes.ps1
@@ -0,0 +1,24 @@
+## This file tries to load functions if they haven't been loaded into scope yet.
+## We know if things are in the scope if the magic function Get-ContentFromFilesInPath has been loaded into the current session scope
+## The way that this is loaded is from this file or manually
+## This file was chosen because it doesn't live in any module for building the powershell modules
+
+## The use of this file is as such:
+## . $PSScriptRoot\.build\Load-Includes.ps1
+## The use-case of this file and the unusual style is that this is just to include things for execution in the build system
+## This is not a typical style of how to load things. This is a micro-op.
+$script:buildFilesLoaded = $script:buildFilesLoaded -or ($null -ne ${function:Get-ContentFromFilesInPath})
+
+if (!($script:buildFilesLoaded)) {
+ $buildScriptsFolder = $PSScriptRoot
+
+ if (Test-Path $buildScriptsFolder) {
+ $buildScripts = (Get-ChildItem *.ps1 -Path $buildScriptsFolder -Exclude "Load-Includes.ps1" -Recurse)
+ foreach($script in $buildScripts) {
+ Write-Verbose "[Clean-Project] - Including the build files: $script"
+ . $script.FullName
+ }
+
+ $script:buildFilesLoaded = $true
+ }
+}
\ No newline at end of file
diff --git a/Modules/.build/Test-FunctionNames.ps1 b/Modules/.build/Test-FunctionNames.ps1
new file mode 100644
index 0000000..9486526
--- /dev/null
+++ b/Modules/.build/Test-FunctionNames.ps1
@@ -0,0 +1,86 @@
+Function Test-FunctionNames {
+<#
+.SYNOPSIS
+ Test that the name of the function/filter/workflow in the file match the name of the file and for verb match
+
+.DESCRIPTION
+ Test that the name of the function/filter/workflow in the file match the name of the file and for verb match
+ * This will look in public/private folders
+ * This will examine for verb match to Get-Verb as part of the testing
+ * This ignores any names that end with .tests.ps1 or .test.ps1
+ * This will check .tests?.ps1 files that they start with a reference to Load-PesterModules
+ * If they don't, it will emit a warning per file
+ The reason for these files to match their names is about process, not a functional concern.
+
+.EXAMPLE
+ Test-FunctionNames .\Alkami.PowerShell.IIS\
+
+.PARAMETER FolderPath
+ The name of the folder to examine all files under
+#>
+ [CmdletBinding()]
+ Param (
+ [String]$FolderPath
+ )
+ process {
+ $verbs = Get-Verb | Select-Object -ExpandProperty Verb
+
+ $badMatch = @()
+ $files = (Get-ChildItem (Join-Path $FolderPath "*.ps1"))
+ foreach($file in $files) {
+ if ($file.FullName -match '(scratch|bak)') {
+ Write-Verbose "Skipping $($file.FullName) for (scratch|bak)"
+ continue
+ }
+ $content = (Get-Content $file)
+ if ($file -match '\.Tests\.ps1' -or $file -match '\.Test\.ps1') {
+ $firstLine = $content[0]
+ if (([string]::IsNullOrWhitespace($firstLine) -or $firstLine -notmatch 'PesterModules') -and $firstline -notmatch "param") {
+ Write-Warning "[$file] does not start with load-pestermodules or parameter declarations. You're gonna have a bad time."
+ } elseif ($firstLine -match "param" -and ($null -eq ($content | Where-Object {$_ -match "PesterModules"}))) {
+ Write-Warning "[$file] has a parameter declaration but does not include load-pestermodules. You're gonna have a bad time."
+ }
+
+ Write-Verbose "skipping testing of test $file"
+ } else {
+ $functionCandidate = $file.BaseName
+ $keywordStartsLine = 0
+
+ $passing = $false
+ foreach($line in $content) {
+ ## Make sure the line doesn't start with whitespace and that people didn't put a bunch of spaces between 'keyword' and 'function-name'
+ $line = ($line -replace '\s+',' ' -replace '^function','' -replace '^filter','' -replace '^workflow','').Trim()
+
+ if ($line.StartsWith($functionCandidate, [StringComparison]::InvariantCultureIgnoreCase)) {
+ $passing = $true
+ ## we found the line we want
+ break
+ }
+
+ $keywordStartsLine += 1
+ }
+
+ # Make sure that the function name uses a good verb-naming pattern
+ $verb = $functionCandidate.Split("-")[0]
+
+ if($verbs -notcontains $verb) {
+ $passing = $false
+ Write-Warning "Function $functionCandidate (current verb: [$verb]) does not use a valid PowerShell verb. See: Get-Verb"
+ }
+
+ if ($keywordStartsLine -gt 1) {
+ Write-Verbose "The file [$file] doesn't start with the keyword and function-name"
+ }
+
+ if (!$passing) {
+ $badMatch += $functionCandidate
+ }
+ }
+ }
+
+ if ($badMatch.Length -gt 0) {
+ $badMatch
+ throw "The previous files failed their test constraints"
+ }
+ }
+}
\ No newline at end of file
diff --git a/Modules/.build/Test-IsTeamCityProcess.ps1 b/Modules/.build/Test-IsTeamCityProcess.ps1
new file mode 100644
index 0000000..0b18a17
--- /dev/null
+++ b/Modules/.build/Test-IsTeamCityProcess.ps1
@@ -0,0 +1,23 @@
+Function Test-IsTeamCityProcess {
+<#
+.SYNOPSIS
+ Determines if the Current Process is a TeamCity Agent Process
+
+.NOTES
+ Will not work except when running in a direct agent process. Checks for TeamCity agent environment
+ variables to exist.
+#>
+## Duplicates some of Alkami.DevOps.Common\Public\Test-IsTeamCityProcess.ps1
+ [CmdletBinding()]
+ [OutputType([System.Boolean])]
+ Param()
+
+ $teamCityJREVariable = $ENV:TEAMCITY_JRE
+
+ if ([String]::IsNullOrEmpty($teamCityJREVariable)) {
+ Write-Verbose "[Test-IsTeamCityProcess] TEAMCITY_JRE User Environment Variable Not Found"
+ return $false
+ }
+
+ return $true
+}
\ No newline at end of file
diff --git a/Modules/.build/Test-ModuleInclusion.ps1 b/Modules/.build/Test-ModuleInclusion.ps1
new file mode 100644
index 0000000..dc25730
--- /dev/null
+++ b/Modules/.build/Test-ModuleInclusion.ps1
@@ -0,0 +1,473 @@
+Function Test-ModuleInclusion {
+<#
+.SYNOPSIS
+ Checks all the modules in the solution to see if there are obvious circular dependencies or if the module includes a higher-order include
+
+.EXAMPLE
+ Test-ModuleInclusion .\
+
+.PARAMETER FolderPath
+ The name of the folder to examine all files under.
+
+.PARAMETER FailBuild
+ Throws if this is true
+
+.PARAMETER ShowObviousCycles
+ Show obvious cycles. This is not as important as higher-order inclusion on lower-order modules
+#>
+ [CmdletBinding()]
+ Param (
+ [string]$FolderPath,
+ [switch]$FailBuild,
+ [switch]$ShowObviousCycles
+ )
+
+$cSharpCode = @"
+using System;
+using System.IO;
+using System.Collections.Generic;
+using System.Linq;
+using System.Diagnostics;
+using System.Text.RegularExpressions;
+using System.Collections;
+
+
+ public static class ModuleChecker {
+ #region static decls
+ static string[] verbs = new[] { "Add", "Approve", "Assert", "Backup", "Block", "Checkpoint", "Clear", "Close", "Compare", "Complete", "Compress", "Confirm", "Connect", "Convert", "ConvertFrom", "ConvertTo", "Copy", "Debug", "Deny", "Disable", "Disconnect", "Dismount", "Edit", "Enable", "Enter", "Exit", "Expand", "Export", "Find", "Format", "Get", "Grant", "Group", "Hide", "Import", "Initialize", "Install", "Invoke", "Join", "Limit", "Lock", "Measure", "Merge", "Mount", "Move", "New", "Open", "Optimize", "Out", "Ping", "Pop", "Protect", "Publish", "Push", "Read", "Receive", "Redo", "Register", "Remove", "Rename", "Repair", "Request", "Reset", "Resize", "Resolve", "Restart", "Restore", "Resume", "Revoke", "Save", "Search", "Select", "Send", "Set", "Show", "Skip", "Split", "Start", "Step", "Stop", "Submit", "Suspend", "Switch", "Sync", "Test", "Trace", "Unblock", "Undo", "Uninstall", "Unlock", "Unprotect", "Unpublish", "Unregister", "Update", "Use", "Wait", "Watch", "Write" };
+
+ static string[] webAdministrationFunctions = new[] { "Add-WebConfiguration", "Add-WebConfigurationLock", "Add-WebConfigurationProperty", "Backup-WebConfiguration", "Clear-WebCentralCertProvider", "Clear-WebConfiguration", "Clear-WebRequestTracingSetting", "Clear-WebRequestTracingSettings", "ConvertTo-WebApplication", "Disable-WebCentralCertProvider", "Disable-WebGlobalModule", "Disable-WebRequestTracing", "Enable-WebCentralCertProvider", "Enable-WebGlobalModule", "Enable-WebRequestTracing", "Get-WebAppDomain", "Get-WebApplication", "Get-WebAppPoolState", "Get-WebBinding", "Get-WebCentralCertProvider", "Get-WebConfigFile", "Get-WebConfiguration", "Get-WebConfigurationBackup", "Get-WebConfigurationLocation", "Get-WebConfigurationLock", "Get-WebConfigurationProperty", "Get-WebFilePath", "Get-WebGlobalModule", "Get-WebHandler", "Get-WebItemState", "Get-WebManagedModule", "Get-WebRequest", "Get-Website", "Get-WebsiteState", "Get-WebURL", "Get-WebVirtualDirectory", "New-WebApplication", "New-WebAppPool", "New-WebBinding", "New-WebFtpSite", "New-WebGlobalModule", "New-WebHandler", "New-WebManagedModule", "New-Website", "New-WebVirtualDirectory", "Remove-WebApplication", "Remove-WebAppPool", "Remove-WebBinding", "Remove-WebConfigurationBackup", "Remove-WebConfigurationLocation", "Remove-WebConfigurationLock", "Remove-WebConfigurationProperty", "Remove-WebGlobalModule", "Remove-WebHandler", "Remove-WebManagedModule", "Remove-Website", "Remove-WebVirtualDirectory", "Rename-WebConfigurationLocation", "Restart-WebAppPool", "Restart-WebItem", "Restore-WebConfiguration", "Select-WebConfiguration", "Set-WebBinding", "Set-WebCentralCertProvider", "Set-WebCentralCertProviderCredential", "Set-WebConfiguration", "Set-WebConfigurationProperty", "Set-WebGlobalModule", "Set-WebHandler", "Set-WebManagedModule", "Start-WebAppPool", "Start-WebCommitDelay", "Start-WebItem", "Start-Website", "Stop-WebAppPool", "Stop-WebCommitDelay", "Stop-WebItem", "Stop-Website" };
+
+ static string[] carbonFunctions = new[] { "Add-CGroupMember", "Add-CIisDefaultDocument", "Add-CTrustedHost", "Add-GroupMember", "Add-GroupMembers", "Add-IisDefaultDocument", "Add-TrustedHost", "Add-TrustedHosts", "Assert-AdminPrivilege", "Assert-AdminPrivileges", "Assert-CAdminPrivilege", "Assert-CFirewallConfigurable", "Assert-CService", "Assert-FirewallConfigurable", "Assert-Service", "Clear-CDscLocalResourceCache", "Clear-CMofAuthoringMetadata", "Clear-CTrustedHost", "Clear-DscLocalResourceCache", "Clear-MofAuthoringMetadata", "Clear-TrustedHost", "Clear-TrustedHosts", "Complete-CJob", "Complete-Job", "Complete-Jobs", "Compress-CItem", "Compress-Item", "Convert-CSecureStringToString", "Convert-CXmlFile", "Convert-SecureStringToString", "Convert-XmlFile", "ConvertFrom-Base64", "ConvertFrom-CBase64", "ConvertTo-Base64", "ConvertTo-CBase64", "ConvertTo-CContainerInheritanceFlags", "ConvertTo-CInheritanceFlag", "ConvertTo-CPropagationFlag", "ConvertTo-CSecurityIdentifier", "ConvertTo-ContainerInheritanceFlags", "ConvertTo-FullPath", "ConvertTo-InheritanceFlag", "ConvertTo-InheritanceFlags", "ConvertTo-PropagationFlag", "ConvertTo-PropagationFlags", "ConvertTo-SecurityIdentifier", "Copy-CDscResource", "Copy-DscResource", "Disable-AclInheritance", "Disable-CAclInheritance", "Disable-CFirewallStatefulFtp", "Disable-CIEEnhancedSecurityConfiguration", "Disable-CIisSecurityAuthentication", "Disable-CNtfsCompression", "Disable-FirewallStatefulFtp", "Disable-IEEnhancedSecurityConfiguration", "Disable-IisSecurityAuthentication", "Disable-NtfsCompression", "Enable-AclInheritance", "Enable-CAclInheritance", "Enable-CFirewallStatefulFtp", "Enable-CIEActivationPermission", "Enable-CIisDirectoryBrowsing", "Enable-CIisSecurityAuthentication", "Enable-CIisSsl", "Enable-CNtfsCompression", "Enable-FirewallStatefulFtp", "Enable-IEActivationPermission", "Enable-IEActivationPermissions", "Enable-IisDirectoryBrowsing", "Enable-IisSecurityAuthentication", "Enable-IisSsl", "Enable-NtfsCompression", "Expand-CItem", "Expand-Item", "Find-ADUser", "Find-CADUser", "Format-ADSearchFilterValue", "Format-ADSpecialCharacters", "Format-CADSearchFilterValue", "Get-ADDomainController", "Get-CADDomainController", "Get-CCertificate", "Get-CCertificateStore", "Get-CComPermission", "Get-CComSecurityDescriptor", "Get-CDscError", "Get-CDscWinEvent", "Get-CFileShare", "Get-CFileSharePermission", "Get-CFirewallRule", "Get-CGroup", "Get-CHttpUrlAcl", "Get-CIPAddress", "Get-CIisAppPool", "Get-CIisApplication", "Get-CIisConfigurationSection", "Get-CIisHttpHeader", "Get-CIisHttpRedirect", "Get-CIisMimeMap", "Get-CIisSecurityAuthentication", "Get-CIisVersion", "Get-CIisWebsite", "Get-CMsi", "Get-CMsmqMessageQueue", "Get-CMsmqMessageQueuePath", "Get-CPathProvider", "Get-CPathToHostsFile", "Get-CPerformanceCounter", "Get-CPermission", "Get-CPowerShellModuleInstallPath", "Get-CPowershellPath", "Get-CPrivilege", "Get-CProgramInstallInfo", "Get-CRegistryKeyValue", "Get-CScheduledTask", "Get-CServiceAcl", "Get-CServiceConfiguration", "Get-CServicePermission", "Get-CServiceSecurityDescriptor", "Get-CSslCertificateBinding", "Get-CTrustedHost", "Get-CUser", "Get-CWmiLocalUserAccount", "Get-Certificate", "Get-CertificateStore", "Get-ComPermission", "Get-ComPermissions", "Get-ComSecurityDescriptor", "Get-DscError", "Get-DscWinEvent", "Get-FileShare", "Get-FileSharePermission", "Get-FirewallRule", "Get-FirewallRules", "Get-Group", "Get-HttpUrlAcl", "Get-IPAddress", "Get-IisAppPool", "Get-IisApplication", "Get-IisConfigurationSection", "Get-IisHttpHeader", "Get-IisHttpRedirect", "Get-IisMimeMap", "Get-IisSecurityAuthentication", "Get-IisVersion", "Get-IisWebsite", "Get-Msi", "Get-MsmqMessageQueue", "Get-MsmqMessageQueuePath", "Get-PathCanonicalCase", "Get-PathProvider", "Get-PathToHostsFile", "Get-PerformanceCounter", "Get-PerformanceCounters", "Get-Permission", "Get-Permissions", "Get-PowerShellModuleInstallPath", "Get-PowershellPath", "Get-Privilege", "Get-Privileges", "Get-ProgramInstallInfo", "Get-RegistryKeyValue", "Get-ScheduledTask", "Get-ServiceAcl", "Get-ServiceConfiguration", "Get-ServicePermission", "Get-ServicePermissions", "Get-ServiceSecurityDescriptor", "Get-SslCertificateBinding", "Get-SslCertificateBindings", "Get-TrustedHost", "Get-TrustedHosts", "Get-User", "Get-WmiLocalUserAccount", "Grant-CComPermission", "Grant-CHttpUrlPermission", "Grant-CMsmqMessageQueuePermission", "Grant-CPermission", "Grant-CPrivilege", "Grant-CServiceControlPermission", "Grant-CServicePermission", "Grant-ComPermission", "Grant-ComPermissions", "Grant-HttpUrlPermission", "Grant-MsmqMessageQueuePermission", "Grant-MsmqMessageQueuePermissions", "Grant-Permission", "Grant-Permissions", "Grant-Privilege", "Grant-ServiceControlPermission", "Grant-ServicePermission", "Initialize-CLcm", "Initialize-Lcm", "Install-CCertificate", "Install-CDirectory", "Install-CFileShare", "Install-CGroup", "Install-CIisAppPool", "Install-CIisApplication", "Install-CIisVirtualDirectory", "Install-CIisWebsite", "Install-CJunction", "Install-CMsi", "Install-CMsmq", "Install-CMsmqMessageQueue", "Install-CPerformanceCounter", "Install-CRegistryKey", "Install-CScheduledTask", "Install-CService", "Install-CUser", "Install-Certificate", "Install-Directory", "Install-FileShare", "Install-Group", "Install-IisAppPool", "Install-IisApplication", "Install-IisVirtualDirectory", "Install-IisWebsite", "Install-Junction", "Install-Msi", "Install-Msmq", "Install-MsmqMessageQueue", "Install-PerformanceCounter", "Install-RegistryKey", "Install-ScheduledTask", "Install-Service", "Install-SmbShare", "Install-User", "Invoke-AppCmd", "Invoke-CAppCmd", "Invoke-CPowerShell", "Invoke-PowerShell", "Invoke-WindowsInstaller", "Join-CIisVirtualPath", "Join-IisVirtualPath", "Lock-CIisConfigurationSection", "Lock-IisConfigurationSection", "New-CCredential", "New-CJunction", "New-CRsaKeyPair", "New-CTempDirectory", "New-Credential", "New-Junction", "New-RsaKeyPair", "New-TempDir", "New-TempDirectory", "Protect-Acl", "Protect-CString", "Protect-String", "Read-CFile", "Read-File", "Remove-CDotNetAppSetting", "Remove-CEnvironmentVariable", "Remove-CGroupMember", "Remove-CHostsEntry", "Remove-CIisMimeMap", "Remove-CIniEntry", "Remove-CJunction", "Remove-CRegistryKeyValue", "Remove-CSslCertificateBinding", "Remove-Certificate", "Remove-DotNetAppSetting", "Remove-EnvironmentVariable", "Remove-GroupMember", "Remove-HostsEntry", "Remove-IisMimeMap", "Remove-IisWebsite", "Remove-IniEntry", "Remove-Junction", "Remove-MsmqMessageQueue", "Remove-RegistryKeyValue", "Remove-Service", "Remove-SslCertificateBinding", "Remove-User", "Reset-CHostsFile", "Reset-CMsmqQueueManagerID", "Reset-HostsFile", "Reset-MsmqQueueManagerID", "Resolve-CFullPath", "Resolve-CIdentity", "Resolve-CIdentityName", "Resolve-CNetPath", "Resolve-CPathCase", "Resolve-CRelativePath", "Resolve-FullPath", "Resolve-Identity", "Resolve-IdentityName", "Resolve-NetPath", "Resolve-PathCase", "Resolve-RelativePath", "Restart-CRemoteService", "Restart-RemoteService", "Revoke-CComPermission", "Revoke-CHttpUrlPermission", "Revoke-CPermission", "Revoke-CPrivilege", "Revoke-CServicePermission", "Revoke-ComPermission", "Revoke-ComPermissions", "Revoke-HttpUrlPermission", "Revoke-Permission", "Revoke-Privilege", "Revoke-ServicePermission", "Set-CDotNetAppSetting", "Set-CDotNetConnectionString", "Set-CEnvironmentVariable", "Set-CHostsEntry", "Set-CIisHttpHeader", "Set-CIisHttpRedirect", "Set-CIisMimeMap", "Set-CIisWebsiteID", "Set-CIisWebsiteSslCertificate", "Set-CIisWindowsAuthentication", "Set-CIniEntry", "Set-CRegistryKeyValue", "Set-CServiceAcl", "Set-CSslCertificateBinding", "Set-CTrustedHost", "Set-DotNetAppSetting", "Set-DotNetConnectionString", "Set-EnvironmentVariable", "Set-HostsEntry", "Set-IisHttpHeader", "Set-IisHttpRedirect", "Set-IisMimeMap", "Set-IisWebsiteID", "Set-IisWebsiteSslCertificate", "Set-IisWindowsAuthentication", "Set-IniEntry", "Set-RegistryKeyValue", "Set-ServiceAcl", "Set-SslCertificateBinding", "Set-TrustedHost", "Set-TrustedHosts", "Split-CIni", "Split-Ini", "Start-CDscPullConfiguration", "Start-DscPullConfiguration", "Test-AdminPrivilege", "Test-AdminPrivileges", "Test-CAdminPrivilege", "Test-CDotNet", "Test-CDscTargetResource", "Test-CFileShare", "Test-CFirewallStatefulFtp", "Test-CGroup", "Test-CGroupMember", "Test-CIPAddress", "Test-CIdentity", "Test-CIisAppPool", "Test-CIisConfigurationSection", "Test-CIisSecurityAuthentication", "Test-CIisWebsite", "Test-CMsmqMessageQueue", "Test-CNtfsCompression", "Test-COSIs32Bit", "Test-COSIs64Bit", "Test-CPathIsJunction", "Test-CPerformanceCounter", "Test-CPerformanceCounterCategory", "Test-CPermission", "Test-CPowerShellIs32Bit", "Test-CPowerShellIs64Bit", "Test-CPrivilege", "Test-CRegistryKeyValue", "Test-CScheduledTask", "Test-CService", "Test-CSslCertificateBinding", "Test-CTypeDataMember", "Test-CUncPath", "Test-CUser", "Test-CWindowsFeature", "Test-CZipFile", "Test-DotNet", "Test-DscTargetResource", "Test-FileShare", "Test-FirewallStatefulFtp", "Test-Group", "Test-GroupMember", "Test-IPAddress", "Test-Identity", "Test-IisAppPool", "Test-IisAppPoolExists", "Test-IisConfigurationSection", "Test-IisSecurityAuthentication", "Test-IisWebsite", "Test-IisWebsiteExists", "Test-MsmqMessageQueue", "Test-NtfsCompression", "Test-OSIs32Bit", "Test-OSIs64Bit", "Test-PathIsJunction", "Test-PerformanceCounter", "Test-PerformanceCounterCategory", "Test-Permission", "Test-PowerShellIs32Bit", "Test-PowerShellIs64Bit", "Test-Privilege", "Test-RegistryKeyValue", "Test-ScheduledTask", "Test-Service", "Test-SslCertificateBinding", "Test-TypeDataMember", "Test-UncPath", "Test-User", "Test-WindowsFeature", "Test-ZipFile", "Uninstall-CCertificate", "Uninstall-CDirectory", "Uninstall-CFileShare", "Uninstall-CGroup", "Uninstall-CIisAppPool", "Uninstall-CIisWebsite", "Uninstall-CJunction", "Uninstall-CMsmqMessageQueue", "Uninstall-CPerformanceCounterCategory", "Uninstall-CScheduledTask", "Uninstall-CService", "Uninstall-CUser", "Uninstall-Certificate", "Uninstall-Directory", "Uninstall-FileShare", "Uninstall-Group", "Uninstall-IisAppPool", "Uninstall-IisWebsite", "Uninstall-Junction", "Uninstall-MsmqMessageQueue", "Uninstall-PerformanceCounterCategory", "Uninstall-ScheduledTask", "Uninstall-Service", "Uninstall-User", "Unlock-CIisConfigurationSection", "Unlock-IisConfigurationSection", "Unprotect-AclAccessRules", "Unprotect-CString", "Unprotect-String", "Write-CDscError", "Write-CFile", "Write-DscError", "Write-File" };
+
+ static string[] windowsFunctions = new[] { "Add-LocalGroupMember", "Add-Member", "Add-Type", "Add-WindowsFeature", "Clear-Host", "Close-SmbOpenFile", "Compare-Object", "Compress-Certificates", "Convert-Path", "ConvertFrom-Json", "ConvertTo-JSON", "ConvertTo-Json", "ConvertTo-SecureString", "ConvertTo-Xml", "Copy-Item", "Enable-WindowsOptionalFeature", "Enter-PSSession", "Exit-PSSession", "Export-PfxCertificate", "Format-List", "Format-Table", "Get-ADComputer", "Get-ADGroupMember", "Get-ADUser", "Get-Acl", "Get-CIMInstance", "Get-ChildItem", "Get-CimInstance", "Get-Content", "Get-Date", "Get-FileHash", "Get-Host", "Get-IISAppPool", "Get-IISSite", "Get-Item", "Get-ItemProperty", "Get-ItemPropertyValue", "Get-LocalGroupMember", "Get-Location", "Get-Member", "Get-Module", "Get-NetIPAddress", "Get-NetTCPConnection", "Get-PSCallStack", "Get-Process", "Get-Random", "Get-Service", "Get-SmbOpenFile", "Get-TimeZone", "Get-Unique", "Get-Variable", "Get-WinEvent", "Get-WindowsFeature", "Get-Childitem", "Group-Object", "Import-Certificate", "Import-Csv", "Import-Module", "Import-PfxCertificate", "Install-ADServiceAccount", "Install-WindowsFeature", "Invoke-Command", "Invoke-Expression", "Invoke-Pester", "Invoke-RestMethod", "Invoke-WebRequest", "Join-Path", "Join-path", "Move-Item", "New-EventLog", "New-Item", "New-ItemProperty", "New-Object", "New-PSDrive", "New-PSSession", "New-ScheduledTaskAction", "New-ScheduledTaskTrigger", "New-Service", "New-TimeSpan", "New-Variable", "New-WebServiceProxy", "Out-File", "Out-Null", "Out-String", "Out-null", "Pop-Location", "Push-Location", "Read-Host", "Receive-Job", "Register-ScheduledTask", "Remove-Item", "Remove-PSDrive", "Remove-PSSession", "Remove-item", "Rename-Item", "Resolve-DnsName", "Resolve-Path", "Select-Object", "Select-Xml", "Select-object", "Send-MailMessage", "Set-Acl", "Set-Alias", "Set-Content", "Set-ExecutionPolicy", "Set-ItemProperty", "Set-Location", "Set-Service", "Set-Variable", "Split-Path", "Start-Job", "Start-Process", "Start-Service", "Start-Sleep", "Stop-Process", "Stop-Service", "Test-ADServiceAccount", "Test-NetConnection", "Test-Path", "Wait-Job", "Write-Debug", "Write-Error", "Write-Host", "Write-Information", "Write-Output", "Write-Verbose", "Write-Warning" ,"Restart-Computer","Restart-Service","Invoke-Item","Get-Counter","ConvertTo-Html","New-Guid"};
+
+ static string[] awsFunctions = new[] { "Remove-S3Object", "Write-S3Object", "Get-S3Object", "Copy-S3Object", "Copy-ServiceFabricApplicationPackage", "New-ASLaunchConfiguration", "Set-ASInstanceHealth", "Get-EC2Image", "Get-EC2Instance", "Get-EC2NetworkInterface", "Get-ELB2LoadBalancer", "Get-ELB2TargetGroup", "Get-ElbHealthcheckEndpoints", "Enter-ASStandby", "Exit-ASStandby", "Get-ASAutoScalingGroup", "Get-ASAutoScalingInstance", "Get-ASInstanceHealth", "Get-ASLaunchConfiguration", "Get-SSMParameter" ,"Read-S3Object","Get-ELB2TargetHealth"};
+
+ static string[] serviceFabricFunctions = new[] { "Get-ServiceFabricApplication", "Get-ServiceFabricApplicationType", "Get-ServiceFabricApplicationUpgrade", "Get-ServiceFabricClusterConfiguration", "Get-ServiceFabricClusterConfigurationUpgradeStatus", "Get-ServiceFabricDeployedApplication", "Get-ServiceFabricDeployedCodePackage", "Get-ServiceFabricNode", "Get-ServiceFabricPartition", "Get-ServiceFabricRegisteredClusterCodeVersion", "Get-ServiceFabricService", "Start-ServiceFabricApplicationUpgrade", "Start-ServiceFabricClusterConfigurationUpgrade", "Start-ServiceFabricClusterUpgrade", "Unregister-ServiceFabricApplicationType", "Update-ServiceFabricService", "Remove-ServiceFabricApplication", "Remove-ServiceFabricCluster", "Register-ServiceFabricApplicationType", "Restart-ServiceFabricDeployedCodePackage", "New-ServiceFabricApplication", "New-ServiceFabricNodeConfiguration", "Connect-ServiceFabricCluster","Invoke-MinimizeEnvironment" };
+
+ static string[] cloudbase_powershell_yaml = new[] { "ConvertFrom-Yaml", "ConvertTo-Yaml" };
+
+ static string[] redisFunctions = new[] { "Invoke-RedisScript","Add-RedisKey" };
+
+ ///
+ /// Most of these fell out of comments because of the matching on verb-word
+ ///
+ static string[] ignoreRandomPhrases = new[] { "add-capability", "install-to", "install-type", "Install-To", "Write-out", "read-only", "test-case", "out-of", "use-case", "use-case", "out-of", "Request-Monitor", "read-to", "read-from", "read-only", "Select-Alkami",/*Used as demo files when generating a module.*/"Get-HelloWorld", "Get-HelloWorldInternal", /*Two aliases that are harder to revert than I expected*/"New-AlkamiWebAppPool", "Get-AlkamiWebAppPool","new-relic","open-block","exit-condition" };
+
+ static List excludeNames = new List { "VariableDeclarations", "ConfigurationValues" };
+
+ const string CarbonModuleName = "Carbon";
+ const string WebAdministrationModuleName = "WebAdministration";
+ const string WindowsModuleName = "Generic Windows";
+ const string AwsModuleName = "AWS";
+ const string CloudbaseModuleName = "Cloudbase";
+ const string RedisModuleName = "Redis";
+ const string ServiceFabricModuleName = "ServiceFabric";
+ const string IgnoredFunctionsModuleName = "ignore anything with this module name";
+ #endregion static decls
+
+
+ public static IEnumerable GetFunctionDefinitionsFromModule(DirectoryInfo directoryInfo, ModuleDefinition module, Regex regex) {
+ var functionDefinitions = new List();
+
+ var publicFiles = directoryInfo.GetDirectories("Public").FirstOrDefault();
+
+ if (publicFiles != null) {
+ foreach (var file in publicFiles.GetFiles("*.ps1")) {
+ var filename = Path.GetFileNameWithoutExtension(file.FullName);
+ if (
+ !(filename.EndsWith(".tests", StringComparison.InvariantCultureIgnoreCase) || filename.EndsWith(".test", StringComparison.InvariantCultureIgnoreCase))
+ && filename.IndexOf("-") > 1
+ && verbs.Contains(filename.Split('-')[0])
+ ) {
+ var referencedFunctions = regex.Matches(File.ReadAllText(file.FullName)).OfType().SelectMany(m => m.Captures.OfType().ToArray().Select(x => x.Value)).Distinct().Where(x => x.ToLowerInvariant() != filename.ToLowerInvariant());
+ var functionDefinition = new ModuleFunctionDefinition(filename, module, referencedFunctions);
+ functionDefinitions.Add(functionDefinition);
+ } else {
+ if (
+ !(filename.EndsWith(".tests", StringComparison.InvariantCultureIgnoreCase) || filename.EndsWith(".test", StringComparison.InvariantCultureIgnoreCase))
+ && !excludeNames.Contains(filename)
+ ) {
+ Console.WriteLine(string.Format("Did not match [{0}] as a valid filename.", file.Name));
+ }
+ }
+ }
+ }
+
+ var privateFiles = directoryInfo.GetDirectories("Private").FirstOrDefault();
+
+ if (privateFiles != null) {
+ foreach (var file in privateFiles.GetFiles("*.ps1")) {
+ var filename = Path.GetFileNameWithoutExtension(file.FullName);
+ if (
+ !(filename.EndsWith(".tests", StringComparison.InvariantCultureIgnoreCase) || filename.EndsWith(".test", StringComparison.InvariantCultureIgnoreCase))
+ && filename.IndexOf("-") > 1
+ && verbs.Contains(filename.Split('-')[0])) {
+ var referencedFunctions = regex.Matches(File.ReadAllText(file.FullName)).OfType().SelectMany(m => m.Captures.OfType().ToArray().Select(x => x.Value)).Distinct();
+ var functionDefinition = new ModuleFunctionDefinition(filename, module, referencedFunctions) { IsPrivate = true };
+ functionDefinitions.Add(functionDefinition);
+ } else {
+ if (
+ !(filename.EndsWith(".tests", StringComparison.InvariantCultureIgnoreCase) || filename.EndsWith(".test", StringComparison.InvariantCultureIgnoreCase))
+ && !excludeNames.Contains(filename)
+ ) {
+ Console.WriteLine(string.Format("Did not match [{0}] as a valid filename.", file.Name));
+ }
+ }
+ }
+ }
+
+ return functionDefinitions;
+ }
+
+
+ public static bool DoIt(string searchPath, bool showObviousCycles = false) {
+ var matchVerbsString = string.Join("|", verbs);
+ var matchString = string.Format("({0})-\\w+",matchVerbsString);
+ var regex = new Regex(matchString, RegexOptions.ExplicitCapture | RegexOptions.IgnoreCase);
+
+ var allFoundFunctions = new List();
+ var allModules = new List();
+
+ #region pre-load ServiceFabric for reference
+ var sf = new ModuleDefinition(ServiceFabricModuleName) { IsStub = true, IsBase = true };
+ foreach (var function in serviceFabricFunctions) {
+ var fd = new ModuleFunctionDefinition(function, sf);
+ sf.AddFunction(fd);
+ allFoundFunctions.Add(fd);
+ }
+ #endregion pre-load Carbon for reference
+
+ #region pre-load AWS for reference
+ var aws = new ModuleDefinition(AwsModuleName) { IsStub = true, IsBase = true };
+ foreach (var function in awsFunctions) {
+ var fd = new ModuleFunctionDefinition(function, aws);
+ aws.AddFunction(fd);
+ allFoundFunctions.Add(fd);
+ }
+ #endregion pre-load AWS for reference
+
+ #region pre-load Carbon for reference
+ var carbon = new ModuleDefinition(CarbonModuleName) { IsBase = true };
+ foreach (var function in carbonFunctions) {
+ var fd = new ModuleFunctionDefinition(function, carbon);
+ carbon.AddFunction(fd);
+ allFoundFunctions.Add(fd);
+ }
+ #endregion pre-load Carbon for reference
+
+ #region pre-load WebAdministration for reference
+ var webAdministration = new ModuleDefinition(WebAdministrationModuleName) { IsBase = true };
+ foreach (var function in webAdministrationFunctions) {
+ var fd = new ModuleFunctionDefinition(function, webAdministration);
+ webAdministration.AddFunction(fd);
+ allFoundFunctions.Add(fd);
+ }
+ #endregion pre-load WebAdministration for reference
+
+ #region pre-load Windows for reference
+ var windows = new ModuleDefinition(WindowsModuleName) { IsStub = true, IsBase = true };
+ foreach (var function in windowsFunctions) {
+ var fd = new ModuleFunctionDefinition(function, windows);
+ windows.AddFunction(fd);
+ allFoundFunctions.Add(fd);
+ }
+ #endregion pre-load Windows for reference
+
+ #region pre-load ignored functions for reference
+ var ignored = new ModuleDefinition(IgnoredFunctionsModuleName) { IsStub = true, IsBase = true };
+ foreach (var function in ignoreRandomPhrases) {
+ var fd = new ModuleFunctionDefinition(function, ignored);
+ ignored.AddFunction(fd);
+ allFoundFunctions.Add(fd);
+ }
+ #endregion pre-load Ignored functions for reference
+
+ #region pre-load cloudbase for reference
+ var cloudbase = new ModuleDefinition(CloudbaseModuleName) { IsStub = true, IsBase = true };
+ foreach (var function in cloudbase_powershell_yaml) {
+ var fd = new ModuleFunctionDefinition(function, cloudbase);
+ windows.AddFunction(fd);
+ allFoundFunctions.Add(fd);
+ }
+ #endregion pre-load cloudbase for reference
+
+ #region pre-load redis for reference
+ var redis = new ModuleDefinition(RedisModuleName) { IsStub = true, IsBase = true };
+ foreach (var function in redisFunctions) {
+ var fd = new ModuleFunctionDefinition(function, redis);
+ aws.AddFunction(fd);
+ allFoundFunctions.Add(fd);
+ }
+ #endregion pre-load redis for reference
+
+ var modules = new DirectoryInfo(searchPath).GetFiles("*.psd1", SearchOption.AllDirectories);
+
+ foreach (var module in modules) {
+ var moduleName = Path.GetFileNameWithoutExtension(module.FullName);
+ var isCsharp = false;
+ if (module.Directory.GetFiles("*.csproj").Any()) {
+ Console.WriteLine(string.Format("Found a CS Project for {0}",moduleName));
+ isCsharp = true;
+ }
+
+ var moduleDefinition = new ModuleDefinition(moduleName) { IsCsharp = isCsharp };
+ var functionDefinitions = GetFunctionDefinitionsFromModule(module.Directory, moduleDefinition, regex);
+ moduleDefinition.AddFunctions(functionDefinitions);
+ allFoundFunctions.AddRange(functionDefinitions);
+
+ allModules.Add(moduleDefinition);
+ }
+
+ // Now that all the modules have been parsed and we have all the function definitions, we need to do something with the functions
+ // Let's parse the functions to find all the places they are used, and see what that web looks like.
+ // So basically, foreach function find modules they need to function.
+ // There may be more than one function defined in a given module, example: Get-Certificate in carbon and alkami.ops.certificates
+
+ Dictionary refDic = new Dictionary();
+ foreach (var function in allFoundFunctions) {
+ if (!refDic.ContainsKey(function.Name.ToLower())) {
+ refDic.Add(function.Name.ToLower(), function);
+ }
+ }
+ foreach (var module in allModules) {
+ foreach (var function in module.ModuleFunctionDefinitions) {
+ foreach (var name in function.ReferencedFunctions) {
+ if (function.ReferencedFunctions.Contains(name) && function.Name != name) {
+ try {
+ refDic[name.ToLower()].Add(function, module);
+ if (!function.MyFunctions.Contains(refDic[name.ToLower()])) {
+ function.MyFunctions.Add(refDic[name.ToLower()]);
+ }
+ } catch (Exception) {
+ Console.WriteLine(string.Format("Could not find a parent-module for {0} in {1}",name,function.Name));
+ }
+ }
+ }
+ }
+ }
+
+ Console.WriteLine("");
+ Console.WriteLine("======================");
+ Console.WriteLine("");
+
+ var shouldThrow = false;
+
+ foreach (var module in allModules.Where(x => !x.IsStub)) {
+ if (module.RelatedModules.Where(x => !x.IsStub).Any()) {
+ Console.WriteLine(string.Format("{0} uses",module.Name));
+ foreach (var subModule in module.RelatedModules.Where(x => !x.IsStub)) {
+ var isWrongOrder = (module.IsBase && !subModule.IsBase);
+ var isObviousCycle = subModule.RelatedModules.Contains(module);
+ if (isWrongOrder) {
+ Console.WriteLine(string.Format(" * {0} - WARNING - SOMETHING REQUIRES A HIGHER-ORDER-FUNCTION", subModule.Name));
+ var exceptions = subModule.ModuleFunctionDefinitions.Where(x => x.ReferencedBy.Where(y => y.Module == module).Any()).Select(x => new { Name = x.Name, Where = x.ReferencedBy.Where(t => t.Module == module).Select(t => t.Name).ToArray() });
+ foreach (var exception in exceptions) {
+ foreach (var functionName in exception.Where) {
+ Console.WriteLine(string.Format(" * !! {0} calls {1}",functionName,exception.Name));
+ }
+ }
+ shouldThrow = true;
+ } else if (isObviousCycle && showObviousCycles) {
+ Console.WriteLine(string.Format(" * {0} - WARNING - OBVIOUS CYCLE",subModule.Name));
+ var exceptions = subModule.ModuleFunctionDefinitions.Where(x => x.ReferencedBy.Where(y => y.Module == module).Any()).Select(x => new { Name = x.Name, Where = x.ReferencedBy.Where(t => t.Module == module).Select(t => t.Name).ToArray() });
+ foreach (var exception in exceptions) {
+ foreach (var functionName in exception.Where) {
+ Console.WriteLine(string.Format(" * !! {0} calls {1}",functionName,exception.Name));
+ }
+ }
+ shouldThrow = true;
+ } else {
+ Console.WriteLine(string.Format(" * {0}",subModule.Name));
+ }
+ }
+ } else {
+ Console.WriteLine(string.Format("{0} doesn't have additional-module usage",module.Name));
+ }
+ Console.WriteLine("");
+ }
+ return (shouldThrow);
+ }
+ }
+
+ public class ModuleDefinition {
+ public string Name { get; set; }
+ public List ModuleFunctionDefinitions { get; set; }
+ public List RelatedModules { get; set; }
+ public int Weight { get; set; }
+ public bool IsCsharp { get; set; }
+ ///
+ /// This flag means we are ignoring this "module" as it is only for comparison purposes
+ ///
+ public bool IsStub { get; set; }
+
+ ///
+ /// This means it's Alkami.PowerShell.* or something like it
+ ///
+ public bool IsBase { get; set; }
+
+ public ModuleDefinition() {
+ ModuleFunctionDefinitions = new List();
+ RelatedModules = new List();
+ Weight = 0;
+ }
+
+ public ModuleDefinition(string name) {
+ Name = name;
+ ModuleFunctionDefinitions = new List();
+ RelatedModules = new List();
+ Weight = 0;
+ IsBase = name.ToLower().StartsWith("Alkami.PowerShell".ToLower());
+ }
+
+ public ModuleDefinition(string name, IEnumerable relatedModuleNames) {
+ Name = name;
+ ModuleFunctionDefinitions = new List();
+ RelatedModules = new List();
+ RelatedModules.AddRange(relatedModuleNames);
+ Weight = 0;
+ IsBase = name.ToLower().StartsWith("Alkami.PowerShell".ToLower());
+ }
+
+ public ModuleDefinition AddFunction(ModuleFunctionDefinition functionDefinition) {
+ ModuleFunctionDefinitions.Add(functionDefinition);
+ return this;
+ }
+
+ public ModuleDefinition AddFunctions(IEnumerable functionDefinitions) {
+ ModuleFunctionDefinitions.AddRange(functionDefinitions);
+ return this;
+ }
+
+ public override bool Equals(Object obj) {
+ //Check for null and compare run-time types.
+ if ((obj == null) || !this.GetType().Equals(obj.GetType())) {
+ return false;
+ } else {
+ ModuleDefinition moduleDefinition = (ModuleDefinition)obj;
+ return Name == moduleDefinition.Name;
+ }
+ }
+
+ public override int GetHashCode() {
+ return Name.GetHashCode();
+ }
+
+ public override string ToString() {
+ return string.Format("ModuleDefintion: {0}",Name);
+ }
+ }
+
+ public class FunctionDefinition {
+ public string Name { get; set; }
+ public ModuleDefinition Module { get; set; }
+ public bool IsPrivate { get; set; }
+
+ public FunctionDefinition() {
+ }
+
+ public FunctionDefinition(string name) {
+ Name = name;
+ }
+
+ public FunctionDefinition(string name, ModuleDefinition module) {
+ Name = name;
+ Module = module;
+ }
+
+ public override bool Equals(Object obj) {
+ //Check for null and compare run-time types.
+ if ((obj == null) || !this.GetType().Equals(obj.GetType())) {
+ return false;
+ } else {
+ FunctionDefinition functionDefinition = (FunctionDefinition)obj;
+ return Name == functionDefinition.Name;
+ }
+ }
+
+ public override int GetHashCode() {
+ return Name.GetHashCode();
+ }
+
+ public override string ToString() {
+ return string.Format("ModuleFunctionDefinition: {0} - ModuleDefinition {1}",Name,Module.Name);
+ }
+ }
+
+ public class ModuleFunctionDefinition : FunctionDefinition {
+ public List ReferencedFunctions { get; set; }
+
+ public ModuleFunctionDefinition() : this(null) { }
+
+ public ModuleFunctionDefinition(string name) : this(name, null) { }
+
+ public ModuleFunctionDefinition(string name, ModuleDefinition module) : this(name, module, null) { }
+
+ public ModuleFunctionDefinition(string name, ModuleDefinition module, IEnumerable referencedFunctions) : base(name, module) {
+ if (string.IsNullOrWhiteSpace(name)) throw new ArgumentNullException("name");
+ if (module == null) throw new ArgumentNullException("module");
+
+ ReferencedFunctions = new List();
+ if (referencedFunctions != null) {
+ ReferencedFunctions.AddRange(referencedFunctions);
+ }
+
+ ReferencedBy = new List();
+ ReferencedIn = new List();
+ MyFunctions = new List();
+ }
+
+ public FunctionDefinition Add(string referencedFunction) {
+ ReferencedFunctions.Add(referencedFunction);
+ return this;
+ }
+
+ public override bool Equals(Object obj) {
+ //Check for null and compare run-time types.
+ if ((obj == null) || !this.GetType().Equals(obj.GetType())) {
+ return false;
+ } else {
+ FunctionDefinition functionDefinition = (FunctionDefinition)obj;
+ return Name == functionDefinition.Name;
+ }
+ }
+
+ public override int GetHashCode() {
+ return Name.GetHashCode();
+ }
+
+ public override string ToString() {
+ return string.Format("ModuleFunctionDefinition: {0} - ModuleDefinition {1}",Name,Module.Name);
+ }
+
+ public void Add(ModuleFunctionDefinition functionDefinition, ModuleDefinition module) {
+ if (!ReferencedBy.Contains(functionDefinition)) { ReferencedBy.Add(functionDefinition); }
+ if (!ReferencedIn.Contains(module)) { ReferencedIn.Add(module); }
+ if (Module != module && !module.RelatedModules.Contains(Module)) { module.RelatedModules.Add(Module); }
+ }
+
+ // These are the functions that reference me
+ public List ReferencedBy { get; set; }
+ // These are the functions that I reference
+ public List ReferencedIn { get; set; }
+
+ public List MyFunctions { get; set; }
+ }
+"@;
+
+Write-Host "testing solution"
+
+Add-Type -TypeDefinition $cSharpCode;
+
+$result = [ModuleChecker]::DoIt($FolderPath, $ShowObviousCycles)
+
+ if ($result) {
+ if ($FailBuild) {
+ throw "Failed processing, check output for bad module definitions"
+ } else {
+ Write-Warning "Failed processing, check output for bad module definitions"
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/Modules/.build/Update-AliasesInPSD1.ps1 b/Modules/.build/Update-AliasesInPSD1.ps1
new file mode 100644
index 0000000..405c4cb
--- /dev/null
+++ b/Modules/.build/Update-AliasesInPSD1.ps1
@@ -0,0 +1,65 @@
+Function Update-AliasesInPSD1 {
+<#
+.SYNOPSIS
+ Concatenates all the aliases from Get-Aliases for insertion into the PSD1 file referenced.
+
+.EXAMPLE
+ Update-AliasesInPSD1 .\Alkami.PowerShell.IIS\Public .\Alkami.PowerShell.IIS\Alkami.PowerShell.IIS.psd1
+
+.PARAMETER FolderPath
+ The name of the folder to examine all files under.
+
+.PARAMETER Psd1FilePath
+ The file to be rewritten. (This is called out in case more than one .psd1 file exists in a folder.)
+#>
+ [CmdletBinding()]
+ Param (
+ [String]$FolderPath,
+ [String]$Psd1FilePath
+ )
+ process {
+ $aliasNames = @(Get-Aliases $FolderPath) | Sort-Object
+
+ if ($aliasNames.Count -eq 0) {
+ return
+ }
+
+ $toBeJoinedAliases = @()
+
+ foreach($name in $aliasNames) {
+ $toBeJoinedAliases += "'$name'"
+ }
+
+ $joinedAliases = $toBeJoinedAliases -Join ','
+
+ $contents = (Get-Content $Psd1FilePath)
+ $newContents = @()
+
+ $foundAliasesLine = $false
+ foreach($line in $contents) {
+ if ($line.Trim().StartsWith("AliasesToExport")) {
+ $splits = $line -split '='
+ $line = $splits[0].TrimEnd() + " = $joinedAliases"
+ $foundAliasesLine = $true
+ }
+ $newContents += $line
+ }
+
+ if (!$foundAliasesLine) {
+ $newContents = @()
+ ## We couldn't find an existing aliases line in that PSD1, so we need to add one.
+ ## We are gonna add it after FunctionsToExport
+ foreach($line in $contents) {
+ $newContents += $line
+ if ($line.Trim().StartsWith("FunctionsToExport")) {
+ $splits = $line -split '='
+ $newLine = $splits[0].TrimEnd().Replace('FunctionsToExport','AliasesToExport') + " = $joinedAliases"
+
+ $newContents += $newLine
+ }
+ }
+ }
+
+ Set-Content -Path $Psd1FilePath -Value $newContents
+ }
+}
\ No newline at end of file
diff --git a/Modules/.build/Update-FormatsToProcessInPSD1.ps1 b/Modules/.build/Update-FormatsToProcessInPSD1.ps1
new file mode 100644
index 0000000..f86a0cc
--- /dev/null
+++ b/Modules/.build/Update-FormatsToProcessInPSD1.ps1
@@ -0,0 +1,61 @@
+Function Update-FormatsToProcessInPSD1 {
+<#
+.SYNOPSIS
+ Updates the PSD1 FormatsToProcess entry
+
+.EXAMPLE
+ Update-FormatsToProcessInPSD1 .\Alkami.PowerShell.IIS\Public .\Alkami.PowerShell.IIS\Alkami.PowerShell.IIS.psd1
+
+.PARAMETER FolderPath
+ The name of the folder to examine all files under.
+
+.PARAMETER Psd1FilePath
+ The file to be rewritten. (This is called out in case more than one .psd1 file exists in a folder.)
+#>
+ [CmdletBinding()]
+ Param (
+ [String]$FolderPath,
+ [String]$Psd1FilePath
+ )
+ process {
+ # In the future, may want to ship all the format files separately
+ # Currently there is Get-ContentFromFormatFilesInPath and Join-PS1XMLFromFiles that smacks these two together
+ # So for now, we just want to write out the name of the built ps1xml to the psd1
+ # The names are the same, so that's easy.
+
+ $contents = (Get-Content $Psd1FilePath)
+ $newContents = @()
+
+ $projectName = [System.IO.Path]::GetFileNameWithoutExtension($psd1Filepath)
+ $magicWord = 'FormatsToProcess'
+ $magicConcat = " = `"$projectName.ps1xml`""
+
+ $foundFormatsLine = $false
+ foreach($line in $contents) {
+ if ($line.Trim().StartsWith($magicWord)) {
+ $splits = $line -split '='
+ $line = $splits[0].TrimEnd() + $magicConcat
+ $foundFormatsLine = $true
+ }
+ $newContents += $line
+ }
+
+ if (!$foundFormatsLine) {
+ $newContents = @()
+ ## We couldn't find an existing formats line in that PSD1, so we need to add one.
+ ## We are gonna add it after FunctionsToExport
+ $duplicatableTag = 'FunctionsToExport'
+ foreach($line in $contents) {
+ $newContents += $line
+ if ($line.Trim().StartsWith($duplicatableTag)) {
+ $splits = $line -split '='
+ $newLine = $splits[0].TrimEnd().Replace($duplicatableTag,$magicWord) + $magicConcat
+
+ $newContents += $newLine
+ }
+ }
+ }
+
+ Set-Content -Path $Psd1FilePath -Value $newContents
+ }
+}
\ No newline at end of file
diff --git a/Modules/.build/Update-FunctionNamesInPSD1.ps1 b/Modules/.build/Update-FunctionNamesInPSD1.ps1
new file mode 100644
index 0000000..c6dc050
--- /dev/null
+++ b/Modules/.build/Update-FunctionNamesInPSD1.ps1
@@ -0,0 +1,43 @@
+Function Update-FunctionNamesInPSD1 {
+<#
+.SYNOPSIS
+ Concatenates all the function names from Get-FunctionNames for insertion into the PSD1 file referenced.
+
+.EXAMPLE
+ Update-FunctionNamesInPSD1 .\Alkami.PowerShell.IIS\Public .\Alkami.PowerShell.IIS\Alkami.PowerShell.IIS.psd1
+
+.PARAMETER FolderPath
+ The name of the folder to examine all files under.
+
+.PARAMETER Psd1FilePath
+ The file to be rewritten. (This is called out in case more than one .psd1 file exists in a folder.)
+#>
+ [CmdletBinding()]
+ Param (
+ [String]$FolderPath,
+ [String]$Psd1FilePath
+ )
+ process {
+ $functionNames = @(Get-FunctionNames $FolderPath) | Sort-Object
+
+ $toBeJoinedFunctionNames = @()
+
+ foreach($name in $functionNames) {
+ $toBeJoinedFunctionNames += "'$name'"
+ }
+
+ $joinedFunctionNames = $toBeJoinedFunctionNames -Join ','
+
+ $contents = (Get-Content $Psd1FilePath)
+ $newContents = @()
+
+ foreach($line in $contents) {
+ if ($line.Trim().StartsWith("FunctionsToExport")) {
+ $splits = $line -split '='
+ $line = $splits[0].TrimEnd() + " = $joinedFunctionNames"
+ }
+ $newContents += $line
+ }
+ Set-Content -Path $Psd1FilePath -Value $newContents
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Certificates/Alkami.DevOps.Certificates.nuspec b/Modules/Alkami.DevOps.Certificates/Alkami.DevOps.Certificates.nuspec
new file mode 100644
index 0000000..c9aca29
--- /dev/null
+++ b/Modules/Alkami.DevOps.Certificates/Alkami.DevOps.Certificates.nuspec
@@ -0,0 +1,34 @@
+
+
+
+ Alkami.DevOps.Certificates
+ $version$
+ Alkami Platform Modules - DevOps - Certificates
+ Alkami Technologies
+ Alkami Technologies
+ https://extranet.alkamitech.com/display/ORB/Alkami.DevOps.Certificates
+ https://www.alkami.com/files/alkamilogo75x75.png
+ http://alkami.com/files/orblicense.html
+ false
+ Installs the DevOps Certificates module for use with PowerShell.
+
+ PowerShell
+ Copyright (c) 2018 Alkami Technologies
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Modules/Alkami.DevOps.Certificates/Alkami.DevOps.Certificates.psd1 b/Modules/Alkami.DevOps.Certificates/Alkami.DevOps.Certificates.psd1
new file mode 100644
index 0000000..35328f0
--- /dev/null
+++ b/Modules/Alkami.DevOps.Certificates/Alkami.DevOps.Certificates.psd1
@@ -0,0 +1,21 @@
+@{
+ RootModule = 'Alkami.DevOps.Certificates.psm1'
+ ModuleVersion = '3.20.11'
+ GUID = 'bf9b5927-92e8-4eff-b8f3-c19cc554e4c2'
+ Author = 'SRE'
+ CompanyName = 'Alkami Technologies, Inc.'
+ Copyright = '(c) 2018 Alkami Technologies, Inc. All rights reserved.'
+ Description = 'A set of functions used to deploy the ORB application'
+ PowerShellVersion = '5.0'
+ RequiredModules = 'Alkami.PowerShell.Common','Alkami.PowerShell.Services','Alkami.Ops.Common','Alkami.PowerShell.IIS','Alkami.Ops.SecretServer'
+ FunctionsToExport = 'Compress-Certificates','Export-Certificates','Export-CertificatesToFileSystem','Get-ExpiringCertificates','Get-PrivateKeyPermissions','Get-SecretServerConnection','Import-Certificates','Import-PfxCertificateWithPermissions','Import-PodFromSecretServer','Publish-PodToSecretServer','Read-AppTierCertificates','Read-Certificates','Read-WebTierCertificates','Remove-Certificate','Save-CertificatesToDisk','Update-CertBindings'
+ AliasesToExport = 'Load-AppTierCertificates','Load-Certificates','Load-WebTierCertificates'
+ PrivateData = @{
+ PSData = @{
+ Tags = @('powershell', 'module', 'deploy', 'deployment')
+ ProjectUri = 'Https://extranet.alkamitech.com/display/SRE/Alkami.DevOps.Certificate+Module'
+ IconUri = 'https://www.alkami.com/files/alkamilogo75x75.png'
+ }
+ }
+ HelpInfoURI = 'Https://extranet.alkamitech.com/display/SRE/Alkami.DevOps.Certificate+Module'
+}
diff --git a/Modules/Alkami.DevOps.Certificates/Alkami.DevOps.Certificates.pssproj b/Modules/Alkami.DevOps.Certificates/Alkami.DevOps.Certificates.pssproj
new file mode 100644
index 0000000..c41c41b
--- /dev/null
+++ b/Modules/Alkami.DevOps.Certificates/Alkami.DevOps.Certificates.pssproj
@@ -0,0 +1,78 @@
+
+
+
+ Debug
+ 2.0
+ {90fa52ce-26c5-44f0-a0c5-0ac3355e6fdc}
+ Exe
+ MyApplication
+ MyApplication
+ Alkami.DevOps.Certificates
+ ..\build-project.ps1 (Join-Path $(SolutionDir) "Alkami.DevOps.Certificates")
+ Invoke-Pester;
+
+
+ true
+ full
+ false
+ bin\Debug\
+ DEBUG;TRACE
+ prompt
+ 4
+
+
+ pdbonly
+ true
+ bin\Release\
+ TRACE
+ prompt
+ 4
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Certificates/AlkamiManifest.xml b/Modules/Alkami.DevOps.Certificates/AlkamiManifest.xml
new file mode 100644
index 0000000..195e5c8
--- /dev/null
+++ b/Modules/Alkami.DevOps.Certificates/AlkamiManifest.xml
@@ -0,0 +1,12 @@
+
+
+ 1.0
+
+ Alkami
+ Alkami.DevOps.Certificates
+ SREModule
+
+
+ Production
+
+
diff --git a/Modules/Alkami.DevOps.Certificates/Private/Confirm-Cert.ps1 b/Modules/Alkami.DevOps.Certificates/Private/Confirm-Cert.ps1
new file mode 100644
index 0000000..74faced
--- /dev/null
+++ b/Modules/Alkami.DevOps.Certificates/Private/Confirm-Cert.ps1
@@ -0,0 +1,46 @@
+function Confirm-Cert {
+<#
+.SYNOPSIS
+ Validates that Certificate is provided.
+#>
+
+ [CmdletBinding()]
+ Param(
+
+ [Parameter(Mandatory=$true)]
+ [string]$certName,
+
+ [parameter(Mandatory=$true)]
+ [System.Security.Cryptography.X509Certificates.StoreName]$storeName
+ )
+
+ try
+ {
+ $OriginalErrorActionPreference = $ErrorActionPreference;
+ $ErrorActionPreference = "Continue";
+
+ Write-Output ("Validating certificate $certName");
+
+ [Alkami.Ops.Common.Cryptography.CertificateHelper]::ValidateCertificate(
+ $certName,
+ $storeName,
+ [System.Security.Cryptography.X509Certificates.StoreLocation]::LocalMachine);
+
+ Write-Output ("Certificate $certName Passed Validation");
+ }
+ catch [Alkami.Ops.Common.Exceptions.InvalidCertificateException]
+ {
+ Write-Warning ("Certificate validation failed");
+ Write-Host (" Error: " + $_.Exception.Message);
+ Write-Host (" Name: " + $_.Exception.CertificateName);
+ Write-Host (" Thumbprint: " + $_.Exception.CertificateThumbPrint);
+ Write-Host (" Effective Date: " + $_.Exception.EffectiveDateTime);
+ Write-Host (" Expiration Date: " + $_.Exception.ExpirationDateTime + "`n");
+ }
+ finally
+ {
+ $ErrorActionPreference = $OriginalErrorActionPreference;
+ }
+}
+
+
diff --git a/Modules/Alkami.DevOps.Certificates/Private/Export-Cert.ps1 b/Modules/Alkami.DevOps.Certificates/Private/Export-Cert.ps1
new file mode 100644
index 0000000..a1c15f2
--- /dev/null
+++ b/Modules/Alkami.DevOps.Certificates/Private/Export-Cert.ps1
@@ -0,0 +1,37 @@
+function Export-Cert {
+<#
+.SYNOPSIS
+ Exports a Certificate.
+#>
+
+ [CmdletBinding()]
+ [OutputType([System.Object])]
+ Param(
+
+ [parameter(Mandatory=$true)]
+ [string]$exportPath,
+
+ [parameter(Mandatory=$false)]
+ [string]$exportPassword,
+
+ [parameter(Mandatory=$true)]
+ [System.Security.Cryptography.X509Certificates.StoreName]$storeName
+ )
+
+ if ($exportPassword)
+ {
+ return ,[Alkami.Ops.Common.Cryptography.CertificateHelper]::ExportAllCertificates(
+ $storeName,
+ [System.Security.Cryptography.X509Certificates.StoreLocation]::LocalMachine,
+ $exportPath,
+ $exportPassword
+ );
+ }
+
+ return ,[Alkami.Ops.Common.Cryptography.CertificateHelper]::ExportAllCertificates(
+ $storeName,
+ [System.Security.Cryptography.X509Certificates.StoreLocation]::LocalMachine,
+ $exportPath
+ );
+}
+
diff --git a/Modules/Alkami.DevOps.Certificates/Private/Export-CertChain.ps1 b/Modules/Alkami.DevOps.Certificates/Private/Export-CertChain.ps1
new file mode 100644
index 0000000..afd0deb
--- /dev/null
+++ b/Modules/Alkami.DevOps.Certificates/Private/Export-CertChain.ps1
@@ -0,0 +1,36 @@
+function Export-CertChain {
+<#
+.SYNOPSIS
+ Exports a Certificate's Chain.
+#>
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory = $True)]
+ [ValidateNotNull()]
+ [System.Security.Cryptography.X509Certificates.X509Certificate2]$Cert,
+ [Parameter(Mandatory = $True)]
+ [string]$ExportStorePath,
+ [Parameter(Mandatory = $True)]
+ [string]$ExportCertPath,
+ $ADGroups
+ )
+ $certName = $exportCertPath.Split("\") | Select-Object -Last 1
+ $chain = Get-CertificateChain $cert $exportStorePath
+ $chainInfo = [System.Collections.ArrayList]::new()
+ foreach ($chainCert in $chain) {
+
+ $chainCertStore = Get-CertificateStoreName $chainCert
+ if (!$chainCertStore) {
+ Write-Warning "Chain is broken for cert $certName and thumbprint $($chainCert.thumbprint)"
+ break
+ }
+ $exportChainPath = $exportCertPath, "ChainedCertificates", $chainCertStore -join "\"
+
+ $exportInfo = Export-CertificateToFileSystem $chainCert $exportChainPath -IsChainExport $true -ADGroups $ADGroups
+ if ($null -eq $exportInfo) {break}
+
+ [void]$chainInfo.Add($exportInfo)
+ }
+
+ return $chainInfo
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Certificates/Private/Export-CertificateToFileSystem.ps1 b/Modules/Alkami.DevOps.Certificates/Private/Export-CertificateToFileSystem.ps1
new file mode 100644
index 0000000..8ed596f
--- /dev/null
+++ b/Modules/Alkami.DevOps.Certificates/Private/Export-CertificateToFileSystem.ps1
@@ -0,0 +1,48 @@
+function Export-CertificateToFileSystem {
+<#
+.SYNOPSIS
+ Exports a Certificate to a Store.
+#>
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory = $True)]
+ [ValidateNotNull()]
+ [System.Security.Cryptography.X509Certificates.X509Certificate2]$Cert,
+ [Parameter(Mandatory = $True)]
+ [ValidateNotNull()]
+ [string]$ExportStorePath,
+ [bool]$IsChainExport = $false,
+ [string[]]$ADGroups
+ )
+ $certName = Get-CertificateExportName $cert
+ $exportCertPath = if ($IsChainExport) {$exportStorePath}else {Join-Path $exportStorePath $certName}
+
+ $exportInfo = Get-CertificateExportInfo $cert $exportCertPath
+ if ($exportInfo.certExportType -eq [System.Security.Cryptography.X509Certificates.X509ContentType]::Unknown) {return $null}
+
+ if (-Not (Test-Path $exportCertPath -PathType Container)) {
+ New-Item $exportCertPath -ItemType Directory | Out-Null
+ }
+
+ $exportInfo.certName = $certName
+ $exportInfo.certPassword = ([char[]]([char]33..[char]95) + ([char[]]([char]97..[char]126)) + 0..9 | Sort-Object {Get-Random})[0..128] -join ''
+ $exportInfo.ADGroups = $ADGroups
+
+ if ($exportInfo.certExportType -eq [System.Security.Cryptography.X509Certificates.X509ContentType]::Pfx) {
+ $password = $exportInfo.certPassword | ConvertTo-SecureString -AsPlainText -Force
+ try {
+ Export-PfxCertificate -Cert $cert -ProtectTo $ADGroups -FilePath $exportInfo.exportCertFile -Password $password
+ }
+ catch {
+ Write-Warning "Certificate $certName with thumbprint $($cert.Thumbprint) but could not be exported
+ $($_.Exception.Message)"
+ return $null
+ }
+ }
+ else {
+ $certBytes = $cert.Export($exportInfo.certExportType)
+ [void][io.file]::WriteAllBytes($exportInfo.exportCertFile, $certBytes)
+ }
+
+ return $exportInfo
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Certificates/Private/Get-Cert.ps1 b/Modules/Alkami.DevOps.Certificates/Private/Get-Cert.ps1
new file mode 100644
index 0000000..358323c
--- /dev/null
+++ b/Modules/Alkami.DevOps.Certificates/Private/Get-Cert.ps1
@@ -0,0 +1,36 @@
+function Get-Cert {
+<#
+.SYNOPSIS
+ Fetches a Certificate.
+#>
+ [CmdletBinding()]
+ param(
+ [string]$Thumbprint,
+ [string]$StoreName,
+ [string]$FriendlyName
+ )
+
+ $certStore = @{ }
+ $certStores = Get-ChildItem Cert:\LocalMachine\ | ForEach-Object { "Cert:\LocalMachine\$($_.Name)" }
+ if ($StoreName) {
+ $certStores = $certStores | Where-Object { $_ -Match "\\$StoreName" }
+ }
+ foreach ($store in $certStores) {
+ Get-ChildItem $store | Where-Object { $_.NotAfter -gt (Get-Date) } | ForEach-Object {
+ if ($certStore.ContainsKey($_.Thumbprint)) {
+ $certStore[$_.Thumbprint].Add($_)
+ } else {
+ $list = [System.Collections.Generic.List[System.Security.Cryptography.X509Certificates.X509Certificate]]::new()
+ $list.Add($_)
+ $certStore.Add($_.Thumbprint, $list)
+ }
+ }
+ }
+
+ if ($Thumbprint) {
+ return $certStore[$Thumbprint]
+ } elseif ($FriendlyName) {
+ return $certStore.Values | Where-Object { $_.FriendlyName -match $FriendlyName }
+ }
+ return $certStore.Values
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Certificates/Private/Get-CertificateChain.ps1 b/Modules/Alkami.DevOps.Certificates/Private/Get-CertificateChain.ps1
new file mode 100644
index 0000000..464fbca
--- /dev/null
+++ b/Modules/Alkami.DevOps.Certificates/Private/Get-CertificateChain.ps1
@@ -0,0 +1,14 @@
+function Get-CertificateChain {
+<#
+.SYNOPSIS
+ Fetches a Certificate's Chain.
+#>
+ [CmdletBinding()]
+ param(
+ [Parameter()]
+ $Cert
+ )
+ $chain = [System.Security.Cryptography.X509Certificates.X509Chain]::new()
+ [void]$chain.Build($cert)
+ return $chain.ChainElements.Certificate | Select-Object -Skip 1
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Certificates/Private/Get-CertificateExportInfo.ps1 b/Modules/Alkami.DevOps.Certificates/Private/Get-CertificateExportInfo.ps1
new file mode 100644
index 0000000..a4141b9
--- /dev/null
+++ b/Modules/Alkami.DevOps.Certificates/Private/Get-CertificateExportInfo.ps1
@@ -0,0 +1,37 @@
+function Get-CertificateExportInfo {
+<#
+.SYNOPSIS
+ Fetches a Certificate's Export Information.
+#>
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory = $true)]
+ [System.Security.Cryptography.X509Certificates.X509Certificate2]$Cert,
+ [Parameter(Mandatory = $true)]
+ [string]$ExportCertPath)
+
+ $exportInfo = [PSCustomObject]@{
+ CertExportType = [System.Security.Cryptography.X509Certificates.X509ContentType]::Pfx
+ ExportCertFile = Join-Path $exportCertPath "$certName.pfx"
+ ExportCertPath = $exportCertPath
+ CertPassword = ""
+ ADGroups = ""
+ CertName = ""
+ ExpirationDate = $cert.NotAfter
+ Thumbprint = $cert.Thumbprint
+ }
+
+ if ($cert.HasPrivateKey) {
+ if (!$cert.PrivateKey.CspKeyContainerInfo.Exportable) {
+ Write-Warning "Certificate $certName with thumbprint $($cert.Thumbprint) has a private key but is marked as unexportable.
+ This certificate will not be exported"
+ $exportInfo.certExportType = [System.Security.Cryptography.X509Certificates.X509ContentType]::Unknown
+ }
+ }
+ else {
+ $exportInfo.certExportType = [System.Security.Cryptography.X509Certificates.X509ContentType]::Cert
+ $exportInfo.exportCertFile = Join-Path $exportCertPath "$certName.cer"
+ }
+
+ return $exportInfo
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Certificates/Private/Get-CertificateExportName.ps1 b/Modules/Alkami.DevOps.Certificates/Private/Get-CertificateExportName.ps1
new file mode 100644
index 0000000..7ae5ed7
--- /dev/null
+++ b/Modules/Alkami.DevOps.Certificates/Private/Get-CertificateExportName.ps1
@@ -0,0 +1,19 @@
+function Get-CertificateExportName {
+<#
+.SYNOPSIS
+ Fetches a Certificate's Export Name.
+#>
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory = $true)]
+ $Cert
+ )
+
+ $canonicalName = ($cert.Subject.Trim().Split(",") | Where-Object {$_ -match "CN="} | Select-Object -First 1 ) -replace "CN=", ""
+ $invalidFileNameChars = [IO.Path]::GetInvalidFileNameChars() -join ''
+ $validFileNameCN = ($canonicalName -replace ("[{0}]" -f [RegEx]::Escape($invalidFileNameChars)))
+
+ $certName = if ($validFileNameCN) { $validFileNameCN } else { $cert.Thumbprint }
+
+ return $certName.Trim()
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Certificates/Private/Get-CertificateStoreName.ps1 b/Modules/Alkami.DevOps.Certificates/Private/Get-CertificateStoreName.ps1
new file mode 100644
index 0000000..309dbb9
--- /dev/null
+++ b/Modules/Alkami.DevOps.Certificates/Private/Get-CertificateStoreName.ps1
@@ -0,0 +1,15 @@
+function Get-CertificateStoreName {
+<#
+.SYNOPSIS
+ Fetches a Certificate's Store Name.
+#>
+ [CmdletBinding()]
+ param(
+ [Parameter()]
+ $Cert
+ )
+ $psParent = Get-Cert -Thumbprint $cert.Thumbprint | Select-Object -ExpandProperty PSParentPath
+ if (!$psParent) {return}
+ $storeName = $psParent.Split("\") | Select-Object -Last 1
+ return $storeName
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Certificates/Private/Import-Cert.ps1 b/Modules/Alkami.DevOps.Certificates/Private/Import-Cert.ps1
new file mode 100644
index 0000000..6f98880
--- /dev/null
+++ b/Modules/Alkami.DevOps.Certificates/Private/Import-Cert.ps1
@@ -0,0 +1,33 @@
+function Import-Cert {
+<#
+.SYNOPSIS
+ Imports a Certificate into a Store.
+#>
+
+ [CmdletBinding()]
+ Param(
+
+ [Parameter(Mandatory=$true)]
+ [string]$certFullName,
+
+ [parameter(Mandatory=$true)]
+ [System.Security.Cryptography.X509Certificates.StoreName]$storeName,
+
+ [Parameter(Mandatory=$false)]
+ [string]$importPassword
+ )
+
+ if ($importPassword)
+ {
+ return [Alkami.Ops.Common.Cryptography.CertificateHelper]::LoadCertificateToStore(
+ $certFullName,
+ $storeName,
+ [System.Security.Cryptography.X509Certificates.StoreLocation]::LocalMachine,
+ $importPassword);
+ }
+
+ return [Alkami.Ops.Common.Cryptography.CertificateHelper]::LoadCertificateToStore(
+ $certFullName,
+ $storeName,
+ [System.Security.Cryptography.X509Certificates.StoreLocation]::LocalMachine);
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Certificates/Private/SecretServerConnection.ps1 b/Modules/Alkami.DevOps.Certificates/Private/SecretServerConnection.ps1
new file mode 100644
index 0000000..c49fb80
--- /dev/null
+++ b/Modules/Alkami.DevOps.Certificates/Private/SecretServerConnection.ps1
@@ -0,0 +1,195 @@
+# Ignore Measure-HelpSynopsis warnings in the PSScript Analyzer
+[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('Alkami.PowerShell.PSScriptAnalyzerRules\Measure-HelpSynopsis', '', Scope = 'Class')]
+# Ignore Measure-HelpSynopsis warnings in the PSScript Analyzer - PS Classes can't do CmdletBinding, afaict
+[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('Alkami.PowerShell.PSScriptAnalyzerRules\Measure-CmdletBinding', '', Scope = 'Class')]
+
+Class SecretServerConnection {
+ [string]$api
+ [string]$site
+ [string]$userName
+ [string]$domain
+ [string]$password
+ [string]$tokenRoute
+ [ScriptBlock]$commonFilters = { "?filter.includeRestricted=true&filter.searchtext=$searchString&filter.folderId=$folderId&filter.includeSubFolders=true" }
+ [ScriptBlock]$updateEndpoint
+ [System.Collections.Generic.Dictionary[[String], [String]]]$commonHeader
+ $token
+
+ SecretServerConnection() { }
+
+ SecretServerConnection([string]$site, [string]$userName, [string]$password) {
+ $this.site = $site
+ $this.tokenRoute = "$site/oauth2/token"
+ $this.api = $site, "api/v1" -join "/"
+ $this.updateEndpoint = { "/secrets/$secretId/fields/$fieldToUpdate" }
+ $this.userName = $userName
+ $this.password = $password
+ $this.commonHeader = [System.Collections.Generic.Dictionary[[String], [String]]]::new()
+ }
+
+ [void]Authenticate() {
+ $this.Authenticate($False)
+ }
+ [void]Authenticate([bool]$UseTwoFactor) {
+ [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
+ $creds = @{
+ username = $this.username
+ password = $this.password
+ grant_type = "password"
+ }
+
+ $headers = $null
+ If ($UseTwoFactor) {
+ $headers = @{
+ "OTP" = (Read-Host -Prompt "Enter your OTP for 2FA: ")
+ }
+ }
+ try {
+ $response = Invoke-RestMethod $this.tokenRoute -Method Post -Body $creds -Headers $headers
+ $this.token = $response.access_token;
+ if ($this.commonHeader.Count -gt 0) { $this.commonHeader.Clear() }
+ $this.commonHeader.Add("Authorization", "Bearer $($this.token)")
+ } catch {
+ throw $_
+ }
+ }
+ [object]GetSecretByName([string]$secretName, [string]$folderId) {
+ # $searchString is used in $commonFilters above. This "not used" warning is a lie.
+ [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '', Justification = 'False Positive')]
+ $searchString = $secretName
+ $filters = $this.commonFilters.Invoke()
+ Write-Debug "$($this.api)/secrets/lookup$filters"
+ $result = Invoke-RestMethod "$($this.api)/secrets$filters" -Headers $this.commonHeader
+
+ return $result
+ }
+
+ [object]GetSecretById([string]$secretId) {
+
+ Write-Debug "$($this.api)/secrets/$secretId"
+ $response = Invoke-RestMethod "$($this.api)/secrets/$secretId" -Headers $this.commonHeader
+
+ return $response
+ }
+ [object]GetSecretByFolderId([string]$folderId) {
+ $parameters = "?filter.folderId=$folderId"
+
+ $response = Invoke-RestMethod "$($this.api)/secrets/$parameters" -Headers $this.commonHeader
+
+ return $response.records
+ }
+
+ [object]GetSecretTemplateById([int]$templateId, [int]$folderId) {
+ $secret = Invoke-RestMethod "$($this.api)/secrets/stub?filter.secrettemplateid=$templateId&filter.folderId=$folderId" -Headers $this.commonHeader
+
+ return $secret
+ }
+
+ [int]GetSecretTemplateIdByName([string]$templateName) {
+ $searchString = "?filter.searchText=$templateName"
+
+ $secret = Invoke-RestMethod -Method Get "$($this.api)/secret-templates$searchString" -Headers $this.commonHeader
+
+ if ($secret.records) {
+ $record = $secret.records | Where-Object { $_.name -eq $templateName }
+ return $record.id;
+ }
+
+ return -1
+ }
+
+ [int]CreateSecret([object]$secret, [int]$folderId, [string]$secretName) {
+
+ $secret.name = $secretName
+ $secret.siteId = 1
+ $secret.folderId = $folderId
+
+ # Get Secret Template first, set up the template with various items and their values and then pass it to create secret
+ $requestCreateSecretParams = $secret | ConvertTo-Json
+ $secret = Invoke-RestMethod "$($this.api)/secrets/" -Method Post -Body $requestCreateSecretParams -Headers $this.commonHeader -ContentType "application/json"
+
+ return $secret.id
+ }
+ [object]UpdateField([string]$secretId, [string]$fieldToUpdate, [string]$newValue) {
+ $body = @{ value = $newValue } | ConvertTo-Json
+ $response = Invoke-RestMethod -Method Put -Uri "$($this.api)$($this.updateEndpoint.Invoke())" -Headers $this.commonHeader -ContentType "application/json" -Body $body
+ return $response
+ }
+
+ [object]GetField([string]$secretId, [string]$field) {
+ $response = Invoke-RestMethod -Method Get -Uri "$($this.api)$($this.updateEndpoint.Invoke())" -Headers $this.commonHeader
+ return $response.Records
+ }
+
+ [void]UploadFile([int]$secretId, [string]$fieldToUpdate, [string]$filePath) {
+
+ $fileName = Get-ChildItem $filePath | Select-Object -ExpandProperty Name
+
+ $requestUploadFileParams = @{
+ fileName = $fileName;
+ fileAttachment = [IO.File]::ReadAllBytes($filePath)
+ } | ConvertTo-Json
+
+ Invoke-RestMethod -Method Put -Uri "$($this.api)$($this.updateEndpoint.Invoke())" -Headers $this.commonHeader -Body $requestUploadFileParams -ContentType "application/json"
+
+ }
+
+ [bool]DownloadFile([int]$secretId, [string]$fieldToupdate, [string]$filePath) {
+ # invokeing $this.updateEndpoint.Invoke() sets variables hidden above in SecretServerConnection
+ Write-Debug "$($this.api)$($this.updateEndpoint.Invoke())"
+ try {
+ Invoke-RestMethod -Method Get -Uri "$($this.api)$($this.updateEndpoint.Invoke())" -Headers $this.commonHeader -OutFile $filePath | Out-Null
+ return $true
+ } catch {
+ Write-Warning "An error occurred while downloading a file."
+ Write-Warning -Message "StatusCode: $($_.Exception.Response.StatusCode.value__)"
+ Write-Warning -Message "StatusDescription: $($_.Exception.Response.StatusDescription)"
+ return $false
+ }
+ }
+
+ [int]AddFolder([int]$parentFolderId, [string]$folderName, [bool]$inheritPermissions, [bool]$inheritSecretPolicy) {
+
+ $folderStub = Invoke-RestMethod "$($this.api)/folders/stub" -Method GET -Headers $this.commonHeader -ContentType "application/json"
+
+ $folderStub.folderName = $folderName
+ $folderStub.folderTypeId = 1
+ $folderStub.inheritPermissions = $inheritPermissions
+ $folderStub.inheritSecretPolicy = $inheritSecretPolicy
+ $folderStub.parentFolderId = $parentFolderId
+
+ $folderArgs = $folderStub | ConvertTo-Json
+
+ $folderAddResult = Invoke-RestMethod "$($this.api)/folders" -Method POST -Body $folderArgs -Headers $this.commonHeader -ContentType "application/json"
+
+ return $folderAddResult.id
+ }
+ [int]AddFolder([int]$parentFolderId, [string]$folderName) {
+ return $this.AddFolder($parentFolderId, $folderName, $true, $true)
+ }
+ [object]GetFolderById([int]$folderId) {
+ $folderGetResult = Invoke-RestMethod "$($this.api)/folders/$folderId" -Method GET -Headers $this.commonHeader -ContentType "application/json"
+ return $folderGetResult.records
+ }
+ [object]GetChildFolders([int]$parentId) {
+ $parameters = "?filter.parentFolderId=$parentId"
+ $folderGetResult = Invoke-RestMethod "$($this.api)/folders/$parameters" -Method GET -Headers $this.commonHeader -ContentType "application/json"
+ return $folderGetResult.records
+ }
+ [object]GetFolderIdByName([string]$folderName) {
+ $searchFilter = "?filter.searchText=$folderName"
+
+ $searchResults = Invoke-RestMethod "$($this.api)/folders$searchFilter" -Method GET -Headers $this.commonHeader -ContentType "application/json"
+
+ return $searchResults.Records.Id
+ }
+ [object]GetFolderIdByName([string]$folderName, [int]$parentFolderId) {
+ $searchString = $folderName
+ $folderId = $parentFolderId
+ $filters = "?filter.includeRestricted=true&filter.parentFolderId=$folderId&filter.searchtext=%$searchString"
+ Write-Debug $filters
+ $searchResults = Invoke-RestMethod "$($this.api)/folders$filters" -Method GET -Headers $this.commonHeader -ContentType "application/json"
+
+ return $searchResults.Records.Id
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Certificates/Private/SecretServerConnection.tests.ps1 b/Modules/Alkami.DevOps.Certificates/Private/SecretServerConnection.tests.ps1
new file mode 100644
index 0000000..f361925
--- /dev/null
+++ b/Modules/Alkami.DevOps.Certificates/Private/SecretServerConnection.tests.ps1
@@ -0,0 +1,61 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$global:functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+#Import-Module $functionPath -Force
+$moduleForMock = ""
+
+InModuleScope -ModuleName Alkami.DevOps.Certificates -ScriptBlock {
+ Write-Host "InModuleScope - Overriding SUT: $($global:functionPath)"
+ Import-Module $global:functionPath -Force
+ $moduleForMock = ""
+ $inScopeModuleForAssert = "Alkami.DevOps.Certificates"
+
+ Describe "SecretServerConnection" {
+ Context "When Calling GetSecretByName" {
+
+ Mock Invoke-RestMethod {
+ #Write-Warning "Mocked Invoke-RestMethod"
+ if ($uri -like "*CertName*123*") {
+ return New-Object psobject -Property @{
+ Name = "I'm a fake Secret"
+ }
+ } else {
+ return $null
+ }
+ } -ModuleName $moduleForMock
+
+ It "Returns Secrets Which Match The Supplied Name And FolderId" {
+
+ $connection = [SecretServerConnection]::new()
+ $result = $connection.GetSecretByName("CertName", 123)
+
+ Assert-MockCalled -ModuleName $inScopeModuleForAssert -CommandName Invoke-RestMethod
+
+ $result.Name | should -BeLike "I'm a fake Secret"
+ }
+
+ It "Does Not Return Secrets Which Do Not Match The Supplied Name" {
+
+ $connection = [SecretServerConnection]::new()
+ $result = $connection.GetSecretByName("BadName", 123)
+
+ Assert-MockCalled -ModuleName $inScopeModuleForAssert -CommandName Invoke-RestMethod
+
+ $result | should -BeNullOrEmpty
+ }
+
+
+ It "Does Not Return Secrets Which Do Match The Supplied Name But Not the Folder Id" {
+
+ $connection = [SecretServerConnection]::new()
+ $result = $connection.GetSecretByName("CertName", 456)
+
+ Assert-MockCalled -ModuleName $inScopeModuleForAssert -CommandName Invoke-RestMethod
+
+ $result | should -BeNullOrEmpty
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Certificates/Private/Set-CertPermissions.ps1 b/Modules/Alkami.DevOps.Certificates/Private/Set-CertPermissions.ps1
new file mode 100644
index 0000000..29230bb
--- /dev/null
+++ b/Modules/Alkami.DevOps.Certificates/Private/Set-CertPermissions.ps1
@@ -0,0 +1,42 @@
+function Set-CertPermissions {
+<#
+.SYNOPSIS
+ Assigns Certificate Permissions for a user.
+#>
+
+ [CmdletBinding()]
+ Param(
+
+ [Parameter(Mandatory=$true)]
+ [string]$certThumprint,
+
+ [Parameter(Mandatory=$true)]
+ [string]$user
+ )
+
+ $logLead = Get-LogLeadName
+
+ $certObj = Get-ChildItem "Cert:\LocalMachine\my\$certThumprint"
+ $rsaCert = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($CertObj)
+
+ if ($rsaCert.key -and $rsaCert.key.UniqueName) {
+ $fileName = $rsaCert.key.UniqueName
+ $directoryRsaMachineKeys = Join-Path "C:\ProgramData\Microsoft\Crypto\RSA\MachineKeys\" $fileName
+ $directoryCryptoKeys = Join-Path "C:\ProgramData\Microsoft\Crypto\Keys\" $fileName
+
+ if (Test-Path $directoryRsaMachineKeys) {
+ $path = $directoryRsaMachineKeys
+ } elseif (Test-Path $directoryCryptoKeys) {
+ $path = $directoryCryptoKeys
+ } else {
+ Write-Error "$logLead : Did not find an associated ACL File for $certThumbprint."
+ }
+ } else {
+ Write-Error "$logLead : Unable to determine Unique Key Name for $certThumprint"
+ }
+
+ $permissions = Get-Acl -Path $path
+ $rule = New-Object Security.AccessControl.FileSystemAccessRule $user, "FullControl", Allow
+ $permissions.AddAccessRule($rule)
+ Set-Acl -Path $path -AclObject $permissions
+}
diff --git a/Modules/Alkami.DevOps.Certificates/Private/VariableDeclarations.ps1 b/Modules/Alkami.DevOps.Certificates/Private/VariableDeclarations.ps1
new file mode 100644
index 0000000..e69de29
diff --git a/Modules/Alkami.DevOps.Certificates/Public/Compress-Certificates.ps1 b/Modules/Alkami.DevOps.Certificates/Public/Compress-Certificates.ps1
new file mode 100644
index 0000000..85254ee
--- /dev/null
+++ b/Modules/Alkami.DevOps.Certificates/Public/Compress-Certificates.ps1
@@ -0,0 +1,21 @@
+function Compress-Certificates {
+<#
+.SYNOPSIS
+ Combine Certificates into a .zip File.
+#>
+ [CmdletBinding()]
+ param(
+ $Certificates,
+ $TempFolder
+ )
+ #Prepare certificate folders by zipping them.
+ foreach ($certificate in $Certificates) {
+ $zipFileName = $certificate.Name.Trim() + ".zip"
+ $CompressedDir = $TempFolder, $zipFileName -join "\"
+ Remove-Item $CompressedDir -Force -ErrorAction SilentlyContinue
+ Remove-Item (Join-Path $certificate.Folder $zipFileName) -Force -ErrorAction SilentlyContinue
+ [System.IO.Compression.ZipFile]::CreateFromDirectory($certificate.Folder,
+ $CompressedDir, [System.IO.Compression.CompressionLevel]::Optimal, $false)
+ Move-Item $CompressedDir $certificate.Folder -Force
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Certificates/Public/Export-Certificates.ps1 b/Modules/Alkami.DevOps.Certificates/Public/Export-Certificates.ps1
new file mode 100644
index 0000000..d6135e7
--- /dev/null
+++ b/Modules/Alkami.DevOps.Certificates/Public/Export-Certificates.ps1
@@ -0,0 +1,153 @@
+function Export-Certificates {
+<#
+.SYNOPSIS
+ Exports certificates from a machine.
+
+ .PARAMETER exportPassword
+
+ The password used to secure the certificate with
+
+ .PARAMETER exportPath
+
+ The path the certificates are exported to. If no path is defined, the current working directory is used
+
+ .PARAMETER skipRootCerts
+
+ When this flag is supplied it will skip the exporting of certificates in the 'Root' store
+
+ .PARAMETER skipPersonalCerts
+
+ When this flag is supplied it will skip the exporting of certificates in the 'My' store
+
+ .PARAMETER skipTrustedCerts
+
+ When this flag is supplied it will skip the exporting of certificates in the 'Trusted' store
+
+ .PARAMETER skipIACerts
+
+ When this flag is supplied it will skip the exporting of certificates in the 'CertificateAuthority' store
+
+#>
+
+ [CmdletBinding()]
+ Param(
+ [parameter(Mandatory=$false)]
+ [string]$exportPassword,
+
+ [Parameter(Mandatory=$false)]
+ [string]$exportPath = $PWD,
+
+ [Parameter(Mandatory=$false)]
+ [switch]$skipRootCerts,
+
+ [Parameter(Mandatory=$false)]
+ [switch]$skipPersonalCerts,
+
+ [Parameter(Mandatory=$false)]
+ [switch]$skipTrustedCerts,
+
+ [Parameter(Mandatory=$false)]
+ [switch]$skipIACerts
+ )
+
+ if (!$skipPersonalCerts.IsPresent -and !$exportPassword)
+ {
+ throw "Export Password cannot be null"
+ }
+
+ if ($skipRootCerts.IsPresent -and $skipPersonalCerts.IsPresent -and $skipTrustedCerts.IsPresent -and $skipIACerts.IsPresent)
+ {
+ throw "All Skip Switches cannot be set"
+ }
+
+ if (!(Test-Path $exportPath))
+ {
+ [System.IO.Directory]::CreateDirectory($exportPath) | Out-Null
+ }
+ ## Removing because of issues mocking. This shouldn't be an issue.
+ # Clear-Host
+
+ [System.Reflection.Assembly]::LoadWithPartialName("System.Security.Cryptography") | Out-Null
+ ## TODO: Don't just blindly set the $ErrorActionPreference
+ $ErrorActionPreference = "Stop"
+
+ [Collections.Generic.List[Alkami.Ops.Common.Exceptions.CertificateExportException]]$exportErrors = @()
+
+ if (!($skipPersonalCerts.IsPresent))
+ {
+ Write-Host "Exporting Personal Certs"
+
+ $pfxExportPath = (Join-Path $exportPath "Personal")
+
+ if (!(Test-Path $pfxExportPath))
+ {
+ Write-Host "Creating directory at $pfxExportPath"
+
+ [System.IO.Directory]::CreateDirectory($pfxExportPath) | Out-Null
+ }
+
+ $errors = Export-Cert -exportPath $pfxExportPath $exportPassword -storeName ([System.Security.Cryptography.X509Certificates.StoreName]::My)
+
+ $exportErrors.AddRange($errors)
+ }
+
+ if (!($skipIACerts.IsPresent))
+ {
+ Write-Host "Exporting IA Certs"
+
+ $iaExportPath = (Join-Path $exportPath "IA")
+
+ if (!(Test-Path $iaExportPath))
+ {
+ [System.IO.Directory]::CreateDirectory($iaExportPath) | Out-Null
+ }
+
+ $errors = Export-Cert -exportPath $iaExportPath -storeName ([System.Security.Cryptography.X509Certificates.StoreName]::CertificateAuthority)
+
+ $exportErrors.AddRange($errors)
+ }
+
+ if (!($skipRootCerts.IsPresent))
+ {
+ Write-Host "Exporting Root Certs"
+
+ $rootExportPath = (Join-Path $exportPath "Root")
+
+ if (!(Test-Path $rootExportPath))
+ {
+ [System.IO.Directory]::CreateDirectory($rootExportPath) | Out-Null
+ }
+
+ $errors = Export-Cert -exportPath $rootExportPath -storeName ([System.Security.Cryptography.X509Certificates.StoreName]::Root)
+
+ $exportErrors.AddRange($errors)
+ }
+
+ if (!($skipTrustedCerts.IsPresent))
+ {
+ Write-Host "Exporting Trusted Certs"
+
+ $trustedPeopleExportPath = (Join-Path $exportPath "TrustedPeople")
+
+ if (!(Test-Path $trustedPeopleExportPath))
+ {
+ [System.IO.Directory]::CreateDirectory($trustedPeopleExportPath) | Out-Null
+ }
+
+ $errors = Export-Cert -exportPath $trustedPeopleExportPath -storeName ([System.Security.Cryptography.X509Certificates.StoreName]::TrustedPeople)
+
+ $exportErrors.AddRange($errors)
+ }
+
+ foreach ($exportError in $exportErrors)
+ {
+ [Alkami.Ops.Common.Exceptions.CertificateExportException]$strongError = $exportError
+ Write-Warning ("{0}" -f $strongError.Message)
+ Write-Warning ("`tError: {0}" -f $strongError.BaseExceptionMessage.TrimEnd())
+ Write-Warning ("`tName: {0}" -f $strongError.CertificateName)
+ Write-Warning ("`tThumbprint: {0}" -f $strongError.CertificateThumbPrint)
+ Write-Warning ("`tSubject: {0}" -f $strongError.Subject.Trim())
+ Write-Output `n
+ }
+}
+
diff --git a/Modules/Alkami.DevOps.Certificates/Public/Export-Certificates.tests.ps1 b/Modules/Alkami.DevOps.Certificates/Public/Export-Certificates.tests.ps1
new file mode 100644
index 0000000..408c5b8
--- /dev/null
+++ b/Modules/Alkami.DevOps.Certificates/Public/Export-Certificates.tests.ps1
@@ -0,0 +1,128 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+# Yes. I hate this at least as much as you do. If you can find a way around it, please show me.
+$global:functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ""
+$compiledModuleForMock = "Alkami.DevOps.Certificates"
+$exportPassword = "Test"
+$exportPath = "c:\temp\CertificateTest"
+
+Remove-FileSystemItem -Path $exportPath -Force -Recurse -ErrorAction SilentlyContinue | Out-Null
+New-Item -ItemType Directory $exportPath -Force | Out-Null
+
+InModuleScope -ModuleName Alkami.DevOps.Certificates -ScriptBlock {
+ Write-Host "InModuleScope - Overriding SUT: $($global:functionPath)"
+ Import-Module $global:functionPath -Force
+ $inScopeModuleForAssert = "Alkami.DevOps.Certificates"
+ $exportPassword = "Test"
+ $exportPath = "c:\temp\CertificateTest"
+ $moduleForMock = ""
+
+ Mock -CommandName Export-Cert {
+ [Collections.Generic.List[Alkami.Ops.Common.Exceptions.CertificateExportException]] $emptyArr = @()
+ return ,$emptyArr
+ } -ModuleName $moduleForMock
+ #prevents the mehtod under test from clearing the screen during testing.
+ Mock -ModuleName $moduleForMock -CommandName Clear-Host {}
+ Mock -CommandName Write-Host -MockWith {} -ModuleName $moduleForMock
+
+ Describe "Export-Certificates" {
+
+ Context "When there are bad inputs when calling Export-Certificates" {
+
+ It "Throws Exception if all skip flags set" {
+
+ { Export-Certificates $exportPassword -skipPersonalCert -skipRootCerts -skipTrustedCert -skipIACert } | Should Throw
+ }
+ }
+
+ Context "When the parameters are valid" {
+
+ It "Doesnt Require Password if not exporting Personal Certificates" {
+ { Export-Certificates -skipPersonalCerts } | Should -Not -Throw
+ }
+
+ It "Creates Path if it doesn't exist" {
+
+ $path = 'c:\temp\badPath'
+
+ Export-Certificates $exportPassword -exportPath $path
+
+ $path | Should -Exist
+ }
+
+ It "Creates Personal Directory" {
+
+ Export-Certificates $exportPassword -exportPath $exportPath
+
+ Join-Path $exportPath "Personal" | Should -Exist
+ }
+
+ It "Creates IA Directory" {
+
+ Export-Certificates $exportPassword -exportPath $exportPath
+
+ Join-Path $exportPath "IA" | Should -Exist
+ }
+
+ It "Creates Root Directory" {
+
+ Export-Certificates $exportPassword -exportPath $exportPath
+
+ Join-Path $exportPath "Root" | Should -Exist
+ }
+
+ It "Creates TrustedPeople Directory" {
+
+ Export-Certificates $exportPassword -exportPath $exportPath
+
+ Join-Path $exportPath "TrustedPeople" | Should -Exist
+ }
+
+ It "Calls Export-Certs 4 times when no filters are used" {
+
+
+ Export-Certificates $exportPassword -exportPath $exportPath
+
+ #$result | Should be {}
+ Assert-MockCalled -ModuleName $inScopeModuleForAssert -CommandName Export-Cert -Times 4 -Exactly -Scope It
+ }
+
+ It "Calls Export-Certs 3 times when skipRootCerts filter used" {
+
+ Export-Certificates $exportPassword -exportPath $exportPath -skipRootCerts
+
+ #$result | Should be {}
+ Assert-MockCalled -ModuleName $inScopeModuleForAssert -CommandName Export-Cert -Times 3 -Exactly -Scope It
+ }
+
+ It "Calls Export-Certs 3 times when skipPersonalCerts filter used" {
+
+ Export-Certificates $exportPassword -exportPath $exportPath -skipPersonalCerts
+
+ #$result | Should be {}
+ Assert-MockCalled -ModuleName $inScopeModuleForAssert -CommandName Export-Cert -Times 3 -Exactly -Scope It
+ }
+
+ It "Calls Export-Certs 3 times when skipTrustedCerts filter used" {
+
+ Export-Certificates $exportPassword -exportPath $exportPath -skipTrustedCerts
+
+ #$result | Should be {}
+ Assert-MockCalled -ModuleName $inScopeModuleForAssert -CommandName Export-Cert -Times 3 -Exactly -Scope It
+ }
+
+ It "Calls Export-Certs 3 times when skipIACerts filter used" {
+
+ Export-Certificates $exportPassword -exportPath $exportPath -skipIACerts
+
+ #$result | Should be {}
+ Assert-MockCalled -ModuleName $inScopeModuleForAssert -CommandName Export-Cert -Times 3 -Exactly -Scope It
+ }
+ }
+
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Certificates/Public/Export-CertificatesToFileSystem.ps1 b/Modules/Alkami.DevOps.Certificates/Public/Export-CertificatesToFileSystem.ps1
new file mode 100644
index 0000000..9076a95
--- /dev/null
+++ b/Modules/Alkami.DevOps.Certificates/Public/Export-CertificatesToFileSystem.ps1
@@ -0,0 +1,167 @@
+function Export-CertificatesToFileSystem {
+<#
+.SYNOPSIS
+Exports certificates from desired stores (My, CertificateAuthority, Root, and TrustedPeople by default)
+onto the file system.
+
+.DESCRIPTION
+This script will export all public certificates and private keys in a given store to the file system. The
+certificate chain is also exported in an individualized folder along with the certificate so that you
+can delineate what certificates are needed for the one exported.
+
+A password is generated for private keys, if ADGroups are given they are assigned to the private keys as well.
+This script also returns a model of the data exported. With this model the file system data is more
+easily manipulated and was originally intended to be used by a module for uploading these certificates
+and their chains to individual secrets in secret server.
+
+.PARAMETER PodName
+String
+Used to create a leaf folder with in which the certificates will be exported and set as a property
+on the model returned as a result of this cmdlet.
+
+.PARAMETER CertRoot
+String
+Location that this cmdlet will be working out of. This is set to LocalMachine but could be set to Personal
+or other certificate providers.
+
+.PARAMETER StoresToExport
+StoreName[]
+All certificates in these stores will be exported along with their certificate chains and private keys.
+
+.PARAMETER ADGroups
+String[]
+Any ADGroups or UserAccounts that are given will be assigned to private keys exported. These users
+will be able to import the private keys without a password, although one is generated and set
+by this cmdlet regardless
+
+.EXAMPLE
+Export-CertificatesToFileSystem -PodName "Pod 1.1.1"
+
+Base usage, will export all certificates in the default stores and their private keys to the Default Export Root
+Folder under leaf folder 'Pod 1.1.1'. A password will be generated for the private keys and returned to the
+caller in the model object.
+
+.EXAMPLE
+Export-CertificatesToFileSystem -PodName "Pod 7.0.5" -ADGroups "corp\JSmith","corp\CCarter" -RootExportFolder "C:\Exports"
+
+Will export all certificates in the default stores and their private keys to the C:\Exports
+Folder under leaf folder 'Pod 7.0.5'. Users JSmith and CCarter will be able to import the private keys
+without the password, however passwords generated for these certificates will be returned with the
+model object that this cmdlet produces.
+
+#>
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory = $True)]
+ [string]$PodName,
+ [Parameter(Mandatory = $False)]
+ [string]$CertRoot = "Cert:\LocalMachine\",
+ [Parameter(Mandatory = $False)]
+ [string]$RootExportFolder = "c:\temp\CertExports",
+ [Parameter(Mandatory = $False)]
+ $StoresToExport = @([System.Security.Cryptography.X509Certificates.StoreName]::My,
+ [System.Security.Cryptography.X509Certificates.StoreName]::CertificateAuthority,
+ [System.Security.Cryptography.X509Certificates.StoreName]::Root,
+ [System.Security.Cryptography.X509Certificates.StoreName]::TrustedPeople),
+ [Parameter(Mandatory = $False)]
+ [string[]]$ADGroups = ""
+ )
+
+ begin {
+
+ $logLead = Get-LogLeadName
+
+ $Pod = [PSObject]@{
+ PodName = $PodName
+ ExportFolder = (Join-Path $RootExportFolder $PodName)
+ StoresToExport = $StoresToExport
+ CertRoot = $CertRoot
+ Stores = @{ }
+ }
+
+ if (Test-Path -Path $Pod.ExportFolder) {
+ Write-Host "$logLead : Removing items from $($Pod.ExportFolder) before beginning"
+ Remove-FileSystemItem -Path $Pod.ExportFolder -Recurse -Force -ErrorAction SilentlyContinue | Out-Null
+ }
+
+ if (-not (Test-Path -PathType Container -Path $Pod.ExportFolder)) {
+ New-Item $Pod.ExportFolder -ItemType Directory | Out-Null
+ }
+ }
+
+ process {
+ foreach ($storeName in $Pod.StoresToExport) {
+ $store = [PSObject]@{
+ Name = $storeName
+ ExportStorePath = Join-Path $Pod.ExportFolder $storeName
+ StorePath = Join-Path $Pod.CertRoot $storeName
+ Certificates = @{ }
+ }
+
+ # Powershell's Paths don't match the X509 enum. So here we do a poor man's lookup. If we ever need more stores than just the CA, this needs to be expanded to something more robust and clean.
+ if ($store.Name -eq "CertificateAuthority") {
+ $store.StorePath = "Cert:\LocalMachine\CA"
+ }
+
+ $Pod.Stores.Add($storeName, $store)
+ $Pod.Stores | Add-Member -MemberType NoteProperty -Name $storeName -Value $store
+
+ New-Item $store.ExportStorePath -ItemType Directory | Out-Null
+
+ #foreach cert in store
+ try {
+ $certificates = Get-ChildItem $store.StorePath;
+ }
+ catch {
+ Write-Warning "Cannot find Certificates in $storeName"
+ }
+ if ($certificates.Count -gt 0) {
+ foreach ($cert in $certificates) {
+
+ try {
+
+ $exportInfo = Export-CertificateToFileSystem -cert $cert -exportStorePath $store.ExportStorePath -ADGroups $ADGroups
+ if ($null -eq $exportInfo) { continue }
+
+ $chainInfo = Export-CertChain -cert $cert -exportStorePath $store.ExportStorePath -exportCertPath $exportInfo.exportCertPath -ADGroups $ADGroups
+
+ $CertificateChain = [PSObject]@{
+ Folder = Join-Path $exportInfo.ExportCertPath "ChainedCertificates"
+ Certificates = @{ }
+ }
+
+ foreach ($chainedCert in $chainInfo) {
+ $CertificateChain.Certificates.Add($chainedCert.certName, $chainedCert)
+ $CertificateChain.Certificates | Add-Member -MemberType NoteProperty -Name $chainedCert.certName -Value $chainedCert
+ }
+
+ $certificate = [PSObject]@{
+ Name = $exportInfo.certName
+ FilePath = $exportInfo.exportCertFile
+ Folder = $exportInfo.exportCertPath
+ Password = $exportInfo.certPassword
+ CertificateChain = $CertificateChain
+ ADGroups = $exportInfo.ADGroups
+ ExpirationDate = $exportInfo.ExpirationDate
+ Thumbprint = $exportInfo.Thumbprint
+ }
+
+ $store.Certificates.Add($certificate.Name, $certificate)
+
+ $store.Certificates | Add-Member -MemberType NoteProperty -Name $certificate.Name -Value $certificate
+ }
+ catch {
+
+ Write-Error "Certificate has failed to export
+ Name: $($certificate.Name), FriendlyName: $($cert.FriendlyName), Thumprint: $($cert.Thumbprint), Store: $storeName
+ $($_.Exception) $($_.Message) $($_.ScriptStackTrace)"
+
+ }
+ }
+ }
+ }
+ }
+ end {
+ return $Pod
+ }
+}
diff --git a/Modules/Alkami.DevOps.Certificates/Public/Get-ExpiringCertificates.ps1 b/Modules/Alkami.DevOps.Certificates/Public/Get-ExpiringCertificates.ps1
new file mode 100644
index 0000000..64eb79c
--- /dev/null
+++ b/Modules/Alkami.DevOps.Certificates/Public/Get-ExpiringCertificates.ps1
@@ -0,0 +1,69 @@
+function Get-ExpiringCertificates {
+<#
+.SYNOPSIS
+Gets certificates that will expire soon.
+
+.DESCRIPTION
+Takes a list of machines and connects to their certificate stores, compares the expiration date
+to a configureable threshold date. If the expiration date is less than the threshold date the
+certificate is returned in a list.
+
+.PARAMETER ComputerName
+[string[]]One or more computers on which to get expired certificates from.
+
+.PARAMETER ExpirationThreshold
+[int] An amount of days you wish to set the threshold.
+Note* Can be negative. Defaults to 30
+
+.EXAMPLE
+Get-ExpiringCertificates "Server1","Server2"
+Will connect to these servers in parallel, and retrieve certificates that are due to expire within 30 days or less from now.
+
+
+.EXAMPLE
+Get-ExpiringCertificates "Server1","Server2" -Threshold 90
+Will connect to these servers in parallel, and retrieve certificates that are due to expire within 90 days or less from now.
+#>
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory=$true)]
+ [Alias("Servers","Machines")]
+ [string[]]$ComputerName,
+ [Parameter(Mandatory=$false)]
+ [int]$ExpirationThreshold = 30
+ )
+
+ begin{
+ #Ensure there are machines to connect to
+ $sessions = New-PSSession $ComputerName -ErrorAction SilentlyContinue;
+ $Unreachable = $ComputerName | Where-Object {$sessions.ComputerName -notcontains $_}
+ if($Unreachable){Write-Host "Could not connect to $Unreachable";}
+ if(!$sessions){throw "Could not connect to any machines";}
+ }
+ process{
+ $ScriptBlock = {
+ param($ExpirationThreshold);
+
+ $personalStore = [System.Security.Cryptography.X509Certificates.StoreName]::My;
+ $machineStore = [System.Security.Cryptography.X509Certificates.StoreLocation]::LocalMachine;
+
+ $certificates = [Alkami.Ops.Common.Cryptography.CertificateHelper]::GetAllCertificates($personalStore, $machineStore, $env:COMPUTERNAME);
+
+ $expirationThresholdDate = (Get-Date).AddDays($ExpirationThreshold);
+
+ #Filter certificates by threshold date
+ $expiredCertificates = $certificates | Where-Object {$_.notAfter -lt $expirationThresholdDate} | `
+ Select-Object @{N="Machine";E={$env:COMPUTERNAME}},@{N="ExpirationDate";E={$_.NotAfter}},`
+ @{N="DaysRemaining";E={(New-TimeSpan -start (get-date) -end $_.notAfter | Select-Object -ExpandProperty days)}},Thumbprint,FriendlyName,Subject;
+
+ if($expiredCertificates){Write-Output $expiredCertificates;}
+ }
+
+ #Connect to machines and execute
+ $expiredCertificates = Invoke-Command -Session $sessions -ScriptBlock $ScriptBlock -ArgumentList $ExpirationThreshold;
+
+ Remove-PSSession $sessions;
+
+ return $expiredCertificates;
+ }
+}
diff --git a/Modules/Alkami.DevOps.Certificates/Public/Get-PrivateKeyPermissions.ps1 b/Modules/Alkami.DevOps.Certificates/Public/Get-PrivateKeyPermissions.ps1
new file mode 100644
index 0000000..990b614
--- /dev/null
+++ b/Modules/Alkami.DevOps.Certificates/Public/Get-PrivateKeyPermissions.ps1
@@ -0,0 +1,37 @@
+function Get-PrivateKeyPermissions {
+<#
+.SYNOPSIS
+ Fetch Private Key Permissions.
+#>
+
+ [CmdletBinding()]
+ Param(
+ [Parameter(Mandatory = $true)]
+ [System.Security.Cryptography.X509Certificates.X509Certificate2]$certificate
+ )
+
+ $logLead = (Get-LogLeadName);
+
+ try {
+
+ $rsaKey = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($certificate)
+ }
+ catch {
+
+ Write-Warning "$logLead : Exception Occurred While Reading Private Key Details for Certificate with Thumbprint $($certificate.Thumbprint): $($_.Exception.Message.Trim())"
+ return $null
+ }
+
+ $rsaKeyFileName = $rsaKey.Key.UniqueName
+ $rsaKeyPath = "${env:ALLUSERSPROFILE}\Microsoft\Crypto\RSA\MachineKeys\$rsaKeyFileName"
+
+ if (Test-Path $rsaKeyPath) {
+
+ return (Get-Acl -Path $rsaKeyPath).Access
+ }
+ else {
+
+ Write-Warning "$logLead : Unable to Find Private Key for Certificate with Thumbprint $($certificate.Thumbprint) at $rsaKeyPath"
+ return $null
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Certificates/Public/Get-SecretServerConnection.ps1 b/Modules/Alkami.DevOps.Certificates/Public/Get-SecretServerConnection.ps1
new file mode 100644
index 0000000..590ddf6
--- /dev/null
+++ b/Modules/Alkami.DevOps.Certificates/Public/Get-SecretServerConnection.ps1
@@ -0,0 +1,26 @@
+function Get-SecretServerConnection {
+<#
+.SYNOPSIS
+ Exports a [SecretServerConnection] which can be used from Powershell's commandline. Largely for testing purposes.
+
+.PARAMETER Site
+ The uri for the Secret Server to connect to.
+
+.PARAMETER userName
+ Secret Server username.
+
+.PARAMETER password
+ Secret Server password.
+#>
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory = $true)]
+ [string]$Site,
+ [Parameter(Mandatory = $true)]
+ [string]$UserName,
+ [Parameter(Mandatory = $true)]
+ [string]$Password
+ )
+
+ return [SecretServerConnection]::new([string]$Site, [string]$UserName, [string]$Password);
+}
diff --git a/Modules/Alkami.DevOps.Certificates/Public/Import-Certificates.ps1 b/Modules/Alkami.DevOps.Certificates/Public/Import-Certificates.ps1
new file mode 100644
index 0000000..0e2d576
--- /dev/null
+++ b/Modules/Alkami.DevOps.Certificates/Public/Import-Certificates.ps1
@@ -0,0 +1,194 @@
+function Import-Certificates {
+ <#
+.SYNOPSIS
+ Imports certificates onto a machine.
+
+ .PARAMETER importPassword
+
+ The password used to import the certificate with. Only used when Personal store certificates are imported.
+
+ .PARAMETER importPath
+ The path the certificates are imported from. If no path is defined, the current working directory is used.
+
+ .PARAMETER usersWhoNeedRights
+ Optional list of users to grant rights for certificates which have private keys. If not supplied, defaults to IIS_IUSRS + $defaultUsersWhoNeedRights array.
+
+ .PARAMETER securityGroup
+ The Security Group for each GMSA account (pod6, pod8). When not supplied, defaults to the value in the machine.config on the current server.
+ If not found in machine.config, throws.
+
+ .PARAMETER skipRootCerts
+
+ When this flag is supplied it will skip the importing of certificates in the 'Root' store.
+
+ .PARAMETER skipPersonalCerts
+
+ When this flag is supplied it will skip the importing of certificates in the 'My' store.
+
+ .PARAMETER skipTrustedCerts
+
+ When this flag is supplied it will skip the importing of certificates in the 'Trusted' store.
+
+ .PARAMETER skipIACerts
+
+ When this flag is supplied it will skip the importing of certificates in the 'CertificateAuthority' store.
+
+#>
+ [CmdletBinding()]
+ Param(
+ [parameter(Mandatory = $false)]
+ [string]$importPassword,
+
+ [Parameter(Mandatory = $false)]
+ [string]$importPath = $PWD,
+
+ [Parameter(Mandatory = $false)]
+ [string[]]$usersWhoNeedRights = @("IIS_IUSRS"),
+
+ [Parameter(Mandatory = $false)]
+ [string]$securityGroup,
+
+ [Parameter(Mandatory = $false)]
+ [hashtable]$certPasswordList = @{ },
+
+ [Parameter(Mandatory = $false)]
+ [switch]$skipRootCerts,
+
+ [Parameter(Mandatory = $false)]
+ [switch]$skipPersonalCerts,
+
+ [Parameter(Mandatory = $false)]
+ [switch]$skipTrustedCerts,
+
+ [Parameter(Mandatory = $false)]
+ [switch]$skipIACerts
+ )
+
+ $unsignedCertFileIncludeFilter = @("*.cer", "*.crt")
+
+ # Modify this to add/remove accounts as defaults when usersWhoNeedRights is not supplied by the user
+ # Will be formatted as FH\.
+ $defaultUsersWhoNeedRights = @("radium$", "nag$", "dbms$", "micro$")
+
+ if (!$skipPersonalCerts.IsPresent -and !$importPassword) {
+ throw "Import Password cannot be null";
+ }
+
+ if ($skipRootCerts.IsPresent -and $skipPersonalCerts.IsPresent -and $skipTrustedCerts.IsPresent -and $skipIACerts.IsPresent) {
+ throw "All Skip Switches cannot be set";
+ }
+
+ if (!(Test-Path $importPath)) {
+ throw "Import Path not found";
+ }
+
+ if ( !($PSBoundParameters.ContainsKey('securityGroup')) ) {
+ Write-Host "securityGroup was not supplied by the user. Attempting to pull securityGroup from Environment.UserPrefix in machine.config."
+
+ if ($securityGroup = Get-AppSetting Environment.UserPrefix) {
+ Write-Host "securityGroup read from machine.config as: $securityGroup"
+ } else {
+ throw("securityGroup was not supplied by the user and could not be found in the machine config. Please specify the security group (i.e. pod6, pod8) and rerun this function.")
+ }
+ }
+
+ if ( !($PSBoundParameters.ContainsKey('usersWhoNeedRights')) ) {
+ Write-Host "usersWhoNeedRights was not supplied by the user. Using default accounts."
+
+ $defaultUsersWhoNeedRights = foreach ($defaultUserWhoNeedsRights in $defaultUsersWhoNeedRights) {
+ "fh\$securityGroup.$defaultUserWhoNeedsRights"
+ }
+
+ $usersWhoNeedRights += $defaultUsersWhoNeedRights
+ }
+
+ $rootImportPath = (Join-Path $importPath "Root");
+
+ if (!($skipRootCerts.IsPresent) -and (Test-Path $rootImportPath)) {
+ Write-Host "Importing Root Certs";
+
+ $certs = Get-ChildItem $rootImportPath -Recurse -include $unsignedCertFileIncludeFilter -Exclude "WMSVC*";
+
+ foreach ($cert in $certs) {
+ Import-Cert $cert.FullName ([System.Security.Cryptography.X509Certificates.StoreName]::Root);
+ }
+ }
+
+ $iaImportPath = (Join-Path $importPath "IA");
+
+ if (!($skipIACerts.IsPresent) -and (Test-Path $iaImportPath)) {
+ Write-Host "Importing IA Certs";
+
+ $certs = Get-ChildItem $iaImportPath -Recurse -include $unsignedCertFileIncludeFilter -Exclude "WMSVC*";
+
+ foreach ($cert in $certs) {
+ Import-Cert $cert.FullName ([System.Security.Cryptography.X509Certificates.StoreName]::CertificateAuthority);
+ }
+ }
+
+ $trustedImportPath = (Join-Path $importPath "TrustedPeople");
+
+ if (!($skipTrustedCerts.IsPresent) -and (Test-Path $trustedImportPath)) {
+ Write-Host "Importing Trusted Certs";
+
+ $certs = Get-ChildItem $trustedImportPath -Recurse -include $unsignedCertFileIncludeFilter -Exclude "WMSVC*";
+
+ foreach ($cert in $certs) {
+ Import-Cert $cert.FullName ([System.Security.Cryptography.X509Certificates.StoreName]::TrustedPeople);
+ }
+ }
+
+ $pfxImportPath = (Join-Path $importPath "Personal");
+
+ if (!($skipPersonalCerts.IsPresent) -and (Test-Path $pfxImportPath)) {
+ Write-Host "Importing Personal Certs";
+
+ $certs = Get-ChildItem $pfxImportPath -Recurse -include @("*.pfx", "*.cer") -Exclude "WMSVC*";
+
+ # Add any additional Service Users to $usersWhoNeedRights if they're assigned to a Windows Service
+ # and are running under the environment's gMSA Security Group
+ $ServiceUserAccounts = Get-CIMInstance Win32_Service | Where-Object { $_.StartName -match $securityGroup }
+ if ($ServiceUserAccounts) {
+ $usersWhoNeedRights += $ServiceUserAccounts.StartName
+ }
+
+ $usersWhoNeedRights = $usersWhoNeedRights | Sort-Object | Get-Unique
+
+ foreach ($cert in $certs) {
+ if ($cert.Extension -eq ".pfx") {
+ $currentCertPassword = $importPassword;
+
+ if ($certPasswordList.ContainsKey($cert.Name)) {
+ $currentCertPassword = certPasswordList.Get_Item($cert.Name);
+ }
+
+ Import-Cert $cert.FullName ([System.Security.Cryptography.X509Certificates.StoreName]::My) $currentCertPassword;
+
+ Confirm-Cert $cert.Name ([System.Security.Cryptography.X509Certificates.StoreName]::My);
+
+ $x509cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2($cert.FullName, $currentCertPassword);
+
+ $usersWhoNeedRights | ForEach-Object {
+
+ $user = $_;
+
+ Write-Host ("Granting {0} rights to PK for certificate {1}" -f $user, $targetCert.Name);
+
+ Set-CertPermissions $x509cert.Thumbprint $user;
+ }
+ } else {
+ $currentCertPassword = $importPassword;
+
+ if ($certPasswordList.ContainsKey($cert.Name)) {
+ $currentCertPassword = certPasswordList.Get_Item($cert.Name);
+ }
+
+ Import-Cert $cert.FullName ([System.Security.Cryptography.X509Certificates.StoreName]::My);
+
+ Confirm-Cert $cert.Name ([System.Security.Cryptography.X509Certificates.StoreName]::My);
+
+
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Certificates/Public/Import-Certificates.tests.ps1 b/Modules/Alkami.DevOps.Certificates/Public/Import-Certificates.tests.ps1
new file mode 100644
index 0000000..1f412e9
--- /dev/null
+++ b/Modules/Alkami.DevOps.Certificates/Public/Import-Certificates.tests.ps1
@@ -0,0 +1,130 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$global:functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ""
+
+$exportPassword = "Test"
+$exportPath = "c:\temp\CertificateTest"
+$usersWhoNeedRights = @("testuser1", "testuser2")
+
+Remove-FileSystemItem -Path $exportPath -Force -Recurse -ErrorAction SilentlyContinue | Out-Null
+New-Item -ItemType Directory $exportPath -Force | Out-Null
+
+InModuleScope -ModuleName Alkami.DevOps.Certificates -ScriptBlock {
+ Write-Host "InModuleScope - Overriding SUT: $($global:functionPath)"
+ Import-Module $global:functionPath -Force
+ $inScopeModuleForAssert = "Alkami.DevOps.Certificates"
+ $moduleForMock = ""
+ $exportPassword = "Test"
+ $exportPath = "c:\temp\CertificateTest"
+ $usersWhoNeedRights = @("testuser1", "testuser2")
+
+
+ Describe "Import-Certificates" {
+
+ Mock -CommandName Write-Host -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Write-Warning -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Get-AppSetting -ModuleName $moduleForMock -MockWith { return $null }
+
+ Context "When there are bad inputs when calling Import-Certificates" {
+
+ It "Throws Exception if all skip flags set" {
+
+ { Import-Certificates $exportPassword -skipPersonalCert -skipRootCerts -skipTrustedCert -skipIACert -securityGroup "pod1" } | Should -Throw
+ }
+
+ It "Throws Exception if path doesn't exist" {
+
+ { Import-Certificates $exportPassword -importPath 'C:\BadPath' -securityGroup "pod1" } | Should -Throw
+ }
+
+ It "Throws Exception if securityGroup is not supplied and it is not found in machine.config" {
+
+ { Import-Certificates $exportPassword } | Should -Throw
+ }
+ }
+
+ Context "When Inputs are correct" {
+
+ Mock -ModuleName $moduleForMock Join-Path { return "C:\temp\testpath" }
+ Mock -ModuleName $moduleForMock Test-Path { return $true }
+ Mock -ModuleName $moduleForMock -CommandName Import-Cert { }
+ Mock -ModuleName $moduleForMock Confirm-Cert { } -Verifiable
+ Mock -ModuleName $moduleForMock Get-ChildItem { return @{ FullName = "c:\temp\testpath\Test.pfx"; Name = "Test.pfx"; Extension = ".pfx"} }
+ Mock -ModuleName $moduleForMock Get-AlkamiServices { @{ Name = "Alkami.Radium"} }
+ Mock -ModuleName $moduleForMock Get-CIMInstance { @{ StartName = "podtest.user"} }
+ Mock -ModuleName $moduleForMock Set-CertPermissions {}
+ Mock -ModuleName $moduleForMock New-Object { @{ Thumbprint = "ABCDEFG"} }
+
+ It "Doesnt Require Password if not exporting Personal Certificates" {
+
+ { Import-Certificates -skipPersonalCerts -securityGroup "pod1" } | Should -Not -Throw
+ }
+
+ It "Calls Import-Cert when importing personal certs" {
+
+ Import-Certificates $exportPassword -skipRootCerts -skipTrustedCerts -skipIACerts -securityGroup "pod1"
+
+ Assert-MockCalled -ModuleName $inScopeModuleForAssert -CommandName Import-Cert -Times 1 -Exactly -Scope It
+ }
+
+ It "Calls Confirm-Cert when importing personal certs" {
+
+ Import-Certificates $exportPassword -skipRootCerts -skipTrustedCerts -skipIACerts -securityGroup "pod1"
+
+ Assert-MockCalled -ModuleName $inScopeModuleForAssert -CommandName Confirm-Cert -Times 1 -Exactly -Scope It
+ }
+
+ It "Calls Set-CertPermissions for default users + test WMI user when usersWhoNeedRights is not supplied" {
+
+ Import-Certificates $exportPassword -skipRootCerts -skipTrustedCerts -skipIACerts -securityGroup "pod1"
+
+ Assert-MockCalled -ModuleName $inScopeModuleForAssert -CommandName Set-CertPermissions -Times 5 -Exactly -Scope It
+ }
+
+ It "Calls Set-CertPermissions for supplied users + test WMI user when usersWhoNeedRights is supplied" {
+ Import-Certificates $exportPassword -skipRootCerts -skipTrustedCerts -skipIACerts -usersWhoNeedRights $usersWhoNeedRights -securityGroup "pod1"
+
+ Assert-MockCalled -ModuleName $inScopeModuleForAssert -CommandName Set-CertPermissions -Times 2 -Exactly -Scope It
+ }
+
+ It "Calls Set-CertPermissions for default users + test WMI user when usersWhoNeedRights is not supplied and additional users found in services" {
+ Mock -ModuleName $moduleForMock Get-CIMInstance { @( @{StartName = "pod1.user"}, @{StartName = "podtest.user"} ) }
+ Import-Certificates $exportPassword -skipRootCerts -skipTrustedCerts -skipIACerts -securityGroup "pod1"
+
+ Assert-MockCalled -ModuleName $inScopeModuleForAssert -CommandName Set-CertPermissions -Times 6 -Exactly -Scope It
+ }
+
+ It "Calls Set-CertPermissions for supplied users + test WMI user when usersWhoNeedRights is supplied and additional users found in services" {
+ Mock -ModuleName $moduleForMock Get-CIMInstance { @( @{StartName = "pod1.user"}, @{StartName = "podtest.user"} ) }
+ Import-Certificates $exportPassword -skipRootCerts -skipTrustedCerts -skipIACerts -usersWhoNeedRights $usersWhoNeedRights -securityGroup "pod1"
+
+ Assert-MockCalled -ModuleName $inScopeModuleForAssert -CommandName Set-CertPermissions -Times 3 -Exactly -Scope It
+ }
+
+ It "Calls Import-Cert when importing root certs" {
+
+ Import-Certificates $exportPassword -skipPersonalCerts -skipTrustedCerts -skipIACerts -securityGroup "pod1"
+
+ Assert-MockCalled -ModuleName $inScopeModuleForAssert -CommandName Import-Cert -Times 1 -Exactly -Scope It
+ }
+
+ It "Calls Import-Cert when importing trusted certs" {
+
+ Import-Certificates $exportPassword -skipPersonalCerts -skipRootCerts -skipIACerts -securityGroup "pod1"
+
+ Assert-MockCalled -ModuleName $inScopeModuleForAssert -CommandName Import-Cert -Times 1 -Exactly -Scope It
+ }
+
+ It "Calls Import-Cert when importing IA certs" {
+
+ Import-Certificates $exportPassword -skipPersonalCerts -skipRootCerts -skipTrustedCerts -securityGroup "pod1"
+
+ Assert-MockCalled -ModuleName $inScopeModuleForAssert -CommandName Import-Cert -Times 1 -Exactly -Scope It
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Certificates/Public/Import-PfxCertificateWithPermissions.ps1 b/Modules/Alkami.DevOps.Certificates/Public/Import-PfxCertificateWithPermissions.ps1
new file mode 100644
index 0000000..4baa547
--- /dev/null
+++ b/Modules/Alkami.DevOps.Certificates/Public/Import-PfxCertificateWithPermissions.ps1
@@ -0,0 +1,98 @@
+function Import-PfxCertificateWithPermissions {
+<#
+.SYNOPSIS
+ Import a PFX certificate with appropriate user permissions without needing the folder structure as specified in Import-Certificates.
+
+ .PARAMETER ImportPassword
+ The password used to import the certificate.
+
+ .PARAMETER PathToPfxCertificate
+ The path to a specified PFX file.
+
+ .PARAMETER UsersWhoNeedRights
+ Optional list of users to grant rights for certificates which have private keys. If not supplied, then the resulting Import-Certificates call will assign the default users
+
+ .EXAMPLE
+
+ Import-PfxCertificateWithPermissions -ImportPassword 'PASSWORD_GOES_HERE' -PathToPfxCertificate "\\10.0.16.67\c`$\temp\mccu.com.pfx"
+ [Import-PfxCertificateWithPermissions] : Copied cert to C:\Users\ccoane\AppData\Local\Temp\2\904bf9e6-4be5-4b8e-805b-03817b6dd198\Personal
+ Importing Personal Certs
+ Validating certificate mccu.com.pfx
+ Certificate mccu.com.pfx Passed Validation
+ Granting fh\dev.dbms$ rights to PK for certificate
+ Granting fh\dev.micro$ rights to PK for certificate
+ Granting FH\dev.micro$ rights to PK for certificate
+ Granting fh\dev.nag$ rights to PK for certificate
+ Granting fh\dev.radium$ rights to PK for certificate
+ Granting iis_iusrs rights to PK for certificate
+ [Import-PfxCertificateWithPermissions] : Removed temporary directory C:\Users\ccoane\AppData\Local\Temp\2\904bf9e6-4be5-4b8e-805b-03817b6dd198
+
+
+ // If you need to make a modification to the default user list used by Import-Certificates e.g. PFX also requires access granted to fh\xxxx.bank$
+ $SecurityGroup = Get-AppSetting -appSettingKey "Environment.UserPrefix"
+ Import-PfxCertificateWithPermissions -ImportPassword 'PASSWORD_GOES_HERE' -PathToPfxCertificate "\\10.0.16.67\c`$\temp\mccu.com.pfx" -UsersWhoNeedRights @("iis_iusrs", "fh\$SecurityGroup.radium$", "fh\$SecurityGroup.nag$", "fh\$SecurityGroup.dbms$", "fh\$SecurityGroup.micro$", "fh\$SecurityGroup.bank$")
+ [Import-PfxCertificateWithPermissions] : Copied cert to C:\Users\ccoane\AppData\Local\Temp\2\a7ad2893-884b-4604-bed2-2c2d6bd597da\Personal
+ Importing Personal Certs
+ Validating certificate mccu.com.pfx
+ Certificate mccu.com.pfx Passed Validation
+ Granting fh\dev.bank$ rights to PK for certificate
+ Granting fh\dev.dbms$ rights to PK for certificate
+ Granting FH\dev.micro$ rights to PK for certificate
+ Granting fh\dev.micro$ rights to PK for certificate
+ Granting fh\dev.nag$ rights to PK for certificate
+ Granting fh\dev.radium$ rights to PK for certificate
+ Granting iis_iusrs rights to PK for certificate
+ [Import-PfxCertificateWithPermissions] : Removed temporary directory C:\Users\ccoane\AppData\Local\Temp\2\a7ad2893-884b-4604-bed2-2c2d6bd597da
+
+#>
+ [CmdletBinding()]
+ Param(
+ [parameter(Mandatory=$true)]
+ [string]$ImportPassword,
+
+ [Parameter(Mandatory=$true)]
+ [string]$PathToPfxCertificate,
+
+ [Parameter(Mandatory=$false)]
+ [string[]]$UsersWhoNeedRights
+ )
+
+ $logLead = (Get-LogLeadName)
+
+ # Verify Pfx is valid and exists/accessible
+ if (!($PathToPfxCertificate -Like "*.pfx")) {
+ Write-Error "$logLead : This function is expecting a certificate with a .pfx extension as the value for `$PathToPfxCertificate. Provided value is: $PathToPfxCertificate"
+ return
+ }
+
+ if (!(Test-Path $PathToPfxCertificate)) {
+ Write-Error "$logLead : Unable to reach the specified file from this server. Verify the path is correct and accessible to this server. Provided value is: $PathToPfxCertificate"
+ return
+ }
+
+ try {
+ $randomPath = Join-Path $Env:Temp $(New-Guid)
+
+ # Copy PFX to a randomly created folder in appropriate Import-Certificates folder structure
+ $tempFolderPersonalPath = New-Item -Path $randomPath -ItemType Directory -Name "Personal" -Force
+ Copy-Item -Path $PathToPfxCertificate -Destination $tempFolderPersonalPath
+ Write-Host "$logLead : Copied cert to $tempFolderPersonalPath"
+
+ # If user provided an argument for $UsersWhoNeedRights
+ if ($UsersWhoNeedRights) {
+ Import-Certificates -importPassword $ImportPassword -importPath $randomPath -usersWhoNeedRights $UsersWhoNeedRights
+ } else {
+ Import-Certificates -importPassword $ImportPassword -importPath $randomPath
+ }
+ } catch {
+ Write-Error "$logLead : $_"
+ } finally {
+ # Delete the randomly created folder if it exists
+ if (Test-Path -Path $randomPath) {
+ Remove-Item -Path $randomPath -Recurse -Force
+ Write-Host "$logLead : Removed temporary directory $randomPath"
+ }
+ }
+
+ return $randomPath
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Certificates/Public/Import-PfxCertificateWithPermissions.tests.ps1 b/Modules/Alkami.DevOps.Certificates/Public/Import-PfxCertificateWithPermissions.tests.ps1
new file mode 100644
index 0000000..2a6170f
--- /dev/null
+++ b/Modules/Alkami.DevOps.Certificates/Public/Import-PfxCertificateWithPermissions.tests.ps1
@@ -0,0 +1,52 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+
+$importPasswordTest = "PASSWORD_GOES_HERE"
+$pathToCertificateIsADirectory = "C:\Temp\Personal"
+$pathToCertificateIsNotAPfxFile = "C:\Temp\Personal\Certificate.crt"
+$pathToCertificateIsNotAccessible = "C:\Temp\Personal\CertificateThatShouldNotBeAccessibleUnlessYouMakeThisFileToSpiteMe.pfx"
+$pathToCertificatePfxFile = "C:\Temp\Personal\Certificate.pfx"
+
+$WriteErrorIncorrectPfxArgument = "This function is expecting a certificate with a .pfx extension as the value for"
+$WriteErrorInaccessiblePfxFile = "Unable to reach the specified file from this server"
+
+Describe "Import-PfxCertificateWithPermissions" {
+
+ Mock -CommandName Copy-Item -ModuleName Alkami.DevOps.Certificates -MockWith { }
+ Mock -CommandName Import-Certificates -ModuleName Alkami.DevOps.Certificates -MockWith { }
+ Mock -CommandName Write-Error -ModuleName Alkami.DevOps.Certificates -MockWith { }
+
+ Context "When there are bad inputs when calling Import-PfxCertificateWithPermissions" {
+
+ Mock -CommandName Test-Path -ModuleName Alkami.DevOps.Certificates -MockWith { return $false }
+
+ It "Writes Error if path argument has a folder path and does not point to a PFX file" {
+ Import-PfxCertificateWithPermissions -ImportPassword $importPasswordTest -PathToPfxCertificate $pathToCertificateIsADirectory
+ Assert-MockCalled -CommandName Write-Error -Times 1 -Exactly -Scope It -ModuleName Alkami.DevOps.Certificates -ParameterFilter { $Message -match $WriteErrorIncorrectPfxArgument }
+ }
+
+ It "Writes Error if path argument has a file path but does not point to a PFX file" {
+ Import-PfxCertificateWithPermissions -ImportPassword $importPasswordTest -PathToPfxCertificate $pathToCertificateIsNotAPfxFile
+ Assert-MockCalled -CommandName Write-Error -Times 1 -Exactly -Scope It -ModuleName Alkami.DevOps.Certificates -ParameterFilter { $Message -match $WriteErrorIncorrectPfxArgument }
+ }
+
+ It "Writes Error if path argument does not point to an accessible PFX file" {
+ Import-PfxCertificateWithPermissions -ImportPassword $importPasswordTest -PathToPfxCertificate $pathToCertificateIsNotAccessible
+ Assert-MockCalled -CommandName Write-Error -Times 1 -Exactly -Scope It -ModuleName Alkami.DevOps.Certificates -ParameterFilter { $Message -match $WriteErrorInaccessiblePfxFile }
+ }
+ }
+
+ Context "When Inputs are correct" {
+
+ Mock -CommandName Test-Path -ModuleName Alkami.DevOps.Certificates -MockWith { return $true }
+
+ It "Assert reaches Import-Certificates " {
+ Import-PfxCertificateWithPermissions -ImportPassword $importPasswordTest -PathToPfxCertificate $pathToCertificatePfxFile
+ Assert-MockCalled -ModuleName Alkami.DevOps.Certificates Import-Certificates -Times 1 -Exactly -Scope It
+ }
+
+ It "Assert randomly created folder doesn't exist after function completion " {
+ $folder = Import-PfxCertificateWithPermissions -ImportPassword $importPasswordTest -PathToPfxCertificate $pathToCertificatePfxFile
+ $folder | Should -Not -Exist
+ }
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Certificates/Public/Import-PodFromSecretServer.ps1 b/Modules/Alkami.DevOps.Certificates/Public/Import-PodFromSecretServer.ps1
new file mode 100644
index 0000000..cd5a1f5
--- /dev/null
+++ b/Modules/Alkami.DevOps.Certificates/Public/Import-PodFromSecretServer.ps1
@@ -0,0 +1,184 @@
+function Import-PodFromSecretServer {
+<#
+.SYNOPSIS
+ Import Pod Certificates from Secret Server.
+#>
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory = $false)]
+ $PodName,
+ [Parameter(Mandatory = $false, ParameterSetName = "credentials")]
+ [string]$SecretServerUrl ="https://alkami.secretservercloud.com",
+ [Parameter(Mandatory = $false, ParameterSetName = "credentials")]
+ $SecretServerUserName,
+ [Parameter(Mandatory = $false, ParameterSetName = "credentials")]
+ $SecretServerPassword,
+ [Parameter(Mandatory = $false)]
+ [ValidateSet("Web", "App")]
+ $ServerType,
+ [Parameter(Mandatory = $false, HelpMessage = "ADGroups that need permission to these certificates")]
+ [string[]]$ADGroups,
+ [Parameter(Mandatory = $false)]
+ $TempDirectory = "c:\temp\importTempDir",
+ [Parameter(Mandatory = $false)]
+ [string]$CertRoot = "Cert:\LocalMachine\",
+ [Parameter(Mandatory = $false)]
+ [switch]$UsePassword,
+ [Parameter(Mandatory = $false)]
+ [ValidateSet("Production", "Staging")]
+ [string]$EnvironmentType
+ )
+ begin {
+ Import-AWSModule # SSM
+
+ New-Item -Path $TempDirectory -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
+
+ switch ($EnvironmentType) {
+ "Production" {$RootFolderName = "Production-CertApi"}
+ "Staging" {$RootFolderName = "Staging-CertApi"}
+ }
+
+ # Get all parmeters which weren't supplied.
+ if(!$EnvironmentType)
+ {
+ $EnvironmentType = Get-AppSetting Environment.Type
+ }
+
+ if(!$PodName)
+ {
+ $PodName = Get-AppSetting Environment.Name
+ }
+
+ if(!$ServerType)
+ {
+ $ServerType = Get-AppSetting Environment.Server
+ }
+
+ if((!$SecretServerUserName -or !$SecretServerPassword))
+ {
+ if(Test-IsAws)
+ {
+ if(!$SecretServerUserName) {
+ $SecretServerUserName = (Get-SSMParameter -Name "secret_server_api_username" -WithDecryption $true).Value
+ }
+
+ if(!$SecretServerPassword) {
+ $SecretServerPassword = (Get-SSMParameter -Name "secret_server_api_password" -WithDecryption $true).Value
+ }
+ }
+ else {
+ Write-Error "Secret credentials must be manually provided if this is run on a non-AWS machine. They cannot be retrieved from SSM."
+ }
+ }
+
+ $secretServer = [SecretServerConnection]::new($SecretServerUrl, $SecretServerUserName, $SecretServerPassword)
+ $UseTwoFactor = $false
+ $secretServer.Authenticate($UseTwoFactor)
+
+ $NameOfCertificateField = "pfx-file"
+ $NameOfPasswordField = "Import Password"
+ Add-Type -Assembly System.IO.Compression.FileSystem
+ }
+ process {
+ $startTime = Get-Date
+ Write-Debug "Getting folder info"
+ $environmentFolderId = $secretServer.GetFolderIdByName($RootFolderName)
+ $podFolderId = $secretServer.GetFolderIdByName($PodName, $environmentFolderId)
+ $subFolderId = $secretServer.GetFolderIdByName($ServerType, $podFolderId) # Should be at an App/Mic/Web folder by this point. These folder names aren't unique, hence getting the environment folder id above.
+
+ $storeFolders = $secretServer.GetChildFolders($subFolderId)
+
+ $timeElapsed = New-TimeSpan -Start $startTime -End (Get-Date)
+ Write-Debug "Folder info retreived - $timeElapsed"
+
+ foreach ($storeFolder in $storeFolders) {
+ if ($storeFolder.FolderName -eq "CertificateAuthority") {$sanitizedStore = "CA"} else {$sanitizedStore = $storeFolder.FolderName} # Powershell's Paths don't match the X509 enum. So here we do a poor man's lookup. If we ever need more stores than just the CA, this needs to be expanded to something more robust and clean.
+
+ $TempStoreDirectory = Join-Path $TempDirectory $storeFolder.FolderName
+
+ $installedCerts = (Get-Cert -StoreName $sanitizedStore).thumbprint # Get certs in the current store.
+
+ Write-Debug "Getting Secrets and downloading certificate chain"
+ $secretInfoList = $secretServer.GetSecretByFolderId($storeFolder.id)
+ $secretInfoList | Where-Object {$installedCerts -notcontains $_.name.split("-")[-1]}
+ foreach ($secretInfo in $secretInfoList) {
+ $secret = $secretServer.GetSecretById($secretInfo.id)
+
+ # Don't import expired certs
+ $certExpirationDate = $secret.items | Where-Object {$_.fieldName -eq "ExperationDate"}
+ if ((Get-Date $certExpirationDate.itemValue) -gt (Get-Date)) {
+ if ($installedCerts) { #Don't die if there are no certs
+ $secretThumbprint = $secret.items | Where-Object {$_.fieldName -eq "Thumbprint"} | Select-Object -ExpandProperty itemValue
+ if ($installedCerts.Contains($secretThumbprint)) {
+ Write-Verbose "Certificate with thumbprint $secretThumbprint already installed"
+ continue
+ }
+ }
+ if ($UsePassword) {
+ $TempPassword = $secret.items | Where-Object {$_.fieldName -eq $NameOfPasswordField} | Select-Object -ExpandProperty itemValue
+ $Password = ConvertTo-SecureString $TempPassword -AsPlainText -Force
+ }
+
+ $folder = Join-Path $TempStoreDirectory $secret.Name
+ Remove-Item $folder -Force -ea SilentlyContinue -Recurse
+ New-Item $folder -ItemType Directory -Force -ea SilentlyContinue | Out-Null
+
+ $zipFolder = $folder, "$($secret.Name).zip" -join "\"
+ if ($secretServer.DownloadFile($secret.Id, $NameOfCertificateField, $zipFolder)) {
+ $timeElapsed = New-TimeSpan -Start $startTime -End (Get-Date)
+ Write-Debug "downloaded... - $timeElapsed"
+
+ [System.IO.Compression.ZipFile]::ExtractToDirectory($zipFolder, $folder)
+ Write-Debug "unzipping complete - $timeElapsed"
+
+ $certificateFiles = Get-ChildItem $folder -Include "*.cer", "*.pfx" -Recurse
+ $certList = [System.Collections.Arraylist]::new()
+ foreach ($file in $certificateFiles) {
+ $certStore = if ($file.Directory.Name -Match $file.BaseName) {$sanitizedStore}else {$file.Directory.Name}
+
+ [void]$certList.Add(([PSObject]@{
+ file = $file
+ certStore = Join-path $CertRoot $certStore
+ }))
+ }
+
+ foreach ($cert in $certList) {
+ if ($cert.file.extension -eq ".cer") {
+ try {
+ Import-Certificate -FilePath $cert.file.FullName -CertStoreLocation $cert.certStore | Out-null
+ }
+ catch {
+ Write-Warning "Failed to import Cert with filename $($cert.file.FullName)"
+ Write-Warning -Message "Error: $($_.Exception.ErrorRecord)"
+ Write-Warning -Message "Stack Trace: $($_.Exception.StackTrace)"
+ }
+ }
+ else {
+ try {
+ $installedCert = Import-PfxCertificate -FilePath $cert.file.FullName -CertStoreLocation $cert.certStore -Exportable -Password $Password
+ foreach ($ADGroup in $ADGroups.Split("{,}")) {
+ Write-Host "Setting Permissions"
+ Set-CertPermissions -certThumprint $installedCert.Thumbprint -user $ADGroup | Out-Null
+ }
+ }
+ catch [System.ComponentModel.Win32Exception] {
+ Write-Warning "Failed to import Cert with filename $($cert.file.FullName)"
+ Write-Warning -Message "Error: $($_.Exception.Message)"
+ Write-Warning -Message "Stack Trace: $($_.Exception.StackTrace)"
+ }
+ catch {
+ Write-Warning "Failed to import Cert with filename $($cert.file.FullName)"
+ Write-Warning -Message "Error: $($_.Exception.ErrorRecord)"
+ Write-Warning -Message "Stack Trace: $($_.Exception.StackTrace)"
+ }
+ }
+ }
+ }
+ else {
+ Write-Warning -Message "File could not be downloaded for certificate with thumbprint $($secretThumbprint)."
+ }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Certificates/Public/Publish-PodToSecretServer.ps1 b/Modules/Alkami.DevOps.Certificates/Public/Publish-PodToSecretServer.ps1
new file mode 100644
index 0000000..6cf9002
--- /dev/null
+++ b/Modules/Alkami.DevOps.Certificates/Public/Publish-PodToSecretServer.ps1
@@ -0,0 +1,148 @@
+function Publish-PodToSecretServer {
+<#
+.SYNOPSIS
+ Publish Pod's Certificates to Secret Server.
+#>
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory = $false)]
+ [string[]]$ADGroups,
+ [Parameter(Mandatory = $false)]
+ [PSObject]$Pod,
+ [Parameter (Mandatory = $false)]
+ [string]$PodName,
+ [Parameter(Mandatory = $false)]
+ [string]$SecretServerUserName,
+ [Parameter(Mandatory = $false)]
+ [string]$SecretServerPassword,
+ [Parameter(Mandatory = $false)]
+ [string]$SecretServerUrl ="https://alkami.secretservercloud.com",
+ [Parameter(Mandatory = $false)]
+ [string]$TempFolder = "C:\Temp",
+ [Parameter(Mandatory = $false)]
+ [ValidateSet("Production", "Staging")]
+ [string]$EnvironmentType,
+ [Parameter(Mandatory = $false)]
+ [string]$SubFolder
+ )
+ begin {
+ Import-AWSModule # SSM
+
+ switch ($EnvironmentType) {
+ "Production" {$RootFolderName = "Production-CertApi"}
+ "Staging" {$RootFolderName = "Staging-CertApi"}
+ }
+
+ # Get all parmeters which weren't supplied.
+ if(!$EnvironmentType)
+ {
+ $EnvironmentType = Get-AppSetting Environment.Type
+ }
+
+ if(!$PodName)
+ {
+ $PodName = Get-AppSetting Environment.Name
+ }
+
+ if(!$SubFolder)
+ {
+ $SubFolder = Get-AppSetting Environment.Server
+ }
+
+ if((!$SecretServerUserName -or !$SecretServerPassword))
+ {
+ if(Test-IsAws)
+ {
+ if(!$SecretServerUserName) {
+ $SecretServerUserName = (Get-SSMParameter -Name "secret_server_api_username" -WithDecryption $true).Value
+ }
+
+ if(!$SecretServerPassword) {
+ $SecretServerPassword = (Get-SSMParameter -Name "secret_server_api_password" -WithDecryption $true).Value
+ }
+ }
+ else {
+ Write-Error "Secret credentials must be manually provided if this is run on a non-AWS machine. They cannot be retrieved from SSM."
+ }
+ }
+
+ if (!$Pod) {
+ $PodName = "$PodName-CertApi" # Tack on something to make the folder name distinct from manually created folders.
+ $Pod = Export-CertificatesToFileSystem -PodName $PodName -ADGroups $ADGroups
+ }
+
+ $secretServer = [SecretServerConnection]::new($SecretServerUrl, $SecretServerUserName, $SecretServerPassword)
+ $UseTwoFactor = $false
+ $secretServer.Authenticate($UseTwoFactor)
+
+ Add-Type -Assembly System.IO.Compression.FileSystem
+ }
+
+ process {
+
+ #Zip certficates and their chain
+ $certificatesFromAllStores = $Pod.Stores.Values.Certificates.Values
+ Compress-Certificates $certificatesFromAllStores $TempFolder
+
+ #Create folder for this pod
+ $rootFolderId = $SecretServer.GetFolderIdByName($RootFolderName)
+
+ $podFolderId = $secretServer.GetFolderIdByName($Pod.PodName, $rootFolderId)
+ if (!$podFolderId) {
+ $podFolderId = $secretServer.AddFolder($rootFolderId, $Pod.PodName)
+ }
+
+ $subFolderId = $secretServer.GetFolderIdByName($Subfolder, $podFolderId)
+ if (!$subFolderId) {
+ $subFolderId = $secretServer.AddFolder($podFolderId, $Subfolder)
+ }
+
+ foreach ($store in $Pod.Stores.Keys) {
+ $storeFolderId = $secretServer.GetFolderIdByName($store, $subFolderId)
+ if (!$storeFolderId) {
+ $storeFolderId = $secretServer.AddFolder($subFolderId, $store)
+ }
+ $certificates = $Pod.Stores.$store.Certificates.Values
+
+ foreach ($certificate in $certificates) {
+
+ try {
+ #Process
+ #Get a new secret template
+ $templateId = $secretServer.GetSecretTemplateIdByName("CertificateStore")
+ $secret = $SecretServer.GetSecretTemplateById($templateId, $storeFolderId)
+
+ # Configure the secret
+ # Each of these two line blocks creates a *reference* to a specific item in the secret.items list. That reference is then set to the correct
+ # certificate field value, which updates the $secret.items[x].itemValue value. Clear as mud?
+ $certName = $secret.items | Where-Object {$_.fieldName -eq "Certificate Name"}
+ $certName.itemValue = $certificate.Name
+
+ $certPassword = $secret.items | Where-Object {$_.fieldName -eq "Import Password"}
+ $certPassword.itemValue = $certificate.Password
+
+ $certNotes = $secret.items | Where-Object {$_.fieldName -eq "Notes"}
+ $certNotes.itemValue = "ADGroups with access to this certificate: $($certificate.ADGroups)"
+
+ $certThumbprint = $secret.items | Where-Object {$_.fieldName -eq "Thumbprint"}
+ $certThumbprint.itemValue = $certificate.Thumbprint
+
+ $certExpirationDate = $secret.items | Where-Object {$_.fieldName -eq "ExperationDate"} # Yes, Expiration is mispelled, because Thycotic.
+ $certExpirationDate.itemValue = $certificate.ExpirationDate.ToShortDateString()
+
+ $secretName = $certificate.Name, $certificate.thumbprint, $store.ToString()[0] -join "-"
+ $secretId = $secretServer.CreateSecret($secret, $storeFolderId, $secretName)
+
+ #Upload certificate zip folder
+ Write-Output "Publishing certificate $($certificate.Name)"
+ $zipFilePath = $certificate.Folder, ($certificate.Name.Trim() + ".zip") -join "\"
+ $secretServer.UploadFile($secretId, "pfx-File", $zipFilePath)
+ }
+ catch [InvalidOperationException] {
+ Write-Warning "There was a problem publishing a certificate."
+ Write-Warning $_
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Certificates/Public/Read-AppTierCertificates.ps1 b/Modules/Alkami.DevOps.Certificates/Public/Read-AppTierCertificates.ps1
new file mode 100644
index 0000000..bd8b7a3
--- /dev/null
+++ b/Modules/Alkami.DevOps.Certificates/Public/Read-AppTierCertificates.ps1
@@ -0,0 +1,26 @@
+function Read-AppTierCertificates {
+<#
+.SYNOPSIS
+ Reads App Tier Certificates.
+#>
+
+ [CmdletBinding()]
+ Param(
+ [string]$baseFolder,
+ [Hashtable[]]$certificatesTable
+ )
+
+ $logLead = (Get-LogLeadName);
+
+ [string[]]$usersWhoNeedRights = $null
+ $usersWhoNeedRights += "IIS_IUSRS"
+
+ foreach ($service in (Get-AppTierServices) | Where-Object {$_.User -notmatch "SYSTEM|REPLACEME"}) {
+ $usersWhoNeedRights += $service.User
+ }
+
+ Write-Verbose ("$logLead : Users who need rights read as {0}" -f ($usersWhoNeedRights -join ","))
+ Read-Certificates $baseFolder $certificatesTable $usersWhoNeedRights
+}
+
+Set-Alias -name Load-AppTierCertificates -value Read-AppTierCertificates;
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Certificates/Public/Read-Certificates.ps1 b/Modules/Alkami.DevOps.Certificates/Public/Read-Certificates.ps1
new file mode 100644
index 0000000..713f8d5
--- /dev/null
+++ b/Modules/Alkami.DevOps.Certificates/Public/Read-Certificates.ps1
@@ -0,0 +1,100 @@
+function Read-Certificates {
+<#
+.SYNOPSIS
+ Reads Certificates.
+#>
+
+ [CmdletBinding()]
+ Param(
+ [string]$baseFolder,
+ [Hashtable[]]$certificatesTable,
+ [string[]]$usersWhoNeedRights
+ )
+
+ $logLead = (Get-LogLeadName);
+
+ $localMachineStoreLocation = [System.Security.Cryptography.X509Certificates.StoreLocation]::LocalMachine
+ $trustedPeopleStore = [System.Security.Cryptography.X509Certificates.StoreName]::TrustedPeople
+ $personalStore = [System.Security.Cryptography.X509Certificates.StoreName]::My
+ $rootStore = [System.Security.Cryptography.X509Certificates.StoreName]::Root
+
+ $folderInfo = @(
+
+ @{ Name = "Root"; Store = $rootStore; Path = (Join-Path $baseFolder "ROOT"); ImportPrivateKey = $false; },
+ @{ Name = "Personal"; Store = $personalStore; Path = (Join-Path $baseFolder "Personal"); ImportPrivateKey = $true; },
+ @{ Name = "TrustedPeople"; Store = $trustedPeopleStore; Path = (Join-Path $baseFolder "TrustedPeople"); ImportPrivateKey = $false; }
+ )
+
+ # Loop through each folder
+ foreach ($folder in $folderInfo) {
+ Write-Output ("$logLead : Loading Certificates from {0}" -f $folder.Path)
+ $certificates = $certificatesTable | Where-Object {$_.FileName.ToUpperInvariant().StartsWith($folder.Path.ToUpperInvariant())}
+
+ # Loop through each certificate found
+ foreach ($certificate in $certificates) {
+ if (!(Test-Path $certificate.FileName)) {
+ Write-Output ("$logLead : Could not locate certificate on disk at {0}" -f $certificate.FileName)
+ continue;
+ }
+
+ if ($certificate.FileName.EndsWith(".pfx")) {
+ # Create an X509Certificate2 Object
+ $x509Cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2(
+ $certificate.FileName,
+ $certificate.Password
+ )
+
+ if ($folder.ImportPrivateKey) {
+ Write-Output ("$logLead : Loading certificate {0} (with private key) to store {1}" -f $certificate.FileName, $folder.Store.ToString())
+
+ # Load the Certificate and Private Key from File
+ [Alkami.Ops.Common.Cryptography.CertificateHelper]::LoadCertificateToStore(
+ $certificate.FileName,
+ $folder.Store,
+ $localMachineStoreLocation,
+ $certificate.Password
+ )
+
+ # Grant rights to the private key
+ $usersWhoNeedRights | ForEach-Object {
+
+ $serviceAccount = $_
+
+ Write-Output ("$logLead : Granting rights to private key for user {0}" -f $serviceAccount)
+
+ [Alkami.Ops.Common.Cryptography.CertificateHelper]::GrantRightsToPrivateKeys(
+ $x509Cert.Thumbprint,
+ $folder.Store,
+ $localMachineStoreLocation,
+ $serviceAccount)
+ }
+ }
+ else {
+ Write-Output ("$logLead : Loading certificate {0} (without private key) to store {1}" -f $certificate.FileName, $folder.Store.ToString())
+
+ # Load only the Certificate from File
+ [Alkami.Ops.Common.Cryptography.CertificateHelper]::LoadCertificateFromPFXToStore(
+ $certificate.FileName,
+ $folder.Store,
+ $localMachineStoreLocation
+ )
+ }
+ }
+ elseif ($certificate.FileName.EndsWith(".cer")) {
+ Write-Output ("$logLead : Loading certificate {0} to store {1}" -f $certificate.FileName, $folder.Store.ToString())
+
+ # Load the Certificate from File
+ [Alkami.Ops.Common.Cryptography.CertificateHelper]::LoadCertificateToStore(
+ $certificate.FileName,
+ $folder.Store,
+ $localMachineStoreLocation
+ )
+ }
+ else {
+ Write-Output ("$logLead : Filetype for {0} does not have any logic associated with it. No action taken" -f $certificate.FileName)
+ }
+ }
+ }
+}
+
+Set-Alias -name Load-Certificates -value Read-Certificates;
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Certificates/Public/Read-WebTierCertificates.ps1 b/Modules/Alkami.DevOps.Certificates/Public/Read-WebTierCertificates.ps1
new file mode 100644
index 0000000..9e61d99
--- /dev/null
+++ b/Modules/Alkami.DevOps.Certificates/Public/Read-WebTierCertificates.ps1
@@ -0,0 +1,23 @@
+function Read-WebTierCertificates {
+<#
+.SYNOPSIS
+ Reads Web Tier Certificates.
+#>
+
+ [CmdletBinding()]
+ Param(
+ [string]$baseFolder,
+ [Hashtable[]]$certificatesTable,
+ [string[]]$usersWhoNeedRights
+ )
+
+ $logLead = (Get-LogLeadName);
+
+ [string[]]$usersWhoNeedRights = $null
+ $usersWhoNeedRights += "IIS_IUSRS"
+
+ Write-Verbose ("$logLead : Users who need rights read as {0}" -f ($usersWhoNeedRights -join ","))
+ Read-Certificates $baseFolder $certificatesTable $usersWhoNeedRights
+}
+
+Set-Alias -name Load-WebTierCertificates -value Read-WebTierCertificates;
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Certificates/Public/Remove-Certificate.ps1 b/Modules/Alkami.DevOps.Certificates/Public/Remove-Certificate.ps1
new file mode 100644
index 0000000..83cbd5d
--- /dev/null
+++ b/Modules/Alkami.DevOps.Certificates/Public/Remove-Certificate.ps1
@@ -0,0 +1,157 @@
+function Remove-Certificate {
+<#
+
+.SYNOPSIS
+ Deletes a specified Certificate
+
+.DESCRIPTION
+ This function helps locate a specific Certificate to delete based on where it is located and by a distinguishing characteristic.
+ Otherwise a Certificate object can be inputted into the function to delete as well.
+ For the function to work as intended, one of the three must be provided...
+
+ FriendlyName [string]
+ Thumbprint [string]
+ Certificate [object]
+
+.PARAMETER
+ [string] Specifying the CurrentUser or LocalComputer when navigating Certificate locations
+
+.PARAMETER
+ [string] Specifying the folder in which to search for a specific Certificate
+
+.PARAMETER
+ [string] A descriptory text to distinguish different Certificates. Not always provided by the Certificate
+
+.PARAMETER
+ [string] A specific code that singles out a Certificate
+
+.PARAMETER
+ [object] This is an item (certificate) that has already been selected to run this function against
+
+.EXAMPLE
+ Remove-Certificate -Store TrustedPublisher -FriendlyName 'Alkami Issued Token'
+
+.EXAMPLE
+ Get-Item 'Cert:\LocalMachine\TrustedPublisher\E7EAC1F158CB26C5D2B061B9085D65F7CCC4ADAC' | Remove-Certificate
+
+#>
+
+ [CmdletBinding(DefaultParameterSetName = 'Certificate', SupportsShouldProcess = $true)]
+ param (
+ [Parameter(Mandatory, ParameterSetName = "Certificate", ValueFromPipeline)]
+ [object]$Certificate,
+
+ [Parameter(ParameterSetName = "Thumbprint")]
+ [string]$Thumbprint,
+
+ [Parameter(ParameterSetName = "FriendlyName")]
+ [string]$FriendlyName,
+
+ [Parameter()]
+ [ValidateSet('CurrentUser','LocalMachine')]
+ [string]$StoreLocation = 'LocalMachine',
+
+ [Parameter()]
+ [ArgumentCompleter({
+ $possibleValues = @('My','TrustedPeople','CA','UserDS','Root','TrustedPublisher', 'AddressBook','Remote Desktop')
+ return $possibleValues | ForEach-Object { $_ } # Runs through the foreach loop while the user is tabbing through the different fields
+ })][string]$Store,
+
+ [Parameter()]
+ [switch]$Force
+ )
+
+ $logLead = Get-LogLeadName
+
+ # If the certificate is populated, and is not a string or is explicitly a certificate that data is used for input
+ if ($null -ne $Certificate ) {
+ $certType = $Certificate.GetType().Name
+
+ if ($certType -eq "String") {
+ Write-Error "$logLead : Certificate parameter was passed as an unrecognizable or unacceptable type: [$certType]. Exiting."
+ throw New-Object System.ArgumentException "Certificate parameter is expected to be a certificate object."
+ }
+ if ($certType -ne "X509Certificate2") {
+ Write-Warning "$logLead : Certificate is of type [$certType]. This is not explicitly an X509Certificate2, but it's not a string either. Proceeding."
+ }
+
+ $CertificatePSPath = $Certificate.PSPath
+ if (![string]::IsNullOrWhiteSpace($CertificatePSPath)) {
+ # Splitting the path to get the $Store and $StoreLocation
+ $pathSplits = $Certificate.PSParentPath -split '::'
+ if ($pathSplits[0] -eq "Microsoft.PowerShell.Security\Certificate") {
+ $CertLocationAndStore = $pathSplits[1] -split "\\"
+ # Overwriting user input with Certificate input for Store and StoreLocation
+ $CertStoreLocation = $CertLocationAndStore[0]
+ $CertStore = $CertLocationAndStore[1]
+ if (![string]::IsNullOrWhiteSpace($StoreLocation)) {
+ if ($CertStoreLocation -ne $StoreLocation) {
+ Write-Warning "$logLead : The Certificate's Store Location [$CertStoreLocation] does not match the inputted Store Location [$StoreLocation]"
+ Write-Warning "$logLead : Using the Certificate provided Store Location [$CertStoreLocation]"
+ $StoreLocation = $CertStoreLocation
+ }
+ if (![string]::IsNullOrWhiteSpace($Store)) {
+ if ($CertStore -ne $Store) {
+ Write-Warning "$logLead : The Certificate's Store [$CertStore] does not match the inputted Store [$Store]"
+ Write-Warning "$logLead : Using the Certificate provided Store [$CertStore]"
+ $Store = $CertStore
+ }
+ }
+ }
+ }
+ }
+ $Thumbprint = $Certificate.Thumbprint
+ }
+
+ $certBasePath = (Join-Path (Join-Path "cert:\" $StoreLocation) $Store)
+ # Testing to see if the $Store provided is an actual directory
+ if (!(Test-Path $certBasePath)) {
+ Write-Warning "$logLead : The specified store [$Store] does not exist on the store location [$StoreLocation]"
+ Write-Warning "$logLead : No work to do, stopping"
+ return
+ }
+
+ $certificates = @()
+
+ if (![string]::IsNullOrWhiteSpace($Thumbprint)) {
+ # Finding cert based on given Thumbprint
+ $certificates = @(Get-ChildItem $certBasePath -Recurse | Where-Object { $_.Thumbprint -eq $Thumbprint})
+ if (Test-IsCollectionNullOrEmpty $certificates) {
+ Write-Warning "$logLead : No certificate could be found with the thumbprint [$Thumbprint] in the specified store and location [$StoreLocation\$Store]"
+ Write-Warning "$logLead : No work to do, stopping"
+ return
+ }
+ } elseif(Test-StringIsNullOrWhiteSpace -value $FriendlyName) {
+ $foundCertificates = @(Get-ChildItem $certBasePath -Recurse | Where-Object { $_.FriendlyName -eq $FriendlyName })
+ Write-Warning "$logLead : Received no thumbprint, and an empty string for FriendlyName. This has resulted in inadvertent mass certificate removal in the past."
+ Write-Warning "$logLead : The following is the list of certificates which would be removed, along with their thumbprints."
+ Write-Warning "$logLead : If you truly intended to remove these certificates, call this function with each thumbprint explicitly:"
+ $foundCertificates
+ return
+ } else {
+ # Finding cert based on given FriendlyName
+ $certificates = @(Get-ChildItem $certBasePath -Recurse | Where-Object { $_.FriendlyName -eq $FriendlyName })
+ if (Test-IsCollectionNullOrEmpty $certificates) {
+ Write-Warning "$logLead : No certificate could be found with the friendly name [$FriendlyName] in the specified store and location [$StoreLocation\$Store]"
+ Write-Warning "$logLead : No work to do, stopping"
+ return
+ }
+ }
+
+ if ($Force -or $PSCmdlet.ShouldProcess("Do you want to delete [$($certificates.Count)] certificate(s) with thumbprint(s) [$($certificates.Thumbprint)] in store(s) [$($certificates.PSPath)]")) {
+ foreach ($cert in $certificates) {
+ # Checking to see if the Certificate has a Private Key
+ if ($cert.HasPrivateKey -eq $true) {
+ Write-Host "$logLead : Private Key Found"
+ # Removal of Certification and Private Key
+ Write-Host "$logLead : Removed $($Cert.PSPath) [$($cert.FriendlyName)] and corresponding Private Key"
+ Remove-Item -Path $Cert.PSPath -DeleteKey -Force
+ } else {
+ # If there is no private key, continue to delete the Certificate
+ Write-Host "$logLead : Private Key Not Found"
+ Write-Host "$logLead : Removing $($Cert.PSPath) $($cert.FriendlyName)"
+ Remove-Item -Path $Cert.PSPath -Force
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Certificates/Public/Save-CertificatesToDisk.ps1 b/Modules/Alkami.DevOps.Certificates/Public/Save-CertificatesToDisk.ps1
new file mode 100644
index 0000000..a2b814c
--- /dev/null
+++ b/Modules/Alkami.DevOps.Certificates/Public/Save-CertificatesToDisk.ps1
@@ -0,0 +1,91 @@
+function Save-CertificatesToDisk {
+<#
+.SYNOPSIS
+ Saves Certificates to Disk.
+#>
+
+ [CmdletBinding()]
+ Param(
+ [Alkami.Ops.SecretServer.Model.Certificate]$cert,
+ [ref]$savedCertificates,
+ [string]$downloadFolder
+ )
+
+ $logLead = (Get-LogLeadName);
+
+ $rootCertFolder = Join-Path $downloadFolder "ROOT"
+ $personalCertFolder = Join-Path $downloadFolder "Personal"
+ $trustedPeopleFolder = Join-Path $downloadFolder "TrustedPeople"
+
+ if (!([System.IO.Directory]::Exists($rootCertFolder))) {
+ Write-Verbose ("$logLead : Creating root cert folder {0}" -f $rootCertFolder)
+ New-Item $rootCertFolder -ItemType Directory -Force | Out-Null
+ }
+
+ if (!([System.IO.Directory]::Exists($personalCertFolder))) {
+ Write-Verbose ("$logLead : Creating personal cert folder {0}" -f $personalCertFolder)
+ New-Item $personalCertFolder -ItemType Directory -Force | Out-Null
+ }
+
+ if (!([System.IO.Directory]::Exists($trustedPeopleFolder))) {
+ Write-Verbose ("$logLead : Creating trusted people folder {0}" -f $trustedPeopleFolder)
+ New-Item $trustedPeopleFolder -ItemType Directory -Force | Out-Null
+ }
+
+ if ($cert.Name -like "*entrust*" -or $cert.Name -like "*identityguard*") {
+ # Entrust must go in to Trusted People and Root
+ Write-Verbose ("$logLead : Downloading Entrust certificate to {0}" -f $rootCertFolder)
+ $savedCertificates.Value += @{FileName = ($cert.SaveFileToDisk($rootCertFolder)); Password = ""; }
+ Write-Verbose ("$logLead : Downloading Entrust certificate to {0}" -f $trustedPeopleFolder)
+ $savedCertificates.Value += @{FileName = ($cert.SaveFileToDisk($trustedPeopleFolder)); Password = ""; }
+ }
+ elseif ($cert.Name -like "*root*") {
+ # If the certificate name contains "root" we will assume it's a root certificate
+ Write-Verbose ("$logLead : Downloading Root certificate to {0}" -f $rootCertFolder)
+ $savedCertificates.Value += @{FileName = ($cert.SaveFileToDisk($rootCertFolder)); Password = ""; }
+ }
+ elseif ($cert.FileName -match "Alkami.+(Issued|Mutual|RPSTS)") {
+ # Certs for Web <-> App Communication go in TrustedPeople and Personal
+ Write-Verbose ("$logLead : Downloading Alkami certificate {0} to {1}" -f $cert.FileName, $trustedPeopleFolder)
+ $savedCertificates.Value += @{FileName = ($cert.SaveFileToDisk($trustedPeopleFolder)); Password = $cert.Password; }
+ Write-Verbose ("$logLead : Downloading Alkami certificate {0} to {1}" -f $cert.FileName, $personalCertFolder)
+ $savedCertificates.Value += @{FileName = ($cert.SaveFileToDisk($personalCertFolder)); Password = $cert.Password; }
+ }
+ elseif ($cert.FileName.EndsWith(".zip")) {
+ # Client Certs are saved in Secret as ZIP files
+ # We need to unzip to Personal
+ Write-Verbose ("$logLead : Downloading certificate ZIP file {0} to {1}" -f $cert.FileName, $downloadFolder)
+ $downloadedZIP = $cert.SaveFileToDisk($downloadFolder)
+
+ $randomFolderName = [System.IO.Path]::GetRandomFileName().Split('.') | Select-Object -First 1
+ $unzipFolder = Join-Path $personalCertFolder $randomFolderName
+
+ if (!([System.IO.Directory]::Exists($unzipFolder))) {
+ Write-Verbose ("$logLead : Creating temporary unzip folder {0}" -f $unzipFolder)
+ New-Item $unzipFolder -ItemType Directory -Force | Out-Null
+ }
+
+ Write-Verbose ("$logLead : Unzipping ZIP file contents to {0}" -f $unzipFolder)
+ [System.IO.Compression.ZipFile]::ExtractToDirectory($downloadedZIP, $unzipFolder)
+ $savedCertificates.Value += @{FileName = (Get-ChildItem $unzipFolder -Recurse -Include *.PFX | Sort-Object -Property LastWriteTimeUtc -Descending | Select-Object -First 1 -ExpandProperty FullName); Password = $cert.Password; }
+ }
+ elseif ($cert.FileName -like "*trusted*") {
+ # If the filename contains "trusted" we will assume it's a trusted people certificate
+ Write-Verbose ("$logLead : Downloading certificate {0} to {1}" -f $cert.FileName, $trustedPeopleFolder)
+ $savedCertificates.Value += @{FileName = ($cert.SaveFileToDisk($trustedPeopleFolder)); Password = ""; }
+ }
+ elseif ($cert.FileName.EndsWith(".cer")) {
+ # Any other .CER files will be saved to ROOT
+ Write-Verbose ("$logLead : Downloading certificate {0} to {1}" -f $cert.FileName, $rootCertFolder)
+ $savedCertificates.Value += @{FileName = ($cert.SaveFileToDisk($rootCertFolder)); Password = ""; }
+ }
+ elseif ($cert.FileName.EndsWith(".pfx")) {
+ # All .PFX files will be saved to Personal
+ Write-Verbose ("$logLead : Downloading certificate with private key {0} to {1}" -f $cert.FileName, $personalCertFolder)
+ $savedCertificates.Value += @{FileName = ($cert.SaveFileToDisk($personalCertFolder)); Password = $cert.Password; }
+ }
+ else {
+ Write-Output ("$logLead : Unable to determine what to do with certificate {0} with SecretID {1}" -f $cert.FileName, $cert.Id)
+ }
+}
+
diff --git a/Modules/Alkami.DevOps.Certificates/Public/Update-CertBindings.ps1 b/Modules/Alkami.DevOps.Certificates/Public/Update-CertBindings.ps1
new file mode 100644
index 0000000..3091c21
--- /dev/null
+++ b/Modules/Alkami.DevOps.Certificates/Public/Update-CertBindings.ps1
@@ -0,0 +1,80 @@
+function Update-CertBindings {
+<#
+.SYNOPSIS
+ Updates all sites in IIS using a certificate to a new certificate if the existing certificate's thumbprint matches the value passed in.
+
+ .PARAMETER existingCertThumbprint
+
+ The existing Cert Thumprint. This must be passed in with the spaces "10 11 14 be"
+
+ .PARAMETER replacementCertThumbprint
+
+ The replacement Cert Thumprint. This must be passed in with the spaces "10 11 14 be"
+#>
+ [CmdletBinding()]
+ Param(
+ [parameter(Mandatory=$true)]
+ [ValidateNotNullorEmpty()]
+ [string]$existingCertThumbprint,
+
+ [parameter(Mandatory=$true)]
+ [ValidateNotNullorEmpty()]
+ [string]$replacementCertThumbprint
+ )
+ $existingCertByteArray = $existingCertThumbprint.Split(" ") | ForEach-Object { [CONVERT]::toint16($_,16)}
+ $existingCertThumbprint = $existingCertThumbprint -replace " "
+ $existingCert = Get-ChildItem -PATH "CERT:\\LocalMachine\My\$existingCertThumbprint" -Recurse
+
+ if (!$existingCert)
+ {
+ throw "Unable to find existing cert in the store with the thumbprint $existingCertThumbprint"
+ }
+
+ $replacementCertByteArray = $replacementCertThumbprint.Split(" ") | ForEach-Object { [CONVERT]::toint16($_,16)}
+ $replacementCertThumbprint = $replacementCertThumbprint -replace " "
+ $replacementCert = Get-ChildItem -PATH "CERT:\\LocalMachine\My\$replacementCertThumbprint" -Recurse
+
+ if (!$replacementCert)
+ {
+ throw "Unable to find replacement cert in the store with the thumbprint $replacementCertThumbprint"
+ }
+
+ $serverManager = New-Object Microsoft.Web.Administration.ServerManager
+
+ foreach ($site in $serverManager.sites) {
+
+ $applicableBindings = $site.Bindings | Where-Object {$null -ne $_.CertificateHash}
+
+ if ($applicableBindings.Count -eq 0)
+ {
+ Write-Host ("Site {0} does not have any existing certificate bindings." -f $site.Name)
+ }
+ else
+ {
+ foreach ($binding in $applicableBindings)
+ {
+ $hash = $binding.CertificateHash
+
+ #Write-Host ("Certificate Hash for site {0} is $hash" -f $binding.CertificateHash)
+
+ if (@(Compare-Object $hash $existingCertByteArray -sync 0).Length -eq 0)
+ {
+ Write-Host ("Updating binding for site {0}" -f $site.Name)
+ $existingBinding = $binding
+ $existingBinding.CertificateHash = $replacementCertByteArray
+
+ Save-IISServerManagerChanges $serverManager
+ }
+ elseif (@(Compare-Object $hash $replacementCertByteArray -sync 0).Length -eq 0)
+ {
+ Write-Host ("The binding for site {0} is already using the new certificate" -f $site.Name)
+ }
+ else
+ {
+ Write-Host ("The binding cert hash did not match the old or new certificate for site {0}." -f $site.Name)
+ }
+ }
+ }
+ }
+}
+
diff --git a/Modules/Alkami.DevOps.Certificates/Public/Update-CertBindings.tests.ps1 b/Modules/Alkami.DevOps.Certificates/Public/Update-CertBindings.tests.ps1
new file mode 100644
index 0000000..5e7f610
--- /dev/null
+++ b/Modules/Alkami.DevOps.Certificates/Public/Update-CertBindings.tests.ps1
@@ -0,0 +1,133 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ""
+
+$exportPassword = "Test"
+$exportPath = "c:\temp\CertificateTest"
+
+Remove-Item $exportPath -Force -Recurse -ErrorAction SilentlyContinue | Out-Null
+New-Item -ItemType Directory $exportPath -Force | Out-Null
+
+Describe "Update-CertBindings" {
+
+ BeforeEach {
+
+ Mock -ModuleName $moduleForMock Get-ChildItem { return @{ PsParentPath="Microsoft.PowerShell.Security\Certificate::LocalMachine\My" }} -ParameterFilter { $Path -and $Path -eq "CERT:\\LocalMachine\My\0102030405" }
+ Mock -ModuleName $moduleForMock Get-ChildItem { return @{ PsParentPath="Microsoft.PowerShell.Security\Certificate::LocalMachine\My" }} -ParameterFilter { $Path -and $Path -eq "CERT:\\LocalMachine\My\1011121314" }
+ Mock -ModuleName $moduleForMock Get-ChildItem { return $null} -ParameterFilter { $PsParentPath -and !$PsParentPath -eq "CERT:\\0102030405" }
+ Mock -ModuleName $moduleForMock Save-IISServerManagerChanges {}
+ }
+
+ Context "When there are bad inputs when calling Update-CertBindings" {
+
+ It "Throws Exception if all skip flags set" {
+ { Update-CertBindings '' "thumbprint" } | Should Throw
+ }
+
+ It "Throws Exception if path doesn't exist" {
+ { Update-CertBindings "thumbprint" '' } | Should Throw
+ }
+ }
+
+ Context "When the inputs are valid and the certificates are missing" {
+
+ It "Throws Exception when existing cert not found" {
+ { Update-CertBindings "99 99 99 99 99" '01 02 03 04 05' } | Should Throw "9999999999"
+ }
+
+ It "Throws Exception when replacement cert not found" {
+ { Update-CertBindings "01 02 03 04 05" '99 99 99 99 99' } | Should Throw "9999999999"
+ }
+ }
+
+ Context "When the inputs are valid and the certificates exist" {
+
+ It "Updates Certificate Hash with new certificate hash when the site is valid and matches existing cert" {
+ Mock -ModuleName $moduleForMock New-Object {
+ @{
+
+ Sites = @{
+ Name = "Test Site"
+ Bindings = @{
+ CertificateHash = "01 02 03 04 05".Split(" ") | ForEach-Object { [CONVERT]::toint16($_,16)}
+ }
+
+ }
+ }
+ } -ParameterFilter { $TypeName -and $TypeName -eq "Microsoft.Web.Administration.ServerManager"}
+
+ Update-CertBindings "01 02 03 04 05" "10 11 12 13 14"
+
+ Assert-MockCalled -ModuleName $moduleForMock Save-IISServerManagerChanges -Times 1 -Exactly -Scope It
+ }
+
+ It "Does not Update Certificate Hash when there are no sites" {
+
+ Mock -ModuleName $moduleForMock New-Object { } -ParameterFilter { $TypeName -and $TypeName -eq "Microsoft.Web.Administration.ServerManager"}
+
+ Update-CertBindings "01 02 03 04 05" "10 11 12 13 14"
+
+ Assert-MockCalled -ModuleName $moduleForMock Save-IISServerManagerChanges -Times 0 -Exactly -Scope It
+ }
+
+ It "Does not Update Certificate Hash when no sites have a certificate binding" {
+
+ Mock -ModuleName $moduleForMock New-Object {
+ @{
+
+ Sites = @{
+ Name = "Test Site"
+ Bindings = $null
+
+ }
+ }
+ } -ParameterFilter { $TypeName -and $TypeName -eq "Microsoft.Web.Administration.ServerManager"}
+
+ Update-CertBindings "01 02 03 04 05" "10 11 12 13 14"
+
+ Assert-MockCalled -ModuleName $moduleForMock Save-IISServerManagerChanges -Times 0 -Exactly -Scope It
+ }
+
+ It "Does not update hash when sites hash matches new cert hash" {
+ Mock -ModuleName $moduleForMock New-Object {
+ @{
+
+ Sites = @{
+ Name = "Test Site"
+ Bindings = @{
+ CertificateHash = "10 11 12 13 14".Split(" ") | ForEach-Object { [CONVERT]::toint16($_,16)}
+ }
+
+ }
+ }
+ } -ParameterFilter { $TypeName -and $TypeName -eq "Microsoft.Web.Administration.ServerManager"}
+
+ Update-CertBindings "01 02 03 04 05" "10 11 12 13 14"
+
+ Assert-MockCalled -ModuleName $moduleForMock Save-IISServerManagerChanges -Times 0 -Exactly -Scope It
+ }
+
+ It "Does not update hash when sites hash does not match existing certificate" {
+ Mock -ModuleName $moduleForMock New-Object {
+ @{
+
+ Sites = @{
+ Name = "Test Site"
+ Bindings = @{
+ CertificateHash = "03 04 02 01".Split(" ") | ForEach-Object { [CONVERT]::toint16($_,16)}
+ }
+
+ }
+ }
+ } -ParameterFilter { $TypeName -and $TypeName -eq "Microsoft.Web.Administration.ServerManager"}
+
+ Update-CertBindings "01 02 03 04 05" "10 11 12 13 14"
+
+ Assert-MockCalled -ModuleName $moduleForMock Save-IISServerManagerChanges -Times 0 -Exactly -Scope It
+ }
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Certificates/tools/chocolateyInstall.ps1 b/Modules/Alkami.DevOps.Certificates/tools/chocolateyInstall.ps1
new file mode 100644
index 0000000..b01306e
--- /dev/null
+++ b/Modules/Alkami.DevOps.Certificates/tools/chocolateyInstall.ps1
@@ -0,0 +1,37 @@
+[CmdletBinding()]
+Param()
+process {
+ $myCurrentPath = $PSScriptRoot;
+ Write-Verbose "Installing the Module from $myCurrentPath";
+
+ $parentPath = (Split-Path $myCurrentPath);
+ $systemModulePath = "C:\Program Files\WindowsPowerShell\Modules\";
+ $myModulePath = (Join-Path $parentPath "module");
+
+ $metadata = ([Xml](Get-Content (Join-Path $parentPath "*.nuspec"))).package.metadata;
+
+ $id = $metadata.id;
+
+ $version = $metadata.version -replace '-pre.+','';
+
+ $targetModulePath = (Join-Path $systemModulePath $id);
+ $targetModuleVersionPath = (Join-Path $targetModulePath $version);
+
+ if (Test-Path $targetModulePath) {
+ ## If the target folder already existed, remove it, because we are re-installing this package, obviously
+ if (Test-Path $targetModuleVersionPath) {
+ Write-Warning "Found an already existing module at [$targetModuleVersionPath]!!"
+ Remove-Item $targetModuleVersionPath -Recurse -Force;
+ }
+
+ ## Clear previous children for name conflicts
+ (Get-ChildItem $targetModulePath) | ForEach-Object {
+ Write-Information "Removing module located at [$_]";
+ Remove-Item $_.FullName -Recurse -Force;
+ }
+ }
+
+ Write-Host "Copying module $id to [$targetModuleVersionPath]";
+ Copy-Item $myModulePath -Destination $targetModuleVersionPath -Recurse -Force;
+
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Certificates/tools/chocolateyUninstall.ps1 b/Modules/Alkami.DevOps.Certificates/tools/chocolateyUninstall.ps1
new file mode 100644
index 0000000..7c36766
--- /dev/null
+++ b/Modules/Alkami.DevOps.Certificates/tools/chocolateyUninstall.ps1
@@ -0,0 +1,25 @@
+[CmdletBinding()]
+Param()
+process {
+ $myCurrentPath = $PSScriptRoot;
+ Write-Verbose "Uninstalling the Module from $myCurrentPath";
+
+ $parentPath = (Split-Path $myCurrentPath);
+ $systemModulePath = "C:\Program Files\WindowsPowerShell\Modules\";
+ $myModulePath = (Join-Path $parentPath "module");
+
+ $metadata = ([Xml](Get-Content (Join-Path $parentPath "*.nuspec"))).package.metadata;
+
+ $id = $metadata.id;
+
+ $version = $metadata.version -replace '-pre.+','';
+
+ $targetModulePath = (Join-Path $systemModulePath $id);
+ $targetModuleVersionPath = (Join-Path $targetModulePath $version);
+
+ if (Test-Path $targetModuleVersionPath) {
+ Write-Information "Removing module at [$targetModuleVersionPath]!!"
+ Remove-Item $targetModuleVersionPath -Recurse -Force;
+ }
+}
+
diff --git a/Modules/Alkami.DevOps.Common/Alkami.DevOps.Common.nuspec b/Modules/Alkami.DevOps.Common/Alkami.DevOps.Common.nuspec
new file mode 100644
index 0000000..c6eaada
--- /dev/null
+++ b/Modules/Alkami.DevOps.Common/Alkami.DevOps.Common.nuspec
@@ -0,0 +1,33 @@
+
+
+
+ Alkami.DevOps.Common
+ $version$
+ Alkami Platform Modules - DevOps - Common
+ Alkami Technologies
+ Alkami Technologies
+ https://extranet.alkamitech.com/display/ORB/Alkami.DevOps.Common
+ https://www.alkami.com/files/alkamilogo75x75.png
+ http://alkami.com/files/orblicense.html
+ false
+ Installs the DevOps Common module for use with PowerShell.
+
+ PowerShell
+ Copyright (c) 2018 Alkami Technologies
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Modules/Alkami.DevOps.Common/Alkami.DevOps.Common.psd1 b/Modules/Alkami.DevOps.Common/Alkami.DevOps.Common.psd1
new file mode 100644
index 0000000..dec8aac
--- /dev/null
+++ b/Modules/Alkami.DevOps.Common/Alkami.DevOps.Common.psd1
@@ -0,0 +1,22 @@
+@{
+ RootModule = 'Alkami.DevOps.Common.psm1'
+ ModuleVersion = '4.2.1'
+ GUID = '5151a169-c763-489e-8213-b437b7a294b1'
+ Author = 'SRE,dsage,cbrand'
+ CompanyName = 'Alkami Technologies, Inc.'
+ Copyright = '(c) 2018 Alkami Technologies, Inc.. All rights reserved.'
+ Description = 'A set of common functions and filters not typically used directly'
+ FileList = @('Resources\ObjectAsTableTemplate.html')
+ FormatsToProcess = @('Resources\Formatters\*.ps1xml')
+ RequiredModules = 'Alkami.PowerShell.Common','Alkami.PowerShell.Configuration','Alkami.Ops.SecretServer','Alkami.DevOps.Certificates'
+ FunctionsToExport = 'Get-AlkamiAwsProfileList','Get-AlkamiServiceFabricHostnamesByTag','Get-ArmorList','Get-AutomoxAgentPath','Get-AwsCredentialConfiguration','Get-AwsStandardDynamicParameters','Get-CatalogsFromMaster','Get-DesignationTagNameByEnvironment','Get-DynamicAwsProfilesParameter','Get-DynamicAwsRegionParameter','Get-EntrustAdminUrlFromClient','Get-Environment','Get-FileContentHash','Get-HostnamesByEnvironmentName','Get-InstanceHostname','Get-InstancesByTag','Get-IPSTSUrlFromClient','Get-LocalNlbIp','Get-LogDiskUtilization','Get-PackageForInstallation','Get-PodName','Get-SecretsForPod','Get-SecurityGroupPrefix','Get-ServerStatusReport','Get-SlackAction','Get-SlackAttachment','Get-SlackAttachmentFields','Get-SlackMessage','Get-SlackMessageColor','Get-UserCredentialsFromSecretServer','Get-UTF8ContentHash','Invoke-GetAllDesignationsByEnvironmentType','Invoke-GetAllHostsByDesignation','Invoke-GetCurrentStatusByDesignation','Invoke-GetCurrentStatusByHostnames','Invoke-GetDesignationExclusionsByEnvironmentType','Invoke-MaximizeDesignation','Invoke-MinimizeDesignation','Invoke-RemoveShutdownPendingTag','Invoke-StartDesignation','Invoke-StartEnvironment','Invoke-StartServers','Invoke-StopDesignation','Invoke-StopEnvironment','Invoke-StopServers','New-AlkamiServiceFabricClusterByTag','New-WebTierMachineConfigAppSettings','Publish-MessageToSlack','Reset-ASInstanceHealth','Select-AvailableWinRmHosts','Send-ObjectAsHtmlTable','Test-IsTeamCityProcess'
+ AliasesToExport = 'Create-WebTierMachineConfigAppSettings'
+ PrivateData = @{
+ PSData = @{
+ Tags = @('powershell', 'module', 'common')
+ ProjectUri = 'Https://extranet.alkamitech.com/display/SRE/Alkami.DevOps.Common+Module'
+ IconUri = 'https://www.alkami.com/files/alkamilogo75x75.png'
+ }
+ }
+ HelpInfoURI = 'https://extranet.alkamitech.com/display/SRE/Alkami.DevOps.Common+Module'
+}
diff --git a/Modules/Alkami.DevOps.Common/Alkami.DevOps.Common.pssproj b/Modules/Alkami.DevOps.Common/Alkami.DevOps.Common.pssproj
new file mode 100644
index 0000000..af0d358
--- /dev/null
+++ b/Modules/Alkami.DevOps.Common/Alkami.DevOps.Common.pssproj
@@ -0,0 +1,114 @@
+
+
+
+ Debug
+ 2.0
+ {1bd8fc22-5882-4d5c-8128-81f1d61f8d77}
+ Exe
+ MyApplication
+ MyApplication
+ Alkami.DevOps.Common
+ SRE
+ Alkami Technology
+ (c) 2017 Alkami Technology. All rights reserved.
+ A set of common functions and filters not typically used directly
+ 5151a169-c763-489e-8213-b437b7a294b1
+ 0.0.0.1
+ Invoke-Pester;
+ ..\build-project.ps1 (Join-Path $(SolutionDir) "Alkami.DevOps.Common")
+
+
+ true
+ full
+ false
+ bin\Debug\
+ DEBUG;TRACE
+ prompt
+ 4
+
+
+ pdbonly
+ true
+ bin\Release\
+ TRACE
+ prompt
+ 4
+
+
+
+ Alkami.Ops.Common
+ {fa9745dd-68ac-4194-9c33-acf19411d357}
+ True
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Common/AlkamiManifest.xml b/Modules/Alkami.DevOps.Common/AlkamiManifest.xml
new file mode 100644
index 0000000..c41ff6d
--- /dev/null
+++ b/Modules/Alkami.DevOps.Common/AlkamiManifest.xml
@@ -0,0 +1,12 @@
+
+
+ 1.0
+
+ Alkami
+ Alkami.DevOps.Common
+ SREModule
+
+
+ Production
+
+
diff --git a/Modules/Alkami.DevOps.Common/Private/ConvertTo-AwsCredentialEntry.ps1 b/Modules/Alkami.DevOps.Common/Private/ConvertTo-AwsCredentialEntry.ps1
new file mode 100644
index 0000000..e72aa45
--- /dev/null
+++ b/Modules/Alkami.DevOps.Common/Private/ConvertTo-AwsCredentialEntry.ps1
@@ -0,0 +1,46 @@
+function ConvertTo-AwsCredentialEntry {
+
+<#
+.SYNOPSIS
+ Used to convert AWS credentials parsed from file to a standard / friendly format
+
+.DESCRIPTION
+ Used to convert AWS credentials parsed from file to a standard / friendly format
+
+.PARAMETER CredentialData
+ Credential data read from file by Get-AwsCredentialConfiguration
+
+.LINK
+ Get-AwsCredentialConfiguration
+#>
+ [CmdletBinding()]
+ param (
+ [Parameter(Mandatory = $true, Position = 0, ValueFromPipeline = $true)]
+ [System.Collections.ArrayList]$CredentialData
+ )
+ begin {
+ $logLead = Get-LogLeadName
+
+ $defaultTypeName = 'AwsCredentialEntry'
+ $defaultKeys = @('Name')
+ $defaultDisplaySet = @('Name', 'role_arn', 'mfa_serial', 'region', 'source_profile')
+ $defaultDisplayPropertySet = New-Object System.Management.Automation.PSPropertySet('DefaultDisplayPropertySet', [string[]]$defaultDisplaySet)
+ $defaultKeyPropertySet = New-Object System.Management.Automation.PSPropertySet('DefaultKeyPropertySet', [string[]]$defaultKeys)
+ $PSStandardMembers = [System.Management.Automation.PSMemberInfo[]]@($defaultDisplayPropertySet, $defaultKeyPropertySet)
+ }
+ process {
+ $entryArray = @()
+
+ foreach ($entry in $CredentialData) {
+ try {
+ $entry.PSObject.TypeNames.Insert(0, $defaultTypeName)
+ Add-Member -MemberType MemberSet -Name PSStandardMembers -Value $PSStandardMembers -InputObject $entry
+ $entryArray += $entry
+ } catch {
+ Write-Warning "$logLead : Could not convert item to AwsCredentialEntry object. Exception: $($_.Exception.Message)"
+ }
+ }
+
+ return $entryArray
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Common/Private/VariableDeclarations.ps1 b/Modules/Alkami.DevOps.Common/Private/VariableDeclarations.ps1
new file mode 100644
index 0000000..db53e64
--- /dev/null
+++ b/Modules/Alkami.DevOps.Common/Private/VariableDeclarations.ps1
@@ -0,0 +1,32 @@
+# Define Tag Magic Strings
+$Global:AlkamiTagKeyEnvironment = "alk:env"
+$Global:AlkamiTagKeyService = "alk:service"
+$Global:AlkamiTagKeyProject = "alk:project"
+$Global:AlkamiTagKeyRole = "alk:role"
+$Global:AlkamiTagKeyHostName = "alk:hostname"
+$Global:AlkamiTagKeyBootstrap = "alk:bootstrap-phase"
+$Global:AlkamiTagKeyInstanceId = "alk:instanceid"
+
+$Global:AlkamiTagValueEnvironmentDev = "dev"
+$Global:AlkamiTagValueEnvironmentQa = "qa"
+$Global:AlkamiTagValueEnvironmentSandbox = "sandbox"
+$Global:AlkamiTagValueEnvironmentStaging = "staging"
+$Global:AlkamiTagValueEnvironmentProd = "prod"
+$Global:AlkamiTagValueEnvironmentDR = "dr"
+$Global:AlkamiTagValueEnvironmentLTM = "ltm"
+$Global:AlkamiTagValueEnvironmentLoadtest = "loadtest"
+
+$Global:AlkamiDesignationEnvironmentDev = "designation"
+$Global:AlkamiDesignationEnvironmentQA = "designation"
+$Global:AlkamiDesignationEnvironmentSandbox = "designation"
+$Global:AlkamiDesignationEnvironmentStaging = "lane"
+$Global:AlkamiDesignationEnvironmentProd = "pod"
+$Global:AlkamiDesignationEnvironmentDR = "pod"
+$Global:AlkamiDesignationEnvironmentLTM = "designation"
+$Global:AlkamiDesignationEnvironmentLoadtest = "designation"
+
+$Global:AlkamiTagValueRoleApp = "app:app"
+$Global:AlkamiTagValueRoleWeb = "web"
+$Global:AlkamiTagValueRoleFab = "app:fab"
+$Global:AlkamiTagValueRoleMicroservice = "app:microservice"
+$Global:AlkamiTagValueRoleEntrust = "app:entrust"
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Common/Public/Get-AlkamiAwsProfileList.ps1 b/Modules/Alkami.DevOps.Common/Public/Get-AlkamiAwsProfileList.ps1
new file mode 100644
index 0000000..6cfeebd
--- /dev/null
+++ b/Modules/Alkami.DevOps.Common/Public/Get-AlkamiAwsProfileList.ps1
@@ -0,0 +1,120 @@
+function Get-AlkamiAwsProfileList {
+
+<#
+.SYNOPSIS
+ Retrieves a list of all Alkami AWS profile names.
+
+.DESCRIPTION
+ Retrieves a list of all Alkami AWS profile names. Will prepend 'temp-' to the profile names if not running
+ through a TeamCity process; this logic can be bypassed using the '-RawOutput' argument.
+
+.PARAMETER RawOutput
+ [switch] Flag indicating that the profiles should not have 'temp-' prepended to them.
+
+.PARAMETER IncludeSubsidiaries
+ [switch] Flag indicating that the profile names for Alkami subsidiaries should also be included.
+
+.EXAMPLE
+ Get-AlkamiAwsProfileList
+
+temp-corp
+temp-dev
+temp-loadtest
+temp-mgmt
+temp-mp
+temp-prod
+temp-qa
+temp-sandbox
+temp-security
+temp-transit
+temp-transitnp
+temp-workspaces
+
+.EXAMPLE
+ Get-AlkamiAwsProfileList -RawOutput
+
+Corp
+Dev
+Loadtest
+Mgmt
+Mp
+Prod
+Qa
+Sandbox
+Security
+Transit
+Transitnp
+Workspaces
+
+.EXAMPLE
+ Get-AlkamiAwsProfileList -IncludeSubsidiaries
+
+temp-achdev
+temp-corp
+temp-dev
+temp-loadtest
+temp-mgmt
+temp-mp
+temp-prod
+temp-qa
+temp-sandbox
+temp-security
+temp-transit
+temp-transitnp
+temp-workspaces
+
+#>
+
+ [OutputType([string[]])]
+ [CmdletBinding()]
+ param (
+ [Parameter(Mandatory = $false)]
+ [switch] $RawOutput,
+
+ [Parameter(Mandatory = $false)]
+ [Alias("All")]
+ [switch] $IncludeSubsidiaries
+ )
+
+ # Define the standard list of AWS profiles for Alkami.
+ $awsProfileList = @(
+ "Corp",
+ "Dev",
+ "Loadtest",
+ "Mgmt",
+ "Mp",
+ "Prod",
+ "Qa",
+ "Sandbox",
+ "Security",
+ "Transit",
+ "Transitnp",
+ "Workspaces"
+ )
+
+ # Define the standard list of AWS profiles for Alkami subsidiaries.
+ $awsProfileListSubsidiaries = @(
+ 'AchDev'
+ 'IgniteDev',
+ 'IgniteMerchantsProd',
+ 'IgniteIam',
+ 'IgniteBanksProd',
+ 'IgniteBanksTest',
+ 'IgniteBanksQa'
+ )
+
+ $result = $awsProfileList
+
+ if ( $IncludeSubsidiaries ) {
+ $result += $awsProfileListSubsidiaries
+ }
+
+ if (( $false -eq ( Test-IsTeamCityProcess ) ) -and ( $false -eq $RawOutput.IsPresent ) ) {
+
+ # Prepend 'temp-' to the profile name if we aren't on TeamCity and the user didn't specify
+ # that they wanted raw output.
+ $result = $result.ForEach( { "temp-" + $_.ToLower() } )
+ }
+
+ return ( $result | Sort-Object )
+}
diff --git a/Modules/Alkami.DevOps.Common/Public/Get-AlkamiAwsProfileList.tests.ps1 b/Modules/Alkami.DevOps.Common/Public/Get-AlkamiAwsProfileList.tests.ps1
new file mode 100644
index 0000000..de5d9e3
--- /dev/null
+++ b/Modules/Alkami.DevOps.Common/Public/Get-AlkamiAwsProfileList.tests.ps1
@@ -0,0 +1,61 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+
+Describe "Get-AlkamiAwsProfileList" {
+
+ Context "Result Respects TeamCity Process" {
+
+ It "Does Not Prepend 'temp-'" {
+
+ Mock -CommandName Test-IsTeamCityProcess -ModuleName Alkami.DevOps.Common -MockWith { return $true }
+
+ (Get-AlkamiAwsProfileList) | Should -Contain "Prod"
+ }
+
+ It "Calls Test-IsTeamCityProcess" {
+
+ Mock -CommandName Test-IsTeamCityProcess -ModuleName Alkami.DevOps.Common -MockWith { return $true }
+
+ Get-AlkamiAwsProfileList | Out-Null
+
+ Assert-MockCalled -CommandName Test-IsTeamCityProcess -Times 1 -Exactly -Scope It -ModuleName Alkami.DevOps.Common
+ }
+ }
+
+ Context "Result Respects 'RawOutput' Flag For Non-TeamCity Processes" {
+
+ It "Prepends 'temp-' By Default" {
+
+ Mock -CommandName Test-IsTeamCityProcess -ModuleName Alkami.DevOps.Common -MockWith { return $false }
+
+ ( Get-AlkamiAwsProfileList ) | Should -Contain 'temp-prod'
+ }
+
+ It "Does Not Prepend 'temp-' When RawOutput is Specified" {
+
+ Mock -CommandName Test-IsTeamCityProcess -ModuleName Alkami.DevOps.Common -MockWith { return $false }
+
+ ( Get-AlkamiAwsProfileList -RawOutput ) | Should -Contain 'Prod'
+ }
+ }
+
+ Context "Result Respects 'IncludeSubsidiaries' Flag" {
+
+ Mock -CommandName Test-IsTeamCityProcess -ModuleName Alkami.DevOps.Common -MockWith { return $false }
+
+ It "Does Not Include AchDev By Default" {
+
+ ( Get-AlkamiAwsProfileList ) | Should -Not -Contain 'temp-achdev'
+ }
+
+ It "Includes AchDev When IncludeSubsidiaries is Specified" {
+
+ ( Get-AlkamiAwsProfileList -IncludeSubsidiaries ) | Should -Contain 'temp-achdev'
+ }
+
+ It "Includes AchDev When All is Specified" {
+
+ ( Get-AlkamiAwsProfileList -All ) | Should -Contain 'temp-achdev'
+ }
+ }
+
+}
diff --git a/Modules/Alkami.DevOps.Common/Public/Get-AlkamiServiceFabricHostnamesByTag.ps1 b/Modules/Alkami.DevOps.Common/Public/Get-AlkamiServiceFabricHostnamesByTag.ps1
new file mode 100644
index 0000000..0abffb1
--- /dev/null
+++ b/Modules/Alkami.DevOps.Common/Public/Get-AlkamiServiceFabricHostnamesByTag.ps1
@@ -0,0 +1,105 @@
+function Get-AlkamiServiceFabricHostnamesByTag {
+<#
+.SYNOPSIS
+ Returns service fabric servers in a pod that are at the minimum bootstrap phase.
+ Writes an error and returns $null if the requested minimum number of servers cannot be found.
+
+.PARAMETER designation
+ The pod tag designation for the cluster.
+
+.PARAMETER minservers
+ The minimum number of servers to wait for in the group, before creating the cluster.
+
+.PARAMETER role
+ The alk:role tag of the instance to query for, to discover peer servers.
+
+.PARAMETER minBootstrapPhase
+ The minimum bootstrap phase of servers that are allowed to join the cluster. 95 (at time of writing) is the ServiceFabric bootstrap phase.
+
+.PARAMETER timeout
+ The amount of time (in minutes) this command will poll for valid servers to join into the cluster.
+#>
+ [CmdletBinding()]
+ Param(
+ [Parameter(Mandatory = $true)]
+ [string]$designation,
+
+ [Parameter(Mandatory = $false)]
+ [int]$minServers = 5,
+
+ [Parameter(Mandatory = $false)]
+ [string]$role = "app:fab",
+
+ [Parameter(Mandatory = $false)]
+ [int]$minBootstrapPhase = 95,
+
+ [Parameter(Mandatory = $false)]
+ [int]$timeout = 15
+ )
+
+ $loglead = Get-LogLeadName;
+
+ if (($minServers -le 0) -or ($minServers -eq 2)) {
+ Write-Error "$loglead : Minimum number of servers in SF cluster must be 1, or 3+"
+ return;
+ }
+
+ $interval = 15; # Seconds
+ # $timeout is in minutes.
+ $numRetries = [Math]::Floor(($timeout * 60) / $interval);
+ $retries = 0;
+
+ $designationTagName = Get-DesignationTagNameByEnvironment
+ Write-Host "$logLead : Designation tag for current environment is $designationTagName"
+
+ # Query for ready-to-join peer FAB servers in this pod.;
+ Write-Host "$loglead : Querying for FAB servers in the pod.";
+ Write-Host "$logLead : Query Criteria: `n alk:$designationTagName = $designation`n $Global:AlkamiTagKeyRole = $role`n State = Running"
+
+ while ($retries -lt $numRetries) {
+
+ # Look for servers from the pod at the service-fabric-joining state of initialization.
+ $servers = Get-InstancesByTag -tags @{ "alk:$designationTagName" = $designation; $Global:AlkamiTagKeyRole = $role }
+
+ # Filter by servers that are ready to be joined into the cluster, or are already in the cluster.
+ # 95 is the userdata bootscrap script phase that joins nodes into the cluster.
+ Write-Host "$logLead : Looking for instances with a bootstrap phase greater than $minBootstrapPhase"
+ $servers = $servers | Where-Object { $null -ne ($_.Tag | Where-Object { ($_.Key -eq $Global:AlkamiTagKeyBootstrap) -and ($_.Value -ge $minBootstrapPhase) }) };
+
+ # Sort them by IP so every server that gets to this step will have the same list in the same order.
+ $servers = $servers | Sort-Object -Property PrivateIpAddress;
+
+ # Filter out servers that aren't running.
+ $servers = $servers | Where-Object { $_.State.Name -eq "Running"; }
+
+ # We need at least 3 instances to join into a cluster.
+ if ($servers.count -ge $minServers) {
+ # We found the instances we needed.
+ break;
+ }
+
+ Write-Verbose "$loglead : Found $($servers.count) ready instances. Need at least $minServers servers to join into a Service Fabric cluster. Retrying in 15s";
+ Start-Sleep -s $interval;
+ $retries++;
+ }
+
+ if ($retries -eq $numRetries) {
+ Write-Error "$loglead : Could not discover at least $minServers ready instances to join into a Service Fabric cluster.";
+ return $null;
+ }
+
+ Write-Host "$loglead : Found $minServers servers at bootstrap phase $minBootstrapPhase to create a ServiceFabric cluster with.";
+
+ # Build the fqdn's from the private IP's of the servers in the list.
+ $serverIps = $servers | select-object -ExpandProperty PrivateIpAddress;
+ $serverNames = @();
+ foreach ($ip in $serverIps) {
+ $hostname = "fab{0}.fh.local" -f $ip.Replace(".", "").Substring(2);
+ $serverNames += $hostname;
+ }
+
+ Write-Host "$loglead : Discovered Fabric Server Peers:";
+ $serverNames | ForEach-Object { Write-Host $_; };
+
+ return $serverNames;
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Common/Public/Get-ArmorList.ps1 b/Modules/Alkami.DevOps.Common/Public/Get-ArmorList.ps1
new file mode 100644
index 0000000..dacbf69
--- /dev/null
+++ b/Modules/Alkami.DevOps.Common/Public/Get-ArmorList.ps1
@@ -0,0 +1,145 @@
+function Get-ArmorList {
+
+<#
+.SYNOPSIS
+ Retrieve hostnames for an environment in a usable format.
+
+.DESCRIPTION
+ A script that allows the user to retrieve all or some of the hosts in an environment, properly formatted for addition to an armor file, or for use in other applications.
+
+.PARAMETER EnvironmentName
+ Required Parameter. The moniker associated with the environment (e.g. '12.4', 'Smith')
+
+.PARAMETER EnvironmentType
+ Optional Parameter. The type of environment (e.g. 'prod', 'dr'). Defaults to the environment of the server the command is run from.
+
+.EXAMPLE
+ Get-ArmorList -EnvironmentName 12.4 -EnvironmentType 'Prod'
+
+[Get-DesignationTagNameByEnvironment] : Checking designation value for environment Prod
+APP16115197.fh.local,APP167765.fh.local,APP1697110.fh.local,MIC1676159.fh.local,MIC169629.fh.local,WEB16118134.fh.local,WEB1671254.fh.local,WEB1698191.fh.local
+
+.PARAMETER Tier
+ Optional Parameter. Filter to a specific tier: App, Web, Mic, Fab. Defaults to include all tiers if not provided.
+
+.EXAMPLE
+ Get-ArmorList -EnvironmentName 12.4 -EnvironmentType 'Prod' -Tier 'Web'
+
+[Get-DesignationTagNameByEnvironment] : Checking designation value for environment Prod
+WEB16118134.fh.local,WEB1671254.fh.local,WEB1698191.fh.local
+
+.PARAMETER Quote
+ Optional Parameter. Wraps each hostname in doublequotes.
+
+.EXAMPLE
+ Get-ArmorList -EnvironmentName 12.4 -EnvironmentType 'Prod' -Tier 'App' -Quote
+
+[Get-DesignationTagNameByEnvironment] : Checking designation value for environment Prod
+"APP16115197.fh.local","APP167765.fh.local","APP1697110.fh.local"
+
+.PARAMETER NoDomain
+ Optional Parameter. Omits the domain name from the hostnames.
+
+.EXAMPLE
+ Get-ArmorList -EnvironmentName 12.4 -EnvironmentType 'Prod' -Tier 'App' -NoDomain
+
+[Get-DesignationTagNameByEnvironment] : Checking designation value for environment Prod
+APP16115197,APP167765,APP1697110
+
+.PARAMETER List
+ Optional Parameter. Returns an array of hostnames instead of a comma-delimited string.
+
+.EXAMPLE
+ Get-ArmorList -EnvironmentName 12.4 -EnvironmentType 'Prod' -Tier 'App' -List
+
+[Get-DesignationTagNameByEnvironment] : Checking designation value for environment Prod
+APP16115197.fh.local
+APP167765.fh.local
+APP1697110.fh.local
+
+.PARAMETER IncludeOffline
+ Optional Parameter. Returns both offline and online hosts.
+
+.EXAMPLE
+ Get-ArmorList -EnvironmentName 12.4 -EnvironmentType 'Prod' -Tier 'App' -IncludeOffline
+
+[Get-DesignationTagNameByEnvironment] : Checking designation value for environment Prod
+APP16111223.fh.local,APP16115197.fh.local,APP16122230.fh.local,APP167765.fh.local,APP1697110.fh.local
+
+.PARAMETER ProfileName
+ Optional Parameter. Specify the AWS profile to use.
+
+.PARAMETER Region
+ Optional Parameter. Specify the AWS region to use.
+
+.EXAMPLE
+ Get-ArmorList -EnvironmentName 17 -EnvironmentType 'Prod' -Tier 'App' -ProfileName 'temp-prod' -Region 'us-west-2'
+
+[Get-DesignationTagNameByEnvironment] : Checking designation value for environment Prod
+APP3210599.fh.local,APP3272118.fh.local,APP327852.fh.local
+#>
+
+ [CmdletBinding()]
+ param (
+ [Parameter(Mandatory = $true)]
+ [ValidateNotNullOrEmpty()]
+ [Alias("Pod")]
+ [string]$EnvironmentName,
+
+ [Parameter(Mandatory = $false)]
+ [string]$EnvironmentType,
+
+ [Parameter(Mandatory = $false)]
+ [ValidateSet("web", "app", "mic", "fab")]
+ [string]$Tier,
+
+ [Parameter(Mandatory = $false)]
+ [Switch]$Quote,
+
+ [Parameter(Mandatory = $false)]
+ [Switch]$NoDomain,
+
+ [Parameter(Mandatory = $false)]
+ [Switch]$List,
+
+ [Parameter(Mandatory = $false)]
+ [Switch]$IncludeOffline,
+
+ [Parameter(Mandatory = $false)]
+ [string]$ProfileName = $null,
+
+ [Parameter(Mandatory = $false)]
+ [string]$Region = $null
+ )
+
+ [string[]] $servers = Get-HostnamesByEnvironmentName -EnvironmentName $EnvironmentName -EnvironmentType $EnvironmentType `
+ -ProfileName $ProfileName -Region $Region -IncludeOffline:$IncludeOffline
+
+ if ( ! $NoDomain ) {
+
+ $servers = foreach ( $server in $servers ) {
+
+ "$server.fh.local"
+ }
+ }
+
+ if ( $PSBoundParameters.ContainsKey('Tier') ) {
+
+ [string[]] $servers = Get-ServerByType -Server $servers -Type $Tier
+ }
+
+ if ( $Quote ) {
+
+ $servers = foreach ( $server in $servers ) {
+
+ "`"$server`""
+ }
+ }
+
+ if ( ! $List ) {
+
+ $servers = $servers -join ','
+ }
+
+ return $servers
+}
diff --git a/Modules/Alkami.DevOps.Common/Public/Get-ArmorList.tests.ps1 b/Modules/Alkami.DevOps.Common/Public/Get-ArmorList.tests.ps1
new file mode 100644
index 0000000..7299064
--- /dev/null
+++ b/Modules/Alkami.DevOps.Common/Public/Get-ArmorList.tests.ps1
@@ -0,0 +1,137 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ""
+
+Describe "Get-ArmorList" {
+
+ Context "Parameter Validation" {
+
+ It "Null Environment Name Should Throw" {
+
+ { Get-ArmorList -EnvironmentName $null } | Should Throw
+ }
+
+ It "Empty Environment Name Should Throw" {
+
+ { Get-ArmorList -EnvironmentName '' } | Should Throw
+ }
+
+ It "Invalid Tier Should Throw" {
+
+ { Get-ArmorList -EnvironmentName 'Test' -Tier 'Test' } | Should Throw
+ }
+ }
+
+ Context "Result Respects NoDomain Parameter" {
+
+ Mock -CommandName Get-HostnamesByEnvironmentName -MockWith { return @( 'A', 'B' ) } -ModuleName $moduleForMock
+
+ It "Appends Domain By Default" {
+
+ ( Get-ArmorList -EnvironmentName 'Test' ) | Should -BeExactly 'A.fh.local,B.fh.local'
+ }
+
+ It "Does Not Append Domain When Switch Is Present" {
+
+ ( Get-ArmorList -EnvironmentName 'Test' -NoDomain ) | Should -BeExactly 'A,B'
+ }
+ }
+
+ Context "Result Respects List Parameter" {
+
+ It "Returns a Comma-Delimited String By Default" {
+
+ Mock -CommandName Get-HostnamesByEnvironmentName -MockWith { return @( 'A', 'B' ) } -ModuleName $moduleForMock
+
+ ( Get-ArmorList -EnvironmentName 'Test' -NoDomain ) | Should -BeExactly 'A,B'
+ }
+
+ It "Returns a String Type By Default" {
+
+ Mock -CommandName Get-HostnamesByEnvironmentName -MockWith { return @( 'A', 'B' ) } -ModuleName $moduleForMock
+
+ ( Get-ArmorList -EnvironmentName 'Test' -NoDomain ) -is [string] | Should -BeTrue
+ }
+
+ It "Returns An Array When Switch Is Present" {
+
+ Mock -CommandName Get-HostnamesByEnvironmentName -MockWith { return @( 'A', 'B' ) } -ModuleName $moduleForMock
+
+ ( Get-ArmorList -EnvironmentName 'Test' -NoDomain -List ) | Should -BeExactly @( 'A', 'B' )
+ }
+
+ It "Returns An Array Type When Switch Is Present" {
+
+ Mock -CommandName Get-HostnamesByEnvironmentName -MockWith { return @( 'A', 'B' ) } -ModuleName $moduleForMock
+
+ ( Get-ArmorList -EnvironmentName 'Test' -NoDomain -List ) -is [array] | Should -BeTrue
+ }
+
+ It "Returns a String Type By Default When There Is A Single Hostname" {
+
+ Mock -CommandName Get-HostnamesByEnvironmentName -MockWith { return @( 'A' ) } -ModuleName $moduleForMock
+
+ ( Get-ArmorList -EnvironmentName 'Test' -NoDomain ) -is [string] | Should -BeTrue
+ }
+
+ It "Returns An Array Type When Switch Is Present When There Is A Single Hostname" {
+
+ Mock -CommandName Get-HostnamesByEnvironmentName -MockWith { return @( 'A' ) } -ModuleName $moduleForMock
+
+ # This test feels worthless, but Powershell is coercing a single element array to a string on return.
+ # I verified through debug prints that the variable is of type [string[]] all the way up to the return
+ # statement. Have fun reading https://superuser.com/a/414666
+ [string[]] $result = Get-ArmorList -EnvironmentName 'Test' -NoDomain -List
+ $result -is [array] | Should -BeTrue
+ }
+ }
+
+ Context "Result Respects Quote Parameter" {
+
+ Mock -CommandName Get-HostnamesByEnvironmentName -MockWith { return @( 'A', 'B' ) } -ModuleName $moduleForMock
+
+ It "Returns Unquoted Values By Default" {
+
+ ( Get-ArmorList -EnvironmentName 'Test' -NoDomain ) | Should -BeExactly 'A,B'
+ }
+
+ It "Returns Quoted Values When Switch Is Present" {
+
+ ( Get-ArmorList -EnvironmentName 'Test' -NoDomain -Quote ) | Should -BeExactly '"A","B"'
+ }
+ }
+
+ Context "Result Respects Tier Parameter" {
+
+ Mock -CommandName Get-HostnamesByEnvironmentName -MockWith { return @( 'WEB123', 'APP123', 'MIC123', 'FAB123' ) } -ModuleName $moduleForMock
+
+ It "Returns All Tiers By Default" {
+
+ ( Get-ArmorList -EnvironmentName 'Test' -NoDomain -List ) | Should -BeExactly @( 'WEB123', 'APP123', 'MIC123', 'FAB123' )
+ }
+
+ It "Returns Only Web Servers When Web Tier Filtering" {
+
+ ( Get-ArmorList -EnvironmentName 'Test' -NoDomain -List -Tier 'Web') | Should -BeExactly @( 'WEB123' )
+ }
+
+ It "Returns Only App Servers When App Tier Filtering" {
+
+ ( Get-ArmorList -EnvironmentName 'Test' -NoDomain -List -Tier 'App') | Should -BeExactly @( 'APP123' )
+ }
+
+ It "Returns Only Mic Servers When Mic Tier Filtering" {
+
+ ( Get-ArmorList -EnvironmentName 'Test' -NoDomain -List -Tier 'Mic') | Should -BeExactly @( 'MIC123' )
+ }
+
+ It "Returns Only Fab Servers When Fab Tier Filtering" {
+
+ ( Get-ArmorList -EnvironmentName 'Test' -NoDomain -List -Tier 'Fab') | Should -BeExactly @( 'FAB123' )
+ }
+ }
+}
diff --git a/Modules/Alkami.DevOps.Common/Public/Get-AutomoxAgentPath.ps1 b/Modules/Alkami.DevOps.Common/Public/Get-AutomoxAgentPath.ps1
new file mode 100644
index 0000000..7454759
--- /dev/null
+++ b/Modules/Alkami.DevOps.Common/Public/Get-AutomoxAgentPath.ps1
@@ -0,0 +1,25 @@
+function Get-AutomoxAgentPath {
+
+<#
+.SYNOPSIS
+Returns the full path and filename of the Automox Agent executable
+
+.DESCRIPTION
+Searches Program Files and Program Files x86 for amagent.exe. Returns the full path and filename
+#>
+
+ [CmdletBinding()]
+ param()
+
+ $logLead = (Get-LogLeadName)
+
+ $automoxAgentDirectory = Get-ChildItem -File -Recurse -Depth 2 -Path @(${env:ProgramFiles(x86)}, $ENV:ProgramFiles) -Filter amagent.exe | Select-Object -First 1
+
+ if ($null -eq $automoxAgentDirectory) {
+
+ Write-Warning "$logLead : Unable to Locate the Automox Agent Executable"
+ return $null
+ }
+
+ return $automoxAgentDirectory.FullName
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Common/Public/Get-AutomoxAgentPath.tests.ps1 b/Modules/Alkami.DevOps.Common/Public/Get-AutomoxAgentPath.tests.ps1
new file mode 100644
index 0000000..ec9cb44
--- /dev/null
+++ b/Modules/Alkami.DevOps.Common/Public/Get-AutomoxAgentPath.tests.ps1
@@ -0,0 +1,25 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ""
+
+Describe "Get-AutomoxAgentPath" {
+
+ Context "Validation" {
+
+ It "Writes a Warning if No Executable Found" {
+
+ Mock -ModuleName $moduleForMock Get-ChildItem { return $null }
+ ( Get-AutomoxAgentPath 3>&1 ) -match "Unable to Locate the Automox Agent Executable" | Should -Be $true
+ }
+
+ It "Returns the FullName Property of the File When Found" {
+
+ Mock -ModuleName $moduleForMock Get-ChildItem { return New-Object PSObject -Property @{ Name="Foo"; FullName="C:\Temp\FooBar\amagent.exe"; } }
+ Get-AutomoxAgentPath | Should -Be "C:\Temp\FooBar\amagent.exe"
+ }
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Common/Public/Get-AwsCredentialConfiguration.ps1 b/Modules/Alkami.DevOps.Common/Public/Get-AwsCredentialConfiguration.ps1
new file mode 100644
index 0000000..7319a80
--- /dev/null
+++ b/Modules/Alkami.DevOps.Common/Public/Get-AwsCredentialConfiguration.ps1
@@ -0,0 +1,144 @@
+function Get-AwsCredentialConfiguration {
+
+<#
+.SYNOPSIS
+ Used to read/parse the AWS credentials file entries to a queryable object array
+
+.DESCRIPTION
+ Used to read/parse the AWS credentials file entries to a queryable object array. Runs parsed values through ConvertTo-AwsCredentialEntry
+
+.EXAMPLE
+ Get-AwsCredentialConfiguration
+
+Name : GrandPooBah
+role_arn : arn:aws:iam::123430414321:role/CLI-God-Mode-On
+mfa_serial : arn:aws:iam::123410044321:mfa/poobah-cli
+region : canada-west-99
+source_profile : default
+
+Name : Gibberish
+role_arn : arn:aws:iam::123495924321:role/CLI-NoGood-DoNothing
+mfa_serial : arn:aws:iam::123410044321:mfa/gwhiting-cli
+region : mexico-north-1
+source_profile : default
+
+.LINK
+ ConvertTo-AwsCredentialEntry
+#>
+
+ [CmdletBinding()]
+ [OutputType([object[]])]
+ param()
+
+ $logLead = Get-LogLeadName
+
+ $userProfileDirectory = Get-EnvironmentVariable -StoreName Process -Name USERPROFILE
+
+ $configurationSources = @()
+ $configurationSources += Join-Path -Path $userProfileDirectory -ChildPath ".aws/config"
+ $configurationSources += Join-Path -Path $userProfileDirectory -ChildPath ".aws/credentials"
+ # ToDo - Add the AWS SDK Credentials Source
+
+ [System.Collections.ArrayList]$objects = @()
+ $awsProfile = @{}
+ $lines = @()
+ $propertiesToIgnore = @("aws_secret_access_key", "aws_session_token")
+
+ foreach ($source in $configurationSources) {
+
+ if (-NOT (Test-Path -Path $source)) {
+
+ Write-Verbose "$logLead : Credential file does not exist at [$source]"
+ continue
+ }
+
+ $fileContent = Get-Content -Path $source
+
+ if (Test-IsCollectionNullOrEmpty -Collection $fileContent) {
+
+ Write-Verbose "$logLead : Credential file at [$source] exists but is empty. Skipping."
+ continue
+ }
+
+ $lines += $fileContent
+ $lines += "EOF"
+ }
+
+ if (Test-IsCollectionNullOrEmpty -Collection $lines) {
+
+ Write-Warning "$logLead : Unable to locate any configured AWS credentials sources. Have you set up your local profiles?"
+ return
+ }
+
+ foreach ($line in $lines) {
+
+ if ($line.StartsWith('#') -or (Test-StringIsNullOrWhitespace -Value $line)) {
+
+ # This is a comment or empty line, ignore it
+ continue
+
+ } elseif ($line -eq "EOF" -and (-NOT (Test-StringIsNullOrWhitespace -Value $awsProfile.Name))) {
+
+ # Save the final profile or in between file swaps
+ $objects += $awsProfile
+
+ } elseif ($line.StartsWith('[')) {
+
+ # This is a new profile, push any old profile into the objects array and start fresh
+ if ($null -ne $awsProfile.Name -and ($null -eq ($objects | Where-Object { $_.Name -eq $awsProfile.Name }))) {
+
+ # $awsProfile object is populated, save it
+ $objects += $awsProfile
+ }
+
+ $sanitizedProfileName = $line -replace "\[|\]|(profile\s+)", ""
+ Write-Verbose "$logLead : Looking for existing parsed profiles named [$sanitizedProfileName]"
+ $existingProfile = $objects | Where-Object { $_.Name -eq $sanitizedProfileName }
+
+ if ($null -ne $existingProfile) {
+
+ # Load the existing profile to attach properties, where unique
+ Write-Verbose "$logLead : Duplicate profile name [$sanitizedProfileName] found. First-in properties win in this scenario."
+
+ $awsProfile = $existingProfile
+ $objects.Remove($existingProfile)
+
+ } else {
+
+ # This is a new profile
+ $awsProfile = New-Object PSObject -Property @{ Name = $sanitizedProfileName }
+ }
+ } else {
+
+ if ($null -eq $awsProfile.Name) {
+
+ # We skip this step unless we're anchored to a profile
+ # to avoid picking up trash or duplicate properties
+ continue
+ }
+
+ # The only things left are valid properties to attach to an object
+ # Unless we're specifically ignoring them
+ $splits = $line -split '='
+ $propertyName = $splits[0].Trim()
+
+ if ($propertiesToIgnore -contains $propertyName) {
+
+ # This is a sensitive property that will never be recorded
+ Write-Verbose "$logLead : Skipping excluded property [$propertyName] for profile [$($awsProfile.Name)]"
+ continue
+
+ } elseif ($null -ne $awsProfile.$propertyName) {
+
+ # This property has been recorded already. It is a duplicate, and first read wins
+ Write-Verbose ("$logLead : Skipping duplicate property [$propertyName] for profile [$($awsProfile.Name)] - existing value: [$($awsProfile.$propertyName)]")
+ continue
+ }
+
+ $propertyValue = $splits[1].Trim()
+ Add-Member -InputObject $awsProfile -NotePropertyName $propertyName -NotePropertyValue $propertyValue -Force
+ }
+ }
+
+ return ($objects | ConvertTo-AwsCredentialEntry | Sort-Object -Property Name)
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Common/Public/Get-AwsCredentialConfiguration.tests.ps1 b/Modules/Alkami.DevOps.Common/Public/Get-AwsCredentialConfiguration.tests.ps1
new file mode 100644
index 0000000..7311444
--- /dev/null
+++ b/Modules/Alkami.DevOps.Common/Public/Get-AwsCredentialConfiguration.tests.ps1
@@ -0,0 +1,114 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ""
+
+Describe "Get-AwsCredentialConfiguration" {
+
+ # This is a private function, so Pester has a fit
+ function ConvertTo-AwsCredentialEntry { param([System.Collections.ArrayList]$CredentialData) }
+
+ Context "Logic" {
+
+ It "Reads from Both Text-Based Credentials Files" {
+
+ $expectedFileReads = @("credentials", "config")
+
+ Mock -CommandName Get-Content -ModuleName $moduleForMock -MockWith {}
+
+ Get-AwsCredentialConfiguration | Should -BeNullOrEmpty
+
+ foreach ($fileRead in $expectedFileReads) {
+
+ Assert-MockCalled -CommandName Get-Content -Scope It -Times 1 -Exactly -ParameterFilter { $Path -match "$fileRead$"}
+ }
+ }
+
+ It "Writes a Warning and Exits Early if No Credentials Files Found" {
+
+ Mock -CommandName Get-Content -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Write-Warning -ModuleName $moduleForMock -MockWith { }
+ Mock -CommandName Write-Verbose -ModuleName $moduleForMock -MockWith { }
+ Mock -CommandName Test-Path -ModuleName $moduleForMock -MockWith { return $false }
+
+ Get-AwsCredentialConfiguration -Verbose
+ Assert-MockCalled -CommandName Write-Warning -Scope It -Times 1 -Exactly -ParameterFilter { $Message -match "Unable to locate any configured AWS credentials sources" }
+ }
+
+ It "Writes a Warning and Exits Early if Credentials Files Found but Empty" {
+
+ Mock -CommandName Get-Content -ModuleName $moduleForMock -MockWith { return @() }
+ Mock -CommandName Write-Warning -ModuleName $moduleForMock -MockWith { }
+ Mock -CommandName Write-Verbose -ModuleName $moduleForMock -MockWith { }
+ Mock -CommandName Test-Path -ModuleName $moduleForMock -MockWith { return $true }
+
+ Get-AwsCredentialConfiguration -Verbose
+ Assert-MockCalled -CommandName Write-Warning -Scope It -Times 1 -Exactly -ParameterFilter { $Message -match "Unable to locate any configured AWS credentials sources" }
+ }
+
+ It "Parses the File Contents Appropriately" {
+
+ Mock -CommandName Get-Content -ModuleName $moduleForMock -MockWith { return @( "[ErMerGerd]","role_arn=some_value","source_profile=another_value","region=some_region","mfa_serial=12345") } `
+ -ParameterFilter { $Path -Match "credentials" }
+ Mock -CommandName Get-Content -ModuleName $moduleForMock -MockWith { return @( "[AllInAll]","role_arn=Its","source_profile=Just","region=Another","mfa_serial=BrickInTheWall") } `
+ -ParameterFilter { $Path -Match "config" }
+
+ Mock -CommandName Test-Path -ModuleName $moduleForMock -MockWith { return $true }
+ Mock -CommandName ConvertTo-AwsCredentialEntry -ModuleName $moduleForMock -MockWith { return $_ }
+
+ $result = Get-AwsCredentialConfiguration
+ $result | Should -HaveCount 2
+ $firstResult = $result | Select -First 1
+ $lastResult = $result | Select -Last 1
+
+ $firstResult.Name | Should -BeExactly "AllInAll"
+ $firstResult.role_arn | Should -BeExactly "Its"
+ $firstResult.source_profile | Should -BeExactly "Just"
+ $firstResult.region | Should -BeExactly "Another"
+ $firstResult.mfa_serial | Should -BeExactly "BrickInTheWall"
+
+ $lastResult.Name | Should -BeExactly "ErMerGerd"
+ $lastResult.role_arn | Should -BeExactly "some_value"
+ $lastResult.source_profile | Should -BeExactly "another_value"
+ $lastResult.region | Should -BeExactly "some_region"
+ $lastResult.mfa_serial | Should -BeExactly "12345"
+ }
+
+ It "Merges Profile Properties Preferring First In When Overlap Occurs" {
+
+ Mock -CommandName Get-Content -ModuleName $moduleForMock -MockWith { return @( "[Shmoo]","role_arn=first_value","source_profile=first_value") } `
+ -ParameterFilter { $Path -Match "config" }
+ Mock -CommandName Get-Content -ModuleName $moduleForMock -MockWith { return @( "[Shmoo]","role_arn=second_value","region=a_value_not_in_object_one" ) } `
+ -ParameterFilter { $Path -Match "credentials" }
+
+ Mock -CommandName Test-Path -ModuleName $moduleForMock -MockWith { return $true }
+ Mock -CommandName ConvertTo-AwsCredentialEntry -ModuleName $moduleForMock -MockWith { return $_ }
+
+ $result = Get-AwsCredentialConfiguration
+ $result | Should -HaveCount 1
+ $result.role_arn | Should -Be "first_value"
+ $result.source_profile | Should -BeExactly "first_value"
+ $result.region | Should -BeExactly "a_value_not_in_object_one"
+ $result.mfa_serial | Should -BeNullOrEmpty
+ }
+
+ It "Refuses to Return Properties Defined as Sensitive" {
+
+ Mock -CommandName Test-Path -ModuleName $moduleForMock -MockWith { return $true }
+ Mock -CommandName ConvertTo-AwsCredentialEntry -ModuleName $moduleForMock -MockWith { return $_ }
+
+ $propertiesToIgnore = @("aws_secret_access_key", "aws_session_token")
+ foreach ($property in $propertiesToIgnore) {
+
+ Mock -CommandName Get-Content -ModuleName $moduleForMock -MockWith { return @( "[OhSay]","role_arn=Can","source_profile=You","$property=See") } `
+ -ParameterFilter { $Path -Match "config" }
+
+ $result = Get-AwsCredentialConfiguration
+ $result[0].$property | Should -BeNullOrEmpty
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Common/Public/Get-AwsStandardDynamicParameters.ps1 b/Modules/Alkami.DevOps.Common/Public/Get-AwsStandardDynamicParameters.ps1
new file mode 100644
index 0000000..8f81863
--- /dev/null
+++ b/Modules/Alkami.DevOps.Common/Public/Get-AwsStandardDynamicParameters.ps1
@@ -0,0 +1,91 @@
+function Get-AwsStandardDynamicParameters {
+
+<#
+.SYNOPSIS
+ Returns a RuntimeDefinedParameterDictionary with all locally configured AWS Profiles and Alkami-supported Regions
+
+.DESCRIPTION
+ Returns a RuntimeDefinedParameterDictionary with all locally configured AWS Profiles and Alkami-supported Regions as DynamicParameters in functions.
+
+.PARAMETER RegionParameterName
+ The parameter name to set on the Region parameter. Defaults to Region
+
+.PARAMETER RegionParameterSetName
+ The parameter set to associate the dynamic Region parameter with. Defaults to all parameter sets.
+
+.PARAMETER RegionParameterRequired
+ Whether or not the Region parameter is mandatory. Defaults to false.
+
+.PARAMETER ProfileParameterName
+ The parameter name to set on the Profile Name parameter. Defaults to ProfileName
+
+.PARAMETER ProfileParameterSetName
+ The parameter set to associate the dynamic ProfileName parameter with. Defaults to all parameter sets.
+
+.PARAMETER ProfileParameterRequired
+ Whether or not the ProfileName parameter is mandatory. Defaults to false.
+
+.EXAMPLE
+ Get-AwsStandardDynamicParameters
+
+Key Value
+--- -----
+Region System.Management.Automation.RuntimeDefinedParameter
+Profile System.Management.Automation.RuntimeDefinedParameter
+
+.LINK
+ Get-DynamicAwsProfilesParameter
+
+.LINK
+ Get-DynamicAwsRegionParameter
+#>
+
+ [Cmdletbinding()]
+ [OutputType([System.Management.Automation.RuntimeDefinedParameterDictionary])]
+ param(
+
+ [Parameter(Mandatory=$false)]
+ [string]$RegionParameterName = "Region",
+
+ [Parameter(Mandatory=$false)]
+ [string]$RegionParameterSetName = "__AllParameterSets",
+
+ [Parameter(Mandatory=$false)]
+ [switch]$RegionParameterRequired,
+
+ [Parameter(Mandatory=$false)]
+ [string]$ProfileParameterName = "ProfileName",
+
+ [Parameter(Mandatory=$false)]
+ [string]$ProfileParameterSetName = "__AllParameterSets",
+
+ [Parameter(Mandatory=$false)]
+ [switch]$ProfileParameterRequired
+ )
+
+ $regionDynamicParams = @{
+
+ "DynamicParameterName" = $RegionParameterName;
+ "ParameterSetName" = $RegionParameterSetName;
+ "IsMandatoryParameter" = $RegionParameterRequired
+ }
+
+ $regionRuntimeParameter = Get-DynamicAwsRegionParameter @regionDynamicParams
+
+ $profileDynamicParams = @{
+
+ "DynamicParameterName" = $ProfileParameterName;
+ "ParameterSetName" = $ProfileParameterSetName;
+ "IsMandatoryParameter" = $ProfileParameterRequired
+ }
+
+ $profileRuntimeParameter = Get-DynamicAwsProfilesParameter @profileDynamicParams
+
+ # The Dynamic params functions return dictionaries so they can be used independently if needed
+ # We will add both returned values to a new Dictionary and return it for use by the calling function
+ $runtimeParameterDictionary = New-Object System.Management.Automation.RuntimeDefinedParameterDictionary
+ $runtimeParameterDictionary.Add($RegionParameterName, $($regionRuntimeParameter.Values[0]))
+ $runtimeParameterDictionary.Add($ProfileParameterName, $($profileRuntimeParameter.Values[0]))
+
+ return $runtimeParameterDictionary
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Common/Public/Get-AwsStandardDynamicParameters.tests.ps1 b/Modules/Alkami.DevOps.Common/Public/Get-AwsStandardDynamicParameters.tests.ps1
new file mode 100644
index 0000000..fdc5df0
--- /dev/null
+++ b/Modules/Alkami.DevOps.Common/Public/Get-AwsStandardDynamicParameters.tests.ps1
@@ -0,0 +1,79 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ""
+
+Describe "Get-AwsStandardDynamicParameters" {
+
+ Context "Logic" {
+
+ Mock -CommandName Get-AwsCredentialConfiguration -ModuleName $moduleForMock -MockWith { return @(
+ (New-Object PSObject -Property @{ "Name"="FogoDeChao"; }),
+ (New-Object PSObject -Property @{ "Name"="12Cuts"; })
+ )}
+
+ Mock -CommandName Get-SupportedAwsRegions -ModuleName $moduleForMock -MockWith { return @(
+ "mexico-south-55",
+ "australia-west-99"
+ )}
+
+ It "Profile Uses the Specified Parameter Name" {
+
+ $testParamName = "OnlyYouCanMakeAllThisWorldSeemRight"
+
+ $params = Get-AwsStandardDynamicParameters -ProfileParameterName $testParamName
+ $profileParam = $params.Item($testParamName)
+ $profileParam | Should -Not -BeNullOrEmpty
+ $profileParam.Attributes.HelpMessage -like "*Profile*" | Should -BeTrue
+ }
+
+ It "Region Uses the Specified Parameter Name" {
+
+ $testParamName = "OnlyYouCanMakeTheDarknessBright"
+
+ $params = Get-AwsStandardDynamicParameters -RegionParameterName $testParamName
+ $regionParam = $params.Item($testParamName)
+ $regionParam | Should -Not -BeNullOrEmpty
+ $regionParam.Attributes.HelpMessage -like "*Region*" | Should -BeTrue
+ }
+
+ It "Profile Uses the Specified ParameterSet Name" {
+
+ $testParamSetName = "OnlyYouAndYouAloneCanThrillMeLikeYouDo"
+
+ $params = Get-AwsStandardDynamicParameters -ProfileParameterSetName $testParamSetName
+ $profileParam = $params.Item("ProfileName")
+ $profileParam | Should -Not -BeNullOrEmpty
+ $profileParam.Attributes.ParameterSetName | Should -BeExactly $testParamSetName
+ }
+
+ It "Region Uses the Specified ParameterSet Name" {
+
+ $testParamSetName = "AndFillMyHeartWithLoveForOnlyYou"
+
+ $params = Get-AwsStandardDynamicParameters -RegionParameterSetName $testParamSetName
+ $regionParam = $params.Item("Region")
+ $regionParam | Should -Not -BeNullOrEmpty
+ $regionParam.Attributes.ParameterSetName | Should -BeExactly $testParamSetName
+ }
+
+ It "Profile Is a Mandatory Parameter if Specified" {
+
+ $params = Get-AwsStandardDynamicParameters -ProfileParameterRequired
+ $profileParam = $params.Item("ProfileName")
+ $profileParam | Should -Not -BeNullOrEmpty
+ $profileParam.Attributes.Mandatory | Should -BeTrue
+ }
+
+ It "Region Is a Mandatory Parameter if Specified" {
+
+ $params = Get-AwsStandardDynamicParameters -RegionParameterRequired
+ $regionParam = $params.Item("Region")
+ $regionParam | Should -Not -BeNullOrEmpty
+ $regionParam.Attributes.Mandatory | Should -BeTrue
+ }
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Common/Public/Get-CatalogsFromMaster.ps1 b/Modules/Alkami.DevOps.Common/Public/Get-CatalogsFromMaster.ps1
new file mode 100644
index 0000000..2c9d448
--- /dev/null
+++ b/Modules/Alkami.DevOps.Common/Public/Get-CatalogsFromMaster.ps1
@@ -0,0 +1,58 @@
+function Get-CatalogsFromMaster {
+<#
+.SYNOPSIS
+ Returns a collection of tenants from the master database as a hashtable
+#>
+ [CmdletBinding()]
+ param(
+ [Parameter(Position = 0, Mandatory = $false)]
+ [string]$ConnectionString
+ )
+
+ if ([String]::IsNullOrEmpty($ConnectionString)) {
+ $masterConnectionString = Get-MasterConnectionString
+ }
+ else {
+ $masterConnectionString = $ConnectionString
+ }
+
+ $conn = New-Object System.Data.SqlClient.SqlConnection
+ $conStrBuilder = New-Object System.Data.SqlClient.SqlConnectionStringBuilder($masterConnectionString)
+
+ $conn.ConnectionString = $conStrBuilder.ToString()
+
+ [hashtable[]]$databases = @()
+
+ try {
+ $conn.Open()
+ $query = New-Object System.Data.SqlClient.SqlCommand("SELECT Name, BankUrlSignatures, BankAdminUrlSignatures, DataSource, Catalog FROM dbo.Tenant", $conn)
+ $results = $query.ExecuteReader()
+
+ if (!$results.HasRows) {
+ Write-Warning (" No rows were returned from the tenant table.`nServer: {0}`nDatabase: {1}" -f $conStrBuilder.DataSource, $conStrBuilder.InitialCatalog)
+ return $null
+ }
+
+ while ($results.Read()) {
+ $tenantConStringBuilder = $conStrBuilder
+ $tenantConStringBuilder.'Data Source' = $results.Item(3)
+ $tenantConStringBuilder.'Initial Catalog' = $results.Item(4)
+ $databases += @{Name = $results.Item(0); Signature = $results.Item(1); AdminSignature = $results.Item(2); DataSource = $results.Item(3); Catalog = $results.Item(4); ConnectionString = $tenantConStringBuilder.ToString()}
+ }
+
+ return $databases
+ }
+ catch {
+ Write-Warning "An exception occurred while trying to pull tenants from the master database"
+ Write-Warning $_ | Format-List -Force
+ return $null
+ }
+ finally {
+ if ($conn.State -ne [System.Data.ConnectionState]::Closed) {
+ $conn.Close()
+ }
+
+ $conn = $null
+ }
+}
+
diff --git a/Modules/Alkami.DevOps.Common/Public/Get-DesignationTagNameByEnvironment.ps1 b/Modules/Alkami.DevOps.Common/Public/Get-DesignationTagNameByEnvironment.ps1
new file mode 100644
index 0000000..d541008
--- /dev/null
+++ b/Modules/Alkami.DevOps.Common/Public/Get-DesignationTagNameByEnvironment.ps1
@@ -0,0 +1,91 @@
+function Get-DesignationTagNameByEnvironment {
+
+ <#
+.SYNOPSIS
+Looks up the appropriate designation string for a given environment
+
+.DESCRIPTION
+Looks up the appropriate designation string such as Pod, Lane, Box, or Designation for a given environment
+Will use the current server's environment for lookup if the machine is an EC2 and no environment parameter has
+been provided
+
+.PARAMETER EnvironmentType
+[String] An optional parameter to look up the string for a particular environment type. When supplied will override
+the current instance value if the instance is in AWS. Is required for execution on non-EC2 machines
+
+.EXAMPLE
+
+Get-DesignationTagNameByEnvironment
+
+[Get-DesignationTagNameByEnvironment] : Checking designation value for environment prod
+pod
+
+.EXAMPLE
+
+Get-DesignationTagNameByEnvironment "prodshared"
+
+[Get-DesignationTagNameByEnvironment] : Checking designation value for environment prodshared
+pod
+
+#>
+
+ [CmdletBinding()]
+ param(
+ [ValidateSet("dev", "qa", "staging", "prod", "dr", "sandbox", "devshared", "qashared", "stagingshared", "prodshared", "drshared", "sandboxshared","ltm","ltmshared")]
+ [Parameter(Mandatory = $false)]
+ [string]$environmentType
+ )
+
+ $logLead = (Get-LogLeadName)
+ $checkCurrentInstanceTags = Test-IsAws
+ $parameterProvided = !([String]::IsNullOrEmpty($environmentType))
+
+ if (!$checkCurrentInstanceTags -and !$parameterProvided) {
+
+ Write-Warning "$logLead : Current server is not in AWS and no environment type name is provided. Execution cannot continue"
+ return $null
+ }
+
+ if ($checkCurrentInstanceTags -and !$parameterProvided) {
+
+ Write-Verbose "$logLead : Current host is in AWS and no Environment Type provided. Getting the current instance's $Global:AlkamiTagKeyEnvironment tag"
+ $environmentTagValue = Get-CurrentInstanceTags $Global:AlkamiTagKeyEnvironment -ValueOnly -ErrorAction Continue
+
+ if ([String]::IsNullOrEmpty($environmentTagValue)) {
+
+ Write-Warning "$logLead : Current server is not configured with the $Global:AlkamiTagKeyEnvironment tag or it could not be retrieved. Execution cannot continue"
+ return $null
+ }
+
+ Write-Verbose "$logLead : Using tag value $environmentTagValue for lookup"
+ }
+ else {
+
+ Write-Verbose "$logLead : Using parameter value $environmentType for lookup"
+ $environmentTagValue = $environmentType
+ }
+
+ Write-Host "$logLead : Checking designation value for environment $environmentTagValue"
+ $lookupValue = switch ($environmentTagValue) {
+
+ "$Global:AlkamiTagValueEnvironmentProd" { $Global:AlkamiDesignationEnvironmentProd }
+ ("$Global:AlkamiTagValueEnvironmentProd" + "shared") { $Global:AlkamiDesignationEnvironmentProd }
+ "$Global:AlkamiTagValueEnvironmentDR" { $Global:AlkamiDesignationEnvironmentDR }
+ ("$Global:AlkamiTagValueEnvironmentDR" + "shared") { $Global:AlkamiDesignationEnvironmentDR }
+ "$Global:AlkamiTagValueEnvironmentStaging" { $Global:AlkamiDesignationEnvironmentStaging }
+ ("$Global:AlkamiTagValueEnvironmentStaging" + "shared") { $Global:AlkamiDesignationEnvironmentStaging }
+ "$Global:AlkamiTagValueEnvironmentSandbox" { $Global:AlkamiDesignationEnvironmentSandbox }
+ ("$Global:AlkamiTagValueEnvironmentSandbox" + "shared") { $Global:AlkamiDesignationEnvironmentSandbox }
+ "$Global:AlkamiTagValueEnvironmentDev" { $Global:AlkamiDesignationEnvironmentDev }
+ ("$Global:AlkamiTagValueEnvironmentDev" + "shared") { $Global:AlkamiDesignationEnvironmentDev }
+ "$Global:AlkamiTagValueEnvironmentQa" { $Global:AlkamiDesignationEnvironmentQA }
+ ("$Global:AlkamiTagValueEnvironmentQa" + "shared") { $Global:AlkamiDesignationEnvironmentQA }
+ "$Global:AlkamiTagValueEnvironmentLTM" { $Global:AlkamiDesignationEnvironmentLTM }
+ ("$Global:AlkamiTagValueEnvironmentLTM" + "shared") { $Global:AlkamiDesignationEnvironmentLTM }
+ "$Global:AlkamiTagValueEnvironmentLoadtest" { $Global:AlkamiDesignationEnvironmentLoadtest }
+ ("$Global:AlkamiTagValueEnvironmentLoadtest" + "shared") { $Global:AlkamiDesignationEnvironmentLoadtest }
+ default { "Unknown" }
+ }
+
+ return $lookupValue
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Common/Public/Get-DesignationTagNameByEnvironment.tests.ps1 b/Modules/Alkami.DevOps.Common/Public/Get-DesignationTagNameByEnvironment.tests.ps1
new file mode 100644
index 0000000..3ab20ef
--- /dev/null
+++ b/Modules/Alkami.DevOps.Common/Public/Get-DesignationTagNameByEnvironment.tests.ps1
@@ -0,0 +1,126 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ""
+
+Describe "Get-DesignationTagNameByEnvironment" {
+
+ Context "Error and Parameter Handling" {
+
+ Mock -CommandName Write-Warning -MockWith {} -ModuleName $moduleForMock
+
+ It "Does Not Execute if No Parameter Provided and Not in AWS" {
+
+ Mock -CommandName Test-IsAws -ModuleName $moduleForMock -MockWith { return $false }
+
+ Get-DesignationTagNameByEnvironment | Should -BeNullOrEmpty
+ Assert-MockCalled -CommandName Write-Warning -Times 1 -Exactly -Scope It `
+ -ModuleName $moduleForMock -ParameterFilter { $Message -match "Current server is not in AWS" }
+ }
+
+ It "Does Not Lookup Instance Tags if a Parameter is Provided" {
+
+ Mock -CommandName Test-IsAws -ModuleName $moduleForMock -MockWith { return $true }
+ Mock -CommandName Get-CurrentInstanceTags -ModuleName $moduleForMock -MockWith { }
+
+ Get-DesignationTagNameByEnvironment "prod" | Out-Null
+ Assert-MockCalled -CommandName Get-CurrentInstanceTags -Times 0 -Exactly -Scope It -ModuleName $moduleForMock
+ }
+
+ It "Pulls Current Instance Tags if in AWS And No Parameter is Supplied" {
+
+ Mock -CommandName Test-IsAws -ModuleName $moduleForMock -MockWith { return $true }
+ Mock -CommandName Get-CurrentInstanceTags -ModuleName $moduleForMock -MockWith { }
+
+ Get-DesignationTagNameByEnvironment | Out-Null
+ Assert-MockCalled -CommandName Get-CurrentInstanceTags -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+ }
+
+ It "Returns Unknown if the Tags Have an Unexpected Value for Environment" {
+
+ Mock -CommandName Test-IsAws -ModuleName $moduleForMock -MockWith { return $true }
+ Mock -CommandName Get-CurrentInstanceTags -ModuleName $moduleForMock -MockWith { return @{
+ Key=$Global:AlkamiTagKeyEnvironment;Value="FoobarDoopityDo";
+ }}
+
+ Get-DesignationTagNameByEnvironment | Should -Be "Unknown"
+ Assert-MockCalled -CommandName Get-CurrentInstanceTags -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+ }
+
+ It "Throws if an Unknown Value is Passed as a Parameter" {
+
+ { Get-DesignationTagNameByEnvironment "IfSomeoneAddsThisToTheValidateSetIllFirethem" } | Should -Throw
+ }
+ }
+
+ Context "Happy Path" {
+
+ It "Returns the Expected Values When Parameters are Provided" {
+
+ Get-DesignationTagNameByEnvironment "prod" | Should -Be $Global:AlkamiDesignationEnvironmentProd
+ Get-DesignationTagNameByEnvironment "prodshared" | Should -Be $Global:AlkamiDesignationEnvironmentProd
+
+ Get-DesignationTagNameByEnvironment "dr" | Should -Be $Global:AlkamiDesignationEnvironmentProd
+ Get-DesignationTagNameByEnvironment "drshared" | Should -Be $Global:AlkamiDesignationEnvironmentProd
+
+ Get-DesignationTagNameByEnvironment "dev" | Should -Be $Global:AlkamiDesignationEnvironmentDev
+ Get-DesignationTagNameByEnvironment "devshared" | Should -Be $Global:AlkamiDesignationEnvironmentDev
+
+ Get-DesignationTagNameByEnvironment "qa" | Should -Be $Global:AlkamiDesignationEnvironmentQA
+ Get-DesignationTagNameByEnvironment "qashared" | Should -Be $Global:AlkamiDesignationEnvironmentQA
+
+ Get-DesignationTagNameByEnvironment "sandbox" | Should -Be $Global:AlkamiDesignationEnvironmentSandbox
+ Get-DesignationTagNameByEnvironment "sandboxshared" | Should -Be $Global:AlkamiDesignationEnvironmentSandbox
+
+ Get-DesignationTagNameByEnvironment "staging" | Should -Be $Global:AlkamiDesignationEnvironmentStaging
+ Get-DesignationTagNameByEnvironment "stagingshared" | Should -Be $Global:AlkamiDesignationEnvironmentStaging
+ }
+
+ It "Returns the Expected Values When Tags are Queried" {
+
+ Mock -CommandName Test-IsAws -ModuleName $moduleForMock -MockWith { return $true }
+ Mock -CommandName Get-CurrentInstanceTags -ModuleName $moduleForMock -MockWith { return $Global:MockedEnvironmentValue }
+
+ $Global:MockedEnvironmentValue = "prod"
+ Get-DesignationTagNameByEnvironment | Should -Be $Global:AlkamiDesignationEnvironmentProd
+
+ $Global:MockedEnvironmentValue = "prodshared"
+ Get-DesignationTagNameByEnvironment | Should -Be $Global:AlkamiDesignationEnvironmentProd
+
+ $Global:MockedEnvironmentValue = "dr"
+ Get-DesignationTagNameByEnvironment | Should -Be $Global:AlkamiDesignationEnvironmentProd
+
+ $Global:MockedEnvironmentValue = "drshared"
+ Get-DesignationTagNameByEnvironment | Should -Be $Global:AlkamiDesignationEnvironmentProd
+
+ $Global:MockedEnvironmentValue = "dev"
+ Get-DesignationTagNameByEnvironment | Should -Be $Global:AlkamiDesignationEnvironmentDev
+
+ $Global:MockedEnvironmentValue = "devshared"
+ Get-DesignationTagNameByEnvironment | Should -Be $Global:AlkamiDesignationEnvironmentDev
+
+ $Global:MockedEnvironmentValue = "qa"
+ Get-DesignationTagNameByEnvironment | Should -Be $Global:AlkamiDesignationEnvironmentQA
+
+ $Global:MockedEnvironmentValue = "qashared"
+ Get-DesignationTagNameByEnvironment | Should -Be $Global:AlkamiDesignationEnvironmentQA
+
+ $Global:MockedEnvironmentValue = "sandbox"
+ Get-DesignationTagNameByEnvironment | Should -Be $Global:AlkamiDesignationEnvironmentSandbox
+
+ $Global:MockedEnvironmentValue = "sandboxshared"
+ Get-DesignationTagNameByEnvironment | Should -Be $Global:AlkamiDesignationEnvironmentSandbox
+
+ $Global:MockedEnvironmentValue = "staging"
+ Get-DesignationTagNameByEnvironment | Should -Be $Global:AlkamiDesignationEnvironmentStaging
+
+ $Global:MockedEnvironmentValue = "stagingshared"
+ Get-DesignationTagNameByEnvironment | Should -Be $Global:AlkamiDesignationEnvironmentStaging
+
+ Assert-MockCalled -CommandName Get-CurrentInstanceTags -Times 1 -Scope It -ModuleName $moduleForMock
+ }
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Common/Public/Get-DynamicAwsProfilesParameter.ps1 b/Modules/Alkami.DevOps.Common/Public/Get-DynamicAwsProfilesParameter.ps1
new file mode 100644
index 0000000..0678175
--- /dev/null
+++ b/Modules/Alkami.DevOps.Common/Public/Get-DynamicAwsProfilesParameter.ps1
@@ -0,0 +1,80 @@
+function Get-DynamicAwsProfilesParameter {
+
+<#
+.SYNOPSIS
+ Returns a RuntimeDefinedParameterDictionary with all locally configured AWS Profiles
+
+.DESCRIPTION
+ Returns a RuntimeDefinedParameterDictionary with all locally configured AWS Profiles for use as a DynamicParameter in functions. Does
+ not retrieve profiles for any AWSSDK configurations.
+
+.PARAMETER DynamicParameterName
+ The parameter name to set on the return value. Defaults to ProfileName
+
+.PARAMETER ParameterSetName
+ The parameter set to associate the dynamic parameter with. Defaults to all parameter sets.
+
+.PARAMETER IsMandatoryParameter
+ Wheter or not the parameter is mandatory. Defaults to false.
+
+.EXAMPLE
+ Get-DynamicAwsProfilesParameter
+
+Key Value
+--- -----
+ProfileName System.Management.Automation.RuntimeDefinedParameter
+
+.EXAMPLE
+
+ Get-DynamicAwsProfilesParameter -DynamicParameterName "HooDoggie"
+
+Key Value
+--- -----
+HooDoggie System.Management.Automation.RuntimeDefinedParameter
+
+.LINK
+ Get-AwsCredentialConfiguration
+#>
+
+ [CmdletBinding()]
+ [OutputType([System.Management.Automation.RuntimeDefinedParameterDictionary])]
+ param(
+
+ [Parameter(Mandatory = $false)]
+ [string]$DynamicParameterName = "ProfileName",
+
+ [Parameter(Mandatory = $false)]
+ [string]$ParameterSetName = "__AllParameterSets",
+
+ [Parameter(Mandatory = $false)]
+ [switch]$IsMandatoryParameter
+ )
+
+ # Define the Paramater Attributes
+ $runtimeParameterDictionary = New-Object -Type System.Management.Automation.RuntimeDefinedParameterDictionary
+ $attributeCollection = New-Object System.Collections.ObjectModel.Collection[System.Attribute]
+
+ $parameterAttribute = New-Object -Type System.Management.Automation.ParameterAttribute
+ $parameterAttribute.Mandatory = $IsMandatoryParameter
+ $parameterAttribute.ParameterSetName = $ParameterSetName
+ $parameterAttribute.HelpMessage = "The Local AWS Credential Profile Name to Use for Requests"
+ $attributeCollection.Add($parameterAttribute)
+
+ # Generate and add the ValidateSet
+ # Do not print warnings because this may be loaded/evaluated on servers without any profiles
+ $profileNames = Get-AwsCredentialConfiguration -WarningAction SilentlyContinue | Select-Object -ExpandProperty Name
+
+ # If no profiles are found, set the only available value to NoLocalProfilesFound
+ if (Test-IsCollectionNullOrEmpty -Collection $profileNames) {
+
+ $profileNames = @( "NoLocalProfilesFound" )
+ }
+
+ $validateSetAttribute = New-Object System.Management.Automation.ValidateSetAttribute($profileNames)
+ $attributeCollection.Add($validateSetAttribute)
+
+ # Create the dynamic parameter
+ $runtimeParameter = New-Object System.Management.Automation.RuntimeDefinedParameter($DynamicParameterName, [string], $attributeCollection)
+ $runtimeParameterDictionary.Add($DynamicParameterName, $RuntimeParameter)
+ return $runtimeParameterDictionary
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Common/Public/Get-DynamicAwsProfilesParameter.tests.ps1 b/Modules/Alkami.DevOps.Common/Public/Get-DynamicAwsProfilesParameter.tests.ps1
new file mode 100644
index 0000000..e84e6e6
--- /dev/null
+++ b/Modules/Alkami.DevOps.Common/Public/Get-DynamicAwsProfilesParameter.tests.ps1
@@ -0,0 +1,53 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ""
+
+Describe "Get-DynamicAwsProfilesParameter" {
+
+ Context "Logic" {
+
+ Mock -CommandName Get-AwsCredentialConfiguration -ModuleName $moduleForMock -MockWith { return @(
+ (New-Object PSObject -Property @{ "Name"="FogoDeChao"; }),
+ (New-Object PSObject -Property @{ "Name"="12Cuts"; })
+ )}
+
+ It "Uses the Specified Parameter Name" {
+
+ $testParamName = "SupaFly"
+
+ $param = Get-DynamicAwsProfilesParameter -DynamicParameterName $testParamName
+ $param.Keys | Should -BeExactly $testParamName
+ }
+
+ It "Uses the Specified ParameterSet Name" {
+
+ $testParamSetName = "MisterP"
+
+ $param = Get-DynamicAwsProfilesParameter -ParameterSetName $testParamSetName
+ $param.Values.Attributes.ParameterSetName | Should -BeExactly $testParamSetName
+ }
+
+ It "Is a Mandatory Parameter if Specified" {
+
+ $param = Get-DynamicAwsProfilesParameter -IsMandatoryParameter
+ $param.Values.Attributes.Mandatory | Should -BeTrue
+ }
+
+ It "Creates a Validate Set with Valid AWS Profile Names" {
+
+ $param = Get-DynamicAwsProfilesParameter
+ $param.Values.Attributes.ValidValues | Should -Be @( "FogoDeChao", "12Cuts")
+ }
+
+ It "Returns a Single Invalid Parameter if None Can be Read from Local Configuration" {
+
+ Mock -CommandName Get-AwsCredentialConfiguration -ModuleName $moduleForMock -MockWith { return $null }
+ $param = Get-DynamicAwsProfilesParameter
+ $param.Values.Attributes.ValidValues | Should -Be @( "NoLocalProfilesFound" )
+ }
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Common/Public/Get-DynamicAwsRegionParameter.ps1 b/Modules/Alkami.DevOps.Common/Public/Get-DynamicAwsRegionParameter.ps1
new file mode 100644
index 0000000..809552b
--- /dev/null
+++ b/Modules/Alkami.DevOps.Common/Public/Get-DynamicAwsRegionParameter.ps1
@@ -0,0 +1,71 @@
+function Get-DynamicAwsRegionParameter {
+
+<#
+.SYNOPSIS
+ Returns a RuntimeDefinedParameterDictionary with all Alkami-permitted AWS Regions
+
+.DESCRIPTION
+ Returns a RuntimeDefinedParameterDictionary with all Alkami-permitted AWS Regions for use as a DynamicParameter in functions.
+
+.PARAMETER DynamicParameterName
+ The parameter name to set on the return value. Defaults to Region
+
+.PARAMETER ParameterSetName
+ The parameter set to associate the dynamic parameter with. Defaults to all parameter sets.
+
+.PARAMETER IsMandatoryParameter
+ Wheter or not the parameter is mandatory. Defaults to false.
+
+.EXAMPLE
+ Get-DynamicAwsRegionParameter
+
+Key Value
+--- -----
+Region System.Management.Automation.RuntimeDefinedParameter
+
+.EXAMPLE
+
+ Get-DynamicAwsRegionParameter -DynamicParameterName "OhNoHeDidnt"
+
+Key Value
+--- -----
+OhNoHeDidnt System.Management.Automation.RuntimeDefinedParameter
+
+.LINK
+ Get-SupportedAwsRegions
+#>
+
+ [CmdletBinding()]
+ [OutputType([System.Management.Automation.RuntimeDefinedParameterDictionary])]
+ param(
+
+ [Parameter(Mandatory = $false)]
+ [string]$DynamicParameterName = "Region",
+
+ [Parameter(Mandatory = $false)]
+ [string]$ParameterSetName = "__AllParameterSets",
+
+ [Parameter(Mandatory = $false)]
+ [switch]$IsMandatoryParameter
+ )
+
+ # Define the Paramater Attributes
+ $runtimeParameterDictionary = New-Object -Type System.Management.Automation.RuntimeDefinedParameterDictionary
+ $attributeCollection = New-Object System.Collections.ObjectModel.Collection[System.Attribute]
+
+ $parameterAttribute = New-Object -Type System.Management.Automation.ParameterAttribute
+ $parameterAttribute.Mandatory = $IsMandatoryParameter
+ $parameterAttribute.ParameterSetName = $ParameterSetName
+ $parameterAttribute.HelpMessage = "The AWS Region to Use for Requests"
+ $attributeCollection.Add($parameterAttribute)
+
+ # Generate and add the ValidateSet
+ $supportedRegions = Get-SupportedAwsRegions
+ $validateSetAttribute = New-Object System.Management.Automation.ValidateSetAttribute($supportedRegions)
+ $attributeCollection.Add($validateSetAttribute)
+
+ # Create the dynamic parameter
+ $runtimeParameter = New-Object System.Management.Automation.RuntimeDefinedParameter($DynamicParameterName, [string], $attributeCollection)
+ $runtimeParameterDictionary.Add($DynamicParameterName, $RuntimeParameter)
+ return $runtimeParameterDictionary
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Common/Public/Get-DynamicAwsRegionParameter.tests.ps1 b/Modules/Alkami.DevOps.Common/Public/Get-DynamicAwsRegionParameter.tests.ps1
new file mode 100644
index 0000000..6bbd5b4
--- /dev/null
+++ b/Modules/Alkami.DevOps.Common/Public/Get-DynamicAwsRegionParameter.tests.ps1
@@ -0,0 +1,46 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ""
+
+Describe "Get-DynamicAwsRegionParameter" {
+
+ Context "Logic" {
+
+ Mock -CommandName Get-SupportedAwsRegions -ModuleName $moduleForMock -MockWith { return @(
+ "mexico-south-55",
+ "australia-west-99"
+ )}
+
+ It "Uses the Specified Parameter Name" {
+
+ $testParamName = "SupaFly"
+
+ $param = Get-DynamicAwsRegionParameter -DynamicParameterName $testParamName
+ $param.Keys | Should -BeExactly $testParamName
+ }
+
+ It "Uses the Specified ParameterSet Name" {
+
+ $testParamSetName = "wOne"
+
+ $param = Get-DynamicAwsRegionParameter -ParameterSetName $testParamSetName
+ $param.Values.Attributes.ParameterSetName | Should -BeExactly $testParamSetName
+ }
+
+ It "Is a Mandatory Parameter if Specified" {
+
+ $param = Get-DynamicAwsRegionParameter -IsMandatoryParameter
+ $param.Values.Attributes.Mandatory | Should -BeTrue
+ }
+
+ It "Creates a Validate Set with Valid AWS Profile Names" {
+
+ $param = Get-DynamicAwsRegionParameter
+ $param.Values.Attributes.ValidValues | Should -Be @( "mexico-south-55", "australia-west-99")
+ }
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Common/Public/Get-EntrustAdminUrlFromClient.ps1 b/Modules/Alkami.DevOps.Common/Public/Get-EntrustAdminUrlFromClient.ps1
new file mode 100644
index 0000000..44a1715
--- /dev/null
+++ b/Modules/Alkami.DevOps.Common/Public/Get-EntrustAdminUrlFromClient.ps1
@@ -0,0 +1,17 @@
+function Get-EntrustAdminUrlFromClient {
+<#
+.SYNOPSIS
+ Returns the Entrust Admin URL based on a Client Database object
+#>
+ [CmdletBinding()]
+ param(
+ [Parameter(Position = 0, Mandatory = $true)]
+ [PSObject]$Client
+ )
+ $queryString = "SELECT s.Value FROM core.ItemSetting s JOIN core.Item i on i.ID = s.ItemID JOIN core.Provider p on p.ID = i.ParentId WHERE REPLACE(p.AssemblyInfo, ' ', '') = " +
+ "'Alkami.Security.Provider.User.Entrust.Provider,Alkami.Security.Provider.User.Entrust' AND s.Name = 'EntrustAdminUrl'"
+
+ $url = Invoke-QueryOnClientDatabase $client $queryString
+ return ($url -ireplace "Service/services/AdminServiceV\d", "/do")
+}
+
diff --git a/Modules/Alkami.DevOps.Common/Public/Get-Environment.ps1 b/Modules/Alkami.DevOps.Common/Public/Get-Environment.ps1
new file mode 100644
index 0000000..d78ff90
--- /dev/null
+++ b/Modules/Alkami.DevOps.Common/Public/Get-Environment.ps1
@@ -0,0 +1,84 @@
+function Get-Environment {
+ <#
+.SYNOPSIS
+ Tries to determine the Environment type (AWS, QA, Prod, Staging, etc.) using environment details
+#>
+ [CmdletBinding()]
+ [OutputType([System.String])]
+ Param()
+
+ $logLead = (Get-LogLeadName);
+ $unknown = "UNKNOWN"
+
+ # Get Environment From Tags if Possible
+ if (Test-IsAws) {
+
+ Write-Verbose "$logLead : Hosting provider is AWS -- checking tags to determine environment"
+
+ $serverEnvironment = Get-CurrentInstanceTags ($Global:AlkamiTagKeyEnvironment) -ValueOnly
+
+ if ([String]::IsNullOrEmpty($serverEnvironment)) {
+
+ Write-Warning "$logLead : Could not determine environment from the $Global:AlkamiTagKeyEnvironment tag. Value returned: <$serverEnvironment>"
+ return $unknown
+ }
+
+ $validTagValues = @(
+ $Global:AlkamiTagValueEnvironmentProd,
+ $Global:AlkamiTagValueEnvironmentStaging,
+ $Global:AlkamiTagValueEnvironmentQa,
+ $Global:AlkamiTagValueEnvironmentDr,
+ $Global:AlkamiTagValueEnvironmentSandbox,
+ $Global:AlkamiTagValueEnvironmentDev,
+ $Global:AlkamiTagValueEnvironmentLTM,
+ $Global:AlkamiTagValueEnvironmentLoadtest
+ )
+
+ if ($serverEnvironment -notin $validTagValues) {
+
+ Write-Warning "$logLead : Unknown tag value '$serverEnvironment' identified. What is going on with your tags?"
+ return $unknown
+ }
+
+ Write-Host "$logLead : Environment determined to be $serverEnvironment based on the $Global:AlkamiTagKeyEnvironment tag value"
+ return $serverEnvironment
+ }
+
+ $environmentType = Get-AppSetting "Environment.Type" -ErrorAction SilentlyContinue
+
+ # Get Environment from Features / Beacon Values if Tags Not Available. So long as it's an ORB server it should never really get past this stage
+ if (!([String]::IsNullOrEmpty($environmentType))) {
+ Write-Host "$logLead : Checking Environment.Type Value $environmentType from Machine Config Against Known List"
+
+ $environment = switch ($environmentType)
+ {
+ "Production" { $Global:AlkamiTagValueEnvironmentProd }
+ "Staging" { $Global:AlkamiTagValueEnvironmentStaging }
+ "QA" { $Global:AlkamiTagValueEnvironmentQa }
+ "TeamQA" { $Global:AlkamiTagValueEnvironmentQa }
+ "Integration" { $Global:AlkamiTagValueEnvironmentQa }
+ "Development" { $Global:AlkamiTagValueEnvironmentDev }
+ }
+
+ Write-Host "$logLead : Environment determined to be $environment based on machine.config"
+ return $environment
+ }
+
+ # Get Environment from Computer Name if We Absolutely Have To. Some misconfigured CORP dev/qa environments might hit this
+ $compName = $env:ComputerName
+ if ($compName -like "ALK-*") {
+ if ($compName -like "*QA*") {
+ Write-Host ("$logLead : Environment determined to be {0} based on hostname {1}" -f $Global:AlkamiTagValueEnvironmentQa, $compName)
+ return $Global:AlkamiTagValueEnvironmentQa
+ }
+ else {
+ Write-Host ("$logLead : Environment determined to be {0} based on hostname {1}" -f $Global:AlkamiTagValueEnvironmentDev, $compName)
+ return $Global:AlkamiTagValueEnvironmentDev
+ }
+ }
+
+ # Give up
+ Write-Warning ("$logLead : Unable to determine environment automatically")
+ return $unknown
+}
+
diff --git a/Modules/Alkami.DevOps.Common/Public/Get-Environment.tests.ps1 b/Modules/Alkami.DevOps.Common/Public/Get-Environment.tests.ps1
new file mode 100644
index 0000000..cf44da4
--- /dev/null
+++ b/Modules/Alkami.DevOps.Common/Public/Get-Environment.tests.ps1
@@ -0,0 +1,113 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ""
+
+Describe "Get-Environment" {
+
+ Context "Happy Path" {
+
+ It "Returns the Expected Environment Based on AWS Tags" {
+
+ Mock -CommandName Get-CurrentInstanceTags -ModuleName $moduleForMock -MockWith { return $Global:MockedEnvironmentValue; }
+ Mock -CommandName Test-IsAws -ModuleName $moduleForMock -MockWith { return $true }
+
+ $Global:MockedEnvironmentValue = $Global:AlkamiTagValueEnvironmentDev
+ Get-Environment | Should -Be $Global:AlkamiTagValueEnvironmentDev
+
+ $Global:MockedEnvironmentValue = $Global:AlkamiTagValueEnvironmentQa
+ Get-Environment | Should -Be $Global:AlkamiTagValueEnvironmentQa
+
+ $Global:MockedEnvironmentValue = $Global:AlkamiTagValueEnvironmentSandbox
+ Get-Environment | Should -Be $Global:AlkamiTagValueEnvironmentSandbox
+
+ $Global:MockedEnvironmentValue = $Global:AlkamiTagValueEnvironmentStaging
+ Get-Environment | Should -Be $Global:AlkamiTagValueEnvironmentStaging
+
+ $Global:MockedEnvironmentValue = $Global:AlkamiTagValueEnvironmentProd
+ Get-Environment | Should -Be $Global:AlkamiTagValueEnvironmentProd
+
+ $Global:MockedEnvironmentValue = $Global:AlkamiTagValueEnvironmentDR
+ Get-Environment | Should -Be $Global:AlkamiTagValueEnvironmentDR
+
+ $Global:MockedEnvironmentValue = $Global:AlkamiTagValueEnvironmentLTM
+ Get-Environment | Should -Be $Global:AlkamiTagValueEnvironmentLTM
+
+ $Global:MockedEnvironmentValue = $Global:AlkamiTagValueEnvironmentLoadtest
+ Get-Environment | Should -Be $Global:AlkamiTagValueEnvironmentLoadtest
+ }
+
+ It "Returns the Expected Environment Based on Machine Config Environment.Type Setting" {
+
+ Mock -CommandName Test-IsAws -ModuleName $moduleForMock -MockWith { return $false }
+ Mock -CommandName Get-AppSetting -ModuleName $moduleForMock -MockWith { return $Global:MockAppSettingValue; }
+
+ $Global:MockAppSettingValue = "Development"
+ Get-Environment | Should -Be $Global:AlkamiTagValueEnvironmentDev
+
+ $Global:MockAppSettingValue = "TeamQA"
+ Get-Environment | Should -Be $Global:AlkamiTagValueEnvironmentQa
+
+ $Global:MockAppSettingValue = "QA"
+ Get-Environment | Should -Be $Global:AlkamiTagValueEnvironmentQa
+
+ $Global:MockAppSettingValue = "Staging"
+ Get-Environment | Should -Be $Global:AlkamiTagValueEnvironmentStaging
+
+ $Global:MockAppSettingValue = "Production"
+ Get-Environment | Should -Be $Global:AlkamiTagValueEnvironmentProd
+ }
+
+ It "Returns the Expected Environment Based on (Shudder) Naming Convention" {
+
+ Mock -CommandName Test-IsAws -ModuleName $moduleForMock -MockWith { return $false }
+ Mock -CommandName Get-AppSetting -ModuleName $moduleForMock -MockWith { return $null; }
+
+ $env:ComputerName = "ALK-PLA1-QA999999999999"
+ Get-Environment | Should -Be $Global:AlkamiTagValueEnvironmentQa
+
+ $env:ComputerName = "ALK-WIDGETSZ-WEB1"
+ Get-Environment | Should -Be $Global:AlkamiTagValueEnvironmentDev
+ }
+ }
+
+ Context "Errors and Unknowns" {
+
+ It "Returns Unknown if the AWS Tag Value is Unexpected" {
+
+ Mock -CommandName Test-IsAws -ModuleName $moduleForMock -MockWith { return $true }
+ Mock -CommandName Get-CurrentInstanceTags -ModuleName $moduleForMock -MockWith { return $Global:MockedEnvironmentValue; }
+
+ $Global:MockedEnvironmentValue = "You fool! You thought you could defeat me? With my power levels at OVER 50000?"
+ Get-Environment | Should -Be "Unknown"
+ }
+
+ It "Returns Unknown if All Efforts At Finding a Value to Key Off of Fail" {
+
+ Mock -CommandName Test-IsAws -ModuleName $moduleForMock -MockWith { return $false }
+ Mock -CommandName Get-AppSetting -ModuleName $moduleForMock -MockWith { return $null; }
+ $env:COMPUTERNAME = "WEB1234567"
+
+ Get-Environment | Should -Be "Unknown"
+ }
+
+ It "Returns Unknown if The $Global:AlkamiTagKeyEnvironment Tag Doesn't Exist" {
+
+ Mock -CommandName Test-IsAws -ModuleName $moduleForMock -MockWith { return $true }
+ Mock -CommandName Get-CurrentInstanceTags -ModuleName $moduleForMock -MockWith { return $null; }
+
+ Get-Environment | Should -Be "Unknown"
+ }
+
+ It "Returns Unknown if The $Global:AlkamiTagKeyEnvironment Tag Is Empty" {
+
+ Mock -CommandName Test-IsAws -ModuleName $moduleForMock -MockWith { return $true }
+ Mock -CommandName Get-CurrentInstanceTags -ModuleName $moduleForMock -MockWith { return ""; }
+
+ Get-Environment | Should -Be "Unknown"
+ }
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Common/Public/Get-FileContentHash.ps1 b/Modules/Alkami.DevOps.Common/Public/Get-FileContentHash.ps1
new file mode 100644
index 0000000..104fe29
--- /dev/null
+++ b/Modules/Alkami.DevOps.Common/Public/Get-FileContentHash.ps1
@@ -0,0 +1,29 @@
+function Get-FileContentHash {
+ <#
+ .SYNOPSIS
+ Retrieves a hash generated from a file's contents.
+
+ .DESCRIPTION
+ Use this command to generate a hash for use in determining if a file's text contents have changed.
+
+ .PARAMETER FilePath
+ [string] The full path (including file name) to the file to be hashed. Required.
+
+ .EXAMPLE
+ Get-FileContentHash "C:\Temp\File.txt"
+
+ .EXAMPLE
+ Get-FileContentHash -FilePath "C:\Temp\File.txt"
+#>
+ [CmdletBinding()]
+ param (
+ [Parameter(Mandatory = $true)]
+ [Alias("FilePath")]
+ [string]$file
+ )
+
+ $fileContent = [System.IO.File]::ReadAllBytes($file)
+ $convertedContent = [System.Text.Encoding]::GetEncoding(1252).GetString($fileContent);
+
+ return Get-UTF8ContentHash $convertedContent
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Common/Public/Get-HostnamesByEnvironmentName.ps1 b/Modules/Alkami.DevOps.Common/Public/Get-HostnamesByEnvironmentName.ps1
new file mode 100644
index 0000000..7c6de0b
--- /dev/null
+++ b/Modules/Alkami.DevOps.Common/Public/Get-HostnamesByEnvironmentName.ps1
@@ -0,0 +1,115 @@
+function Get-HostnamesByEnvironmentName {
+
+<#
+
+.SYNOPSIS
+ Retrieves the hostnames for a specified environment.
+
+.PARAMETER EnvironmentName
+ [string] The moniker associated with the environment (e.g. 'Smith', '16.1')
+
+.PARAMETER EnvironmentType
+ [string] The type associated with the environment (e.g. 'prod', 'dr').
+
+.PARAMETER IncludeOffline
+ [switch] Flag indicating whether or not to retrieve hostnames of offline instances.
+
+.PARAMETER ProfileName
+ [string] AWS profile name to use in the query.
+
+.PARAMETER Region
+ [string] AWS region to use in the query.
+
+.EXAMPLE
+ Get-HostnamesByEnvironmentName -EnvironmentName '16' -EnvironmentType 'DR' -Region 'us-west-2' -IncludeOffline -Verbose
+
+VERBOSE: [Get-HostnamesByEnvironmentName] : Using environment value 'DR'
+[Get-DesignationTagNameByEnvironment] : Checking designation value for environment DR
+VERBOSE: [Get-HostnamesByEnvironmentName] : Designation value resolved to 'pod'
+APP32101109
+APP326418
+MIC3210483
+MIC327179
+WEB3229114
+WEB323468
+
+.EXAMPLE
+ Get-HostnamesByEnvironmentName -EnvironmentName 16 -Verbose
+
+VERBOSE: [Get-HostnamesByEnvironmentName] : Environment type not specified; attempting to determine value.
+[Get-Environment] : Environment determined to be prod based on the alk:env tag value
+VERBOSE: [Get-HostnamesByEnvironmentName] : Using environment value 'prod'
+[Get-DesignationTagNameByEnvironment] : Checking designation value for environment prod
+VERBOSE: [Get-HostnamesByEnvironmentName] : Designation value resolved to 'pod'
+APP16107240
+APP1611088
+APP1612031
+APP16121149
+APP16122255
+MIC16106199
+MIC16108100
+MIC16117162
+MIC1612736
+MIC1665169
+WEB162253
+WEB1623247
+WEB163753
+WEB1647241
+WEB165798
+WEB166072
+#>
+
+ [OutputType([string[]])]
+ [CmdletBinding()]
+ param(
+ [Parameter (Mandatory = $true)]
+ [ValidateNotNullOrEmpty()]
+ [string]$EnvironmentName,
+
+ [Parameter (Mandatory = $false)]
+ [string]$EnvironmentType = $null,
+
+ [Parameter(Mandatory = $false)]
+ [switch]$IncludeOffline,
+
+ [Parameter(Mandatory = $false)]
+ [string]$ProfileName = $null,
+
+ [Parameter(Mandatory = $false)]
+ [string]$Region = $null
+ )
+
+ $logLead = (Get-LogLeadName)
+
+ if ( [string]::IsNullOrEmpty( $EnvironmentType ) ) {
+ Write-Verbose "$logLead : Environment type not specified; attempting to determine value."
+ [string] $EnvironmentType = ( Get-Environment )
+ }
+
+ Write-Verbose "$logLead : Using environment value '$EnvironmentType'"
+
+ [string] $designation = ( Get-DesignationTagNameByEnvironment $environmentType )
+ Write-Verbose "$logLead : Designation value resolved to '$designation'"
+
+ $designationTag = ( "alk:{0}" -f $designation )
+
+ $searchTags = @{
+ $designationTag = $EnvironmentName
+ $Global:AlkamiTagKeyEnvironment = $EnvironmentType.ToLowerInvariant()
+ }
+
+ [Amazon.EC2.Model.Instance[]] $Instances = Get-InstancesByTag -tags $searchTags -IncludeOffline:$IncludeOffline -ProfileName $ProfileName -Region $Region
+
+ [string[]] $hostNames = @()
+ foreach ( $instance in $instances ) {
+
+ $hostname = Get-InstanceHostname $instance
+
+ if ( ! [string]::IsNullOrEmpty( $hostname ) ) {
+ $hostNames += $hostname
+ }
+ }
+
+ [Array]::Sort($hostNames)
+ return $hostNames
+}
diff --git a/Modules/Alkami.DevOps.Common/Public/Get-HostnamesByEnvironmentName.tests.ps1 b/Modules/Alkami.DevOps.Common/Public/Get-HostnamesByEnvironmentName.tests.ps1
new file mode 100644
index 0000000..473c0a6
--- /dev/null
+++ b/Modules/Alkami.DevOps.Common/Public/Get-HostnamesByEnvironmentName.tests.ps1
@@ -0,0 +1,164 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ""
+
+Describe "Get-HostnamesByEnvironmentName" {
+
+ # Load up AWSPowerShell for Mocking if Available
+ $awsPowerShellLoaded = $false
+ if ($null -ne (Get-Module -ListAvailable AWSPowerShell)) {
+
+ Import-AWSModule # EC2
+ $awsPowerShellLoaded = $true
+ } else {
+
+ Write-Warning "AWSPowerShell Module *NOT* installed. Some tests will not execute."
+ }
+
+ Context "Parameter Validation" {
+
+ It "Null Environment Name Should Throw" {
+
+ { Get-HostnamesByEnvironmentName -EnvironmentName $null } | Should Throw
+ }
+
+ It "Empty Environment Name Should Throw" {
+
+ { Get-HostnamesByEnvironmentName -EnvironmentName '' } | Should Throw
+ }
+ }
+
+ Context "Get-Environment Is Called When EnvironmentType Parameter Is Null" {
+ Mock -CommandName Get-Environment -ModuleName $moduleForMock -MockWith { return 'Prod' }
+ Mock -CommandName Get-DesignationTagNameByEnvironment -ModuleName $moduleForMock -MockWith { return 'Prod' }
+ Mock -CommandName Get-InstancesByTag -ModuleName $moduleForMock -MockWith { return @() }
+
+ $result = ( Get-HostnamesByEnvironmentName -EnvironmentName 'Test' -EnvironmentType $null )
+
+ It "Get-Environment Is Called One Time" {
+
+ Assert-MockCalled Get-Environment -Times 1 -Exactly -Scope Context -ModuleName $moduleForMock
+ }
+ }
+
+ Context "Get-Environment Is Called When EnvironmentType Parameter Is Empty" {
+ Mock -CommandName Get-Environment -ModuleName $moduleForMock -MockWith { return 'Prod' }
+ Mock -CommandName Get-DesignationTagNameByEnvironment -ModuleName $moduleForMock -MockWith { return 'Prod' }
+ Mock -CommandName Get-InstancesByTag -ModuleName $moduleForMock -MockWith { return @() }
+
+ $result = ( Get-HostnamesByEnvironmentName -EnvironmentName 'Test' -EnvironmentType '' )
+
+ It "Get-Environment Is Called One Time" {
+
+ Assert-MockCalled Get-Environment -Times 1 -Exactly -Scope Context -ModuleName $moduleForMock
+ }
+ }
+
+ Context "Get-Environment Is Not Called When EnvironmentType Parameter Is Provided" {
+ Mock -CommandName Get-Environment -ModuleName $moduleForMock -MockWith { return 'Prod' }
+ Mock -CommandName Get-DesignationTagNameByEnvironment -ModuleName $moduleForMock -MockWith { return 'Prod' }
+ Mock -CommandName Get-InstancesByTag -ModuleName $moduleForMock -MockWith { return @() }
+
+ $result = ( Get-HostnamesByEnvironmentName -EnvironmentName 'Test' -EnvironmentType 'Prod' )
+
+ It "Get-Environment Is Not Called" {
+
+ Assert-MockCalled Get-Environment -Times 0 -Exactly -Scope Context -ModuleName $moduleForMock
+ }
+ }
+
+ Context "Returns Empty Array When No Instances Are Found" {
+ Mock -CommandName Get-Environment -ModuleName $moduleForMock -MockWith { return 'Prod' }
+ Mock -CommandName Get-DesignationTagNameByEnvironment -ModuleName $moduleForMock -MockWith { return 'Prod' }
+ Mock -CommandName Get-InstancesByTag -ModuleName $moduleForMock -MockWith { return @() }
+
+ It "Is Empty" {
+
+ ( Get-HostnamesByEnvironmentName -EnvironmentName 'Test' -EnvironmentType 'Prod' ) | Should -BeExactly @()
+ }
+ }
+
+ Context "Sorted Result Does Not Include Null Or Empty Entries" {
+ Mock -CommandName Get-Environment -ModuleName $moduleForMock -MockWith { return 'Prod' }
+ Mock -CommandName Get-DesignationTagNameByEnvironment -ModuleName $moduleForMock -MockWith { return 'Prod' }
+
+ It "Does Not Include Null" {
+
+ if (!($awsPowerShellLoaded)) {
+
+ Set-ItResult -Inconclusive -Because "AWSPowerShell Not Installed"
+ continue;
+ }
+
+ Mock -CommandName Get-InstancesByTag -ModuleName $moduleForMock -MockWith {
+ $testInstance = (New-Object Amazon.EC2.Model.Instance)
+ $testInstance.InstanceId = 'Test'
+
+ $nullInstance = (New-Object Amazon.EC2.Model.Instance)
+ $nullInstance.InstanceId = $null
+
+ return @( $testInstance, $nullInstance )
+ }
+
+ Mock -CommandName Get-InstanceHostname -ModuleName $moduleForMock -MockWith {
+ return $Instance.InstanceId.ToUpperInvariant()
+ }
+
+ ( Get-HostnamesByEnvironmentName -EnvironmentName 'Test' -EnvironmentType 'Prod' ) | Should -BeExactly @( 'TEST' )
+ }
+
+ It "Does Not Include Empty String" {
+
+ if (!($awsPowerShellLoaded)) {
+
+ Set-ItResult -Inconclusive -Because "AWSPowerShell Not Installed"
+ continue;
+ }
+
+ Mock -CommandName Get-InstancesByTag -ModuleName $moduleForMock -MockWith {
+ $testInstance = (New-Object Amazon.EC2.Model.Instance)
+ $testInstance.InstanceId = 'Test'
+
+ $emptyInstance = (New-Object Amazon.EC2.Model.Instance)
+ $emptyInstance.InstanceId = ''
+
+ return @( $testInstance, $emptyInstance )
+ }
+
+ Mock -CommandName Get-InstanceHostname -ModuleName $moduleForMock -MockWith {
+ return $Instance.InstanceId.ToUpperInvariant()
+ }
+
+ ( Get-HostnamesByEnvironmentName -EnvironmentName 'Test' -EnvironmentType 'Prod' ) | Should -BeExactly @( 'TEST' )
+ }
+
+ It "Is Sorted" {
+
+ if (!($awsPowerShellLoaded)) {
+
+ Set-ItResult -Inconclusive -Because "AWSPowerShell Not Installed"
+ continue;
+ }
+
+ Mock -CommandName Get-InstancesByTag -ModuleName $moduleForMock -MockWith {
+ $bInstance = (New-Object Amazon.EC2.Model.Instance)
+ $bInstance.InstanceId = 'B'
+
+ $aInstance = (New-Object Amazon.EC2.Model.Instance)
+ $aInstance.InstanceId = 'A'
+
+ return @( $bInstance, $aInstance )
+ }
+
+ Mock -CommandName Get-InstanceHostname -ModuleName $moduleForMock -MockWith {
+ return $Instance.InstanceId.ToUpperInvariant()
+ }
+
+ ( Get-HostnamesByEnvironmentName -EnvironmentName 'Test' -EnvironmentType 'Prod' ) | Should -BeExactly @( 'A', 'B' )
+ }
+ }
+}
diff --git a/Modules/Alkami.DevOps.Common/Public/Get-IPSTSUrlFromClient.ps1 b/Modules/Alkami.DevOps.Common/Public/Get-IPSTSUrlFromClient.ps1
new file mode 100644
index 0000000..bdb3376
--- /dev/null
+++ b/Modules/Alkami.DevOps.Common/Public/Get-IPSTSUrlFromClient.ps1
@@ -0,0 +1,17 @@
+function Get-IPSTSUrlFromClient {
+<#
+.SYNOPSIS
+ Returns the IPSTS URL based on a Client Database object
+#>
+ [CmdletBinding()]
+ param(
+ [Parameter(Position = 0, Mandatory = $true)]
+ [PSObject]$Client
+ )
+ $queryString = "SELECT s.Value FROM core.ItemSetting s JOIN core.Item i on i.ID = s.ItemID JOIN core.Provider p on p.ID = i.ParentId WHERE REPLACE(p.AssemblyInfo, ' ', '') = " +
+ "'Alkami.Security.Provider.TokenTranslator.Entrust,Alkami.Security.Provider.TokenTranslator.Entrust.Provider' AND s.Name = 'Issuer'"
+
+ return (Invoke-QueryOnClientDatabase $client $queryString)
+}
+
+
diff --git a/Modules/Alkami.DevOps.Common/Public/Get-InstanceHostname.ps1 b/Modules/Alkami.DevOps.Common/Public/Get-InstanceHostname.ps1
new file mode 100644
index 0000000..e52778e
--- /dev/null
+++ b/Modules/Alkami.DevOps.Common/Public/Get-InstanceHostname.ps1
@@ -0,0 +1,39 @@
+function Get-InstanceHostname {
+
+<#
+.SYNOPSIS
+ Gets the hostname for an EC2 instance.
+
+.PARAMETER Instance
+ [Amazon.EC2.Model.Instance] The instance under inspection.
+
+.EXAMPLE
+ Get-InstanceHostname -Instance (Get-CurrentInstance)
+
+APP1611088
+#>
+
+ [OutputType([string])]
+ [CmdletBinding()]
+ param (
+ [Parameter(Mandatory = $true)]
+ [ValidateNotNull()]
+ [Amazon.EC2.Model.Instance]$Instance
+ )
+
+ $logLead = (Get-LogLeadName)
+
+ [string] $hostname = ($instance.Tags | Where-Object { $_.Key -eq $Global:AlkamiTagKeyHostName } | Select-Object -First 1).Value
+
+ if ( [string]::IsNullOrEmpty( $hostname ) ) {
+
+ Write-Warning ( "{0} : No hostname found for {1}" -f $logLead, $instance.InstanceId )
+ $hostname = $null
+
+ } else {
+
+ $hostname = $hostname.ToUpperInvariant()
+ }
+
+ return $hostname
+}
diff --git a/Modules/Alkami.DevOps.Common/Public/Get-InstanceHostname.tests.ps1 b/Modules/Alkami.DevOps.Common/Public/Get-InstanceHostname.tests.ps1
new file mode 100644
index 0000000..6c6eb83
--- /dev/null
+++ b/Modules/Alkami.DevOps.Common/Public/Get-InstanceHostname.tests.ps1
@@ -0,0 +1,198 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ""
+
+Describe "Get-InstanceHostname" {
+
+ # Load up AWSPowerShell for mocking if available
+ $awsPowerShellLoaded = $false
+ if ($null -ne (Get-Module -ListAvailable AWSPowerShell)) {
+
+ Import-AWSModule # EC2
+ $awsPowerShellLoaded = $true
+
+ } else {
+
+ Write-Warning "AWSPowerShell Module *NOT* installed. Some tests will not execute."
+ }
+
+ Context "Parameter Validation" {
+
+ It "Null Instance Should Throw" {
+
+ if (!($awsPowerShellLoaded)) {
+
+ Set-ItResult -Inconclusive -Because "AWSPowerShell Not Installed"
+ continue;
+ }
+
+ { Get-InstanceHostname -Instance $null } | Should Throw
+ }
+
+ It "Invalid Parameter Type Should Throw" {
+
+ if (!($awsPowerShellLoaded)) {
+
+ Set-ItResult -Inconclusive -Because "AWSPowerShell Not Installed"
+ continue;
+ }
+
+ { Get-InstanceHostname -Instance 'test' } | Should Throw
+ }
+ }
+
+ Context "Hostname Determined By Instance Tag" {
+
+ Mock -CommandName Write-Warning -MockWith {} -ModuleName $moduleForMock
+
+ It "Writes Warning When Hostname Tag Does Not Exist" {
+
+ if (!($awsPowerShellLoaded)) {
+
+ Set-ItResult -Inconclusive -Because "AWSPowerShell Not Installed"
+ continue;
+ }
+
+ $testInstance = (New-Object Amazon.EC2.Model.Instance)
+ $testInstance.InstanceId = 'Test'
+
+ Get-InstanceHostname -Instance $testInstance | Out-Null
+
+ Assert-MockCalled -CommandName Write-Warning -Times 1 -Exactly -Scope It `
+ -ModuleName $moduleForMock -ParameterFilter { $Message -match "No hostname found" }
+
+ }
+
+ It "Returns Null When Hostname Tag Does Not Exist" {
+
+ if (!($awsPowerShellLoaded)) {
+
+ Set-ItResult -Inconclusive -Because "AWSPowerShell Not Installed"
+ continue;
+ }
+
+ $testInstance = (New-Object Amazon.EC2.Model.Instance)
+ $testInstance.InstanceId = 'Test'
+
+ ( Get-InstanceHostname -Instance $testInstance ) | Should -BeNull
+ }
+
+ It "Writes Warning When Hostname Tag Is Null" {
+
+ if (!($awsPowerShellLoaded)) {
+
+ Set-ItResult -Inconclusive -Because "AWSPowerShell Not Installed"
+ continue;
+ }
+
+ $testTag = (New-Object Amazon.EC2.Model.Tag($Global:AlkamiTagKeyHostName, $null))
+
+ $testInstance = (New-Object Amazon.EC2.Model.Instance)
+ $testInstance.Tags.Add($testTag)
+ $testInstance.InstanceId = 'Test'
+
+ Get-InstanceHostname -Instance $testInstance | Out-Null
+
+ Assert-MockCalled -CommandName Write-Warning -Times 1 -Exactly -Scope It `
+ -ModuleName $moduleForMock -ParameterFilter { $Message -match "No hostname found" }
+
+ }
+
+ It "Returns Null When Hostname Tag Is Null" {
+
+ if (!($awsPowerShellLoaded)) {
+
+ Set-ItResult -Inconclusive -Because "AWSPowerShell Not Installed"
+ continue;
+ }
+
+ $testTag = (New-Object Amazon.EC2.Model.Tag($Global:AlkamiTagKeyHostName, $null))
+
+ $testInstance = (New-Object Amazon.EC2.Model.Instance)
+ $testInstance.Tags.Add($testTag)
+ $testInstance.InstanceId = 'Test'
+
+ ( Get-InstanceHostname -Instance $testInstance ) | Should -BeNull
+ }
+
+ It "Writes Warning When Hostname Tag Is Null" {
+
+ if (!($awsPowerShellLoaded)) {
+
+ Set-ItResult -Inconclusive -Because "AWSPowerShell Not Installed"
+ continue;
+ }
+
+ $testTag = (New-Object Amazon.EC2.Model.Tag($Global:AlkamiTagKeyHostName, ''))
+
+ $testInstance = (New-Object Amazon.EC2.Model.Instance)
+ $testInstance.Tags.Add($testTag)
+ $testInstance.InstanceId = 'Test'
+
+ Get-InstanceHostname -Instance $testInstance | Out-Null
+
+ Assert-MockCalled -CommandName Write-Warning -Times 1 -Exactly -Scope It `
+ -ModuleName $moduleForMock -ParameterFilter { $Message -match "No hostname found" }
+
+ }
+
+ It "Returns Null When Hostname Tag Is Null" {
+
+ if (!($awsPowerShellLoaded)) {
+
+ Set-ItResult -Inconclusive -Because "AWSPowerShell Not Installed"
+ continue;
+ }
+
+ $testTag = (New-Object Amazon.EC2.Model.Tag($Global:AlkamiTagKeyHostName, ''))
+
+ $testInstance = (New-Object Amazon.EC2.Model.Instance)
+ $testInstance.Tags.Add($testTag)
+ $testInstance.InstanceId = 'Test'
+
+ ( Get-InstanceHostname -Instance $testInstance ) | Should -BeNull
+ }
+
+ It "Does Not Write Warning When Hostname Tag Is Valid" {
+
+ if (!($awsPowerShellLoaded)) {
+
+ Set-ItResult -Inconclusive -Because "AWSPowerShell Not Installed"
+ continue;
+ }
+
+ $testTag = (New-Object Amazon.EC2.Model.Tag($Global:AlkamiTagKeyHostName, 'Test'))
+
+ $testInstance = (New-Object Amazon.EC2.Model.Instance)
+ $testInstance.Tags.Add($testTag)
+ $testInstance.InstanceId = 'Test'
+
+ Get-InstanceHostname -Instance $testInstance | Out-Null
+
+ Assert-MockCalled -CommandName Write-Warning -Times 0 -Exactly -Scope It `
+ -ModuleName $moduleForMock
+
+ }
+
+ It "Returns Uppercased Hostname Tag Value When Hostname Tag Is Valid" {
+
+ if (!($awsPowerShellLoaded)) {
+
+ Set-ItResult -Inconclusive -Because "AWSPowerShell Not Installed"
+ continue;
+ }
+
+ $testTag = (New-Object Amazon.EC2.Model.Tag($Global:AlkamiTagKeyHostName, 'Test'))
+
+ $testInstance = (New-Object Amazon.EC2.Model.Instance)
+ $testInstance.Tags.Add($testTag)
+ $testInstance.InstanceId = 'Test'
+
+ ( Get-InstanceHostname -Instance $testInstance ) | Should -BeExactly 'TEST'
+ }
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Common/Public/Get-InstancesByTag.ps1 b/Modules/Alkami.DevOps.Common/Public/Get-InstancesByTag.ps1
new file mode 100644
index 0000000..8b860cd
--- /dev/null
+++ b/Modules/Alkami.DevOps.Common/Public/Get-InstancesByTag.ps1
@@ -0,0 +1,68 @@
+function Get-InstancesByTag {
+<#
+.SYNOPSIS
+ Gets EC2 Instances By Tag Filter
+
+.PARAMETER Tags
+ [hashtable] Mandatory. Keys are tag names, values are the value.
+
+.PARAMETER IncludeOffline
+ [switch] Include Instances not in a "running" state
+
+.PARAMETER ProfileName
+ [string] Specific AWS CLI Profile to use in AWS API calls
+
+.PARAMETER Region
+ [string] Specific AWS CLI Region to use in AWS API calls
+#>
+ [CmdletBinding()]
+ param(
+ [Parameter (Mandatory = $true)]
+ [ValidateNotNullOrEmpty()]
+ [hashtable]$Tags,
+
+ [Parameter(Mandatory = $false)]
+ [switch]$IncludeOffline,
+
+ [Parameter(Mandatory = $false)]
+ [string]$ProfileName,
+
+ [Parameter(Mandatory = $false)]
+ [string]$Region
+ )
+
+ $logLead = (Get-LogLeadName)
+
+ Import-AWSModule # EC2
+
+ $splatParams = @{}
+
+ $filters = @()
+ foreach ($key in $tags.Keys) {
+ $filter = (New-Object Amazon.EC2.Model.Filter);
+ $filter.Name = "tag:$key";
+ $filter.Value = $tags.$key;
+ $filters += $filter;
+ }
+
+ if (!($includeOffline.IsPresent)) {
+ $onlineFilter = (New-Object Amazon.EC2.Model.Filter)
+ $onlineFilter.Name = "instance-state-name"
+ $onlineFilter.Value = "running"
+ $filters += $onlineFilter;
+ }
+
+ if (!([string]::IsNullOrEmpty($ProfileName))) {
+ $splatParams["ProfileName"] = "$ProfileName"
+ }
+
+ if (!([string]::IsNullOrEmpty($Region))) {
+ $splatParams["Region"] = "$Region"
+ }
+
+ $instances = Get-EC2Instance -Filter $filters @splatParams
+
+ Write-Verbose ("$logLead : Found {0} Instances with Filter" -f $instances.Count)
+
+ return $instances.RunningInstance
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Common/Public/Get-InstancesByTag.tests.ps1 b/Modules/Alkami.DevOps.Common/Public/Get-InstancesByTag.tests.ps1
new file mode 100644
index 0000000..01597f7
--- /dev/null
+++ b/Modules/Alkami.DevOps.Common/Public/Get-InstancesByTag.tests.ps1
@@ -0,0 +1,67 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ""
+
+Describe "Get-InstancesByTag" {
+
+ # Load up AWSPowerShell for Mocking if Available
+ $awsPowerShellLoaded = $false
+ if ($null -ne (Get-Module -ListAvailable AWSPowerShell)) {
+
+ Import-AWSModule # EC2
+ $awsPowerShellLoaded = $true
+ } else {
+
+ Write-Warning "AWSPowerShell Module *NOT* installed. Some tests will not execute."
+ }
+
+ Context "Parameter and Environment Validation" {
+
+ Mock -ModuleName $moduleForMock Get-EC2Instance { return @("I'm not Null!") }
+
+ It "Excludes Offline Instances by Default" {
+
+ if (!($awsPowerShellLoaded)) {
+
+ Set-ItResult -Inconclusive -Because "AWSPowerShell Not Installed"
+ continue;
+ }
+
+ Get-InstancesByTag -tags @{"alk:pod" = "fakepod" }
+ Assert-MockCalled Get-EC2Instance -Times 1 -Exactly -Scope It `
+ -ModuleName $moduleForMock -ParameterFilter { ($null -ne ($Filter | Where-Object { $_.Name -eq "instance-state-name" })) }
+ }
+
+ It "Includes Offline Instances When Specified" {
+
+ if (!($awsPowerShellLoaded)) {
+
+ Set-ItResult -Inconclusive -Because "AWSPowerShell Not Installed"
+ continue;
+ }
+
+ # This test is less than ideal, since all we can realistically test is that the if statement which controls adding this filter or not
+ # is hit
+ Get-InstancesByTag -tags @{"alk:pod" = "fakepod" } -IncludeOffline
+ Assert-MockCalled Get-EC2Instance -Times 1 -Exactly -Scope It `
+ -ModuleName $moduleForMock -ParameterFilter { ($null -eq ($Filter | Where-Object { $_.Name -eq "instance-state-name" })) }
+ }
+
+ It "Correctly splats Region" {
+ Get-InstancesByTag -Tags @{"alk:pod" = "fakepod"} -Region "us-fake-1"
+ Assert-MockCalled Get-EC2Instance -Times 1 -Exactly -Scope It `
+ -ModuleName $moduleForMock -ParameterFilter {$Region -eq "us-fake-1"}
+
+ }
+ It "Correctly splats Profile" {
+ Get-InstancesByTag -Tags @{"alk:pod" = "fakepod"} -ProfileName "fake-profile"
+ Assert-MockCalled Get-EC2Instance -Times 1 -Exactly -Scope It `
+ -ModuleName $moduleForMock -ParameterFilter {$ProfileName -eq "fake-profile"}
+
+ }
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Common/Public/Get-LocalNlbIp.ps1 b/Modules/Alkami.DevOps.Common/Public/Get-LocalNlbIp.ps1
new file mode 100644
index 0000000..07051da
--- /dev/null
+++ b/Modules/Alkami.DevOps.Common/Public/Get-LocalNlbIp.ps1
@@ -0,0 +1,91 @@
+function Get-LocalNlbIp {
+
+<#
+.SYNOPSIS
+ Gets the Ip for the NLB NIC which is in the same AZ as the server from which it's run.
+
+.DESCRIPTION
+ Gets the Ip for the NLB NIC which is in the same AZ as the server from which it's run. Uses the current availability zone, ENI description, and interfacetype to determine the appropriate IP
+
+.EXAMPLE
+ Get-LocalNlbIp -verbose
+
+VERBOSE: [Get-LocalNlbIp] : Current Instance AZ Read as us-east-1b
+VERBOSE: [Get-LocalNlbIp] : Environment Read as qa
+[Get-DesignationTagNameByEnvironment] : Checking designation value for environment qa
+VERBOSE: [Get-LocalNlbIp] : Read designation tag value Smith
+VERBOSE: [Get-LocalNlbIp] : Using Expected NLB Name ELB net/Smith-qa-nlb for Filtering
+VERBOSE: Invoking Amazon Elastic Compute Cloud operation 'DescribeNetworkInterfaces' in region 'us-east-1'
+Returning IP Address for ENI with Description: ELB net/smith-qa-nlb/93947386b64a5aac, Id: eni-0718dc98cdcec5e18
+10.26.91.212
+#>
+
+ [CmdletBinding()]
+ param()
+
+ $logLead = (Get-LogLeadName)
+
+ Import-AWSModule # EC2
+
+ if (!(Test-IsAws))
+ {
+ Write-Warning "$logLead : This function can only be executed on an AWS server"
+ return
+ }
+
+ # Get the current instance and AZ
+ $currentInstance = Get-CurrentInstance;
+ $currentAz = $currentInstance.Placement.AvailabilityZone;
+ Write-Verbose "$logLead : Current Instance AZ Read as $currentAz"
+
+ # Check the current server's role
+ $serverRole = $currentInstance.Tag | Where-Object {$_.Key -eq $Global:AlkamiTagKeyRole}
+ if ($serverRole.Value -eq 'app:app')
+ {
+ # App servers should use 127.0.0.1
+ Write-Warning "This is currently running on an app server. The IP returned shouldn't be used in the host file."
+ }
+
+ # Get the expected designation tag name
+ $environment = $currentInstance.Tag | Where-Object { $_.Key -eq $Global:AlkamiTagKeyEnvironment; };
+ Write-Verbose "$logLead : Environment Read as $($environment.Value)"
+
+ $targetTag = Get-DesignationTagNameByEnvironment $environment.Value
+
+ if ($null -ne $targetTag) {
+
+ # Pull the Designation Tag Value
+ $environmentTagValue = $currentInstance.Tag | Where-Object {$_.Key -eq "alk:$targetTag" }
+ Write-Verbose "$logLead : Read designation tag value $($environmentTagValue.Value)"
+
+ } else {
+
+ Write-Warning "$logLead : Unable to pull $Global:AlkamiTagKeyEnvironment for the current instance. Execution cannot continue."
+ return $null;
+ }
+
+ $cleanedName = $environmentTagValue.Value.replace('.','-');
+ $nlbName = "ELB net/" + $cleanedName + '-' + $environment.Value + '-nlb';
+ Write-Verbose "$logLead : Using Expected NLB Name $nlbName for Filtering"
+
+ $nlbNics = Get-EC2NetworkInterface -Filter @( @{name='availability-zone';values=$currentAz} );
+ [array]$filteredNics = $nlbNics | Where-Object { $_.InterfaceType -eq 'network_load_balancer' -and $_.Description -match $nlbName}
+ $matchCount = $filteredNics.Count
+ Write-Verbose "$logLead : Found $matchCount Matching ENIs with InterfaceType: network_load_balancer, Description: $nlbName, Availability Zone $currentAz"
+
+ if ($null -ne $filteredNics -and $filteredNics.Count -eq 1) {
+
+ $nic = $filteredNics | Select-Object -First 1
+ Write-Host ("Returning IP Address for ENI with Description: {0}, Id: {1}" -f $nic.Description, $nic.NetworkInterfaceId)
+ return (($nic | Select-Object -First 1).PrivateIpAddress);
+ }
+
+ if ($null -eq $filteredNics) {
+
+ Write-Warning "$logLead : No ENIs found with Description $nlbName for AZ $currentAz"
+ return $null
+ }
+
+ Write-Warning ("$logLead : {0} ENIs found with Description $nlbName for AZ $currentAz. Execution cannot continue." -f $filteredNics.Count)
+ return $null
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Common/Public/Get-LocalNlbIp.tests.ps1 b/Modules/Alkami.DevOps.Common/Public/Get-LocalNlbIp.tests.ps1
new file mode 100644
index 0000000..95b924d
--- /dev/null
+++ b/Modules/Alkami.DevOps.Common/Public/Get-LocalNlbIp.tests.ps1
@@ -0,0 +1,329 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ""
+
+#region Get-LocalNlbIp
+
+# Handle the AWS Native Functions When AWSPowerShell Not Available
+if ($null -eq (Get-Command "Get-EC2NetworkInterface" -ErrorAction SilentlyContinue))
+{
+ function Get-EC2NetworkInterface {
+
+ throw "This Should Never Be Actually Called"
+ }
+}
+
+Describe "Get-LocalNlbIp" {
+
+ Mock -ModuleName $moduleForMock Test-IsAws { return $true }
+ Mock -ModuleName $moduleForMock Get-EC2NetworkInterface { return $null }
+ Mock -ModuleName $moduleForMock Get-CurrentInstance {
+
+ $placementMock = New-Object PSObject -Property @{
+
+ "AvailabilityZone" = "us-east-1a";
+ }
+
+ $pod6Instance = New-Object PSObject -Property @{
+
+ Placement = $placementMock;
+
+ Tag = @(
+ @{Key = $Global:AlkamiTagKeyRole; Value = $Global:AlkamiTagValueRoleWeb},
+ @{Key = $Global:AlkamiTagKeyEnvironment; Value = $Global:AlkamiTagValueEnvironmentProd},
+ @{Key = "alk:pod"; Value = "6"}
+ );
+ }
+
+ return $pod6Instance
+ }
+
+ Context "When Environment Names Overlap" {
+
+ Mock -ModuleName $moduleForMock Get-EC2NetworkInterface {
+
+ # args[1] is an ec2 filter based on the AvailabilityZone from the current instance
+ # https://docs.aws.amazon.com/sdkfornet/v3/apidocs/items/EC2/TFilter.html
+ $ec2Filter = $args[1]
+
+ Write-Warning "Using EC2 Filter Value: $($ec2Filter.Values)"
+
+ $possibleReturns = @(
+
+ @{ "Description"="ELB net/12.6-prod-nlb-something"; Id="Test"; "AvailabilityZone"="us-east-1a"; "PrivateIpAddress"="8.8.8.8"; InterfaceType="network_load_balancer"},
+ @{ "Description"="ELB net/6-prod-nlb-something"; Id="Test"; "AvailabilityZone"="us-east-1a"; "PrivateIpAddress"="100.100.100.100"; InterfaceType="network_load_balancer"}
+ )
+
+ return $possibleReturns | Where-Object {$_.AvailabilityZone -like $ec2Filter.Values}
+ }
+
+ It "Returns the Correct IP When Searching for POD 6 and 12.6 is Available" {
+
+ Get-LocalNlbIp | Should -Be "100.100.100.100"
+ }
+ }
+
+ Context "When the Environment is an App Server" {
+
+ Mock -ModuleName $moduleForMock Get-CurrentInstance {
+
+ $placementMock = New-Object PSObject -Property @{
+
+ "AvailabilityZone" = "us-east-1a";
+ }
+
+ $pod6Instance = New-Object PSObject -Property @{
+
+ Placement = $placementMock;
+
+ Tag = @(
+ @{Key = $Global:AlkamiTagKeyRole; Value = $Global:AlkamiTagValueRoleApp},
+ @{Key = $Global:AlkamiTagKeyEnvironment; Value = $Global:AlkamiTagValueEnvironmentProd},
+ @{Key = "alk:pod"; Value = "6"}
+ );
+ }
+
+ return $pod6Instance
+ }
+
+ It "Writes a Warning" {
+
+ ( Get-LocalNlbIp 3>&1 ) -match "This is currently running on an app server." | Should -Be $true
+ }
+ }
+
+ Context "Environment Tag Switch" {
+
+ $validEnvironments = @("Prod", "DR")
+
+ foreach ($Global:podBasedEnvironment in $validEnvironments) {
+
+ It "Uses the alk:pod tag when the environment is $podBasedEnvironment" {
+
+ Mock -ModuleName $moduleForMock Get-CurrentInstance {
+
+ $placementMock = New-Object PSObject -Property @{
+
+ "AvailabilityZone" = "us-east-1a";
+ }
+
+ $pod6Instance = New-Object PSObject -Property @{
+
+ Placement = $placementMock;
+
+ Tag = @(
+ @{Key = $Global:AlkamiTagKeyRole; Value = $Global:AlkamiTagValueRoleWeb},
+ @{Key = $Global:AlkamiTagKeyEnvironment; Value = "$podBasedEnvironment"},
+ @{Key = "alk:pod"; Value = "Pass"},
+ @{Key = "alk:lane"; Value = "Fail"}
+ );
+ }
+
+ return $pod6Instance
+ }
+
+ Mock -ModuleName $moduleForMock Get-EC2NetworkInterface {
+
+ # args[1] is an ec2 filter based on the NLB name constructed from the current instance
+ # https://docs.aws.amazon.com/sdkfornet/v3/apidocs/items/EC2/TFilter.html
+ $ec2Filter = $args[1]
+
+ Write-Warning "Using EC2 Filter Value: $($ec2Filter.Values)"
+
+ $possibleReturns = @(
+
+ @{ "Description"="ELB net/Pass-$podBasedEnvironment-nlb-something"; Id="Test"; "AvailabilityZone"="us-east-1a"; "PrivateIpAddress"="100.100.100.100"; InterfaceType="network_load_balancer"},
+ @{ "Description"="ELB net/Fail-$podBasedEnvironment-nlb-something"; Id="Test"; "AvailabilityZone"="us-east-1a"; "PrivateIpAddress"="9.9.9.9"; InterfaceType="network_load_balancer"},
+ @{ "Description"="ELB net/Fail-staging-nlb-something"; Id="Test"; "AvailabilityZone"="us-east-1a"; "PrivateIpAddress"="8.8.8.8"; InterfaceType="network_load_balancer"}
+ )
+
+ return $possibleReturns | Where-Object {$_.AvailabilityZone -like $ec2Filter.Values}
+ }
+
+ Get-LocalNlbIp | Should -Be 100.100.100.100
+ }
+ }
+
+ It "Uses the alk:lane tag when the environment is Staging" {
+
+ Mock -ModuleName $moduleForMock Get-CurrentInstance {
+
+ $placementMock = New-Object PSObject -Property @{
+
+ "AvailabilityZone" = "us-east-1a";
+ }
+
+ $pod6Instance = New-Object PSObject -Property @{
+
+ Placement = $placementMock;
+
+ Tag = @(
+ @{Key = $Global:AlkamiTagKeyRole; Value = $Global:AlkamiTagValueRoleWeb},
+ @{Key = $Global:AlkamiTagKeyEnvironment; Value = $Global:AlkamiTagValueEnvironmentStaging},
+ @{Key = "alk:pod"; Value = "Fail"},
+ @{Key = "alk:lane"; Value = "Pass"}
+ );
+ }
+
+ return $pod6Instance
+ }
+
+
+ Mock -ModuleName $moduleForMock Get-EC2NetworkInterface {
+
+ # args[1] is an ec2 filter based on the NLB name constructed from the current instance
+ # https://docs.aws.amazon.com/sdkfornet/v3/apidocs/items/EC2/TFilter.html
+ $ec2Filter = $args[1]
+
+ Write-Warning "Using EC2 Filter Value: $($ec2Filter.Values)"
+
+ $possibleReturns = @(
+
+ @{ "Description"="ELB net/Pass-staging-nlb-something"; Id="Test"; "AvailabilityZone"="us-east-1a"; "PrivateIpAddress"="100.100.100.100"; InterfaceType="network_load_balancer"},
+ @{ "Description"="ELB net/Fail-staging-nlb-something"; Id="Test"; "AvailabilityZone"="us-east-1a"; "PrivateIpAddress"="9.9.9.9"; InterfaceType="network_load_balancer"}
+ @{ "Description"="ELB net/Fail-prod-nlb-something"; Id="Test"; "AvailabilityZone"="us-east-1a"; "PrivateIpAddress"="8.8.8.8"; InterfaceType="network_load_balancer"}
+ )
+
+ return $possibleReturns | Where-Object {$_.AvailabilityZone -like $ec2Filter.Values}
+ }
+
+ Get-LocalNlbIp | Should -Be 100.100.100.100
+ }
+
+ $validEnvironments = @("Dev", "QA")
+
+ foreach ($Global:designationBasedEnvironment in $validEnvironments) {
+ It "Uses the alk:designation tag when the environment is $designationBasedEnvironment" {
+
+ Mock -ModuleName $moduleForMock Get-CurrentInstance {
+
+ $placementMock = New-Object PSObject -Property @{
+ "AvailabilityZone" = "us-east-1a";
+ }
+
+ $instance = New-Object PSObject -Property @{
+
+ Placement = $placementMock;
+
+ Tag = @(
+ @{Key = $Global:AlkamiTagKeyRole; Value = $Global:AlkamiTagValueRoleWeb},
+ @{Key = $Global:AlkamiTagKeyEnvironment; Value = "$designationBasedEnvironment"},
+ @{Key = "alk:pod"; Value = "Fail"},
+ @{Key = "alk:lane"; Value = "Fail"},
+ @{Key = "alk:designation"; Value = "Pass"}
+ );
+ }
+
+ return $instance
+ }
+
+ Mock -ModuleName $moduleForMock Get-EC2NetworkInterface {
+
+ # args[1] is an ec2 filter based on the NLB name constructed from the current instance
+ # https://docs.aws.amazon.com/sdkfornet/v3/apidocs/items/EC2/TFilter.html
+ $ec2Filter = $args[1]
+
+ Write-Warning "Using EC2 Filter Value: $($ec2Filter.Values)"
+
+ $possibleReturns = @(
+ @{ "Description"="ELB net/Pass-$designationBasedEnvironment-nlb-something"; Id="Test"; "AvailabilityZone"="us-east-1a"; "PrivateIpAddress"="100.100.100.100"; InterfaceType="network_load_balancer"},
+ @{ "Description"="ELB net/Fail-$designationBasedEnvironment-nlb-something"; Id="Test"; "AvailabilityZone"="us-east-1a"; "PrivateIpAddress"="9.9.9.9"; InterfaceType="network_load_balancer"}
+ @{ "Description"="ELB net/Fail-prod-nlb-something"; Id="Test"; "AvailabilityZone"="us-east-1a"; "PrivateIpAddress"="8.8.8.8"; InterfaceType="network_load_balancer"}
+ )
+
+ return $possibleReturns | Where-Object {$_.AvailabilityZone -like $ec2Filter.Values}
+ }
+
+ Get-LocalNlbIp | Should -Be 100.100.100.100
+ }
+ }
+ }
+
+ Context "Error Scenarios" {
+
+ Mock -ModuleName $moduleForMock Get-EC2NetworkInterface {
+
+ # args[1] is an ec2 filter based on the NLB name constructed from the current instance
+ # https://docs.aws.amazon.com/sdkfornet/v3/apidocs/items/EC2/TFilter.html
+ $ec2Filter = $args[1]
+
+ Write-Warning "Using EC2 Filter Value: $($ec2Filter.Values)"
+
+ $possibleReturns = @(
+
+ @{ "Description"="ELB net/Pass-$podBasedEnvironment-nlb-something"; Id="Test"; "AvailabilityZone"="us-east-1a"; "PrivateIpAddress"="100.100.100.100"; InterfaceType="network_load_balancer"},
+ @{ "Description"="ELB net/Pass-$podBasedEnvironment-nlb-something"; Id="Test"; "AvailabilityZone"="us-east-1a"; "PrivateIpAddress"="111.111.111.111"; InterfaceType="network_load_balancer"}
+ )
+
+ return $possibleReturns | Where-Object {$_.AvailabilityZone -like $ec2Filter.Values}
+ }
+
+ It "Writes a Warning and Returns Null When No Match Found" {
+
+ Mock -ModuleName $moduleForMock Get-CurrentInstance {
+
+ $placementMock = New-Object PSObject -Property @{
+
+ "AvailabilityZone" = "us-east-999";
+ }
+
+ $pod6Instance = New-Object PSObject -Property @{
+
+ Placement = $placementMock;
+
+ Tag = @(
+ @{Key = $Global:AlkamiTagKeyRole; Value = $Global:AlkamiTagValueRoleWeb},
+ @{Key = $Global:AlkamiTagKeyEnvironment; Value = "$podBasedEnvironment"},
+ @{Key = "alk:pod"; Value = "Pass"},
+ @{Key = "alk:lane"; Value = "Fail"}
+ );
+ }
+
+ return $pod6Instance
+ }
+
+ Mock -CommandName Write-Warning -MockWith {} -ModuleName $moduleForMock
+
+ Get-LocalNlbIp | Should -BeNullOrEmpty
+ Assert-MockCalled -CommandName Write-Warning -Times 1 -Exactly -Scope It `
+ -ModuleName $moduleForMock -ParameterFilter { $Message -match "No ENIs found with Description" }
+ }
+
+ It "Writes a Warning and Returns Null When More Than One Match Found" {
+
+ Mock -ModuleName $moduleForMock Get-CurrentInstance {
+
+ $placementMock = New-Object PSObject -Property @{
+
+ "AvailabilityZone" = "us-east-1a";
+ }
+
+ $pod6Instance = New-Object PSObject -Property @{
+
+ Placement = $placementMock;
+
+ Tag = @(
+ @{Key = $Global:AlkamiTagKeyRole; Value = $Global:AlkamiTagValueRoleWeb},
+ @{Key = $Global:AlkamiTagKeyEnvironment; Value = "$podBasedEnvironment"},
+ @{Key = "alk:pod"; Value = "Pass"},
+ @{Key = "alk:lane"; Value = "Fail"}
+ );
+ }
+
+ return $pod6Instance
+ }
+
+ Mock -CommandName Write-Warning -MockWith {} -ModuleName $moduleForMock
+
+ Get-LocalNlbIp | Should -BeNullOrEmpty
+ Assert-MockCalled -CommandName Write-Warning -Times 1 -Exactly -Scope It `
+ -ModuleName $moduleForMock -ParameterFilter { $Message -match "2 ENIs found with Description" }
+ }
+ }
+}
+
+#endregion Get-LocalNlbIp
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Common/Public/Get-LogDiskUtilization.ps1 b/Modules/Alkami.DevOps.Common/Public/Get-LogDiskUtilization.ps1
new file mode 100644
index 0000000..d5427d4
--- /dev/null
+++ b/Modules/Alkami.DevOps.Common/Public/Get-LogDiskUtilization.ps1
@@ -0,0 +1,109 @@
+function Get-LogDiskUtilization {
+
+<#
+.SYNOPSIS
+ Gets disk utilization from ORB Logs
+
+.PARAMETER ComputerName
+ The single remote computer name to execute against. If not supplied, runs on the local system
+#>
+
+
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory = $false)]
+ [string]$ComputerName = ""
+ )
+
+ $logLead = Get-LogLeadName
+ $isLocalExecution = $false
+
+ if (( [String]::IsNullOrEmpty($ComputerName) ) -or (Compare-StringToLocalMachineIdentifiers -stringToCheck $ComputerName )) {
+
+ Write-Verbose "$logLead : Setting local execution to true"
+ $ComputerName = $ENV:ComputerName
+ $isLocalExecution = $true
+ }
+
+ $logPath = Get-ORBLogsPath
+ if ($isLocalExecution) {
+
+ Write-Verbose "$logLead : Searching for log files locally"
+ $logFiles = Get-ChildItem -Path $logPath -Filter *.log* -File
+ } else {
+
+ Write-Verbose "$logLead : Searching for log files on $ComputerName"
+ $logFiles = Invoke-Command $ComputerName { Get-ChildItem -Path $using:logPath -Filter *.log* -File }
+ }
+
+ if (Test-IsCollectionNullOrEmpty $logFiles) {
+
+ Write-Host "$logLead : No log files found under $logPath on computer $ComputerName"
+ return
+ }
+
+ $logDetails = @()
+ foreach ($logFile in $logFiles) {
+ $logDetails += New-Object PSObject -Property @{
+
+ Name = $logFile.BaseName -replace ".log$", "";
+ Size = $logFile.Length
+ }
+ }
+
+ $groups = $logDetails | Group-Object -Property Name
+ $totalDiskUtilization = 0
+
+ $analysisResults = @()
+ foreach ($logGroup in ($groups | Sort-Object -Property Count -Descending)) {
+
+ [array]$sizeDetails = $logGroup.Group | Select-Object -ExpandProperty Size
+ if ($sizeDetails.Count -eq 0) {
+ $cumulativeSize = $sizeDetails;
+ } else {
+
+ [int]$cumulativeSize = 0
+ $sizeDetails | ForEach-Object {
+ $cumulativeSize += [int]$_
+ }
+ }
+
+ $totalDiskUtilization += $cumulativeSize
+ $analysisResults += New-Object PSObject -Property @{
+
+ LogFile = $logGroup.Name;
+ LogCount = $logGroup.Count;
+ Size = $cumulativeSize;
+ }
+ }
+
+ $topTenLogSizes = $analysisResults | Sort-Object -Property Size -Descending | Select-Object -First 10
+
+ # Begin unnecessarily complicated table print that I threw together during a meeting because I was bored
+ $maxLogNameLength = ($topTenLogSizes | Select-Object -ExpandProperty LogFile | Sort-Object -Property Length -Descending | Select-Object -First 1).Length
+ $maxLogNamePadding = [math]::Max($maxLogNameLength, 8)
+
+ $maxLogSizeLength = ($topTenLogSizes | Select-Object -ExpandProperty Size | ForEach-Object { $_.ToString() } | Sort-Object -Property Length -Descending | Select-Object -First 1).Length
+ $logSizePadding = [math]::Max($maxLogSizeLength , 4)
+
+ $headerRow = (" | " + ("Log File".PadRight($maxLogNameLength), "Size".PadRight($maxLogSizeLength), "Log Count" -Join " | ") + " | ")
+ $headerRowLength = $headerRow.Length - 4
+
+ $totalUtilizationLine = " | $ComputerName Log Utilization: $([math]::Round(($totalDiskUtilization/1024/1024),2))mb".PadRight($headerRowLength + 2) + "|"
+
+ $border = " %" + ("=" * $headerRowLength) + "%"
+
+ Write-Host $border
+ Write-Host $totalUtilizationLine
+ Write-Host $border
+ Write-Host $headerRow
+ Write-Host $border
+
+ foreach ($topLog in $topTenLogSizes) {
+
+ $outputLine = " | " + ($topLog.LogFile.PadRight($maxLogNamePadding), $topLog.Size.ToString().PadRight($logSizePadding), $topLog.LogCount.ToString().PadRight(9) -Join " | ") + " | "
+ Write-Host $outputLine
+ }
+
+ Write-Host $border
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Common/Public/Get-PackageForInstallation.ps1 b/Modules/Alkami.DevOps.Common/Public/Get-PackageForInstallation.ps1
new file mode 100644
index 0000000..75f582b
--- /dev/null
+++ b/Modules/Alkami.DevOps.Common/Public/Get-PackageForInstallation.ps1
@@ -0,0 +1,39 @@
+function Get-PackageForInstallation {
+<#
+.SYNOPSIS
+ Downloads a File to the Specified Outputfolder
+#>
+
+ [CmdletBinding()]
+ Param(
+ [Parameter(Mandatory = $true)]
+ [string]$loglead,
+
+ [Parameter(Mandatory = $true)]
+ [string]$outputFolder,
+
+ [Parameter(Mandatory = $true)]
+ [string]$outputfileName,
+
+ [Parameter(Mandatory = $true)]
+ [string]$downloadUrl
+ )
+
+ if (!(Test-Path $outputFolder)) {
+ Write-Verbose ("$logLead : Creating Package Download Folder {0}" -f $outputFolder)
+ New-Item $outputFolder -ItemType Directory | Out-Null
+ }
+
+ $downloadFile = (Join-Path $outputFolder $outputfileName)
+
+ if (Test-Path $downloadFile) {
+ # If a job is rerun, this should prevent downloading again, unless it spans a day
+ Write-Host ("$logLead : Using Existing Package from {0}" -f $downloadFile)
+ }
+ else {
+ Write-Host ("$logLead : Downloading File from {0} to {1}" -f $downloadUrl, $downloadFile)
+ Invoke-WebRequest -Uri $downloadUrl -OutFile $downloadFile
+ }
+
+ return $downloadFile
+}
diff --git a/Modules/Alkami.DevOps.Common/Public/Get-PodName.ps1 b/Modules/Alkami.DevOps.Common/Public/Get-PodName.ps1
new file mode 100644
index 0000000..d3a2b59
--- /dev/null
+++ b/Modules/Alkami.DevOps.Common/Public/Get-PodName.ps1
@@ -0,0 +1,39 @@
+function Get-PodName {
+ <#
+ .SYNOPSIS
+ Easily return human-readable name for the pod, lane, designation in which a host belongs.
+ .DESCRIPTION
+ Query the host's Machine.config to determine the pod, lane, designation, etc, and return in a format people can use.
+
+ .PARAMETER ComputerName
+ Optional Parameter. If specified, gets the name for that server's pod, lane, designation, etc.
+
+ .PARAMETER Full
+ Optional Parameter. If specified, returns the full name of the environment. Otherwise, returns the short name.
+
+ .EXAMPLE
+
+ Get-PodName -ComputerName APP169671
+ #>
+ [CmdletBinding()]
+ param (
+ [Parameter(Mandatory = $false)]
+ [ValidateNotNullOrEmpty()]
+ [string]$ComputerName = "$env:computerName",
+ [Parameter(Mandatory = $false)]
+ [switch]$Full
+ )
+
+ # get the FQDN no matter what for simplicity
+ $ComputerName=[System.Net.Dns]::GetHostByName($ComputerName).HostName
+
+ # get the key value
+ $keyValue = Get-AppSetting -Key Environment.Name -ComputerName $ComputerName
+
+ if ( $Full ) {
+ $keyValue
+ } else {
+ $podName = ($keyValue -split " ")[-1]
+ $podName
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Common/Public/Get-SecretsForPod.ps1 b/Modules/Alkami.DevOps.Common/Public/Get-SecretsForPod.ps1
new file mode 100644
index 0000000..2be196d
--- /dev/null
+++ b/Modules/Alkami.DevOps.Common/Public/Get-SecretsForPod.ps1
@@ -0,0 +1,50 @@
+function Get-SecretsForPod {
+<#
+.SYNOPSIS
+ Gets Secrets for Pod.
+#>
+
+ [CmdletBinding()]
+ Param(
+ [string]$secretUserName,
+ [string]$secretPassword,
+ [string]$secretDomain,
+ [string]$secretFolderNames
+ )
+
+ $client = New-Object Alkami.Ops.SecretServer.Client
+ $authResult = $client.AuthenticateAsync($secretUserName, $secretPassword, $secretDomain).GetAwaiter().GetResult()
+
+ if ($authResult.Status -ne [Alkami.Ops.SecretServer.Enum.ResultStatus]::Success) {
+ Write-Warning ("Unable to authenticate with SecretServer: {0}" -f $authResult.Message)
+
+ if ($authResult.Errors.Count -gt 0) {
+ $errors = ("Error(s) from server - " + ($authResult.Errors | Select-Object -ExpandProperty "ErrorMessage") -join ", ")
+ Write-Warning -Message $errors
+ }
+
+ return
+ }
+
+ [HashTable]$secrets = $null
+
+ foreach ($secretFolder in $secretFolderNames.Split(',')) {
+ $result = $client.GetFolderSecretsAsync($secretFolder.Trim()).GetAwaiter().GetResult()
+
+ if ($result.Status -ne [Alkami.Ops.SecretServer.Enum.ResultStatus]::Success) {
+ Write-Warning ("Error pulling secrets for {0} from SecretServer: {1}" -f $secretFolder, $secretResult.Message)
+
+ if ($secretResult.Errors.Count -gt 0) {
+ $errors = ("Error(s) from server - " + ($secretResult.Errors | Select-Object -ExpandProperty "ErrorMessage") -join ", ")
+ Write-Warning -Message $errors
+ }
+
+ return
+ }
+
+ $secrets += $result.Secrets
+ }
+
+ return $secrets
+}
+
diff --git a/Modules/Alkami.DevOps.Common/Public/Get-SecurityGroupPrefix.ps1 b/Modules/Alkami.DevOps.Common/Public/Get-SecurityGroupPrefix.ps1
new file mode 100644
index 0000000..a983684
--- /dev/null
+++ b/Modules/Alkami.DevOps.Common/Public/Get-SecurityGroupPrefix.ps1
@@ -0,0 +1,28 @@
+function Get-SecurityGroupPrefix {
+ <#
+ .SYNOPSIS
+ Easily return human-readable name for the Security Group in which a host belongs.
+ .DESCRIPTION
+ Query the host's Machine.config to determine the Security Group for a host and return in a format people can use.
+
+ .PARAMETER ComputerName
+ Optional Parameter. If specified, gets the Security Group for that server's pod, lane, designation, etc. Defaults to local computer.
+
+ .EXAMPLE
+
+ Get-SecurityGroup -ComputerName APP169671
+ #>
+ [CmdletBinding()]
+ param (
+ [Parameter(Mandatory = $false)]
+ [ValidateNotNullOrEmpty()]
+ [string]$ComputerName="$env:computerName"
+ )
+
+ # get the FQDN no matter what for simplicity
+ $ComputerName=[System.Net.Dns]::GetHostByName($ComputerName).HostName
+
+ $securityGroup=Get-AppSetting -Key Environment.UserPrefix -ComputerName $ComputerName
+
+ return $securityGroup
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Common/Public/Get-ServerStatusReport.ps1 b/Modules/Alkami.DevOps.Common/Public/Get-ServerStatusReport.ps1
new file mode 100644
index 0000000..53fabc5
--- /dev/null
+++ b/Modules/Alkami.DevOps.Common/Public/Get-ServerStatusReport.ps1
@@ -0,0 +1,166 @@
+function Get-ServerStatusReport {
+<#
+.SYNOPSIS
+Start the Server Status check, typically run after a Scale-Up event.
+
+.DESCRIPTION
+Performs several sanity tests to ensure the servers are fully operational after a Scale-Up event.
+Main entrypoint that calls this is in teamcity.sre.code/ScaleEnvironments/Invoke-ReportServerStatus.ps1
+
+.PARAMETER Servers
+[string[]]Array of servers to run tests against. Usually this is a list of servers by Designation (LC3, Morph, etc.)
+
+.PARAMETER ProfileName
+[string] Specific AWS CLI Profile to use in AWS API calls.
+
+.PARAMETER Region
+[string] Specific AWS CLI Region to use in AWS API calls.
+
+.NOTES
+Outputs a table with the results of the test by host.
+Returns custom object with the following properties:
+ "Hostname"
+ "Designation"
+ "InLoadBalancer"
+ "IsNagRunning"
+ "IsSubServiceRunning"
+ "AllAlkServicesRunning"
+ "TagsCorrect"
+ "IsAppServer"
+ "IsWebServer"
+ "IsMicServer"
+#>
+ [CmdletBinding()]
+ [OutputType([Object])]
+ Param (
+ [Parameter(Mandatory = $true)]
+ [string[]] $Servers,
+
+ [Parameter(Mandatory = $true)]
+ [string] $ProfileName,
+
+ [Parameter(Mandatory = $true)]
+ [string] $Region
+ )
+
+ $logLead = (Get-LogLeadName)
+
+ Write-Host "$logLead : Running for servers:`n$Servers"
+
+ # Script Block to do the tests.
+ $testsScriptBlock = {
+ param ($hostname, $arguments)
+
+ Write-Host "##teamcity[blockOpened name='Running tests for hostname $hostname']"
+
+ $testResults = Invoke-Command -ComputerName $hostname -ScriptBlock {
+ $computername = (Get-FullyQualifiedServerName)
+ $isAppServer = Test-IsAppServer -ComputerName $computername
+ $isWebServer = Test-IsWebServer -ComputerName $computername
+ $isMicServer = Test-IsMicroServer -ComputerName $computername
+
+ # Make an object to hold the results of the tests
+ $testResult = New-Object PSObject -Property @{
+ "Hostname" = $computername
+ "Designation" = "Unknown"
+ "InLoadBalancer" = $null
+ "IsNagRunning" = $null
+ "IsSubServiceRunning" = $null
+ "AllAlkServicesRunning" = $null
+ "TagsCorrect" = $null
+ "IsAppServer" = $isAppServer
+ "IsWebServer" = $isWebServer
+ "IsMicServer" = $isMicServer
+ }
+
+ # NAG test
+ try {
+ if($IsAppServer) {
+ Write-Host "Running NAG test"
+ $nagStatus = Test-IsNagRunning -Server $computername 2>$null
+ $testResult.IsNagRunning = $nagStatus
+ } else {
+ $testResult.IsNagRunning = "N/A"
+ }
+ } catch {
+ Resolve-Error -ErrorRecord $_
+ $testResult.IsNagRunning = "Unknown"
+ }
+
+
+ # Subscription Service test
+ try {
+ if($isMicServer -or $isWebServer -or $isAppServer) {
+ Write-Host "Running Subscription Service test"
+ $chocoServices = Get-ChocolateyServices 2>$null
+ $subscriptionService = ($chocoServices | Where-Object {$_.Name -eq "Alkami.Services.Subscriptions.Host"})
+ if($null -ne $subscriptionService) {
+ $testResult.IsSubServiceRunning = ($subscriptionService.State -eq "Running")
+ } else {
+ $testResult.IsSubServiceRunning = $false
+ }
+ } else {
+ $testResult.IsSubServiceRunning = "N/A"
+ }
+ } catch {
+ Resolve-Error -ErrorRecord $_
+ $testResult.IsSubServiceRunning = "Unknown"
+ }
+
+
+ # All ALK Services running test?
+ Write-Host "Running All Alk Services test"
+ $alkServices = ($chocoServices | Where-Object {$_.Name -like "Alkami.*"})
+ $stoppedAlkServiceCount = ($alkServices | Where-Object {$_.State -eq "Stopped"}).count
+ $testResult.AllAlkServicesRunning = !($stoppedAlkServiceCount -gt 0)
+
+
+ # Tags test
+ try {
+ Write-Host "Running Tags test"
+ $tags = Get-CurrentInstanceTags
+ $autoShutdownTagKey = "alk:autoshutdown"
+ $autoShutdownTag = ($tags | Where-Object { ($_.Key -eq $autoShutdownTagKey -and $_.Value -eq "true") })
+ $isAutoShutdown = ($null -ne $autoShutdownTag)
+ $testResult.TagsCorrect = (!($isAutoShutdown))
+ } catch {
+ Resolve-Error -ErrorRecord $_
+ $testResult.TagsCorrect = "Unknown"
+ }
+
+ return $testResult
+ }
+
+ # LoadBalancer test
+ # This test is done outside of the above script block as it needs to be run off-host.
+ # Running this on host will fail due to the IAM creds not having permissions for some
+ # resources (Only for NGinx lookups).
+ try {
+ if($testResults.IsAppServer -or $testResults.IsWebServer) {
+ Write-Host "Running LoadBalancer test"
+ $lbState = Get-LoadBalancerState -Server $hostname -AwsProfileName $arguments.ProfileName -AwsRegion $arguments.Region
+ $testResults.InLoadBalancer = ($lbState -eq "Active")
+ } else {
+ $testResults.InLoadBalancer = "N/A"
+ }
+ } catch {
+ Resolve-Error -ErrorRecord $_
+ $testResults.InLoadBalancer = "Unknown"
+ }
+
+ Write-Host "##teamcity[blockClosed name='Running tests for hostname $hostname']"
+
+ # Return the test
+ return $testResults
+ }
+
+ # Parallel over the servers to test.
+ try {
+ $results = (Invoke-Parallel2 -Objects $Servers -Arguments @{ProfileName = $ProfileName; Region = $Region;} -Script $testsScriptBlock).Result
+ } catch {
+ Resolve-Error -ErrorRecord $_
+ }
+
+ # Send results to caller.
+ return $results
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Common/Public/Get-SlackAction.ps1 b/Modules/Alkami.DevOps.Common/Public/Get-SlackAction.ps1
new file mode 100644
index 0000000..5e5a161
--- /dev/null
+++ b/Modules/Alkami.DevOps.Common/Public/Get-SlackAction.ps1
@@ -0,0 +1,34 @@
+function Get-SlackAction {
+<#
+.SYNOPSIS
+ Used to create interactive elements in messages
+
+.PARAMETER Text
+ The text to display
+
+.PARAMETER Url
+ The URL the user will interact with
+
+.PARAMETER Type
+ Defaults to button
+#>
+ [CmdletBinding()]
+ [OutputType([object])]
+ param (
+ [Parameter(Mandatory = $true)]
+ [string]$Text,
+
+ [Parameter(Mandatory = $true)]
+ [string]$Url,
+
+ [Parameter(Mandatory = $false)]
+ [ValidateSet('button')] # We only usually offer one option type. Feel free to update when you've got a new use-case
+ [string]$Type = 'button'
+ )
+
+ return @{
+ text = $Text
+ url = $Url
+ type = $Type
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Common/Public/Get-SlackAttachment.ps1 b/Modules/Alkami.DevOps.Common/Public/Get-SlackAttachment.ps1
new file mode 100644
index 0000000..b7f6af5
--- /dev/null
+++ b/Modules/Alkami.DevOps.Common/Public/Get-SlackAttachment.ps1
@@ -0,0 +1,87 @@
+function Get-SlackAttachment {
+<#
+.SYNOPSIS
+ Used to create an attachment for a Slack Message that adds further context or additional information.
+
+.DESCRIPTION
+ Content that can be attached to messages to include lower priority content - content that doesn't necessarily need to be seen to appreciate the intent of the message.
+
+.PARAMETER Fallback
+ A plain text summary of the attachment used in clients that don't show formatted text (eg. IRC, mobile notifications).
+ The top-level text field from the message payload.
+
+.PARAMETER Color
+ The color showcased in the message to show status.
+
+.PARAMETER Fields
+ An array of field objects that get displayed in a table-like way. For best results, include no more than 2-3 field objects.
+
+.PARAMETER Text
+ The main body text of the attachment.
+
+.PARAMETER Timestamp
+ The attachment will display the additional timestamp value as part of the attachment's footer.
+ Your message's timestamp will be displayed in varying ways, depending on how far in the past or future it is, relative to the present. Form factors, like mobile versus desktop may also transform its rendered appearance.
+
+.PARAMETER Actions
+ A set of actions to be taken for this attachment.
+
+.PARAMETER ActionText
+ The text for an attachment.
+
+.PARAMETER ActionUrl
+ The URL for an attachment.
+#>
+ [CmdletBinding()]
+ [OutputType([object])]
+ param (
+ [Parameter(Mandatory = $true)]
+ [string]$Fallback,
+
+ [Parameter(Mandatory = $false)]
+ [string]$Color = $null,
+
+ [Parameter(Mandatory = $false)]
+ [string]$Text = $null,
+
+ [Parameter(Mandatory = $false)]
+ [DateTime]$Timestamp = (Get-Date),
+
+ [Parameter(Mandatory = $false)]
+ [object[]]$Fields = $null,
+
+ [Parameter(ParameterSetName = 'Actions')]
+ [object[]]$Actions = $null,
+
+ [Parameter(Mandatory = $true, ParameterSetName = 'ActionPair')]
+ [string]$ActionText,
+
+ [Parameter(Mandatory = $true, ParameterSetName = 'ActionPair')]
+ [string]$ActionUrl
+ )
+
+ $messageColor = Get-SlackMessageColor -Text $Color
+ Write-Verbose "Message Color: $messageColor"
+ $attachment = @{
+ fallback = $Fallback
+ color = $messageColor
+ ts = [Math]::Floor([decimal](Get-Date($Timestamp).ToUniversalTime() -uformat "%s"))
+ }
+
+ if ($null -ne $Fields) {
+ $attachment.fields = $Fields
+ }
+
+ if (![string]::IsNullOrWhiteSpace($Text)) {
+ $attachment.text = $Text
+ }
+
+ if ($PSCmdlet.ParameterSetName -eq 'ActionPair') {
+ $Actions = Get-SlackAction -Text $ActionText -Url $ActionUrl
+ }
+
+ if ($null -ne $Actions) {
+ $attachment.actions = @($Actions)
+ }
+ return $attachment
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Common/Public/Get-SlackAttachmentFields.ps1 b/Modules/Alkami.DevOps.Common/Public/Get-SlackAttachmentFields.ps1
new file mode 100644
index 0000000..08eb512
--- /dev/null
+++ b/Modules/Alkami.DevOps.Common/Public/Get-SlackAttachmentFields.ps1
@@ -0,0 +1,35 @@
+function Get-SlackAttachmentFields {
+<#
+.SYNOPSIS
+ Used to create a Field object for the Slack Attachment that attaches to the Slack Message.
+
+.PARAMETER Title
+ Shown as a bold heading displayed in the field object.
+
+.PARAMETER Value
+ The text value displayed in the field object.
+
+.PARAMETER Short
+ Indicates whether the field object is short enough to be displayed side-by-side with other field objects. Defaults to false.
+#>
+ [CmdletBinding()]
+ [OutputType([object])]
+ param (
+ [Parameter(Mandatory = $false)]
+ [string]$Title = "",
+
+ [Parameter(Mandatory = $false)]
+ [string]$Value = "",
+
+ [Parameter(Mandatory = $false)]
+ [switch]$Short
+ )
+
+ $result = @{
+ title = $Title
+ value = $Value
+ short = $Short
+ }
+
+ return $result
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Common/Public/Get-SlackMessage.ps1 b/Modules/Alkami.DevOps.Common/Public/Get-SlackMessage.ps1
new file mode 100644
index 0000000..33d5050
--- /dev/null
+++ b/Modules/Alkami.DevOps.Common/Public/Get-SlackMessage.ps1
@@ -0,0 +1,59 @@
+function Get-SlackMessage {
+<#
+.SYNOPSIS
+ Create a Slack Message.
+
+.PARAMETER Username
+ To specify the username for the published message.
+
+.PARAMETER IconEmoji
+ To specify an emoji (using colon shortcodes, eg. :white_check_mark:) to use as the profile photo alongside the message.
+
+.PARAMETER Text
+ The main body text of the message.
+
+.PARAMETER Channel
+ Channel, private group, or IM channel to send message to.
+
+.PARAMETER Attachments
+ One of these arguments is required to describe the content of the message. See the function 'Get-SlackAttachment' for more information.
+#>
+ [CmdletBinding()]
+ [OutputType([object])]
+ param (
+ [Parameter(Mandatory = $true)]
+ [string]$Username,
+
+ [Parameter(Mandatory = $true)]
+ [string]$IconEmoji,
+
+ [Parameter(Mandatory = $true)]
+ [string]$Text,
+
+ [Parameter(Mandatory = $true)]
+ [string]$Channel,
+
+ [Parameter(Mandatory = $false)]
+ [object[]]$Attachments = $null
+ )
+
+ $IconEmoji = $IconEmoji.replace(':', '')
+ $normalizedIconEmoji = ":$($IconEmoji):"
+
+ $message = @{
+ username = $Username
+ icon_emoji = $normalizedIconEmoji
+ text = $Text
+ channel = $Channel
+ }
+
+ if (![string]::IsNullOrWhiteSpace($Channel)) {
+ $message.channel = $Channel
+ }
+
+ if ($null -ne $Attachments) {
+ $message.attachments = @($Attachments)
+ }
+
+ return $message
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Common/Public/Get-SlackMessageColor.ps1 b/Modules/Alkami.DevOps.Common/Public/Get-SlackMessageColor.ps1
new file mode 100644
index 0000000..f54954d
--- /dev/null
+++ b/Modules/Alkami.DevOps.Common/Public/Get-SlackMessageColor.ps1
@@ -0,0 +1,68 @@
+function Get-SlackMessageColor {
+<#
+.SYNOPSIS
+ Provide a standardized way to get slack channel colors
+ This way we use a consistent color set, etc
+
+.PARAMETER Success
+ Will return a green color
+
+.PARAMETER Failure
+ Will return a red color
+
+.PARAMETER Unknown
+ Will return a blue color
+
+.PARAMETER Text
+ Will attempt to find a suitable color to match
+ Can also be provided a known hex value, which will be returned again (helpful for pass-through cases from prior function calls)
+ Will return blue if the input is neither "success" nor "failure" (until we add more cases)
+#>
+ [CmdletBinding(DefaultParameterSetName = 'text')]
+ [OutputType([string])]
+ param (
+ [Parameter(ParameterSetName = 'success')]
+ [switch]$Success,
+
+ [Parameter(ParameterSetName = 'failure')]
+ [Alias('Fail')]
+ [switch]$Failure,
+
+ [Parameter(ParameterSetName = 'unknown')]
+ [switch]$Unknown,
+
+ [Parameter(Mandatory = $false, ParameterSetName = 'text')]
+ [string]$Text = ""
+ )
+
+ $messageColors = @{
+ SUCCESS = "#119e42"
+ FAILURE = "#cc2614"
+ DEFAULT = "#0000ff"
+ }
+
+ # Early bail, PS does not support eager evaluation, so nest
+ if (![string]::IsNullOrWhiteSpace($Text)) {
+ if ($Text.StartsWith('#')) {
+ return $Text
+ }
+ }
+
+ if ($Success) {
+ $Text = 'Success'
+ }
+
+ if ($Failure) {
+ $Text = 'Failure'
+ }
+
+ if ($Unknown) {
+ $Text = 'Unknown'
+ }
+
+ switch ($Text) {
+ 'Success' { $messageColors.SUCCESS }
+ 'Failure' { $messageColors.FAILURE }
+ default { $messageColors.DEFAULT }
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Common/Public/Get-UTF8ContentHash.ps1 b/Modules/Alkami.DevOps.Common/Public/Get-UTF8ContentHash.ps1
new file mode 100644
index 0000000..7f1947c
--- /dev/null
+++ b/Modules/Alkami.DevOps.Common/Public/Get-UTF8ContentHash.ps1
@@ -0,0 +1,34 @@
+function Get-UTF8ContentHash {
+ <#
+ .SYNOPSIS
+ Retrieves a hash generated from a UTF8 string.
+
+ .PARAMETER UTF8Content
+ [string] The UTF8 string to be hashed. Required.
+
+ .EXAMPLE
+ Get-UTF8ContentHash "Test"
+
+ .EXAMPLE
+ $myString = "Test"
+ Get-UTF8ContentHash -UTF8Content $myString
+#>
+ [CmdletBinding()]
+ param (
+ [Parameter(Mandatory = $true)]
+ [Alias("UTF8Content")]
+ [string]$content
+ )
+
+ $encoding = New-Object System.Text.UTF8Encoding($false)
+ $bytes = $encoding.GetBytes($content)
+ $hashAlgorithm = [System.Security.Cryptography.HashAlgorithm]::Create("SHA256")
+ $hash = $hashAlgorithm.ComputeHash($bytes)
+
+ $builder = New-Object -TypeName "System.Text.StringBuilder"
+ foreach ($byte in $hash) {
+ $builder.Append($byte.ToString("x2")) | Out-Null
+ }
+
+ return $builder.ToString()
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Common/Public/Get-UserCredentialsFromSecretServer.ps1 b/Modules/Alkami.DevOps.Common/Public/Get-UserCredentialsFromSecretServer.ps1
new file mode 100644
index 0000000..78fb3f3
--- /dev/null
+++ b/Modules/Alkami.DevOps.Common/Public/Get-UserCredentialsFromSecretServer.ps1
@@ -0,0 +1,122 @@
+function Get-UserCredentialsFromSecretServer () {
+<#
+.SYNOPSIS
+ Gets the username and password of a user secret from Secret Server.
+
+.PARAMETER secretCredential
+ Credentials of the user to authenticate with on Secret Server.
+
+.PARAMETER folderName
+ The name of the folder the secret is in on Secret Server.
+
+.PARAMETER secretName
+ The name of the secret on Secret Server
+
+.OUTPUTS
+ Either a credential object containing the username and password of the user or null.
+
+.EXAMPLE
+ Get-UserCredentialsFromSecretServer -secretCredential $credential -folderName "Entrust 17-0" -secretName "Entrust 17-0 Master1 User"
+
+Password Username
+-------- --------
+SecretStuffs Master1
+#>
+ [CmdletBinding()]
+ Param(
+ [Parameter(Mandatory=$true)]
+ [PSCredential]$secretCredential,
+ [Parameter(Mandatory=$true)]
+ [String]$folderName,
+ [Parameter(Mandatory=$true)]
+ [String]$secretName
+ )
+
+ $loglead = (Get-LogLeadName)
+
+ $secretServerUri = (Get-SecretServerUri)
+
+ # TODO: If the low-level function accepts credential objects, this can be simplified.
+ Write-Verbose "$loglead : Creating Secret Server Connection to $secretServerUri with UserName $($secretCredential.UserName)"
+ $secretServer = Get-SecretServerConnection $secretServerUri $secretCredential.UserName (Get-PasswordFromCredential $secretCredential)
+
+ Write-Verbose "$loglead : Authenticating to SecretServer with No 2FA"
+ $secretServer.Authenticate($false)
+
+ Write-Verbose "$loglead : Searching for folder named '$folderName'"
+ [array]$folderIds = $secretServer.GetFolderIdByName($folderName)
+ if ( Test-IsCollectionNullOrEmpty $folderIds ) {
+ Write-Error "$logLead : Could not find a match for folder name '$folderName'. Returning null"
+ return $null
+ }
+
+ foreach ($folderIdentifier in $folderIds) {
+
+ # This will return an array of secret records
+ Write-Host "$loglead Searching for secrets with name '$secretName' in folder ID '$folderIdentifier'"
+ $matchingSecrets = $secretServer.GetSecretByName($secretName, $folderIdentifier)
+
+ $potentialSecretsCount = $matchingSecrets.Records.Count
+ if($null -eq $matchingSecrets -or $potentialSecretsCount -eq 0) {
+
+ if ($folderIds.Count -eq 1) {
+
+ Write-Error "$loglead : Could not find secrets matching derived secret name '$secretName' in folder with ID $folderIdentifier"
+ return $null
+ }
+
+ Write-Host "$loglead : Could not find secrets matching derived secret name '$secretName' in folder with ID $folderIdentifier. Continuing..."
+ continue
+ }
+
+ if ($potentialSecretsCount -gt 1) {
+
+ # We found more than one secret. Look for an exact name match
+ Write-Verbose "$logLead : Found $potentialSecretsCount Secrets Matching Search String '$secretName'."
+
+ foreach ($potentialSecret in $matchingSecrets.Records) {
+
+ if ($potentialSecret.name -eq $secretName) {
+
+ Write-Host "$logLead : Secret with ID $($potentialSecret.Id) has an exact name match with search string '$secretName'"
+ $targetSecret = $potentialSecret
+ break
+ }
+ }
+
+ if ($null -eq $targetSecret) {
+
+ Write-Error "$logLead : Could not find an exact match to search string '$secretName' and more than one partial match was found. Returning null"
+ return $null
+ }
+ } else {
+
+ # Only 1 result was found, we're using it
+ $targetSecret = $matchingSecrets.Records | Select-Object -First 1
+ }
+ }
+
+ $targetSecretId = $targetSecret.Id
+ Write-Host "$logLead : Pulling secret details for secret with ID '$targetSecretId'"
+ $secret = $secretServer.GetSecretById($targetSecretId)
+
+ $username = ($secret.Items | Where-Object { $_.fieldName -eq "Username" } | Select-Object -First 1).ItemValue
+ $password = ($secret.Items | Where-Object { $_.fieldName -eq "Password" } | Select-Object -First 1).ItemValue
+
+ $returnEarly = $false
+ if([string]::IsNullOrWhiteSpace($username)) {
+ Write-Error "$loglead : Could not find Username value in secret '$($secret.Name)'"
+ $returnEarly = $true
+ } elseif([string]::IsNullOrWhiteSpace($password)) {
+ Write-Error "$loglead : Could not find Password value in secret '$($secret.Name)'"
+ $returnEarly = $true
+ }
+
+ if ($returnEarly) {
+
+ Write-Warning "$logLead : Either the Username or Password (or both) were null/empty for secret '$secretName'"
+ return $null
+ }
+
+ return ( New-Object System.Management.Automation.PSCredential ( $username , ( Get-SecureString $password ) ) )
+}
diff --git a/Modules/Alkami.DevOps.Common/Public/Invoke-GetAllDesignationsByEnvironmentType.ps1 b/Modules/Alkami.DevOps.Common/Public/Invoke-GetAllDesignationsByEnvironmentType.ps1
new file mode 100644
index 0000000..429f959
--- /dev/null
+++ b/Modules/Alkami.DevOps.Common/Public/Invoke-GetAllDesignationsByEnvironmentType.ps1
@@ -0,0 +1,67 @@
+function Invoke-GetAllDesignationsByEnvironmentType {
+<#
+.SYNOPSIS
+This wraps the APIGateway/Lambda for scaling environments. Helper for the MinimizeDesignation/MaximizeDesignation functions.
+
+.DESCRIPTION
+Gets all EC2 instances where the alk:env tag value = parameter EnvironmentName.
+
+.PARAMETER Fqdn
+Fully Qualified Domain Name of the API Gateway. Each environment/account has a unique API Gateway endpoint.
+
+.PARAMETER EnvironmentName
+Environment name to get instances by. Examples are 'dev','staging','qa'. Case sensitive.
+
+.PARAMETER ApiGatewayKey
+Unique key for authenticating to the API Gateway.
+
+.PARAMETER ExclusionListCSV
+Comma-seperated list of alk:designation strings to exclude from the returned list. Case sensitive.
+
+.EXAMPLE
+Invoke-GetAllDesignationsByEnvironmentType -fqdn "vyq7hqcx55.execute-api.us-east-1.amazonaws.com" -environmentName "dev" -apiGatewayKey "123456789" -exclusionListCSV "Red1,Red17"
+
+returns JSON:
+[
+ {
+ "Designation": "tde2",
+ "Environment": "dev"
+ },
+ {
+ "Designation": "FabricSeed",
+ "Environment": "dev"
+ },
+ {
+ "Designation": "ai",
+ "Environment": "dev"
+ }
+]
+.NOTES
+Dev FQDN : vyq7hqcx55.execute-api.us-east-1.amazonaws.com
+Staging FQDN : cox3133b67.execute-api.us-east-1.amazonaws.com
+Qa FQDN :
+#>
+ [CmdletBinding()]
+
+ Param (
+ [Parameter(Mandatory = $true)]
+ [string] $Fqdn,
+ [Parameter(Mandatory = $true)]
+ [string] $EnvironmentName,
+ [Parameter(Mandatory = $true)]
+ [string] $ApiGatewayKey,
+ [Parameter(Mandatory = $false)]
+ [string] $ExclusionListCSV
+ )
+
+ [System.Net.ServicePointManager]::SecurityProtocol = "Tls12"
+
+ if(![string]::IsNullOrEmpty($ExclusionListCSV)) {
+ $ExclusionListCSV = $ExclusionListCSV.Replace(',', '","')
+ }
+
+ $jsonBody = '{"EnvironmentType":"' + $EnvironmentName + '","ExclusionList":["' + $ExclusionListCSV + '"]}'
+ $response = Invoke-RestMethod -Uri "https://$Fqdn/Prod/GetAllDesignationsByEnvironmentType" -Headers @{"x-api-key"="$ApiGatewayKey"} -Method POST -body $jsonBody -ContentType "application/json"
+
+ return $response
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Common/Public/Invoke-GetAllHostsByDesignation.ps1 b/Modules/Alkami.DevOps.Common/Public/Invoke-GetAllHostsByDesignation.ps1
new file mode 100644
index 0000000..52b2b7e
--- /dev/null
+++ b/Modules/Alkami.DevOps.Common/Public/Invoke-GetAllHostsByDesignation.ps1
@@ -0,0 +1,59 @@
+function Invoke-GetAllHostsByDesignation {
+<#
+.SYNOPSIS
+This wraps the APIGateway/Lambda for scaling environments. Helper for the MinimizeEnvironment/MaximizeEnvironment functions.
+
+.DESCRIPTION
+Gets all EC2 instances where the alk:designation/alk:lane tag value = parameter Designation.
+
+.PARAMETER Fqdn
+Fully Qualified Domain Name of the API Gateway. Each environment/account has a unique API Gateway endpoint.
+
+.PARAMETER Designation
+Designation name to get instances by. Examples are 'ci1','Red17'. Case sensitive.
+
+.PARAMETER ApiGatewayKey
+Unique key for authenticating to the API Gateway.
+
+.EXAMPLE
+Invoke-GetAllHostsByDesignation -fqdn "vyq7hqcx55.execute-api.us-east-1.amazonaws.com" -designation "Red17" -apiGatewayKey "123456789"
+
+returns JSON:
+[
+ {
+ "IsExcluded": false,
+ "IsApp": false,
+ "IsMic": false,
+ "IsWeb": true,
+ "IsRunning": true,
+ "IsStopped": false,
+ "InstanceId": "i-0fa21749b4e4b81ea",
+ "Designation": "Red17",
+ "HostName": "web27425",
+ "Environment": "dev",
+ "Service": "orb"
+ }
+]
+.NOTES
+Dev FQDN : vyq7hqcx55.execute-api.us-east-1.amazonaws.com
+Staging FQDN : cox3133b67.execute-api.us-east-1.amazonaws.com
+Qa FQDN :
+#>
+ [CmdletBinding()]
+
+ Param (
+ [Parameter(Mandatory = $true)]
+ [string] $Fqdn,
+ [Parameter(Mandatory = $true)]
+ [string] $Designation,
+ [Parameter(Mandatory = $true)]
+ [string] $ApiGatewayKey
+ )
+
+ [System.Net.ServicePointManager]::SecurityProtocol = "Tls12"
+
+ $jsonBody = '{"Designation":"' + $Designation + '"}'
+ $response = Invoke-RestMethod -Uri "https://$Fqdn/Prod/GetAllHostsByDesignation" -Headers @{"x-api-key"="$ApiGatewayKey"} -Method POST -body $jsonBody -ContentType "application/json"
+
+ return $response
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Common/Public/Invoke-GetCurrentStatusByDesignation.ps1 b/Modules/Alkami.DevOps.Common/Public/Invoke-GetCurrentStatusByDesignation.ps1
new file mode 100644
index 0000000..b150dd0
--- /dev/null
+++ b/Modules/Alkami.DevOps.Common/Public/Invoke-GetCurrentStatusByDesignation.ps1
@@ -0,0 +1,59 @@
+function Invoke-GetCurrentStatusByDesignation {
+<#
+.SYNOPSIS
+This wraps the APIGateway/Lambda for scaling environments. Helper for the MinimizeDesignation/MaximizeDesignation functions.
+
+.DESCRIPTION
+Gets description of all EC2 instances where the alk:designation tag value = parameter Designation.
+
+.PARAMETER Fqdn
+Fully Qualified Domain Name of the API Gateway. Each environment/account has a unique API Gateway endpoint.
+
+.PARAMETER Designation
+alk:designation tag to filter which instances to query. Case sensitive.
+
+.PARAMETER ApiGatewayKey
+Unique key for authenticating to the API Gateway.
+
+.EXAMPLE
+Invoke-GetCurrentStatusByDesignation -fqdn "vyq7hqcx55.execute-api.us-east-1.amazonaws.com" -designation "ci1" -apiGatewayKey "123456789"
+
+returns JSON:
+[
+ {
+ "IsExcluded": false,
+ "IsApp": false,
+ "IsMic": false,
+ "IsWeb": true,
+ "IsRunning": true,
+ "IsStopped": false,
+ "InstanceId": "i-0fa21749b4e4b81ea",
+ "Designation": "ci1",
+ "HostName": "web27425",
+ "Environment": "dev",
+ "Service": "orb"
+ }
+]
+.NOTES
+Dev FQDN : vyq7hqcx55.execute-api.us-east-1.amazonaws.com
+Staging FQDN : cox3133b67.execute-api.us-east-1.amazonaws.com
+Qa FQDN :
+#>
+ [CmdletBinding()]
+
+ Param (
+ [Parameter(Mandatory = $true)]
+ [string] $Fqdn,
+ [Parameter(Mandatory = $true)]
+ [string] $Designation,
+ [Parameter(Mandatory = $true)]
+ [string] $ApiGatewayKey
+ )
+
+ [System.Net.ServicePointManager]::SecurityProtocol = "Tls12"
+
+ $jsonBody = '{"Designation":"' + $Designation + '"}'
+ $response = Invoke-RestMethod -Uri "https://$Fqdn/Prod/GetCurrentStatusByDesignation" -Headers @{"x-api-key"="$ApiGatewayKey"} -Method POST -body $jsonBody -ContentType "application/json"
+
+ return $response
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Common/Public/Invoke-GetCurrentStatusByHostnames.ps1 b/Modules/Alkami.DevOps.Common/Public/Invoke-GetCurrentStatusByHostnames.ps1
new file mode 100644
index 0000000..9b93d9c
--- /dev/null
+++ b/Modules/Alkami.DevOps.Common/Public/Invoke-GetCurrentStatusByHostnames.ps1
@@ -0,0 +1,62 @@
+function Invoke-GetCurrentStatusByHostnames {
+<#
+.SYNOPSIS
+This wraps the APIGateway/Lambda for scaling environments. Helper for the MinimizeDesignation/MaximizeDesignation functions.
+
+.DESCRIPTION
+Gets description of all EC2 instances where the alk:hostname tag value = parameter HostnamesCSV.
+
+.PARAMETER Fqdn
+Fully Qualified Domain Name of the API Gateway. Each environment/account has a unique API Gateway endpoint.
+
+.PARAMETER HostnamesCSV
+alk:hostname tag to filter which instances to query. Comma-seperated list. Case sensitive.
+
+.PARAMETER ApiGatewayKey
+Unique key for authenticating to the API Gateway.
+
+.EXAMPLE
+Invoke-GetCurrentStatusByHostnames -fqdn "vyq7hqcx55.execute-api.us-east-1.amazonaws.com" -hostnamesCsv "web27425" -apiGatewayKey "123456789"
+
+returns JSON:
+[
+ {
+ "IsExcluded": false,
+ "IsApp": false,
+ "IsMic": false,
+ "IsWeb": true,
+ "IsRunning": true,
+ "IsStopped": false,
+ "InstanceId": "i-0fa21749b4e4b81ea",
+ "Designation": "ci1",
+ "HostName": "web27425",
+ "Environment": "dev",
+ "Service": "orb"
+ }
+]
+.NOTES
+Dev FQDN : vyq7hqcx55.execute-api.us-east-1.amazonaws.com
+Staging FQDN : cox3133b67.execute-api.us-east-1.amazonaws.com
+Qa FQDN :
+#>
+ [CmdletBinding()]
+
+ Param (
+ [Parameter(Mandatory = $true)]
+ [string] $Fqdn,
+ [Parameter(Mandatory = $true)]
+ [string] $ApiGatewayKey,
+ [Parameter(Mandatory = $true)]
+ [string] $HostnamesCSV
+
+ )
+
+ [System.Net.ServicePointManager]::SecurityProtocol = "Tls12"
+
+ $HostnamesCSV = $HostnamesCSV.Replace(',', '","')
+
+ $jsonBody = '{"Hostnames":["' + $HostnamesCSV + '"]}'
+ $response = Invoke-RestMethod -Uri "https://$Fqdn/Prod/GetCurrentStatusByHostnames" -Headers @{"x-api-key"="$ApiGatewayKey"} -Method POST -body $jsonBody -ContentType "application/json"
+
+ return $response
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Common/Public/Invoke-GetDesignationExclusionsByEnvironmentType.ps1 b/Modules/Alkami.DevOps.Common/Public/Invoke-GetDesignationExclusionsByEnvironmentType.ps1
new file mode 100644
index 0000000..8769d89
--- /dev/null
+++ b/Modules/Alkami.DevOps.Common/Public/Invoke-GetDesignationExclusionsByEnvironmentType.ps1
@@ -0,0 +1,51 @@
+function Invoke-GetDesignationExclusionsByEnvironmentType {
+<#
+.SYNOPSIS
+This wraps the APIGateway/Lambda for scaling environments. Helper for determining excluded designations.
+
+.DESCRIPTION
+Gets designations by environment type that will be excluded from scale-down for that day.
+
+.PARAMETER Fqdn
+Fully Qualified Domain Name of the API Gateway. Each environment/account has a unique API Gateway endpoint.
+
+.PARAMETER EnvironmentType
+Environment name to get designations by. Examples are 'dev','staging','qa'. Case sensitive.
+
+.PARAMETER ApiGatewayKey
+Unique key for authenticating to the API Gateway.
+
+.EXAMPLE
+Invoke-GetDesignationExclusionsByEnvironmentType -fqdn "vyq7hqcx55.execute-api.us-east-1.amazonaws.com" -environmentType "dev" -apiGatewayKey "123456789"
+
+returns JSON:
+[
+ {
+ "Designation": "Ci1",
+ "Environment": "dev"
+ }
+]
+.NOTES
+Dev FQDN : vyq7hqcx55.execute-api.us-east-1.amazonaws.com
+Staging FQDN : cox3133b67.execute-api.us-east-1.amazonaws.com
+Qa FQDN :
+#>
+ [CmdletBinding()]
+
+ Param (
+ [Parameter(Mandatory = $true)]
+ [string] $Fqdn,
+ [Parameter(Mandatory = $true)]
+ [string] $ApiGatewayKey,
+ [Parameter(Mandatory = $true)]
+ [string] $EnvironmentType
+
+ )
+
+ [System.Net.ServicePointManager]::SecurityProtocol = "Tls12"
+
+ $jsonBody = '{"EnvironmentType":"' + $EnvironmentType + '"}'
+ $response = Invoke-RestMethod -Uri "https://$Fqdn/Prod/GetDesignationExclusionsByEnvironmentType" -Headers @{"x-api-key"="$ApiGatewayKey"} -Method POST -body $jsonBody -ContentType "application/json"
+
+ return $response
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Common/Public/Invoke-MaximizeDesignation.ps1 b/Modules/Alkami.DevOps.Common/Public/Invoke-MaximizeDesignation.ps1
new file mode 100644
index 0000000..8622048
--- /dev/null
+++ b/Modules/Alkami.DevOps.Common/Public/Invoke-MaximizeDesignation.ps1
@@ -0,0 +1,64 @@
+function Invoke-MaximizeDesignation {
+<#
+.SYNOPSIS
+This wraps the APIGateway/Lambda for scaling environments. Scales the environment up. Turns on all EC2 instances by designation.
+
+.DESCRIPTION
+Sends startup commands to EC2 instances where the alk:designation tag value = parameter Designation.
+
+.PARAMETER Fqdn
+Fully Qualified Domain Name of the API Gateway. Each environment/account has a unique API Gateway endpoint.
+
+.PARAMETER Designation
+alk:designation tag to filter which instances to query. Case sensitive.
+
+.PARAMETER ApiGatewayKey
+Unique key for authenticating to the API Gateway.
+
+.PARAMETER IsTest
+Switch to determine if it should actually send the commands to the EC2 instances, or just report back what it will do, without actually sending instance commands.
+
+.EXAMPLE
+Invoke-MaximizeDesignation -fqdn "vyq7hqcx55.execute-api.us-east-1.amazonaws.com" -designation "ci1" -apiGatewayKey "123456789" -isTest $true
+
+returns JSON:
+[
+ {
+ "IsExcluded": false,
+ "IsApp": false,
+ "IsMic": false,
+ "IsWeb": true,
+ "IsRunning": true,
+ "IsStopped": false,
+ "InstanceId": "i-0fa21749b4e4b81ea",
+ "Designation": "ci1",
+ "HostName": "web27425",
+ "Environment": "dev",
+ "Service": "orb"
+ }
+]
+.NOTES
+Dev FQDN : vyq7hqcx55.execute-api.us-east-1.amazonaws.com
+Staging FQDN : cox3133b67.execute-api.us-east-1.amazonaws.com
+Qa FQDN :
+#>
+ [CmdletBinding()]
+
+ Param (
+ [Parameter(Mandatory = $true)]
+ [string] $Fqdn,
+ [Parameter(Mandatory = $true)]
+ [string] $Designation,
+ [Parameter(Mandatory = $true)]
+ [string] $ApiGatewayKey,
+ [Parameter(Mandatory = $true)]
+ [switch] $IsTest
+ )
+
+ [System.Net.ServicePointManager]::SecurityProtocol = "Tls12"
+
+ $jsonBody = '{"Designation":"' + $Designation + '","IsTest":"' + $IsTest + '"}'
+ $response = Invoke-RestMethod -Uri "https://$Fqdn/Prod/MaximizeDesignation" -Headers @{"x-api-key"="$ApiGatewayKey"} -Method POST -body $jsonBody -ContentType "application/json"
+
+ return $response
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Common/Public/Invoke-MinimizeDesignation.ps1 b/Modules/Alkami.DevOps.Common/Public/Invoke-MinimizeDesignation.ps1
new file mode 100644
index 0000000..8c8fbaf
--- /dev/null
+++ b/Modules/Alkami.DevOps.Common/Public/Invoke-MinimizeDesignation.ps1
@@ -0,0 +1,75 @@
+function Invoke-MinimizeDesignation {
+<#
+.SYNOPSIS
+This wraps the APIGateway/Lambda for scaling environments. Scales the environment down to a 1x1x1.
+
+.DESCRIPTION
+Sends shutdown commands to EC2 instances where the alk:designation tag value = parameter Designation, keeping 1 each of App, Mic and Web running. 1x1x1.
+
+.PARAMETER Fqdn
+Fully Qualified Domain Name of the API Gateway. Each environment/account has a unique API Gateway endpoint.
+
+.PARAMETER Designation
+alk:designation tag to filter which instances to query. Case sensitive.
+
+.PARAMETER ApiGatewayKey
+Unique key for authenticating to the API Gateway.
+
+.PARAMETER IsTest
+Switch to determine if it should actually send the commands to the EC2 instances, or just report back what it will do, without actually sending instance commands.
+
+.PARAMETER ExclusionListCSV
+Comma-seperated list of alk:hostname strings to exclude from the shutdown command. Case sensitive.
+
+.EXAMPLE
+Invoke-MinimizeDesignation -fqdn "vyq7hqcx55.execute-api.us-east-1.amazonaws.com" -designation "ci1" -apiGatewayKey "123456789" -isTest $true -exclusionList "app2778144"
+
+returns JSON:
+[
+ {
+ "IsExcluded": false,
+ "IsApp": false,
+ "IsMic": false,
+ "IsWeb": true,
+ "IsRunning": false,
+ "IsStopped": true,
+ "InstanceId": "i-0fa21749b4e4b81ea",
+ "Designation": "ci1",
+ "HostName": "web27425",
+ "Environment": "dev",
+ "Service": "orb"
+ }
+]
+
+.NOTES
+Dev FQDN : vyq7hqcx55.execute-api.us-east-1.amazonaws.com
+Staging FQDN : cox3133b67.execute-api.us-east-1.amazonaws.com
+Qa FQDN :
+#>
+ [CmdletBinding()]
+
+ Param (
+ [Parameter(Mandatory = $true)]
+ [string] $Fqdn,
+ [Parameter(Mandatory = $true)]
+ [string] $Designation,
+ [Parameter(Mandatory = $true)]
+ [string] $ApiGatewayKey,
+ [Parameter(Mandatory = $true)]
+ [string] $IsTest,
+ [Parameter(Mandatory = $false)]
+ [AllowEmptyString()]
+ [string] $ExclusionListCSV
+ )
+
+ [System.Net.ServicePointManager]::SecurityProtocol = "Tls12"
+
+ $ExclusionListCSV = $ExclusionListCSV.Replace(',', '","')
+
+
+ $jsonBody = '{"Designation":"' + $Designation + '","ExclusionList":["' + $ExclusionListCSV + '"],"IsTest":"' + $IsTest + '"}'
+ #Write-Host "Calling Invoke-MinimizeEnvironment with: $jsonBody"
+ $response = Invoke-RestMethod -Uri "https://$Fqdn/Prod/MinimizeDesignation" -Headers @{"x-api-key" = $ApiGatewayKey} -Method POST -body $jsonBody -ContentType "application/json"
+
+ return $response
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Common/Public/Invoke-RemoveShutdownPendingTag.ps1 b/Modules/Alkami.DevOps.Common/Public/Invoke-RemoveShutdownPendingTag.ps1
new file mode 100644
index 0000000..fe5469e
--- /dev/null
+++ b/Modules/Alkami.DevOps.Common/Public/Invoke-RemoveShutdownPendingTag.ps1
@@ -0,0 +1,63 @@
+function Invoke-RemoveShutdownPendingTag {
+ <#
+ .SYNOPSIS
+ This wraps the APIGateway/Lambda for scaling environments. Removes "alk:autoshutdown = pending" tag from hostnames.
+
+ .DESCRIPTION
+ Removes the tag "alk:autoshutdown = pending" from hosts passed in.
+
+ .PARAMETER Fqdn
+ Fully Qualified Domain Name of the API Gateway. Each environment/account has a unique API Gateway endpoint.
+
+ .PARAMETER HostnamesCSV
+ alk:hostname tag to filter which instances to query. Comma-seperated list. Case sensitive.
+
+ .PARAMETER ApiGatewayKey
+ Unique key for authenticating to the API Gateway.
+
+ .EXAMPLE
+ $hosts = @("app2775216","app2776249")
+ $hostsCsv = ($hosts -join ',')
+ Invoke-RemoveShutdownPendingTag -fqdn "vyq7hqcx55.execute-api.us-east-1.amazonaws.com" -hostnamesCsv $hostsCsv -apiGatewayKey "123456789"
+
+ returns JSON:
+ [
+ {
+ "IsExcluded": false,
+ "IsApp": false,
+ "IsMic": false,
+ "IsWeb": true,
+ "IsRunning": true,
+ "IsStopped": false,
+ "InstanceId": "i-0fa21749b4e4b81ea",
+ "Designation": "ci1",
+ "HostName": "web27425",
+ "Environment": "dev",
+ "Service": "orb"
+ }
+ ]
+ .NOTES
+ Dev FQDN : vyq7hqcx55.execute-api.us-east-1.amazonaws.com
+ Staging FQDN : cox3133b67.execute-api.us-east-1.amazonaws.com
+ Qa FQDN :
+ #>
+ [CmdletBinding()]
+
+ Param (
+ [Parameter(Mandatory = $true)]
+ [string] $Fqdn,
+ [Parameter(Mandatory = $true)]
+ [string] $ApiGatewayKey,
+ [Parameter(Mandatory = $true)]
+ [string] $HostnamesCSV
+ )
+
+ [System.Net.ServicePointManager]::SecurityProtocol = "Tls12"
+
+ $HostnamesCSV = $HostnamesCSV.Replace(',', '","')
+
+ $jsonBody = '{"Hostnames":["' + $HostnamesCSV + '"]}'
+ $response = Invoke-RestMethod -Uri "https://$Fqdn/Prod/RemoveShutdownPendingTag" -Headers @{"x-api-key"="$ApiGatewayKey"} -Method POST -body $jsonBody -ContentType "application/json"
+
+ return $response
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Common/Public/Invoke-StartDesignation.ps1 b/Modules/Alkami.DevOps.Common/Public/Invoke-StartDesignation.ps1
new file mode 100644
index 0000000..cb57087
--- /dev/null
+++ b/Modules/Alkami.DevOps.Common/Public/Invoke-StartDesignation.ps1
@@ -0,0 +1,59 @@
+function Invoke-StartDesignation {
+ <#
+ .SYNOPSIS
+ This wraps the APIGateway/Lambda for scaling environments. Starts all instances by Designation.
+
+ .DESCRIPTION
+ Will start all instances by Designation tag.
+
+ .PARAMETER Fqdn
+ Fully Qualified Domain Name of the API Gateway. Each environment/account has a unique API Gateway endpoint.
+
+ .PARAMETER Designation
+ alk:designation tag to filter which instances to query. Case sensitive.
+
+ .PARAMETER ApiGatewayKey
+ Unique key for authenticating to the API Gateway.
+
+ .EXAMPLE
+ Invoke-StartDesignation -fqdn "vyq7hqcx55.execute-api.us-east-1.amazonaws.com" -designation "ci1" -apiGatewayKey "123456789"
+
+ returns JSON:
+ [
+ {
+ "IsExcluded": false,
+ "IsApp": false,
+ "IsMic": false,
+ "IsWeb": true,
+ "IsRunning": true,
+ "IsStopped": false,
+ "InstanceId": "i-0fa21749b4e4b81ea",
+ "Designation": "ci1",
+ "HostName": "web27425",
+ "Environment": "dev",
+ "Service": "orb"
+ }
+ ]
+ .NOTES
+ Dev FQDN : vyq7hqcx55.execute-api.us-east-1.amazonaws.com
+ Staging FQDN : cox3133b67.execute-api.us-east-1.amazonaws.com
+ Qa FQDN :
+ #>
+ [CmdletBinding()]
+
+ Param (
+ [Parameter(Mandatory = $true)]
+ [string] $Fqdn,
+ [Parameter(Mandatory = $true)]
+ [string] $Designation,
+ [Parameter(Mandatory = $true)]
+ [string] $ApiGatewayKey
+ )
+
+ [System.Net.ServicePointManager]::SecurityProtocol = "Tls12"
+
+ $jsonBody = '{"Designation":"' + $Designation + '"}'
+ $response = Invoke-RestMethod -Uri "https://$Fqdn/Prod/StartDesignation" -Headers @{"x-api-key"="$ApiGatewayKey"} -Method POST -body $jsonBody -ContentType "application/json"
+
+ return $response
+ }
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Common/Public/Invoke-StartEnvironment.ps1 b/Modules/Alkami.DevOps.Common/Public/Invoke-StartEnvironment.ps1
new file mode 100644
index 0000000..4b39976
--- /dev/null
+++ b/Modules/Alkami.DevOps.Common/Public/Invoke-StartEnvironment.ps1
@@ -0,0 +1,59 @@
+function Invoke-StartEnvironment {
+ <#
+ .SYNOPSIS
+ This wraps the APIGateway/Lambda for scaling environments. Starts all instances by environment name, eg. Dev,Qa,Staging.
+
+ .DESCRIPTION
+ Will start all instances by environment name.
+
+ .PARAMETER Fqdn
+ Fully Qualified Domain Name of the API Gateway. Each environment/account has a unique API Gateway endpoint.
+
+ .PARAMETER EnvironmentName
+ Environment name to get instances by. Examples are 'dev','staging','qa'. Case sensitive.
+
+ .PARAMETER ApiGatewayKey
+ Unique key for authenticating to the API Gateway.
+
+ .EXAMPLE
+ Invoke-StartEnvironment -fqdn "vyq7hqcx55.execute-api.us-east-1.amazonaws.com" -environmentName "dev" -apiGatewayKey "123456789"
+
+ returns JSON:
+ [
+ {
+ "IsExcluded": false,
+ "IsApp": false,
+ "IsMic": false,
+ "IsWeb": true,
+ "IsRunning": true,
+ "IsStopped": false,
+ "InstanceId": "i-0fa21749b4e4b81ea",
+ "Designation": "ci1",
+ "HostName": "web27425",
+ "Environment": "dev",
+ "Service": "orb"
+ }
+ ]
+ .NOTES
+ Dev FQDN : vyq7hqcx55.execute-api.us-east-1.amazonaws.com
+ Staging FQDN : cox3133b67.execute-api.us-east-1.amazonaws.com
+ Qa FQDN :
+ #>
+ [CmdletBinding()]
+
+ Param (
+ [Parameter(Mandatory = $true)]
+ [string] $Fqdn,
+ [Parameter(Mandatory = $true)]
+ [string] $EnvironmentName,
+ [Parameter(Mandatory = $true)]
+ [string] $ApiGatewayKey
+ )
+
+ [System.Net.ServicePointManager]::SecurityProtocol = "Tls12"
+
+ $jsonBody = '{"EnvironmentType":"' + $EnvironmentName + '"}'
+ $response = Invoke-RestMethod -Uri "https://$Fqdn/Prod/StartEnvironment" -Headers @{"x-api-key"="$ApiGatewayKey"} -Method POST -body $jsonBody -ContentType "application/json"
+
+ return $response
+ }
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Common/Public/Invoke-StartServers.ps1 b/Modules/Alkami.DevOps.Common/Public/Invoke-StartServers.ps1
new file mode 100644
index 0000000..64e1895
--- /dev/null
+++ b/Modules/Alkami.DevOps.Common/Public/Invoke-StartServers.ps1
@@ -0,0 +1,62 @@
+function Invoke-StartServers {
+<#
+.SYNOPSIS
+This wraps the APIGateway/Lambda for scaling environments. Helper for the MinimizeDesignation/MaximizeDesignation functions.
+
+.DESCRIPTION
+Sends Start command to all EC2 instances where the alk:hostname tag value = parameter HostnamesCSV.
+
+.PARAMETER Fqdn
+Fully Qualified Domain Name of the API Gateway. Each environment/account has a unique API Gateway endpoint.
+
+.PARAMETER HostnamesCSV
+alk:hostname tag to filter which instances to start. Comma-seperated list. Case sensitive.
+
+.PARAMETER ApiGatewayKey
+Unique key for authenticating to the API Gateway.
+
+.EXAMPLE
+Invoke-StartServers -fqdn "vyq7hqcx55.execute-api.us-east-1.amazonaws.com" -hostnamesCsv "web27425" -apiGatewayKey "123456789"
+
+returns JSON:
+[
+ {
+ "IsExcluded": false,
+ "IsApp": false,
+ "IsMic": false,
+ "IsWeb": true,
+ "IsRunning": true,
+ "IsStopped": false,
+ "InstanceId": "i-0fa21749b4e4b81ea",
+ "Designation": "ci1",
+ "HostName": "web27425",
+ "Environment": "dev",
+ "Service": "orb"
+ }
+]
+.NOTES
+Dev FQDN : vyq7hqcx55.execute-api.us-east-1.amazonaws.com
+Staging FQDN : cox3133b67.execute-api.us-east-1.amazonaws.com
+Qa FQDN :
+#>
+ [CmdletBinding()]
+
+ Param (
+ [Parameter(Mandatory = $true)]
+ [string] $Fqdn,
+ [Parameter(Mandatory = $true)]
+ [string] $ApiGatewayKey,
+ [Parameter(Mandatory = $true)]
+ [string] $HostnamesCSV
+
+ )
+
+ [System.Net.ServicePointManager]::SecurityProtocol = "Tls12"
+
+ $HostnamesCSV = $HostnamesCSV.Replace(',', '","')
+
+ $jsonBody = '{"Hostnames":["' + $HostnamesCSV + '"]}'
+ $response = Invoke-RestMethod -Uri "https://$Fqdn/Prod/StartServers" -Headers @{"x-api-key"="$ApiGatewayKey"} -Method POST -body $jsonBody -ContentType "application/json"
+
+ return $response
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Common/Public/Invoke-StopDesignation.ps1 b/Modules/Alkami.DevOps.Common/Public/Invoke-StopDesignation.ps1
new file mode 100644
index 0000000..bd3769d
--- /dev/null
+++ b/Modules/Alkami.DevOps.Common/Public/Invoke-StopDesignation.ps1
@@ -0,0 +1,59 @@
+function Invoke-StopDesignation {
+ <#
+ .SYNOPSIS
+ This wraps the APIGateway/Lambda for scaling environments. Stops all instances by Designation.
+
+ .DESCRIPTION
+ Will stop all instances by Designation tag except those that are configured to not be stopped.
+
+ .PARAMETER Fqdn
+ Fully Qualified Domain Name of the API Gateway. Each environment/account has a unique API Gateway endpoint.
+
+ .PARAMETER Designation
+ alk:designation tag to filter which instances to query. Case sensitive.
+
+ .PARAMETER ApiGatewayKey
+ Unique key for authenticating to the API Gateway.
+
+ .EXAMPLE
+ Invoke-StopDesignation -fqdn "vyq7hqcx55.execute-api.us-east-1.amazonaws.com" -designation "ci1" -apiGatewayKey "123456789"
+
+ returns JSON:
+ [
+ {
+ "IsExcluded": false,
+ "IsApp": false,
+ "IsMic": false,
+ "IsWeb": true,
+ "IsRunning": true,
+ "IsStopped": false,
+ "InstanceId": "i-0fa21749b4e4b81ea",
+ "Designation": "ci1",
+ "HostName": "web27425",
+ "Environment": "dev",
+ "Service": "orb"
+ }
+ ]
+ .NOTES
+ Dev FQDN : vyq7hqcx55.execute-api.us-east-1.amazonaws.com
+ Staging FQDN : cox3133b67.execute-api.us-east-1.amazonaws.com
+ Qa FQDN :
+ #>
+ [CmdletBinding()]
+
+ Param (
+ [Parameter(Mandatory = $true)]
+ [string] $Fqdn,
+ [Parameter(Mandatory = $true)]
+ [string] $Designation,
+ [Parameter(Mandatory = $true)]
+ [string] $ApiGatewayKey
+ )
+
+ [System.Net.ServicePointManager]::SecurityProtocol = "Tls12"
+
+ $jsonBody = '{"Designation":"' + $Designation + '"}'
+ $response = Invoke-RestMethod -Uri "https://$Fqdn/Prod/StopDesignation" -Headers @{"x-api-key"="$ApiGatewayKey"} -Method POST -body $jsonBody -ContentType "application/json"
+
+ return $response
+ }
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Common/Public/Invoke-StopEnvironment.ps1 b/Modules/Alkami.DevOps.Common/Public/Invoke-StopEnvironment.ps1
new file mode 100644
index 0000000..4cfc220
--- /dev/null
+++ b/Modules/Alkami.DevOps.Common/Public/Invoke-StopEnvironment.ps1
@@ -0,0 +1,59 @@
+function Invoke-StopEnvironment {
+ <#
+ .SYNOPSIS
+ This wraps the APIGateway/Lambda for scaling environments. Stops all instances by environment name, eg. Dev,Qa,Staging.
+
+ .DESCRIPTION
+ Will stop all instances by environment name except those that are configured to not be stopped.
+
+ .PARAMETER Fqdn
+ Fully Qualified Domain Name of the API Gateway. Each environment/account has a unique API Gateway endpoint.
+
+ .PARAMETER EnvironmentName
+ Environment name to get instances by. Examples are 'dev','staging','qa'. Case sensitive.
+
+ .PARAMETER ApiGatewayKey
+ Unique key for authenticating to the API Gateway.
+
+ .EXAMPLE
+ Invoke-StopEnvironment -fqdn "vyq7hqcx55.execute-api.us-east-1.amazonaws.com" -environmentName "dev" -apiGatewayKey "123456789"
+
+ returns JSON:
+ [
+ {
+ "IsExcluded": false,
+ "IsApp": false,
+ "IsMic": false,
+ "IsWeb": true,
+ "IsRunning": true,
+ "IsStopped": false,
+ "InstanceId": "i-0fa21749b4e4b81ea",
+ "Designation": "ci1",
+ "HostName": "web27425",
+ "Environment": "dev",
+ "Service": "orb"
+ }
+ ]
+ .NOTES
+ Dev FQDN : vyq7hqcx55.execute-api.us-east-1.amazonaws.com
+ Staging FQDN : cox3133b67.execute-api.us-east-1.amazonaws.com
+ Qa FQDN :
+ #>
+ [CmdletBinding()]
+
+ Param (
+ [Parameter(Mandatory = $true)]
+ [string] $Fqdn,
+ [Parameter(Mandatory = $true)]
+ [string] $EnvironmentName,
+ [Parameter(Mandatory = $true)]
+ [string] $ApiGatewayKey
+ )
+
+ [System.Net.ServicePointManager]::SecurityProtocol = "Tls12"
+
+ $jsonBody = '{"EnvironmentType":"' + $EnvironmentName + '"}'
+ $response = Invoke-RestMethod -Uri "https://$Fqdn/Prod/StopEnvironment" -Headers @{"x-api-key"="$ApiGatewayKey"} -Method POST -body $jsonBody -ContentType "application/json"
+
+ return $response
+ }
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Common/Public/Invoke-StopServers.ps1 b/Modules/Alkami.DevOps.Common/Public/Invoke-StopServers.ps1
new file mode 100644
index 0000000..4f3b8be
--- /dev/null
+++ b/Modules/Alkami.DevOps.Common/Public/Invoke-StopServers.ps1
@@ -0,0 +1,62 @@
+function Invoke-StopServers {
+<#
+.SYNOPSIS
+This wraps the APIGateway/Lambda for scaling environments. Helper for the MinimizeDesignation/MaximizeDesignation functions.
+
+.DESCRIPTION
+Sends Shutdown command to all EC2 instances where the alk:hostname tag value = parameter HostnamesCSV.
+
+.PARAMETER Fqdn
+Fully Qualified Domain Name of the API Gateway. Each environment/account has a unique API Gateway endpoint.
+
+.PARAMETER HostnamesCSV
+alk:hostname tag to filter which instances to stop. Comma-seperated list. Case sensitive.
+
+.PARAMETER ApiGatewayKey
+Unique key for authenticating to the API Gateway.
+
+.EXAMPLE
+Invoke-StartServers -fqdn "vyq7hqcx55.execute-api.us-east-1.amazonaws.com" -hostnamesCsv "web27425" -apiGatewayKey "123456789"
+
+returns JSON:
+[
+ {
+ "IsExcluded": false,
+ "IsApp": false,
+ "IsMic": false,
+ "IsWeb": true,
+ "IsRunning": false,
+ "IsStopped": true,
+ "InstanceId": "i-0fa21749b4e4b81ea",
+ "Designation": "ci1",
+ "HostName": "web27425",
+ "Environment": "dev",
+ "Service": "orb"
+ }
+]
+.NOTES
+Dev FQDN : vyq7hqcx55.execute-api.us-east-1.amazonaws.com
+Staging FQDN : cox3133b67.execute-api.us-east-1.amazonaws.com
+Qa FQDN :
+#>
+ [CmdletBinding()]
+
+ Param (
+ [Parameter(Mandatory = $true)]
+ [string] $Fqdn,
+ [Parameter(Mandatory = $true)]
+ [string] $ApiGatewayKey,
+ [Parameter(Mandatory = $true)]
+ [string] $HostnamesCSV
+
+ )
+
+ [System.Net.ServicePointManager]::SecurityProtocol = "Tls12"
+
+ $HostnamesCSV = $HostnamesCSV.Replace(',', '","')
+
+ $jsonBody = '{"Hostnames":["' + $HostnamesCSV + '"]}'
+ $response = Invoke-RestMethod -Uri "https://$Fqdn/Prod/StopServers" -Headers @{"x-api-key"="$ApiGatewayKey"} -Method POST -body $jsonBody -ContentType "application/json"
+
+ return $response
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Common/Public/New-AlkamiServiceFabricClusterByTag.ps1 b/Modules/Alkami.DevOps.Common/Public/New-AlkamiServiceFabricClusterByTag.ps1
new file mode 100644
index 0000000..2769c34
--- /dev/null
+++ b/Modules/Alkami.DevOps.Common/Public/New-AlkamiServiceFabricClusterByTag.ps1
@@ -0,0 +1,77 @@
+function New-AlkamiServiceFabricClusterByTag {
+
+<#
+.SYNOPSIS
+ Creates a service fabric cluster with by pod tag.
+ Security group prefix is 'Stage' for Staging, 'DEV' for QA. Production is generally pod[X]
+
+.PARAMETER pod
+ The pod tag designation for the cluster.
+.PARAMETER securityGroupPrefix
+ The security group prefix for the GMSA accounts. 'Stage' for Staging, 'DEV' for Dev/QA
+ Production usually follows the convention 'pod[X]'
+.PARAMETER adminUsers
+ The AD groups/users who are allowed access to the service fabric admin functionality on the CLI and Dashboard.
+.PARAMETER minBootstrapPhase
+ The minimum bootstrap phase of servers that are allowed to join the cluster. 95 (at time of writing) is the ServiceFabric bootstrap phase.
+.PARAMETER timeout
+ The amount of time (in minutes) this command will poll for valid servers to join into the cluster.
+#>
+
+ [CmdletBinding()]
+ Param(
+ [Parameter(Mandatory = $true)]
+ [string]$pod,
+ [Parameter(Mandatory = $true)]
+ [string]$securityGroupPrefix,
+ [Parameter(Mandatory = $true)]
+ [string[]]$adminUsers,
+ [Parameter(Mandatory = $false)]
+ [string[]]$clientUsers,
+ [Parameter(Mandatory = $false)]
+ [string]$role = "app:fab",
+ [Parameter(Mandatory = $false)]
+ [int]$minServers = 5,
+ [Parameter(Mandatory = $false)]
+ [int]$minBootstrapPhase = 95,
+ [Parameter(Mandatory = $false)]
+ [int]$timeout = 15,
+
+ [Parameter(Mandatory = $false)]
+ [string]$ProfileName,
+
+ [Parameter(Mandatory = $false)]
+ [string]$Region
+
+ )
+
+ $loglead = (Get-LogLeadName)
+
+ Import-AWSModule # SSM
+
+ # Grab the fab server hostnames for the pod.
+ $serverNames = Get-AlkamiServiceFabricHostnamesByTag -designation $pod -role $role -minServers $minServers -minBootstrapPhase $minBootstrapPhase -timeout $timeout;
+
+ # Create a Service Fabric Cluster.
+ $splatParams = @{}
+
+ if (!([string]::IsNullOrEmpty($ProfileName))) {
+ $splatParams["ProfileName"] = "$ProfileName"
+ }
+
+ if (!([string]::IsNullOrEmpty($Region))) {
+ $splatParams["Region"] = "$Region"
+ }
+ $user = (Get-SSMParameter -Name "account-cicd-username" -WithDecryption $true @splatParams).Value;
+ $password = ((Get-SSMParameter -Name "account-cicd-password" -WithDecryption $true @splatParams).Value | ConvertTo-SecureString -AsPlainText -Force);
+ $credential = New-Object System.Management.Automation.PSCredential ($user, $password);
+
+ try {
+ Enter-PSSession -Credential $credential -ComputerName "."
+
+ New-AlkamiServiceFabricCluster -servers $serverNames -securityGroupPrefix $securityGroupPrefix -adminUsers $adminUsers -clientUsers $clientUsers;
+ Write-Host "$loglead : Cluster creation complete."
+ } finally {
+ Exit-PSSession;
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Common/Public/New-WebTierMachineConfigAppSettings.ps1 b/Modules/Alkami.DevOps.Common/Public/New-WebTierMachineConfigAppSettings.ps1
new file mode 100644
index 0000000..c9603b3
--- /dev/null
+++ b/Modules/Alkami.DevOps.Common/Public/New-WebTierMachineConfigAppSettings.ps1
@@ -0,0 +1,81 @@
+function New-WebTierMachineConfigAppSettings {
+<#
+.SYNOPSIS
+ Upserts Web Tier Machine Config App Settings.
+#>
+
+ [CmdletBinding()]
+ Param()
+
+ $logLead = (Get-LogLeadName);
+
+ [XML]$machineConfig = Read-MachineConfig
+ [System.Xml.XmlElement]$configRoot = $machineConfig.configuration
+ $machineConfigIsDirty = $false
+
+ $appSettingsNode = $configRoot.SelectSingleNode("//appSettings")
+
+ if ($null -eq $appSettingsNode) {
+ Write-Verbose "$logLead : Creating appSettings Node"
+ $appSettingsNode = $machineConfig.CreateElement("appSettings")
+ $configRoot.AppendChild($appSettingsNode) | Out-Null
+ }
+
+
+ # Add App Settings
+ foreach ($setting in $webTierAppSettings) {
+ # Some settings are derived values which depend on other settings being instrumented.
+ # We will change the value to REPLACEME here if the dependent values are not properly set so that they're skipped
+ if ($setting.Name -eq "ReportServerUrl" -and ($webTierAppSettings | Where-Object {$_.Name -eq "ReportServer"}).Value -eq "REPLACEME") {
+ $setting.Value = "REPLACEME"
+ }
+ elseif ($setting.Name -eq "ReportServerPath" -and $setting.Value -eq "/" -and [String]::IsNullOrEmpty([Environment]::GetEnvironmentVariable("POD", "Machine"))) {
+ $setting.Value = "REPLACEME"
+ }
+
+ $appSetting = $appSettingsNode.SelectSingleNode(("//add[@key='{0}']" -f $setting.Name))
+
+ if ($null -eq $appSetting) {
+ Write-Output ("$logLead : Adding {0} node with value {1}" -f $setting.Name, $setting.Value)
+ $appSettingElement = $machineConfig.CreateElement("add")
+ $appSettingElement.SetAttribute("key", $setting.Name)
+
+ if ($setting.Value -eq "REPLACEME") {
+ Write-Warning ("$logLead : Value read as REPLACEME. Node {0} will be added with an empty value" -f $setting.Name)
+ $appSettingElement.SetAttribute("value", "")
+ }
+ else {
+ $appSettingElement.SetAttribute("value", $setting.Value)
+ }
+
+ $appSettingsNode.AppendChild($appSettingElement) | Out-Null
+ $machineConfigIsDirty = $true
+ }
+ elseif ($appSetting.Attributes["value"].Value -ne $setting.Value) {
+ if ($setting.Value -ne "REPLACEME" -and $setting.Name -ne "ReportServerPath") {
+ Write-Output ("$logLead : Update setting {0} value from {1} to {2}" -f $setting.Name, $appSetting.Attributes["value"].Value, $setting.Value)
+ $appSetting.SetAttribute("value", $setting.Value)
+ $machineConfigIsDirty = $true
+ }
+ elseif ($setting.Value -ne "REPLACEME" -and $setting.Name -eq "ReportServerPath") {
+ Write-Warning ("$logLead : The ReportServerPath setting exists but does not follow convention. Please confirm the value is correct. Value in machine.config: {0}. Value in script: {1}" -f $appSetting.Attributes["value"].Value, $setting.Value)
+ }
+ else {
+ Write-Warning ("$logLead : Value read as REPLACEME. Node {0} will not be updated" -f $setting.Name)
+ }
+ }
+ else {
+ Write-Verbose ("$logLead : AppSetting {0} already exists with correct value" -f $setting.Name)
+ }
+ }
+
+ if ($machineConfigIsDirty) {
+ Write-Output ("$logLead : Saving Modified machine.config")
+ $machineConfig.Save($machineConfigPath)
+ }
+ else {
+ Write-Output ("$logLead : No changes required to the machine.config")
+ }
+}
+
+Set-Alias -name Create-WebTierMachineConfigAppSettings -value New-WebTierMachineConfigAppSettings;
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Common/Public/Publish-MessageToSlack.ps1 b/Modules/Alkami.DevOps.Common/Public/Publish-MessageToSlack.ps1
new file mode 100644
index 0000000..e5a5e67
--- /dev/null
+++ b/Modules/Alkami.DevOps.Common/Public/Publish-MessageToSlack.ps1
@@ -0,0 +1,163 @@
+function Publish-MessageToSlack {
+<#
+.SYNOPSIS
+ Posts a message to Slack.
+
+.DESCRIPTION
+ Posts a message to Slack.
+
+.PARAMETER Channels
+ Array of channels to post to, such as @("@username","#some-channel-name")
+
+.PARAMETER SlackHookUrl
+ Slack webhook url
+
+.PARAMETER SlackParameters
+ Hashtable of "messagebody" parameters. Cannot be used in conjunction with
+ MessageText, UserName and IconEmoji. This is used when the caller wants to
+ build their own message body JSON.
+
+.PARAMETER SlackAttachments
+ Hashtable of Slack message attachments to be added to the "messagebody"
+
+.PARAMETER MessageText
+ String of simple message text. If this is populated, MessageBody must be
+ left empty or MessageBody will be used instead. This is used in combination
+ with UserName and IconEmoji to construct a simple message JSON object. Caller
+ does not have to build JSON object to send a simple message.
+
+.EXAMPLE
+ $chan = @("@trowton","#eng-sre-ci-cd-alerts")
+ $buildlog = "http://duckduckgo.com"
+ $fallback = "View the Build: $buildLog"
+ $actions = @(
+ @{
+ "type" = "button"
+ "text" = "Build Log"
+ "url" = $buildLog
+ }
+ )
+ $attachments = @{
+ fallback = $fallback
+ color = "#009EFF"
+ actions = $actions
+ }
+ $params = @{
+ "username"="someuser";"icon_emoji"=":teamcity:";"text"="Text [with square brackets] and a period."
+ }
+
+ Publish-MessageToSlack -Channels $chan -SlackHookUrl $slackHookUrl -MessageBody $params -SlackAttachments $attachments
+
+ Publish-MessageToSlack -Channels $chan -SlackHookUrl $slackHookUrl -MessageText "sampletext" -IconEmoji ":smiley:" -Username "TC job" -SlackAttachments $attachments
+
+.LINK
+ https://api.slack.com/methods/chat.postMessage
+
+.NOTES
+ Either MessageBody or MessageText must be populated to send a message. If MessageBody
+ is populated, it will take precedence over MessageText.
+#>
+ [CmdletBinding()]
+ [OutputType([void])]
+ param(
+ [Parameter(Mandatory = $True)]
+ [array]$Channels,
+ [Parameter(Mandatory = $True)]
+ [string]$SlackHookUrl,
+ [Parameter(Mandatory = $False)]
+ [Alias ("MessageBody")]
+ [hashtable]$SlackParameters,
+ [Parameter(Mandatory = $False)]
+ [hashtable]$SlackAttachments,
+ [Parameter(Mandatory = $False)]
+ [string] $MessageText,
+ [Parameter(Mandatory = $False)]
+ [string] $Username = "Publish Message",
+ [Parameter(Mandatory = $False)]
+ [string] $IconEmoji = ":slack:"
+ )
+
+ $loglead = (Get-LogLeadName)
+
+ # Test if either MessageBody and MessageText are populated. One must be populated.
+ if($null -eq $SlackParameters -and [string]::IsNullOrWhiteSpace($MessageText)) {
+ throw "SlackParameters or MessageText must be populated to send a message!"
+ }
+
+ # Test if both MessageBody and MessageText are populated.
+ if($null -ne $SlackParameters -and !([string]::IsNullOrWhiteSpace($MessageText))) {
+ Write-Host "$loglead Both SlackParameters and MessageText parameters are not null. Choosing SlackParameters as message body."
+ }
+
+ # If MessageText is populated and SlackParameters is null, build a body.
+ if($null -eq $SlackParameters) {
+ Write-Verbose "$loglead SlackParameters is null, using MessageText [$MessageText], UserName [$UserName] and Icon [$IconEmoji] to send message."
+ $messageBody = @{
+ "username" = "$Username";
+ "text" = "$MessageText";
+ "icon_emoji" = "$IconEmoji";
+ }
+ } else {
+ # Parameters are a direct mapping to the message body.
+ $messageBody = $SlackParameters
+ }
+
+ # Add attachments to message body, if they're provided.
+ if ($null -ne $SlackAttachments) {
+ $messageBody["attachments"] = @($SlackAttachments)
+ }
+
+ # Test for required keys in attachments.
+ if($messageBody["attachments"].Count -gt 0) {
+ $hasFallback = $false
+ foreach ($attachment in $messageBody["attachments"]) {
+ foreach ($aKey in $attachment.Keys) {
+ if ($aKey -eq "fallback") {
+ $hasFallback = $true
+ }
+ }
+ }
+
+ if ($hasFallback -eq $false) {
+ throw "Missing required parameter key 'fallback' on 'SlackAttachments'."
+ }
+ }
+
+ # Test for 'text' key in MessageBody.
+ $hasText = $false
+ foreach ($param in $messageBody) {
+ foreach ($key in $param.Keys) {
+ if ($key -eq "text") {
+ $hasText = $true
+ }
+ }
+ }
+
+ if ($hasText -eq $false) {
+ throw "Missing required parameter key 'text' on 'SlackParameters'."
+ }
+
+ # Check $channels for commas to split on.
+ if ($Channels[0].Contains(",")) {
+ Write-Host "$loglead : Channels was supplied as a comma delimited string, not an array. Splitting..."
+ $sanitizedChannels = $Channels[0].Split(",")
+ } else {
+ $sanitizedChannels = $Channels
+ }
+
+ # Send message to each specified channel(s).
+ foreach ($channel in $sanitizedChannels) {
+ $messageBody["channel"] = $channel;
+ $messageJson = ($messageBody | ConvertTo-Json -Depth 100).Replace('\\r\\n', '\r\n')
+ Write-Verbose "$loglead : Posting JSON:"
+ Write-Verbose $messageJson
+
+ try {
+ (Invoke-RestMethod -Uri $SlackHookUrl -Method POST -Body $messageJson -ContentType "application/json") | Out-Null
+ } catch {
+ $errorMessage = $_.Exception.Message
+ Write-Warning "$logLead : Could not post message to Slack.`nError message is: $errorMessage"
+ # TODO: Metric this so we know that errors occurred.
+ }
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Common/Public/Publish-MessageToSlack.tests.ps1 b/Modules/Alkami.DevOps.Common/Public/Publish-MessageToSlack.tests.ps1
new file mode 100644
index 0000000..3b87e73
--- /dev/null
+++ b/Modules/Alkami.DevOps.Common/Public/Publish-MessageToSlack.tests.ps1
@@ -0,0 +1,260 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ""
+
+Describe "Publish-MessageToSlack" {
+ Context "When Sending Messages To Multiple Channels " {
+ It "Calls Invoke-RestMethod multiple times" {
+ $channels = @(
+ "#testChannel1",
+ "#testChannel2"
+ )
+
+ $params = @{
+ "username" = "@user1"
+ "icon_emoji" = ":smiley:"
+ "text" = "sampletext"
+ }
+ Mock -ModuleName $moduleForMock Invoke-RestMethod { return $true }
+
+ Publish-MessageToSlack -channels $channels -slackHookUrl "www.dummyurl.com" -MessageBody $params
+
+ Assert-MockCalled Invoke-RestMethod -Times 2 -ModuleName $moduleForMock
+ }
+ }
+
+ Context "When Message Body Does Not Contain Required Values" {
+ It "Should Throw" {
+ $channels = @(
+ "#testChannel1",
+ "#testChannel2"
+ )
+
+ # No text value, which is required
+ $params = @{
+ "username" = "@user1"
+ "icon_emoji" = ":smiley:"
+ }
+
+ Mock -ModuleName $moduleForMock Invoke-RestMethod { return $true }
+
+ { Publish-MessageToSlack -channels $channels -slackHookUrl "www.dummyurl.com" -slackParameters $params } | Should -Throw "Missing required parameter key 'text' on 'SlackParameters'."
+ }
+ }
+
+ Context "When Message Body And Message Text Are Both Empty" {
+ It "Should Throw" {
+ $channels = @(
+ "#testChannel1",
+ "#testChannel2"
+ )
+
+ Mock -ModuleName $moduleForMock Invoke-RestMethod { return $true }
+
+ { Publish-MessageToSlack -channels $channels -slackHookUrl "www.dummyurl.com" } | Should -Throw "SlackParameters or MessageText must be populated to send a message!"
+ }
+ }
+
+ Context "When Attachment List Is Invalid" {
+ It "Should Throw" {
+ $channels = @(
+ "#testChannel1",
+ "#testChannel2"
+ )
+
+ # Actions.url must be in a valid http format. Can't just add "sometext".
+ $actions = @(
+ @{
+ "type" = "button";
+ "text" = "Nginx Server";
+ "url" = "http://google.com"
+ }
+ )
+
+ # Attachment is missing fallback, which is required.
+ $params = @{
+ "username" = "@user1"
+ "icon_emoji" = ":smiley:"
+ "text" = "I am a test with an invalid attachment"
+ "attachments" = @(
+ @{
+ color = "green";
+ actions = $actions
+ }
+ )
+ }
+
+ Mock -ModuleName $moduleForMock Invoke-RestMethod { return $true }
+
+ { Publish-MessageToSlack -channels $channels -slackHookUrl "www.dummyurl.com" -slackParameters $params } | Should -Throw "Missing required parameter key 'fallback' on 'SlackAttachments'."
+ }
+ }
+}
+
+Context "When Sending Message is Successful" {
+ It "Should Not Throw" {
+ $channels = @(
+ "#testChannel1",
+ "#testChannel2"
+ )
+
+ $params = @{
+ "username" = "@user1"
+ "icon_emoji" = ":smiley:"
+ "text" = "sampletext"
+ }
+ # Actions.url must be in a valid http format. Can't just add "sometext".
+ $buildLog = "https://google.com"
+ $fallback = "View the Build: $buildLog"
+ $actions = @(
+ @{
+ "type" = "button"
+ "text" = "Build Log"
+ "url" = $buildLog
+ }
+ )
+ $attachments = @{
+ fallback = $fallback
+ color = "#009EFF"
+ actions = $actions
+ }
+ Mock -ModuleName $moduleForMock Invoke-RestMethod { return $true }
+ Mock Write-Host -ModuleName $moduleForMock `
+ -ParameterFilter { $Object -like "*Both SlackParameters and MessageText parameters are not null. Choosing SlackParameters as message body." } `
+ -MockWith { }
+
+ { Publish-MessageToSlack -channels $channels -slackHookUrl "www.dummyurl.com" -MessageBody $params -SlackAttachments $attachments } | Should -Not -Throw
+
+ Assert-MockCalled Write-Host -Exactly 0 -ModuleName $moduleForMock -Scope It
+ }
+
+ It "Should Not Throw" {
+ $channels = @(
+ "#testChannel1",
+ "#testChannel2"
+ )
+ Mock -ModuleName $moduleForMock Invoke-RestMethod { return $true }
+ Mock Write-Host -ModuleName $moduleForMock `
+ -ParameterFilter { $Object -like "*Both SlackParameters and MessageText parameters are not null. Choosing SlackParameters as message body." } `
+ -MockWith { }
+
+ { Publish-MessageToSlack -channels $channels -slackHookUrl "www.dummyurl.com" -MessageText "sample text" -IconEmoji ":slack:" -Username "someuser" } | Should -Not -Throw
+
+ Assert-MockCalled Write-Host -Exactly 0 -ModuleName $moduleForMock -Scope It
+ }
+
+ It "Should Not Throw" {
+ $channels = @(
+ "#testChannel1",
+ "#testChannel2"
+ )
+
+ $params = @{
+ "username" = "@user1"
+ "icon_emoji" = ":smiley:"
+ "text" = "sampletext"
+ }
+ Mock -ModuleName $moduleForMock Invoke-RestMethod { return $true }
+ Mock Write-Host -ModuleName $moduleForMock `
+ -ParameterFilter { $Object -like "*Both SlackParameters and MessageText parameters are not null. Choosing SlackParameters as message body." } `
+ -MockWith { }
+
+ { Publish-MessageToSlack -channels $channels -slackHookUrl "www.dummyurl.com" -MessageBody $params -MessageText "sample text" -IconEmoji ":slack:" -Username "someuser" } | Should -Not -Throw
+
+ Assert-MockCalled Write-Host -Exactly 1 -ModuleName $moduleForMock -Scope It
+ }
+
+ It "Should Call Invoke-RestMethod" {
+ $channels = @(
+ "#testChannel1",
+ "#testChannel2"
+ )
+
+ $params = @{
+ "username" = "@user1"
+ "icon_emoji" = ":smiley:"
+ "text" = "sampletext"
+ }
+ Mock -ModuleName $moduleForMock Invoke-RestMethod { return $true }
+
+ Publish-MessageToSlack -channels $channels -slackHookUrl "www.dummyurl.com" -MessageBody $params
+
+ Assert-MockCalled Invoke-RestMethod -ModuleName $moduleForMock
+ }
+}
+
+Context "When Setting Both MessageBody And MessageText" {
+ It "Should Not Throw" {
+ $channels = @(
+ "#testChannel1",
+ "#testChannel2"
+ )
+
+ $params = @{
+ "username" = "@user1"
+ "icon_emoji" = ":smiley:"
+ "text" = "sampletext"
+ }
+
+ $messageText = "sampletext"
+
+ Mock -ModuleName $moduleForMock Invoke-RestMethod { return $true }
+ Mock Write-Host -ModuleName $moduleForMock `
+ -ParameterFilter { $Object -like "*Both SlackParameters and MessageText parameters are not null. Choosing SlackParameters as message body." } `
+ -MockWith { }
+
+ { Publish-MessageToSlack -channels $channels -slackHookUrl "www.dummyurl.com" -MessageBody $params -MessageText $messageText } | Should -Not -Throw
+ }
+
+ It "Should Write Override Output" {
+ $channels = @(
+ "#testChannel1",
+ "#testChannel2"
+ )
+
+ $params = @{
+ "username" = "@user1"
+ "icon_emoji" = ":smiley:"
+ "text" = "sampletext"
+ }
+
+ $messageText = "sampletext"
+
+ Mock -ModuleName $moduleForMock Invoke-RestMethod { return $true }
+ Mock Write-Host -ModuleName $moduleForMock `
+ -ParameterFilter { $Object -like "*Both SlackParameters and MessageText parameters are not null. Choosing SlackParameters as message body." } `
+ -MockWith { }
+
+ Publish-MessageToSlack -channels $channels -slackHookUrl "www.dummyurl.com" -MessageBody $params -MessageText $messageText
+
+ Assert-MockCalled Write-Host -Exactly 1 -ModuleName $moduleForMock -Scope It
+ }
+
+ It "Should Call Invoke-RestMethod" {
+ $channels = @(
+ "#testChannel1",
+ "#testChannel2"
+ )
+
+ $params = @{
+ "username" = "@user1"
+ "icon_emoji" = ":smiley:"
+ "text" = "sampletext"
+ }
+
+ $messageText = "sampletext"
+
+ Mock -ModuleName $moduleForMock Invoke-RestMethod { return $true }
+ Mock Write-Host -ModuleName $moduleForMock `
+ -ParameterFilter { $Object -like "*Both SlackParameters and MessageText parameters are not null. Choosing SlackParameters as message body." } `
+ -MockWith { }
+
+ Publish-MessageToSlack -channels $channels -slackHookUrl "www.dummyurl.com" -MessageBody $params -MessageText $messageText
+
+ Assert-MockCalled Invoke-RestMethod -ModuleName $moduleForMock
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Common/Public/Reset-ASInstanceHealth.ps1 b/Modules/Alkami.DevOps.Common/Public/Reset-ASInstanceHealth.ps1
new file mode 100644
index 0000000..0e65b4d
--- /dev/null
+++ b/Modules/Alkami.DevOps.Common/Public/Reset-ASInstanceHealth.ps1
@@ -0,0 +1,89 @@
+function Reset-ASInstanceHealth {
+<#
+.SYNOPSIS
+ Method to reset instance health status as reported to its auto scale group.
+
+.DESCRIPTION
+ Will attempt to set instance health to "Healthy" for all servers passed in. Will sleep for the defined wait period (2 minutes default)
+ and query again the status. If any are still reporting as "Unhealthy" then this will Write-Error.
+
+.PARAMETER Servers
+ List of servers to work with. Usually grouped by App, Web. Assumes all servers passed in are in the same ASG. Assumes all servers have
+ "".fh.local" appended to the hostname.
+
+.PARAMETER SkipHealthCheck
+ Switch to skip the 2 minute wait period and return immediately following the set operation.
+
+.PARAMETER WaitSeconds
+ Number of seconds to wait. Default if not provided is 120 seconds (2 minutes.)
+#>
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory = $true)]
+ [Alias('Computers')]
+ [string[]]$Servers,
+
+ [Parameter(Mandatory = $true)]
+ [string]$ProfileName,
+
+ [Parameter(Mandatory = $false)]
+ [switch]$SkipHealthCheck = $false,
+
+ [Parameter(Mandatory = $false)]
+ [Alias('Sleep')]
+ [int]$WaitSeconds = 120
+ )
+
+ $allHealthy = $true
+ $logLead = (Get-LogLeadName)
+ $instanceHash = @{}
+
+ Import-AWSModule # AS
+
+ # Test each server for "Healthy" ASG status and set to "Healthy" if it's not.
+ foreach ($computerName in $servers) {
+ Write-Verbose "$logLead : Attempting to Get Computer $computerName ASG status."
+ # $getHealthStatusScriptBlock
+ $region = Get-AwsRegionByHostname -ComputerName $computerName
+ $currentInstance = Get-EC2InstancesByHostname -Servers $computerName -ProfileName $ProfileName
+ $instanceHash += @{$computerName = $currentInstance }
+ $asInstance = Get-ASAutoScalingInstance -InstanceId $currentInstance.InstanceId -Region $region -ProfileName $ProfileName
+ Write-Verbose "$logLead : $computerName status is $($asInstance.HealthStatus)"
+
+ if ($asInstance.HealthStatus -ne "HEALTHY") {
+ Write-Verbose "$logLead : $computerName is not well. Setting to Healthy status."
+ Set-ASInstanceHealth -InstanceId $currentInstance.InstanceId -HealthStatus "Healthy" -ProfileName $ProfileName -Region $region -Force
+ $allHealthy = $false
+ }
+ }
+
+ # Take a nap if not all healthy. This allows the ASG to re-report any that switch back to "Unhealthy".
+ if (!$allHealthy -and !$SkipHealthCheck) {
+ Write-Host "$logLead : Not all instances are reporting Healthy. Setting to Healthy status and sleeping for $waitSeconds seconds."
+ Start-Sleep -Seconds $WaitSeconds
+ Write-Host "$logLead : Checking again for health status."
+
+ # Array to hold any errors.
+ $errors = @()
+
+ # Check again for "Healthy" ASG status on all servers.
+ foreach ($computerName in $servers) {
+ Write-Verbose "$logLead : Attempting to Get Computer $computerName ASG status."
+ $currentInstance = $instanceHash.$computerName
+ $actionResult = Get-ASAutoScalingInstance -InstanceId $currentInstance.InstanceId -Region $region -ProfileName $ProfileName
+ Write-Verbose "$logLead : $computerName status is $($actionResult.HealthStatus)"
+
+ if ($actionResult.HealthStatus -ne "HEALTHY") {
+ # All-stop when one switches back to "Unhealthy".
+ $errors += "$logLead : Computer $computerName is not in a Healthy status as reported to the ASG."
+ }
+ Write-Verbose "$logLead : $computerName status is $actionResult"
+ }
+
+ if (!(Test-IsCollectionNullOrEmpty $errors)) {
+ Write-Warning "Error(s) found"
+ $allErrors = $errors -Join ","
+ throw $allErrors
+ }
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Common/Public/Select-AvailableWinRmHosts.ps1 b/Modules/Alkami.DevOps.Common/Public/Select-AvailableWinRmHosts.ps1
new file mode 100644
index 0000000..b579270
--- /dev/null
+++ b/Modules/Alkami.DevOps.Common/Public/Select-AvailableWinRmHosts.ps1
@@ -0,0 +1,46 @@
+function Select-AvailableWinRmHosts {
+ <#
+ .SYNOPSIS
+ Returns a filtered list of servers that are responding to WinRM requests.
+
+ .PARAMETER ComputerName
+ List of servers to test WinRM connections against.
+
+ .PARAMETER ReturnBadServers
+ Returns the list of servers that could not be connected to.
+ #>
+ [CmdletBinding()]
+ param(
+ [string[]]$ComputerName,
+ [switch]$ReturnBadServers
+ )
+
+ if(Test-IsCollectionNullOrEmpty $ComputerName) {
+ return $null
+ }
+
+ $results = Invoke-Parallel -objects $ComputerName -returnObjects -numThreads 32 -script {
+ param($server)
+
+ # Invoke a simple script block to verify that WinRM is listening.
+ try {
+ $success = (1 -eq (Invoke-Command -ComputerName $server -ErrorAction Stop -ScriptBlock { return 1 }))
+ if($success) {
+ return $server
+ } else {
+ return $null
+ }
+ } catch {
+ Write-Warning "Select-AvailableWinRMHosts: Could not connect to [$server]`n$_"
+ return $null
+ }
+ }
+ $results = ($results | Where-Object { $null -ne $_ })
+
+ # Produce a list of the unavailable servers from the available servers.
+ if($ReturnBadServers.IsPresent) {
+ $results = $ComputerName | Where-Object { $results -notcontains $_ }
+ }
+
+ return $results
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Common/Public/Send-ObjectAsHtmlTable.ps1 b/Modules/Alkami.DevOps.Common/Public/Send-ObjectAsHtmlTable.ps1
new file mode 100644
index 0000000..bcc891b
--- /dev/null
+++ b/Modules/Alkami.DevOps.Common/Public/Send-ObjectAsHtmlTable.ps1
@@ -0,0 +1,73 @@
+function Send-ObjectAsHtmlTable {
+<#
+.SYNOPSIS
+Sends a formatted email representing a given powershell object.
+
+.DESCRIPTION
+Translates a powershell object into an html table, keeping the ordering of the
+properties (if they were ordered). Joins the object html to an html template
+located in the modules file list and emails it to the specified recipients.
+
+.PARAMETER ToAddress
+[string[]]One or more email address to send the powershell object to.
+
+.PARAMETER FromAddress
+[string]The from address for this email, defaults to 'SreAlerts@alkamitech.com'
+
+.PARAMETER Subject
+[string] The subject of the email you wish to send
+
+.PARAMETER SMTPUsername
+[string] The username which has access to the mail server.
+
+.PARAMETER SMTPPassword
+[string] The password for the user that has access to the mail server.
+
+.PARAMETER SMTPServer
+[string] The url of the SMTPServer
+
+.EXAMPLE
+Send-ObjectAsHtmlTable -ToAddress "toUser@alkamitech.com" -Subject "Something Happened" -InputObject (get-process | Select-Object -First 1 Handles,ProcessName) `
+ -SMTPUsername "user@smtp.com -SMTPPassword "anSmtpPassword" -SMTPServer "smtp.someServer.org"
+
+Will translate the input object and converting it's properties, in this case 'Handles' and 'ProcessName' into fields for an html table.
+Then join the html table to an email template and send to the user specified in the to address.
+#>
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory=$True)]
+ [string[]]$ToAddress,
+ [Parameter()]
+ [string]$FromAddress = "SreAlerts@alkamitech.com",
+ [Parameter(Mandatory=$True)]
+ [string]$Subject,
+ [Parameter(Mandatory=$True)]
+ [object[]]$InputObject,
+ [Parameter(Mandatory=$True)]
+ [string]$SMTPUsername,
+ [Parameter(Mandatory=$True)]
+ [SecureString]$SMTPPassword,
+ [Parameter(Mandatory=$True)]
+ [string]$SMTPServer
+ )
+ begin{
+ $Credential = New-Object -TypeName "System.Management.Automation.PSCredential" -ArgumentList $SMTPUsername, $SMTPPassword
+ if(!$Credential){throw "Credentials for sending mail message could not be created"}
+
+ $HtmlTemplatePath = $MyInvocation.MyCommand.Module.FileList | Where-Object {$_ -match "ObjectAsTableTemplate.html"}
+ if(!(Test-Path $HtmlTemplatePath)){throw "HTML Template could not be found at path $HtmlTemplatePath"}
+ $HtmlTemplate = Get-Content $HtmlTemplatePath | Out-String
+ }
+ process{
+
+ $headers = $InputObject | Select-Object -first 1 | ForEach-Object {$_.psobject.Properties | ForEach-Object {$_.Name}}
+ $width = "width:$(100/ $headers.Count)%"
+ $headerData = $headers | ForEach-Object {"$_ | "}
+ $bodyData = $InputObject | ForEach-Object {"`n";$obj = $_;$headers | ForEach-Object {"`n$($obj.$_) | "};"`n
"}
+
+ $HtmlTemplate = $HtmlTemplate.Replace("[TableHeaderData]",$headerData)
+ $HtmlTemplate = $HtmlTemplate.Replace("[TableBodyData]",$bodyData)
+
+ Send-MailMessage -Body $HtmlTemplate -BodyAsHtml -from $FromAddress -To $ToAddress -Subject $Subject -UseSsl -Credential $Credential -Port 587 -SmtpServer $SMTPServer
+ }
+}
diff --git a/Modules/Alkami.DevOps.Common/Public/Test-IsTeamCityProcess.ps1 b/Modules/Alkami.DevOps.Common/Public/Test-IsTeamCityProcess.ps1
new file mode 100644
index 0000000..fa9d1e2
--- /dev/null
+++ b/Modules/Alkami.DevOps.Common/Public/Test-IsTeamCityProcess.ps1
@@ -0,0 +1,25 @@
+function Test-IsTeamCityProcess {
+<#
+.SYNOPSIS
+ Determines if the Current Process is a TeamCity Agent Process.
+
+.NOTES
+ Will not work except when running in a direct agent process. Checks for TeamCity agent environment
+ variables to exist.
+#>
+
+ [CmdletBinding()]
+ Param()
+
+ $logLead = (Get-LogLeadName);
+
+ $teamCityJREVariable = $ENV:TEAMCITY_JRE
+
+ if ([String]::IsNullOrEmpty($teamCityJREVariable)) {
+
+ Write-Verbose "$logLead : TEAMCITY_JRE User Environment Variable Not Found"
+ return $false
+ }
+
+ return $true
+}
diff --git a/Modules/Alkami.DevOps.Common/Resources/Formatters/ConvertTo-AwsCredentialEntry.ps1xml b/Modules/Alkami.DevOps.Common/Resources/Formatters/ConvertTo-AwsCredentialEntry.ps1xml
new file mode 100644
index 0000000..27e3713
--- /dev/null
+++ b/Modules/Alkami.DevOps.Common/Resources/Formatters/ConvertTo-AwsCredentialEntry.ps1xml
@@ -0,0 +1,109 @@
+
+
+
+
+
+ AlkamiFormatters
+
+
+
+ AwsCredentialEntry
+
+
+
+
+
+
+
+ DefaultAWSCredentialEntryView
+
+
+
+ AWSTypes
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Name
+
+
+
+
+ role_arn
+
+
+
+
+ mfa_serial
+
+
+
+
+ region
+
+
+
+
+ source_profile
+
+
+
+
+ credential_process
+
+
+
+
+ output
+
+
+
+
+
+
+
+
+
diff --git a/Modules/Alkami.DevOps.Common/Resources/ObjectAsTableTemplate.html b/Modules/Alkami.DevOps.Common/Resources/ObjectAsTableTemplate.html
new file mode 100644
index 0000000..0af3128
--- /dev/null
+++ b/Modules/Alkami.DevOps.Common/Resources/ObjectAsTableTemplate.html
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+
+
+
+
+ [TableHeaderData]
+
+ [TableBodyData]
+
+
+
+
diff --git a/Modules/Alkami.DevOps.Common/tools/chocolateyInstall.ps1 b/Modules/Alkami.DevOps.Common/tools/chocolateyInstall.ps1
new file mode 100644
index 0000000..d36dca2
--- /dev/null
+++ b/Modules/Alkami.DevOps.Common/tools/chocolateyInstall.ps1
@@ -0,0 +1,67 @@
+[CmdletBinding()]
+Param()
+process {
+ $myCurrentPath = $PSScriptRoot;
+ Write-Verbose "Installing the Module from $myCurrentPath";
+
+ ######
+ ## Begin Very Special Logic
+ ######
+
+ ## Kill Alkami.DevOps.Deployment if it exists. This is a legacy concern
+
+ $pathToKill = "c:\Program Files\WindowsPowerShell\Modules\Alkami.DevOps.Deployment";
+ if (Test-Path $pathToKill) {
+ Remove-Item $pathToKill -Recurse -Force;
+ }
+
+ $chocoInstallPath = Get-ChocolateyInstallPath
+ $pathToKill = Join-Path $chocoInstallPath "lib\Alkami.DevOps.Deployment"
+ if (Test-Path $pathToKill) {
+ Remove-Item $pathToKill -Recurse -Force;
+ }
+
+ ######
+ ## End Very Special Logic
+ ######
+
+
+
+ $parentPath = (Split-Path $myCurrentPath);
+ $systemModulePath = "C:\Program Files\WindowsPowerShell\Modules\";
+ $myModulePath = (Join-Path $parentPath "module");
+
+ $metadata = ([Xml](Get-Content (Join-Path $parentPath "*.nuspec"))).package.metadata;
+
+ $id = $metadata.id;
+
+ $version = $metadata.version -replace '-pre.+','';
+
+ $targetModulePath = (Join-Path $systemModulePath $id);
+ $targetModuleVersionPath = (Join-Path $targetModulePath $version);
+
+ if (Test-Path $targetModulePath) {
+ ## If the target folder already existed, remove it, because we are re-installing this package, obviously
+ if (Test-Path $targetModuleVersionPath) {
+ Write-Warning "Found an already existing module at [$targetModuleVersionPath]!!"
+ Remove-Item $targetModuleVersionPath -Recurse -Force;
+ }
+
+ ## Clear previous children for name conflicts
+ (Get-ChildItem $targetModulePath) | ForEach-Object {
+ Write-Information "Removing module located at [$_]";
+ Remove-Item $_.FullName -Recurse -Force;
+ }
+ }
+
+ Write-Host "Copying module $id to [$targetModuleVersionPath]";
+ Copy-Item $myModulePath -Destination $targetModuleVersionPath -Recurse -Force;
+
+ $resourcesFolder = (Join-Path $parentPath "Resources")
+ if (Test-Path $resourcesFolder) {
+
+ Write-Host "Copying resources folder for module $id to [$targetModuleVersionPath]"
+ Copy-Item $resourcesFolder -Destination $targetModuleVersionPath -Recurse -Force
+ }
+
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Common/tools/chocolateyUninstall.ps1 b/Modules/Alkami.DevOps.Common/tools/chocolateyUninstall.ps1
new file mode 100644
index 0000000..7c36766
--- /dev/null
+++ b/Modules/Alkami.DevOps.Common/tools/chocolateyUninstall.ps1
@@ -0,0 +1,25 @@
+[CmdletBinding()]
+Param()
+process {
+ $myCurrentPath = $PSScriptRoot;
+ Write-Verbose "Uninstalling the Module from $myCurrentPath";
+
+ $parentPath = (Split-Path $myCurrentPath);
+ $systemModulePath = "C:\Program Files\WindowsPowerShell\Modules\";
+ $myModulePath = (Join-Path $parentPath "module");
+
+ $metadata = ([Xml](Get-Content (Join-Path $parentPath "*.nuspec"))).package.metadata;
+
+ $id = $metadata.id;
+
+ $version = $metadata.version -replace '-pre.+','';
+
+ $targetModulePath = (Join-Path $systemModulePath $id);
+ $targetModuleVersionPath = (Join-Path $targetModulePath $version);
+
+ if (Test-Path $targetModuleVersionPath) {
+ Write-Information "Removing module at [$targetModuleVersionPath]!!"
+ Remove-Item $targetModuleVersionPath -Recurse -Force;
+ }
+}
+
diff --git a/Modules/Alkami.DevOps.Installation/Alkami.DevOps.Installation.nuspec b/Modules/Alkami.DevOps.Installation/Alkami.DevOps.Installation.nuspec
new file mode 100644
index 0000000..26ec659
--- /dev/null
+++ b/Modules/Alkami.DevOps.Installation/Alkami.DevOps.Installation.nuspec
@@ -0,0 +1,39 @@
+
+
+
+ Alkami.DevOps.Installation
+ $version$
+ Alkami Platform Modules - DevOps - Installation
+ Alkami Technologies
+ Alkami Technologies
+ https://extranet.alkamitech.com/display/ORB/Alkami.DevOps.Installation
+ https://www.alkami.com/files/alkamilogo75x75.png
+ http://alkami.com/files/orblicense.html
+ false
+ Installs the DevOps Installation module for use with PowerShell.
+
+ PowerShell
+ Copyright (c) 2018 Alkami Technologies
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Modules/Alkami.DevOps.Installation/Alkami.DevOps.Installation.psd1 b/Modules/Alkami.DevOps.Installation/Alkami.DevOps.Installation.psd1
new file mode 100644
index 0000000..08d8ed4
--- /dev/null
+++ b/Modules/Alkami.DevOps.Installation/Alkami.DevOps.Installation.psd1
@@ -0,0 +1,21 @@
+@{
+ RootModule = 'Alkami.DevOps.Installation.psm1'
+ ModuleVersion = '3.25.0'
+ GUID = 'dc9924e9-7646-4e11-ba59-48d6a715617c'
+ Author = 'SRE,dsage,cbrand'
+ CompanyName = 'Alkami Technologies, Inc.'
+ Copyright = '(c) 2018 Alkami Technologies, Inc.. All rights reserved.'
+ Description = 'A set of functions to configure servers and install the ORB application'
+ RequiredModules = 'Alkami.PowerShell.Common', 'Alkami.PowerShell.IIS', 'Alkami.DevOps.Common', 'Alkami.DevOps.Certificates', 'Alkami.PowerShell.Configuration', 'Alkami.PowerShell.AD', 'Alkami.PowerShell.Services', 'Alkami.Ops.SecretServer', 'Alkami.Ops.Common'
+ FunctionsToExport = 'Add-OldHotfixPackagesToUninstallList','Add-OverflowCustomAttribute','Copy-NewRelicCustomInstrumentationFiles','Get-BadPackages','Get-ClientWebSiteInformationFromDatabase','Get-EnvironmentData','Get-MicroserviceNewRelicMapping','Get-NewRelicAccountDetails','Get-NewRelicYamlPath','Get-PackageServerMapping','Get-ServerPackageInformation','Install-NewRelicDotNetAgent','Install-NewRelicInfrastructure','Install-NewRelicServerMonitor','Install-ORB','Install-ORBAppServer','Install-ORBWebServer','New-DummyPackageInstallationData','New-PackageMetadataObject','New-WebTierWebSites','Read-AppTierSecrets','Read-WebTierSecrets','Remove-OutdatedNewHotfixPackages','Set-InfrastructureConfiguration','Set-NewRelicConfigurationValues','Set-NewRelicDeployment','Set-RapidFailSettings','Set-ServiceAccountValue','Submit-DeploymentToNewRelic','Uninstall-NewRelicDotNetAgent','Update-Borg','Write-PackageList'
+ AliasesToExport = 'Create-WebTierWebSites','Load-AppTierSecrets','Load-WebTierSecrets'
+ HelpInfoURI = 'https://extranet.alkamitech.com/display/SRE/Alkami.DevOps.Installation+Module'
+ FileList = @('.\NewRelicCustomInstrumentation\CustomInstrumentation.xml', '.\NewRelicCustomInstrumentation\RemoveActivity.xml')
+ PrivateData = @{
+ PSData = @{
+ Tags = @('powershell', 'module', 'installation')
+ ProjectUri = 'Https://extranet.alkamitech.com/display/SRE/Alkami.DevOps.Installation+Module'
+ IconUri = 'https://www.alkami.com/files/alkamilogo75x75.png'
+ }
+ }
+}
diff --git a/Modules/Alkami.DevOps.Installation/Alkami.DevOps.Installation.pssproj b/Modules/Alkami.DevOps.Installation/Alkami.DevOps.Installation.pssproj
new file mode 100644
index 0000000..3515589
--- /dev/null
+++ b/Modules/Alkami.DevOps.Installation/Alkami.DevOps.Installation.pssproj
@@ -0,0 +1,90 @@
+
+
+
+ Debug
+ 2.0
+ {3e38f1af-8451-4617-a24c-941552efc43a}
+ Exe
+ MyApplication
+ MyApplication
+ Alkami.DevOps.Installation
+
+ Invoke-Pester;
+ ..\build-project.ps1 (Join-Path $(SolutionDir) "Alkami.DevOps.Installation")
+
+
+ true
+ full
+ false
+ bin\Debug\
+ DEBUG;TRACE
+ prompt
+ 4
+
+
+ pdbonly
+ true
+ bin\Release\
+ TRACE
+ prompt
+ 4
+
+
+
+ Alkami.Ops.Common
+ {fa9745dd-68ac-4194-9c33-acf19411d357}
+ True
+
+
+ Alkami.DevOps.Common
+ {1bd8fc22-5882-4d5c-8128-81f1d61f8d77}
+ True
+
+
+ Alkami.DevOps.Operations
+ {6cafc0c6-a428-4d30-a9f9-700e829fea51}
+ True
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Installation/AlkamiManifest.xml b/Modules/Alkami.DevOps.Installation/AlkamiManifest.xml
new file mode 100644
index 0000000..d995be3
--- /dev/null
+++ b/Modules/Alkami.DevOps.Installation/AlkamiManifest.xml
@@ -0,0 +1,12 @@
+
+
+ 1.0
+
+ Alkami
+ Alkami.DevOps.Installation
+ SREModule
+
+
+ Production
+
+
diff --git a/Modules/Alkami.DevOps.Installation/NewRelicCustomInstrumentation/CustomInstrumentation.xml b/Modules/Alkami.DevOps.Installation/NewRelicCustomInstrumentation/CustomInstrumentation.xml
new file mode 100644
index 0000000..832e456
--- /dev/null
+++ b/Modules/Alkami.DevOps.Installation/NewRelicCustomInstrumentation/CustomInstrumentation.xml
@@ -0,0 +1,711 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Installation/NewRelicCustomInstrumentation/RemoveActivity.xml b/Modules/Alkami.DevOps.Installation/NewRelicCustomInstrumentation/RemoveActivity.xml
new file mode 100644
index 0000000..623cb0e
--- /dev/null
+++ b/Modules/Alkami.DevOps.Installation/NewRelicCustomInstrumentation/RemoveActivity.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Installation/Private/Set-AppTierGMSAAccounts.ps1 b/Modules/Alkami.DevOps.Installation/Private/Set-AppTierGMSAAccounts.ps1
new file mode 100644
index 0000000..e91cad4
--- /dev/null
+++ b/Modules/Alkami.DevOps.Installation/Private/Set-AppTierGMSAAccounts.ps1
@@ -0,0 +1,58 @@
+function Set-AppTierGMSAAccounts {
+<#
+.SYNOPSIS
+ Sets App Tier GMS Accounts.
+#>
+
+ [CmdletBinding()]
+ Param(
+ [Parameter(Mandatory = $true)]
+ [Alias("PodGMSAAccount")]
+ [string]$podGMSAAccountParent
+ )
+
+ $DEFAULTVALUE = "DEFAULTVALUE"
+
+ Write-Host "If a warning happens next for [The AppSetting with Key Environment.UserPrefix could not be found], you can ignore that completely"
+ ## We want to set the value if it does not exist, because eventually all machines should have this configured.
+ ## This pairs with Alkami.PowerShell.Configuration\Get-AppServiceAccountName to go in the local web.config or app.config
+ ## Only write it as we consume it.
+ if ($null -eq (Get-AppSetting -appSettingKey "Environment.UserPrefix")) {
+ Write-Verbose "Adding Environment.UserPrefix to match this function"
+ Set-AppSetting -key "Environment.UserPrefix" -Value $podGMSAAccountParent
+ ## This is so we can use this later as ($domain)\(Get-AppSetting -appSettingKey "Environment.UserPrefix").$MatrixLookup[appName]$
+ ## see also Get-AppServiceAccountName
+ }
+
+ $applicationsDictionary = @{
+ 'AuditService' = 'fh\DEFAULTVALUE.audit$';
+ 'BankService' = 'fh\DEFAULTVALUE.bank$';
+ 'ContentService' = 'fh\DEFAULTVALUE.content$';
+ 'CoreService' = 'fh\DEFAULTVALUE.core$';
+ 'ExceptionService' = 'fh\DEFAULTVALUE.exception$';
+ 'MessageCenterService' = 'fh\DEFAULTVALUE.msgctr$';
+ 'NagConfigurationService' = 'fh\DEFAULTVALUE.nag$';
+ 'NotificationService' = 'fh\DEFAULTVALUE.notify$';
+ 'RP-STS' = 'fh\DEFAULTVALUE.rpsts$';
+ 'SchedulerService' = 'fh\DEFAULTVALUE.schedule$';
+ 'SecurityManagementService' = 'fh\DEFAULTVALUE.secmgr$';
+ 'STSConfiguration' = 'fh\DEFAULTVALUE.stsconf$';
+ 'SymConnectMultiplexer' = 'fh\DEFAULTVALUE.multiplx$';
+ 'Alkami Radium Scheduler Service' = 'fh\DEFAULTVALUE.radium$';
+ 'Alkami Nag Service' = 'fh\DEFAULTVALUE.nag$';
+ }
+
+ foreach ($appTierApplication in $appTierApplications) {
+ $appName = $appTierApplication.Name
+ $newUserName = $applicationsDictionary[$appName] -replace $DEFAULTVALUE,$podGMSAAccountParent
+ $appTierApplication.User = $newUserName
+ Write-Host "$($appTierApplication.Name) : $($appTierApplication.User)"
+ }
+
+ foreach ($appTierService in (Get-AppTierServices)) {
+ $appName = $appTierService.Name
+ $newUserName = $applicationsDictionary[$appName] -replace $DEFAULTVALUE,$podGMSAAccountParent
+ $appTierService.User = $newUserName
+ Write-Host "$($appTierService.Name) : $($appTierService.User)"
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Installation/Private/VariableDeclarations.ps1 b/Modules/Alkami.DevOps.Installation/Private/VariableDeclarations.ps1
new file mode 100644
index 0000000..5f79653
--- /dev/null
+++ b/Modules/Alkami.DevOps.Installation/Private/VariableDeclarations.ps1
@@ -0,0 +1 @@
+[System.Reflection.Assembly]::LoadWithPartialName("System.Xml") | Out-Null
diff --git a/Modules/Alkami.DevOps.Installation/Public/Add-OldHotfixPackagesToUninstallList.ps1 b/Modules/Alkami.DevOps.Installation/Public/Add-OldHotfixPackagesToUninstallList.ps1
new file mode 100644
index 0000000..1328dfa
--- /dev/null
+++ b/Modules/Alkami.DevOps.Installation/Public/Add-OldHotfixPackagesToUninstallList.ps1
@@ -0,0 +1,187 @@
+function Add-OldHotfixPackagesToUninstallList {
+
+ <#
+ .SYNOPSIS
+ Adds hotfix packages which need to be removed to the package uninstall list.
+
+ .PARAMETER DependencyReleaseValue
+ The value of the orb release being classified.
+
+ .PARAMETER PackageMetadata
+ Packages object to populate.
+
+ .PARAMETER DebugMetadata
+ Meta object used to determine what goes where.
+ #>
+
+ [CmdletBinding()]
+ Param(
+ [Parameter(Mandatory = $false)]
+ [string]$DependencyReleaseValue = "",
+
+ [Parameter(Mandatory = $true)]
+ [PSObject] $PackageMetadata,
+
+ [Parameter(Mandatory = $true)]
+ [PSObject] $DebugMetadata
+ )
+ $loglead = Get-LogleadName
+
+ $releaseVersion = $null
+ $releaseVersionIsCanonical = $false
+
+ # We now run both orb and package deploys through here. So we handle messaging different based on which path we came from.
+ if ($PackageMetadata.ForceReinstallPackages) {
+ if (Test-StringIsNullOrWhiteSpace -Value $DependencyReleaseValue) {
+ Write-Host "##teamcity[message text='DependencyReleaseValue is NullOrWhiteSpace on an ORB deploy!' status='WARNING']"
+ }
+
+ # matches
+ # release dot 4digits dot at-least-one-digit dot at-least-one-digit dot at-least-one-digit dot zip
+ # parens around (\d{4}\.\d+\.\d+\.\d+) "captures" that string that looks like YYYY.N.N.N or 2022.3.0.9
+ # which is our Release Version Number, or OrbVersion, matching what Get-OrbVersion would return
+ $regexOrbReleaseFile = "release\.(\d{4}\.\d+\.\d+\.\d+)\.zip"
+ if ($DependencyReleaseValue -match $regexOrbReleaseFile) {
+ $releaseVersion = $Matches[1]
+ Write-Host "$loglead : Valid release version found in dependency parameters"
+ $PackageMetadata.InstalledOrbVersion = $releaseVersion
+ $releaseVersionIsCanonical = $true
+ } else {
+ Write-Host "##teamcity[message text='DependencyReleaseValue does not match release name format. Fallback to Get-OrbVersion from remote host' status='WARNING']"
+ }
+ } else {
+ Write-Host "$loglead : This is a package deploy, so we're using Get-OrbVersion from a remote host to determine what to do with hotfixes."
+ }
+
+ $firstWebORBServer = $PackageMetadata.WebServers | Select-Object -First 1
+ $firstAppORBServer = $PackageMetadata.AppServers | Select-Object -First 1
+ $firstMicORBServer = $PackageMetadata.MicServers | Select-Object -First 1
+
+ $orbServersToCheck = @($firstWebORBServer, $firstAppORBServer, $firstMicORBServer)
+
+ $foundAtLeastOneOrbServer = $false
+
+ if($null -eq $DebugMetadata.ExistingInstalledHotfixes){
+ $DebugMetadata.ExistingInstalledHotfixes = @()
+ }
+
+ foreach ($server in $orbServersToCheck) {
+ Write-Host "$loglead : Testing $server for hotfix installs..."
+ if ( -NOT (Test-StringIsNullOrWhiteSpace -Value $server)) {
+ # The anticipated number of packages in this block is 0 or 1 according to business rules
+ # So while this may take a little time for the Invoke-Command to run, the resulting array values should be tiny
+ # That being said, expect the business rules of 0 or 1 to be broken, this could end up with many values
+ if (-NOT $releaseVersionIsCanonical) {
+ $PackageMetadata.InstalledOrbVersion = Get-OrbVersion -ComputerName $server
+ }
+
+ if ( -NOT (Test-StringIsNullOrWhiteSpace -Value $PackageMetadata.InstalledOrbVersion)) {
+ $remoteHotfix = Invoke-Command -ComputerName $server -ScriptBlock {
+ return (Get-AllInstalledComponentsByType -ComparableComponentType Hotfix)
+ }
+
+ if( -NOT (Test-IsCollectionNullOrEmpty -Collection $remoteHotfix)){
+ $DebugMetadata.ExistingInstalledHotfixes += $remoteHotfix
+ }
+ } else {
+ Write-Host "##teamcity[message='Could not retrieve the ORB version as a valid value from $server - ORB Version (Get-OrbVersion) value was empty or missing.' status='WARNING']"
+ }
+ $foundAtLeastOneOrbServer = $true
+ }
+ }
+
+ # Some things are more readable if we write out the values as variables rather than magic numbers
+ # Oh to have enums
+ $versionIsLessThanTarget = -1
+ $versionIsSameAsTarget = 0
+ $versionIsGreaterThanTarget = 1
+
+ # Detect that this hotfix targets a valid version, aka: the version of ORB on or after-which, the hotfix is not required
+ <#
+ By way of example
+ 2021.5.0.10 has a bug
+ The bug is fixed in 2021.5.0.26
+ The manifest for the hotfix package would state 2021.5.0.26
+ The version of ORB is currently deployed at 2021.5.0.10
+ #>
+ foreach ($existingHotfix in $DebugMetadata.ExistingInstalledHotfixes) {
+ Write-Host "$loglead : Existing Hotfix found: $($existingHotfix.PackageName)"
+ $compareSemverResult = Compare-SemVer -Version1 $existingHotfix.Manifest.hotfixManifest.fixedInOrbVersion -Version2 $PackageMetadata.InstalledOrbVersion
+
+ switch ($compareSemverResult) {
+ $versionIsLessThanTarget { $DebugMetadata.UninstallExistingHotfixPackageNames += $existingHotfix.PackageName }
+ $versionIsSameAsTarget { $DebugMetadata.UninstallExistingHotfixPackageNames += $existingHotfix.PackageName }
+ $versionIsGreaterThanTarget { <# Do nothing, this will get reinstalled if it should be reinstalled at this point #> }
+ }
+ }
+
+ if ( -NOT (Test-IsCollectionNullOrEmpty -Collection $DebugMetadata.UninstallExistingHotfixPackageNames)) {
+ Write-Host "$loglead : Found hotfixes to be uninstalled: $($DebugMetadata.UninstallExistingHotfixPackageNames -join ",")"
+ }
+
+ if (-NOT $foundAtLeastOneOrbServer) {
+ Write-Host "##teamcity[message='Could not calculate the ORB version as no orb servers were resolved (web, app)' status='WARNING']"
+ }
+
+ $allWebTierHotfixPackages = @()
+ $allAppTierHotfixPackages = @()
+ $allMicTierHotfixPackages = @()
+
+ # Slice the packages out of the arrays so we can move them to the uninstall lists
+ foreach ($hotfixPackageName in $DebugMetadata.UninstallExistingHotfixPackageNames) {
+ # we use this to pull the hydrated object that has already be interogated and classified
+ Write-Host "$loglead : Getting Already Installed hotfixes."
+ [array]$allWebTierHotfixPackages += $DebugMetadata.WebServerPackages.Where( { $_.Name -eq $hotfixPackageName })
+ [array]$allAppTierHotfixPackages += $DebugMetadata.AppServerPackages.Where( { $_.Name -eq $hotfixPackageName })
+ [array]$allMicTierHotfixPackages += $DebugMetadata.MicServerPackages.Where( { $_.Name -eq $hotfixPackageName })
+
+ # The following lines are to slice out the hot fix package from the install list
+ # Something in this block (presumably the MicPackagesToInstall collection) below is coming in as a [Deserialized.System.Management.Automation.PSCustomObject].
+ # PSCustomObjects don't know what .Where is, so this throws.
+ # The parens/[array] typecasting are an attempt to address that.
+ Write-Host "$loglead : Removing old hotfixes from install lists."
+ [array]$PackageMetadata.WebPackagesToInstall = ([array]$PackageMetadata.WebPackagesToInstall).Where( { $_.Name -ne $hotfixPackageName })
+ [array]$PackageMetadata.AppPackagesToInstall = ([array]$PackageMetadata.AppPackagesToInstall).Where( { $_.Name -ne $hotfixPackageName })
+ [array]$PackageMetadata.MicPackagesToInstall = ([array]$PackageMetadata.MicPackagesToInstall).Where( { $_.Name -ne $hotfixPackageName })
+ }
+
+ $allWebTierHotfixPackages.ForEach( {
+ Add-Member -InputObject $_ -NotePropertyName "SkipUninstallScripts" -NotePropertyValue $false -Force
+ Add-Member -InputObject $_ -NotePropertyName "ActionType" -NotePropertyValue "Uninstall" -Force
+ Add-Member -InputObject $_ -NotePropertyName "ActionReason" -NotePropertyValue "ORB_HOTFIX_OUTDATED" -Force
+ })
+
+ $allAppTierHotfixPackages.ForEach( {
+ Add-Member -InputObject $_ -NotePropertyName "SkipUninstallScripts" -NotePropertyValue $false -Force
+ Add-Member -InputObject $_ -NotePropertyName "ActionType" -NotePropertyValue "Uninstall" -Force
+ Add-Member -InputObject $_ -NotePropertyName "ActionReason" -NotePropertyValue "ORB_HOTFIX_OUTDATED" -Force
+ })
+
+ # If it's in the user-supplied uninstall list, we should not add it to the uninstall list, it will just get uninstalled for us, which is fine
+ # Adding it when it already exists can trip the duplicates-in-list state
+ foreach ($package in $allWebTierHotfixPackages) {
+ if ($PackageMetadata.WebPackagesToUninstall.Name -notcontains $package.Name) {
+ # Only add it to the uninstall list if it wasn't given to us by the user already
+ if (Test-IsCollectionNullOrEmpty -Collection $PackageMetadata.WebPackagesToUninstall) { $PackageMetadata.WebPackagesToUninstall = @() }
+ $PackageMetadata.WebPackagesToUninstall += $package
+ }
+ }
+
+ foreach ($package in $allAppTierHotfixPackages) {
+ if ($PackageMetadata.AppPackagesToUninstall.Name -notcontains $package.Name) {
+ # Only add it to the uninstall list if it wasn't given to us by the user already
+ if (Test-IsCollectionNullOrEmpty -Collection $PackageMetadata.AppPackagesToUninstall) { $PackageMetadata.AppPackagesToUninstall = @() }
+ $PackageMetadata.AppPackagesToUninstall += $package
+ }
+ }
+
+ foreach($package in $allMicTierHotfixPackages) {
+ if ($PackageMetadata.MicPackagesToUninstall.Name -notcontains $package.Name) {
+ # Only add it to the uninstall list if it wasn't given to us by the user already
+ if (Test-IsCollectionNullOrEmpty -Collection $PackageMetadata.MicPackagesToUninstall) { $PackageMetadata.MicPackagesToUninstall = @() }
+ $PackageMetadata.MicPackagesToUninstall += $package
+ }
+ }
+
+ return $PackageMetadata, $DebugMetadata
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Installation/Public/Add-OldHotfixPackagesToUninstallList.tests.ps1 b/Modules/Alkami.DevOps.Installation/Public/Add-OldHotfixPackagesToUninstallList.tests.ps1
new file mode 100644
index 0000000..c273ec4
--- /dev/null
+++ b/Modules/Alkami.DevOps.Installation/Public/Add-OldHotfixPackagesToUninstallList.tests.ps1
@@ -0,0 +1,903 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+# Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ""
+
+Describe "Add-OldHotfixPackagesToUninstallList" {
+ $defaultOrbValue = "2022.1.0.0"
+ $defaultReleaseValue = "2022.1.0.0"
+ $defaultFixedInValue = "2022.1.0.0"
+
+ Mock -ModuleName $moduleForMock -CommandName Write-Host
+ Mock -ModuleName $moduleForMock -CommandName Get-LogLeadName -MockWith { return "UnitTest" }
+ Mock -ModuleName $moduleForMock -CommandName Get-OrbVersion -MockWith { return $defaultOrbValue }
+
+ $fakeHotfix = New-DummyPackageInstallationData -PackageName "fake.hotfix" -PackageVersion "1.1.0" -IsFullScale -IsHotfix
+
+ $webDummyPackage = New-DummyPackageInstallationData -PackageName "web.dummy" -PackageVersion "1.1.0" -IsWebOnly
+ $appDummyPackage = New-DummyPackageInstallationData -PackageName "app.dummy" -PackageVersion "1.1.0" -IsAppOnly
+
+ # Define wrapper objects
+ $packageData = New-PackageMetadataObject
+
+ $packageData.WebPackagesToInstall += $webDummyPackage
+ $packageData.AppPackagesToInstall += $appDummyPackage
+ $packageData.WebPackagesToInstall += $fakeHotfix
+ $packageData.AppPackagesToInstall += $fakeHotfix
+
+ $debugMetadata = New-Object psobject -property @{
+ WebServerPackages = @()
+ AppServerPackages = @()
+ MicServerPackages = @()
+ FabServerPackages = @()
+ UninstallExistingHotfixPackageNames = @()
+
+ WebPackagesToInstallMap = @{}
+ AppPackagesToInstallMap = @{}
+ ClassifiedPackagesMap = @{}
+ ExistingInstalledHotfixes = @()
+ }
+
+ Context "When DependencyReleaseValue is invalid and it's an orb release" {
+ It "Warns the User" {
+ $packageData.ForceReinstallPackages = $true
+ Add-OldHotfixPackagesToUninstallList -DependencyReleaseValue "WHARRGARBL" -PackageMetadata $packageData -DebugMetadata $debugMetadata
+
+ Assert-MockCalled -CommandName Write-Host -ModuleName $moduleForMock -Times 1 -Exactly -Scope It -ParameterFilter { $Object -eq "##teamcity`[message text='DependencyReleaseValue does not match release name format. Fallback to Get-OrbVersion from remote host' status='WARNING'`]" }
+ }
+ }
+
+ Context "When it's a package release" {
+ It "Doesn't Warn the User About DependencyReleaseValue" {
+ $packageData.ForceReinstallPackages = $false
+ Add-OldHotfixPackagesToUninstallList -DependencyReleaseValue "" -PackageMetadata $packageData -DebugMetadata $debugMetadata
+
+ Assert-MockCalled -CommandName Write-Host -ModuleName $moduleForMock -Times 0 -Exactly -Scope It -ParameterFilter { $Object -eq "##teamcity`[message text='DependencyReleaseValue does not match release name format. Fallback to Get-OrbVersion from remote host' status='WARNING'`]" }
+ }
+ }
+
+ Context "When both app and web orb servers are Null" {
+ It "Warns the User" {
+ Add-OldHotfixPackagesToUninstallList -DependencyReleaseValue $defaultReleaseValue -PackageMetadata $packageData -DebugMetadata $debugMetadata
+
+ Assert-MockCalled -CommandName Write-Host -ModuleName $moduleForMock -Times 1 -Exactly -Scope It -ParameterFilter { $Object -eq "##teamcity`[message='Could not calculate the ORB version as no orb servers were resolved (web, app)' status='WARNING'`]" }
+ }
+ }
+
+ # Define web, app servers for future tests
+ $packageData.WebServers = @("webfake1.fh.local", "webfake2.fh.local")
+ $packageData.AppServers = @("appfake1.fh.local", "appfake2.fh.local")
+
+ # We're assuming this is null/unretrievable for some reason on both app and web servers for test simplicity.
+ Context "When InstalledOrbVersion is Null" {
+ It "Warns the User" {
+ Mock -ModuleName $moduleForMock -CommandName Get-OrbVersion -MockWith { return $null }
+
+ Add-OldHotfixPackagesToUninstallList -DependencyReleaseValue "bad.Release" -PackageMetadata $packageData -DebugMetadata $debugMetadata
+
+ Assert-MockCalled -CommandName Write-Host -ModuleName $moduleForMock -Times 2 -Exactly -Scope It -ParameterFilter { $Object -like "##teamcity``[message='Could not retrieve the ORB version as a valid value from * - ORB Version (Get-OrbVersion) value was empty or missing.' status='WARNING'``]" }
+ }
+ }
+
+#region Tests for hotfix on all server types
+ Context "When The Existing Hotfix Version is Less Than The Target On All Server Types" {
+ # Define wrapper objects
+ $packageData = New-PackageMetadataObject
+ $packageData.WebServers = @("webfake1.fh.local", "webfake2.fh.local")
+ $packageData.AppServers = @("appfake1.fh.local", "appfake2.fh.local")
+ $packageData.MicServers = @("micfake1.fh.local", "micfake2.fh.local")
+
+ $packageData.WebPackagesToInstall += $webDummyPackage
+ $packageData.AppPackagesToInstall += $appDummyPackage
+ $packageData.MicPackagesToInstall += $appDummyPackage
+
+ $packageData.WebPackagesToInstall += $fakeHotfix
+ $packageData.AppPackagesToInstall += $fakeHotfix
+ $packageData.MicPackagesToInstall += $fakeHotfix
+
+ $debugMetadata = New-Object psobject -property @{
+ WebServerPackages = @()
+ AppServerPackages = @()
+ MicServerPackages = @()
+ FabServerPackages = @()
+ UninstallExistingHotfixPackageNames = @()
+
+ WebPackagesToInstallMap = @{}
+ AppPackagesToInstallMap = @{}
+ ClassifiedPackagesMap = @{}
+ ExistingInstalledHotfixes = @()
+ }
+
+ $debugMetadata.WebServerPackages += $fakeHotfix
+ $debugMetadata.AppServerPackages += $fakeHotfix
+ $debugMetadata.MicServerPackages += $fakeHotfix
+
+ # This returns a hotfix package which is installed on an orb server.
+ Mock -ModuleName $moduleForMock -CommandName Invoke-Command -MockWith {
+ return [PSCustomObject]@{
+ PackageName = "fake.hotfix"
+ ManifestPath = "TestDrive:\FakeManifest.Xml"
+ Manifest = @{
+ "hotfixManifest" = @{fixedInORBVersion = "2010.0.0.0" }
+ "general" = @{
+ creatorCode = "Alkami"
+ element = "Alkami.Hotfix.UnitTest"
+ componentType = "Hotfix"
+ }
+ "version" = 1.0
+ }
+ }
+ }
+
+ $outputMetaData, $outputDebugMetadata = Add-OldHotfixPackagesToUninstallList -DependencyReleaseValue $defaultReleaseValue -PackageMetadata $packageData -DebugMetadata $debugMetadata
+
+ # this test is when the same hotfix is installed on both app and web
+ It "Tells The User It Will Uninstall The Hotfix" {
+ Assert-MockCalled -CommandName Write-Host -ModuleName $moduleForMock -Times 1 -Exactly -Scope Context -ParameterFilter { $Object -like "*Found hotfixes to be uninstalled*" }
+ }
+
+ It "Adds The Package To The Uninstall List"{
+ $outputDebugMetadata.UninstallExistingHotfixPackageNames | Should -Contain "fake.hotfix"
+
+ $outputMetaData.WebPackagesToUninstall | Should -Contain $fakeHotfix
+ $outputMetaData.AppPackagesToUninstall | Should -Contain $fakeHotfix
+ $outputMetaData.MicPackagesToUninstall | Should -Contain $fakeHotfix
+ }
+
+ It "Removes The Package From The Install List" {
+ $outputMetaData.AppPackagesToInstall | Should -Not -Be $null
+ $outputMetaData.WebPackagesToInstall | Should -Not -Be $null
+ $outputMetaData.MicPackagesToInstall | Should -Not -Be $null
+
+ $outputMetaData.AppPackagesToInstall | Should -Not -Contain $fakeHotfix
+ $outputMetaData.WebPackagesToInstall | Should -Not -Contain $fakeHotfix
+ $outputMetaData.MicPackagesToInstall | Should -Not -Contain $fakeHotfix
+ }
+ }
+
+ Context "When The Existing Hotfix Version is Equal To The Target On All Server Types" {
+ # Define wrapper objects
+ $packageData = New-PackageMetadataObject
+ $packageData.WebServers = @("webfake1.fh.local", "webfake2.fh.local")
+ $packageData.AppServers = @("appfake1.fh.local", "appfake2.fh.local")
+ $packageData.MicServers = @("micfake1.fh.local", "micfake2.fh.local")
+
+ $packageData.WebPackagesToInstall += $webDummyPackage
+ $packageData.AppPackagesToInstall += $appDummyPackage
+ $packageData.MicPackagesToInstall += $appDummyPackage
+
+ $packageData.WebPackagesToInstall += $fakeHotfix
+ $packageData.AppPackagesToInstall += $fakeHotfix
+ $packageData.MicPackagesToInstall += $fakeHotfix
+
+ $debugMetadata = New-Object psobject -property @{
+ WebServerPackages = @()
+ AppServerPackages = @()
+ MicServerPackages = @()
+ FabServerPackages = @()
+ UninstallExistingHotfixPackageNames = @()
+
+ WebPackagesToInstallMap = @{}
+ AppPackagesToInstallMap = @{}
+ ClassifiedPackagesMap = @{}
+ ExistingInstalledHotfixes = @()
+ }
+
+ $debugMetadata.WebServerPackages += $fakeHotfix
+ $debugMetadata.AppServerPackages += $fakeHotfix
+ $debugMetadata.MicServerPackages += $fakeHotfix
+
+ Mock -ModuleName $moduleForMock -CommandName Invoke-Command -MockWith {
+ return [PSCustomObject]@{
+ PackageName = "fake.hotfix"
+ ManifestPath = "TestDrive:\FakeManifest.Xml"
+ Manifest = @{
+ "hotfixManifest" = @{fixedInORBVersion = $defaultFixedInValue }
+ "general" = @{
+ creatorCode = "Alkami"
+ element = "Alkami.Hotfix.UnitTest"
+ componentType = "Hotfix"
+ }
+ "version" = 1.0
+ }
+ }
+ }
+
+ $outputMetaData, $outputDebugMetadata = Add-OldHotfixPackagesToUninstallList -DependencyReleaseValue $defaultReleaseValue -PackageMetadata $packageData -DebugMetadata $debugMetadata
+
+ # this test is when the same hotfix is installed on both app and web
+ It "Tells The User It Will Uninstall The Hotfix" {
+ Assert-MockCalled -CommandName Write-Host -ModuleName $moduleForMock -Times 1 -Exactly -Scope Context -ParameterFilter { $Object -like "*Found hotfixes to be uninstalled*" }
+ }
+
+ It "Adds The Package To The Uninstall List"{
+ $outputDebugMetadata.UninstallExistingHotfixPackageNames | Should -Contain "fake.hotfix"
+
+ $outputMetaData.WebPackagesToUninstall | Should -Contain $fakeHotfix
+ $outputMetaData.AppPackagesToUninstall | Should -Contain $fakeHotfix
+ $outputMetaData.MicPackagesToUninstall | Should -Contain $fakeHotfix
+ }
+
+ It "Removes The Package From The Install List" {
+ $outputMetaData.AppPackagesToInstall | Should -Not -Be $null
+ $outputMetaData.WebPackagesToInstall | Should -Not -Be $null
+ $outputMetaData.MicPackagesToInstall | Should -Not -Be $null
+
+ $outputMetaData.AppPackagesToInstall | Should -Not -Contain $fakeHotfix
+ $outputMetaData.WebPackagesToInstall | Should -Not -Contain $fakeHotfix
+ $outputMetaData.MicPackagesToInstall | Should -Not -Contain $fakeHotfix
+ }
+ }
+
+ Context "When The Existing Hotfix Version is Greater Than The Target On All Server Types" {
+ # Define wrapper objects
+ $packageData = New-PackageMetadataObject
+ $packageData.WebServers = @("webfake1.fh.local", "webfake2.fh.local")
+ $packageData.AppServers = @("appfake1.fh.local", "appfake2.fh.local")
+ $packageData.MicServers = @("micfake1.fh.local", "micfake2.fh.local")
+
+ $packageData.WebPackagesToInstall += $webDummyPackage
+ $packageData.AppPackagesToInstall += $appDummyPackage
+ $packageData.MicPackagesToInstall += $appDummyPackage
+
+ $packageData.WebPackagesToInstall += $fakeHotfix
+ $packageData.AppPackagesToInstall += $fakeHotfix
+ $packageData.MicPackagesToInstall += $fakeHotfix
+
+ $debugMetadata = New-Object psobject -property @{
+ WebServerPackages = @()
+ AppServerPackages = @()
+ MicServerPackages = @()
+ FabServerPackages = @()
+ UninstallExistingHotfixPackageNames = @()
+
+ WebPackagesToInstallMap = @{}
+ AppPackagesToInstallMap = @{}
+ ClassifiedPackagesMap = @{}
+ ExistingInstalledHotfixes = @()
+ }
+
+ $debugMetadata.WebServerPackages += $fakeHotfix
+ $debugMetadata.AppServerPackages += $fakeHotfix
+ $debugMetadata.MicServerPackages += $fakeHotfix
+
+ Mock -ModuleName $moduleForMock -CommandName Invoke-Command -MockWith {
+ return [PSCustomObject]@{
+ PackageName = "fake.hotfix"
+ ManifestPath = "TestDrive:\FakeManifest.Xml"
+ Manifest = @{
+ "hotfixManifest" = @{fixedInORBVersion = "9999.0.0.0" }
+ "general" = @{
+ creatorCode = "Alkami"
+ element = "Alkami.Hotfix.UnitTest"
+ componentType = "Hotfix"
+ }
+ "version" = 1.0
+ }
+ }
+ }
+
+ $outputMetaData, $outputDebugMetadata = Add-OldHotfixPackagesToUninstallList -DependencyReleaseValue $defaultReleaseValue -PackageMetadata $packageData -DebugMetadata $debugMetadata
+
+ It "Does Not Tell The User It Will Uninstall The Hotfix" {
+ Assert-MockCalled -CommandName Write-Host -ModuleName $moduleForMock -Times 0 -Exactly -Scope Context -ParameterFilter { $Object -like "*Found hotfixes to be uninstalled*" }
+ }
+
+ It "Does Not Add The Package To The Uninstall List"{
+ $outputDebugMetadata.UninstallExistingHotfixPackageNames | Should -Not -Contain "fake.hotfix"
+
+ $outputMetaData.WebPackagesToUninstall | Should -Not -Contain $fakeHotfix
+ $outputMetaData.AppPackagesToUninstall | Should -Not -Contain $fakeHotfix
+ $outputMetaData.MicPackagesToUninstall | Should -Not -Contain $fakeHotfix
+ }
+
+ It "Does Not Remove The Package From The Install List" {
+ $outputMetaData.AppPackagesToInstall | Should -Not -Be $null
+ $outputMetaData.MicPackagesToInstall | Should -Not -Be $null
+ $outputMetaData.WebPackagesToInstall | Should -Not -Be $null
+
+ $outputMetaData.AppPackagesToInstall | Should -Contain $fakeHotfix
+ $outputMetaData.WebPackagesToInstall | Should -Contain $fakeHotfix
+ $outputMetaData.MicPackagesToInstall | Should -Contain $fakeHotfix
+ }
+ }
+#endregion Tests for hotfix on all server types
+
+# #region Tests for hotfix only on app servers
+Context "When The Existing Hotfix Version is Less Than The Target On App Servers" {
+ # Define wrapper objects
+ $packageData = New-PackageMetadataObject
+ $packageData.WebServers = @("webfake1.fh.local", "webfake2.fh.local")
+ $packageData.AppServers = @("appfake1.fh.local", "appfake2.fh.local")
+
+ $packageData.AppPackagesToInstall += $appDummyPackage
+ $packageData.AppPackagesToInstall += $fakeHotfix
+
+ $debugMetadata = New-Object psobject -property @{
+ WebServerPackages = @()
+ AppServerPackages = @()
+ MicServerPackages = @()
+ FabServerPackages = @()
+ UninstallExistingHotfixPackageNames = @()
+
+ WebPackagesToInstallMap = @{}
+ AppPackagesToInstallMap = @{}
+ ClassifiedPackagesMap = @{}
+ ExistingInstalledHotfixes = @()
+ }
+
+ $debugMetadata.AppServerPackages += $fakeHotfix
+
+ # This returns a hotfix package which is installed on an orb server.
+
+ Mock -ModuleName $moduleForMock -CommandName Invoke-Command -MockWith {
+ return $null
+ } -ParameterFilter {$ComputerName -like "*webfake1*" }
+
+ Mock -ModuleName $moduleForMock -CommandName Invoke-Command -MockWith {
+ return [PSCustomObject]@{
+ PackageName = "fake.hotfix"
+ ManifestPath = "TestDrive:\FakeManifest.Xml"
+ Manifest = @{
+ "hotfixManifest" = @{fixedInORBVersion = "2010.0.0.0" }
+ "general" = @{
+ creatorCode = "Alkami"
+ element = "Alkami.Hotfix.UnitTest"
+ componentType = "Hotfix"
+ }
+ "version" = 1.0
+ }
+ }
+ } -ParameterFilter {$ComputerName -like "*appfake1*" }
+
+ $outputMetaData, $outputDebugMetadata = Add-OldHotfixPackagesToUninstallList -DependencyReleaseValue $defaultReleaseValue -PackageMetadata $packageData -DebugMetadata $debugMetadata
+
+ # this test is when the same hotfix is installed on both app and web
+ It "Tells The User It Will Uninstall The Hotfix" {
+ Assert-MockCalled -CommandName Write-Host -ModuleName $moduleForMock -Times 1 -Exactly -Scope Context -ParameterFilter { $Object -like "*Found hotfixes to be uninstalled*" }
+ }
+
+ It "Adds The Package To The Uninstall List"{
+ $outputDebugMetadata.UninstallExistingHotfixPackageNames | Should -Contain "fake.hotfix"
+
+ $outputMetaData.AppPackagesToUninstall | Should -Contain $fakeHotfix
+ }
+
+ It "Removes The Package From The App Install List" {
+ $outputMetaData.AppPackagesToInstall | Should -Not -Be $null
+
+ $outputMetaData.AppPackagesToInstall | Should -Not -Contain $fakeHotfix
+ }
+}
+
+Context "When The Existing Hotfix Version is Equal To The Target On App Servers" {
+ # Define wrapper objects
+ $packageData = New-PackageMetadataObject
+ $packageData.WebServers = @("webfake1.fh.local", "webfake2.fh.local")
+ $packageData.AppServers = @("appfake1.fh.local", "appfake2.fh.local")
+
+ $packageData.AppPackagesToInstall += $appDummyPackage
+ $packageData.AppPackagesToInstall += $fakeHotfix
+
+ $debugMetadata = New-Object psobject -property @{
+ WebServerPackages = @()
+ AppServerPackages = @()
+ MicServerPackages = @()
+ FabServerPackages = @()
+ UninstallExistingHotfixPackageNames = @()
+
+ WebPackagesToInstallMap = @{}
+ AppPackagesToInstallMap = @{}
+ ClassifiedPackagesMap = @{}
+ ExistingInstalledHotfixes = @()
+ }
+
+ $debugMetadata.AppServerPackages += $fakeHotfix
+
+ Mock -ModuleName $moduleForMock -CommandName Invoke-Command -MockWith {
+ return $null
+ } -ParameterFilter {$ComputerName -like "*webfake1*" }
+
+ Mock -ModuleName $moduleForMock -CommandName Invoke-Command -MockWith {
+ return [PSCustomObject]@{
+ PackageName = "fake.hotfix"
+ ManifestPath = "TestDrive:\FakeManifest.Xml"
+ Manifest = @{
+ "hotfixManifest" = @{fixedInORBVersion = $defaultFixedInValue }
+ "general" = @{
+ creatorCode = "Alkami"
+ element = "Alkami.Hotfix.UnitTest"
+ componentType = "Hotfix"
+ }
+ "version" = 1.0
+ }
+ }
+ } -ParameterFilter {$ComputerName -like "*appfake1*" }
+
+ $outputMetaData, $outputDebugMetadata = Add-OldHotfixPackagesToUninstallList -DependencyReleaseValue $defaultReleaseValue -PackageMetadata $packageData -DebugMetadata $debugMetadata
+
+ # this test is when the same hotfix is installed on both app and web
+
+ It "Tells The User It Will Uninstall The Hotfix" {
+ Assert-MockCalled -CommandName Write-Host -ModuleName $moduleForMock -Times 1 -Exactly -Scope Context -ParameterFilter { $Object -like "*Found hotfixes to be uninstalled*" }
+ }
+
+ It "Adds The Package To The Uninstall List"{
+ $outputDebugMetadata.UninstallExistingHotfixPackageNames | Should -Contain "fake.hotfix"
+
+ $outputMetaData.AppPackagesToUninstall | Should -Contain $fakeHotfix
+ }
+
+ It "Removes The Package From The App Install List" {
+ $outputMetaData.AppPackagesToInstall | Should -Not -Be $null
+
+ $outputMetaData.AppPackagesToInstall | Should -Not -Contain $fakeHotfix
+ }
+
+}
+
+Context "When The Existing Hotfix Version is Greater Than The Target On App Servers" {
+
+ # Define wrapper objects
+ $packageData = New-PackageMetadataObject
+ $packageData.WebServers = @("webfake1.fh.local", "webfake2.fh.local")
+ $packageData.AppServers = @("appfake1.fh.local", "appfake2.fh.local")
+
+ $packageData.AppPackagesToInstall += $appDummyPackage
+ $packageData.AppPackagesToInstall += $fakeHotfix
+
+ $debugMetadata = New-Object psobject -property @{
+ WebServerPackages = @()
+ AppServerPackages = @()
+ MicServerPackages = @()
+ FabServerPackages = @()
+ UninstallExistingHotfixPackageNames = @()
+
+ WebPackagesToInstallMap = @{}
+ AppPackagesToInstallMap = @{}
+ ClassifiedPackagesMap = @{}
+ ExistingInstalledHotfixes = @()
+ }
+
+ $debugMetadata.AppServerPackages += $fakeHotfix
+
+ Mock -ModuleName $moduleForMock -CommandName Invoke-Command -MockWith {
+ return $null
+ } -ParameterFilter {$ComputerName -like "*webfake1*" -or $ComputerName -like "*micfake1*"}
+
+ Mock -ModuleName $moduleForMock -CommandName Invoke-Command -MockWith {
+ return [PSCustomObject]@{
+ PackageName = "fake.hotfix"
+ ManifestPath = "TestDrive:\FakeManifest.Xml"
+ Manifest = @{
+ "hotfixManifest" = @{fixedInORBVersion = "9999.0.0.0" }
+ "general" = @{
+ creatorCode = "Alkami"
+ element = "Alkami.Hotfix.UnitTest"
+ componentType = "Hotfix"
+ }
+ "version" = 1.0
+ }
+ }
+ } -ParameterFilter {$ComputerName -like "*appfake1*" }
+
+ $outputMetaData, $outputDebugMetadata = Add-OldHotfixPackagesToUninstallList -DependencyReleaseValue $defaultReleaseValue -PackageMetadata $packageData -DebugMetadata $debugMetadata
+
+ It "Does Not Tell The User It Will Uninstall The Hotfix" {
+ Assert-MockCalled -CommandName Write-Host -ModuleName $moduleForMock -Times 0 -Exactly -Scope Context -ParameterFilter { $Object -like "*Found hotfixes to be uninstalled*" }
+ }
+
+ It "Does Not Add The Package To The Uninstall List"{
+ $outputDebugMetadata.UninstallExistingHotfixPackageNames | Should -Not -Contain "fake.hotfix"
+
+ $outputMetaData.AppPackagesToUninstall | Should -Not -Contain $fakeHotfix
+ }
+
+ It "Does Not Remove The Package From The App Install List" {
+ $outputMetaData.AppPackagesToInstall | Should -Not -Be $null
+
+ $outputMetaData.AppPackagesToInstall | Should -Contain $fakeHotfix
+ }
+}
+# #endregion Tests for hotfix only on app servers
+
+# #region Tests for hotfix only on mic servers
+Context "When The Existing Hotfix Version is Less Than The Target On Mic Servers" {
+ # Define wrapper objects
+ $packageData = New-PackageMetadataObject
+ $packageData.WebServers = @("webfake1.fh.local", "webfake2.fh.local")
+ $packageData.AppServers = @("appfake1.fh.local", "appfake2.fh.local")
+ $packageData.MicServers = @("micfake1.fh.local", "micfake2.fh.local")
+
+ $packageData.MicPackagesToInstall += $appDummyPackage
+ $packageData.MicPackagesToInstall += $fakeHotfix
+
+ $debugMetadata = New-Object psobject -property @{
+ WebServerPackages = @()
+ AppServerPackages = @()
+ MicServerPackages = @()
+ FabServerPackages = @()
+ UninstallExistingHotfixPackageNames = @()
+
+ WebPackagesToInstallMap = @{}
+ AppPackagesToInstallMap = @{}
+ ClassifiedPackagesMap = @{}
+ ExistingInstalledHotfixes = @()
+ }
+
+ $debugMetadata.MicServerPackages += $fakeHotfix
+
+ # This returns a hotfix package which is installed on an orb server.
+
+ Mock -ModuleName $moduleForMock -CommandName Invoke-Command -MockWith {
+ return $null
+ } -ParameterFilter { ($ComputerName -like "*webfake1*") -or ($ComputerName -like "*appfake1*") }
+
+ Mock -ModuleName $moduleForMock -CommandName Invoke-Command -MockWith {
+ return [PSCustomObject]@{
+ PackageName = "fake.hotfix"
+ ManifestPath = "TestDrive:\FakeManifest.Xml"
+ Manifest = @{
+ "hotfixManifest" = @{fixedInORBVersion = "2010.0.0.0" }
+ "general" = @{
+ creatorCode = "Alkami"
+ element = "Alkami.Hotfix.UnitTest"
+ componentType = "Hotfix"
+ }
+ "version" = 1.0
+ }
+ }
+ } -ParameterFilter {$ComputerName -like "*micfake1*" }
+
+ $outputMetaData, $outputDebugMetadata = Add-OldHotfixPackagesToUninstallList -DependencyReleaseValue $defaultReleaseValue -PackageMetadata $packageData -DebugMetadata $debugMetadata
+
+ # this test is when the same hotfix is installed on both app and web
+ It "Tells The User It Will Uninstall The Hotfix" {
+ Assert-MockCalled -CommandName Write-Host -ModuleName $moduleForMock -Times 1 -Exactly -Scope Context -ParameterFilter { $Object -like "*Found hotfixes to be uninstalled*" }
+ }
+
+ It "Adds The Package To The Uninstall List"{
+ $outputDebugMetadata.UninstallExistingHotfixPackageNames | Should -Contain "fake.hotfix"
+
+ $outputMetaData.MicPackagesToUninstall | Should -Contain $fakeHotfix
+ }
+
+ It "Removes The Package From The Mic Install List" {
+ $outputMetaData.MicPackagesToInstall | Should -Not -Be $null
+
+ $outputMetaData.MicPackagesToInstall | Should -Not -Contain $fakeHotfix
+ }
+}
+
+Context "When The Existing Hotfix Version is Equal To The Target On Mic Servers" {
+ # Define wrapper objects
+ $packageData = New-PackageMetadataObject
+ $packageData.WebServers = @("webfake1.fh.local", "webfake2.fh.local")
+ $packageData.AppServers = @("appfake1.fh.local", "appfake2.fh.local")
+ $packageData.MicServers = @("micfake1.fh.local", "micfake2.fh.local")
+
+ $packageData.MicPackagesToInstall += $appDummyPackage
+ $packageData.MicPackagesToInstall += $fakeHotfix
+
+ $debugMetadata = New-Object psobject -property @{
+ WebServerPackages = @()
+ AppServerPackages = @()
+ MicServerPackages = @()
+ FabServerPackages = @()
+ UninstallExistingHotfixPackageNames = @()
+
+ WebPackagesToInstallMap = @{}
+ AppPackagesToInstallMap = @{}
+ ClassifiedPackagesMap = @{}
+ ExistingInstalledHotfixes = @()
+ }
+
+ $debugMetadata.MicServerPackages += $fakeHotfix
+
+ Mock -ModuleName $moduleForMock -CommandName Invoke-Command -MockWith {
+ return $null
+ } -ParameterFilter {$ComputerName -like "*webfake1*" -or $ComputerName -like "*appfake1*" }
+
+ Mock -ModuleName $moduleForMock -CommandName Invoke-Command -MockWith {
+ return [PSCustomObject]@{
+ PackageName = "fake.hotfix"
+ ManifestPath = "TestDrive:\FakeManifest.Xml"
+ Manifest = @{
+ "hotfixManifest" = @{fixedInORBVersion = $defaultFixedInValue }
+ "general" = @{
+ creatorCode = "Alkami"
+ element = "Alkami.Hotfix.UnitTest"
+ componentType = "Hotfix"
+ }
+ "version" = 1.0
+ }
+ }
+ } -ParameterFilter {$ComputerName -like "*micfake1*" }
+
+ $outputMetaData, $outputDebugMetadata = Add-OldHotfixPackagesToUninstallList -DependencyReleaseValue $defaultReleaseValue -PackageMetadata $packageData -DebugMetadata $debugMetadata
+
+ # this test is when the same hotfix is installed on both app and web
+
+ It "Tells The User It Will Uninstall The Hotfix" {
+ Assert-MockCalled -CommandName Write-Host -ModuleName $moduleForMock -Times 1 -Exactly -Scope Context -ParameterFilter { $Object -like "*Found hotfixes to be uninstalled*" }
+ }
+
+ It "Adds The Package To The Uninstall List"{
+ $outputDebugMetadata.UninstallExistingHotfixPackageNames | Should -Contain "fake.hotfix"
+
+ $outputMetaData.MicPackagesToUninstall | Should -Contain $fakeHotfix
+ }
+
+ It "Removes The Package From The Mic Install List" {
+ $outputMetaData.MicPackagesToInstall | Should -Not -Be $null
+
+ $outputMetaData.MicPackagesToInstall | Should -Not -Contain $fakeHotfix
+ }
+}
+
+Context "When The Existing Hotfix Version is Greater Than The Target On Mic Servers" {
+
+ # Define wrapper objects
+ $packageData = New-PackageMetadataObject
+ $packageData.WebServers = @("webfake1.fh.local", "webfake2.fh.local")
+ $packageData.AppServers = @("appfake1.fh.local", "appfake2.fh.local")
+
+ $packageData.MicPackagesToInstall += $appDummyPackage
+ $packageData.MicPackagesToInstall += $fakeHotfix
+
+ $debugMetadata = New-Object psobject -property @{
+ WebServerPackages = @()
+ AppServerPackages = @()
+ MicServerPackages = @()
+ FabServerPackages = @()
+ UninstallExistingHotfixPackageNames = @()
+
+ WebPackagesToInstallMap = @{}
+ AppPackagesToInstallMap = @{}
+ ClassifiedPackagesMap = @{}
+ ExistingInstalledHotfixes = @()
+ }
+
+ $debugMetadata.MicServerPackages += $fakeHotfix
+
+ Mock -ModuleName $moduleForMock -CommandName Invoke-Command -MockWith {
+ return $null
+ } -ParameterFilter { $ComputerName -like "*webfake1*" -or $ComputerName -like "*appfake1*" }
+
+ Mock -ModuleName $moduleForMock -CommandName Invoke-Command -MockWith {
+ return [PSCustomObject]@{
+ PackageName = "fake.hotfix"
+ ManifestPath = "TestDrive:\FakeManifest.Xml"
+ Manifest = @{
+ "hotfixManifest" = @{fixedInORBVersion = "9999.0.0.0" }
+ "general" = @{
+ creatorCode = "Alkami"
+ element = "Alkami.Hotfix.UnitTest"
+ componentType = "Hotfix"
+ }
+ "version" = 1.0
+ }
+ }
+ } -ParameterFilter {$ComputerName -like "*micfake1*" }
+
+ $outputMetaData, $outputDebugMetadata = Add-OldHotfixPackagesToUninstallList -DependencyReleaseValue $defaultReleaseValue -PackageMetadata $packageData -DebugMetadata $debugMetadata
+
+ It "Does Not Tell The User It Will Uninstall The Hotfix" {
+ Assert-MockCalled -CommandName Write-Host -ModuleName $moduleForMock -Times 0 -Exactly -Scope Context -ParameterFilter { $Object -like "*Found hotfixes to be uninstalled*" }
+ }
+
+ It "Does Not Add The Package To The Uninstall List"{
+ $outputDebugMetadata.UninstallExistingHotfixPackageNames | Should -Not -Contain "fake.hotfix"
+
+ $outputMetaData.MicPackagesToUninstall | Should -Not -Contain $fakeHotfix
+ }
+
+ It "Does Not Remove The Package From The Mic Install List" {
+ $outputMetaData.MicPackagesToInstall | Should -Not -Be $null
+
+ $outputMetaData.MicPackagesToInstall | Should -Contain $fakeHotfix
+ }
+}
+# #endregion Tests for hotfix only on Mic servers
+
+#region Tests for hotfix only on web servers
+Context "When The Existing Hotfix Version is Less Than The Target On Web Servers" {
+ # Define wrapper objects
+ $packageData = New-PackageMetadataObject
+ $packageData.WebServers = @("webfake1.fh.local", "webfake2.fh.local")
+ $packageData.AppServers = @("appfake1.fh.local", "appfake2.fh.local")
+
+ $packageData.WebPackagesToUninstall += $fakeHotfix
+ $packageData.WebPackagesToInstall += $webDummyPackage
+ $packageData.WebPackagesToInstall += $fakeHotfix
+
+ $debugMetadata = New-Object psobject -property @{
+ WebServerPackages = @()
+ AppServerPackages = @()
+ MicServerPackages = @()
+ FabServerPackages = @()
+ UninstallExistingHotfixPackageNames = @()
+
+ WebPackagesToInstallMap = @{}
+ AppPackagesToInstallMap = @{}
+ ClassifiedPackagesMap = @{}
+ ExistingInstalledHotfixes = @()
+ }
+
+ $debugMetadata.WebServerPackages += $fakeHotfix
+
+ Mock -ModuleName $moduleForMock -CommandName Invoke-Command -MockWith {
+ return $null
+ } -ParameterFilter {$ComputerName -like "*appfake1*" }
+
+ # This returns a hotfix package which is installed on an orb server.
+ Mock -ModuleName $moduleForMock -CommandName Invoke-Command -MockWith {
+ return [PSCustomObject]@{
+ PackageName = "fake.hotfix"
+ ManifestPath = "TestDrive:\FakeManifest.Xml"
+ Manifest = @{
+ "hotfixManifest" = @{fixedInORBVersion = "2010.0.0.0" }
+ "general" = @{
+ creatorCode = "Alkami"
+ element = "Alkami.Hotfix.UnitTest"
+ componentType = "Hotfix"
+ }
+ "version" = 1.0
+ }
+ }
+ } -ParameterFilter {$ComputerName -like "*webfake1*" }
+
+ $outputMetaData, $outputDebugMetadata = Add-OldHotfixPackagesToUninstallList -DependencyReleaseValue $defaultReleaseValue -PackageMetadata $packageData -DebugMetadata $debugMetadata
+
+ # this test is when the same hotfix is installed on both app and web
+ It "Tells The User It Will Uninstall The Hotfix" {
+ Assert-MockCalled -CommandName Write-Host -ModuleName $moduleForMock -Times 1 -Exactly -Scope Context -ParameterFilter { $Object -like "*Found hotfixes to be uninstalled*" }
+ }
+
+ It "Adds The Package To The Uninstall List"{
+ $outputDebugMetadata.UninstallExistingHotfixPackageNames | Should -Contain "fake.hotfix"
+
+ $outputMetaData.WebPackagesToUninstall | Should -Contain $fakeHotfix
+ }
+
+ It "Removes The Package From The App Install List" {
+ $outputMetaData.WebPackagesToInstall | Should -Not -Be $null
+
+ $outputMetaData.WebPackagesToInstall | Should -Not -Contain $fakeHotfix
+ }
+}
+
+Context "When The Existing Hotfix Version is Equal To The Target On All Server Types" {
+ # Define wrapper objects
+ $packageData = New-PackageMetadataObject
+ $packageData.WebServers = @("webfake1.fh.local", "webfake2.fh.local")
+ $packageData.AppServers = @("appfake1.fh.local", "appfake2.fh.local")
+
+ $packageData.WebPackagesToInstall += $webDummyPackage
+ $packageData.WebPackagesToInstall += $fakeHotfix
+ $packageData.WebPackagesToUninstall += $fakeHotfix
+
+ $debugMetadata = New-Object psobject -property @{
+ WebServerPackages = @()
+ AppServerPackages = @()
+ MicServerPackages = @()
+ FabServerPackages = @()
+ UninstallExistingHotfixPackageNames = @()
+
+ WebPackagesToInstallMap = @{}
+ AppPackagesToInstallMap = @{}
+ ClassifiedPackagesMap = @{}
+ ExistingInstalledHotfixes = @()
+ }
+
+ $debugMetadata.WebServerPackages += $fakeHotfix
+
+ Mock -ModuleName $moduleForMock -CommandName Invoke-Command -MockWith {
+ return $null
+ } -ParameterFilter {$ComputerName -like "*appfake1*" }
+
+ Mock -ModuleName $moduleForMock -CommandName Invoke-Command -MockWith {
+ return [PSCustomObject]@{
+ PackageName = "fake.hotfix"
+ ManifestPath = "TestDrive:\FakeManifest.Xml"
+ Manifest = @{
+ "hotfixManifest" = @{fixedInORBVersion = $defaultFixedInValue }
+ "general" = @{
+ creatorCode = "Alkami"
+ element = "Alkami.Hotfix.UnitTest"
+ componentType = "Hotfix"
+ }
+ "version" = 1.0
+ }
+ }
+ } -ParameterFilter {$ComputerName -like "*webfake1*" }
+
+ $outputMetaData, $outputDebugMetadata = Add-OldHotfixPackagesToUninstallList -DependencyReleaseValue $defaultReleaseValue -PackageMetadata $packageData -DebugMetadata $debugMetadata
+
+ It "Tells The User It Will Uninstall The Hotfix" {
+ Assert-MockCalled -CommandName Write-Host -ModuleName $moduleForMock -Times 1 -Exactly -Scope Context -ParameterFilter { $Object -like "*Found hotfixes to be uninstalled*" }
+ }
+
+ It "Adds The Package To The Uninstall List"{
+ $outputDebugMetadata.UninstallExistingHotfixPackageNames | Should -Contain "fake.hotfix"
+
+ $outputMetaData.WebPackagesToUninstall | Should -Contain $fakeHotfix
+ }
+
+ It "Removes The Package From The Install Web List" {
+ $outputMetaData.WebPackagesToInstall | Should -Not -Be $null
+
+ $outputMetaData.WebPackagesToInstall | Should -Not -Contain $fakeHotfix
+ }
+
+}
+
+Context "When The Existing Hotfix Version is Greater Than The Target On Web Servers" {
+
+ # Define wrapper objects
+ $packageData = New-PackageMetadataObject
+ $packageData.WebServers = @("webfake1.fh.local", "webfake2.fh.local")
+ $packageData.AppServers = @("appfake1.fh.local", "appfake2.fh.local")
+
+ $packageData.WebPackagesToInstall += $webDummyPackage
+
+ $packageData.WebPackagesToInstall += $fakeHotfix
+
+ $debugMetadata = New-Object psobject -property @{
+ WebServerPackages = @()
+ AppServerPackages = @()
+ MicServerPackages = @()
+ FabServerPackages = @()
+ UninstallExistingHotfixPackageNames = @()
+
+ WebPackagesToInstallMap = @{}
+ AppPackagesToInstallMap = @{}
+ ClassifiedPackagesMap = @{}
+ ExistingInstalledHotfixes = @()
+ }
+
+ $debugMetadata.WebServerPackages += $fakeHotfix
+
+ Mock -ModuleName $moduleForMock -CommandName Invoke-Command -MockWith {
+ return $null
+ } -ParameterFilter {$ComputerName -like "*appfake1*" }
+
+ Mock -ModuleName $moduleForMock -CommandName Invoke-Command -MockWith {
+ return [PSCustomObject]@{
+ PackageName = "fake.hotfix"
+ ManifestPath = "TestDrive:\FakeManifest.Xml"
+ Manifest = @{
+ "hotfixManifest" = @{fixedInORBVersion = "9999.0.0.0" }
+ "general" = @{
+ creatorCode = "Alkami"
+ element = "Alkami.Hotfix.UnitTest"
+ componentType = "Hotfix"
+ }
+ "version" = 1.0
+ }
+ }
+ } -ParameterFilter {$ComputerName -like "*webfake1*" }
+
+ $outputMetaData, $outputDebugMetadata = Add-OldHotfixPackagesToUninstallList -DependencyReleaseValue $defaultReleaseValue -PackageMetadata $packageData -DebugMetadata $debugMetadata
+
+ It "Does Not Tell The User It Will Uninstall The Hotfix" {
+ Assert-MockCalled -CommandName Write-Host -ModuleName $moduleForMock -Times 0 -Exactly -Scope Context -ParameterFilter { $Object -like "*Found hotfixes to be uninstalled*" }
+ }
+
+ It "Does Not Add The Package To The Uninstall List"{
+ $outputDebugMetadata.UninstallExistingHotfixPackageNames | Should -Not -Contain "fake.hotfix"
+
+ $outputMetaData.WebPackagesToUninstall | Should -Not -Contain $fakeHotfix
+ }
+
+ It "Does Not Remove The Package From The Install List" {
+ $outputMetaData.WebPackagesToInstall | Should -Not -Be $null
+
+ $outputMetaData.WebPackagesToInstall | Should -Contain $fakeHotfix
+ }
+}
+#endregion Tests for hotfix only on web servers
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Installation/Public/Add-OverflowCustomAttribute.ps1 b/Modules/Alkami.DevOps.Installation/Public/Add-OverflowCustomAttribute.ps1
new file mode 100644
index 0000000..e0e4fc2
--- /dev/null
+++ b/Modules/Alkami.DevOps.Installation/Public/Add-OverflowCustomAttribute.ps1
@@ -0,0 +1,53 @@
+function Add-OverflowCustomAttribute {
+<#
+.SYNOPSIS
+ Adds Overflow: true or false as custom node attributes in newrelic-infra.yml
+
+.DESCRIPTION
+ If a server is an overflow server, Perf wants to know via new relic custom attribute.
+ This affects the server that this function is being run on.
+
+.PARAMETER IsOverflow
+ IsOverflow sets the value to true or false
+
+.EXAMPLE
+ Add-OverflowCustomAttribute -IsOverflow $true
+#>
+
+ [CmdletBinding()]
+ Param(
+ [Parameter(Mandatory = $true)]
+ [bool]$IsOverflow
+ )
+
+ $logLead = Get-LogLeadName
+
+ $isDirty = $false
+ Import-Module powershell-yaml
+
+ $Path = Get-NewRelicYamlPath
+ # Returning/stopping exactly where the error is
+ if (Test-StringIsNullOrWhitespace $Path) { return }
+
+ $fileContent = Get-Content $Path -Raw
+ $yaml = ConvertFrom-Yaml -Yaml $fileContent -Ordered
+
+ # This will add the custom_attribute field if it isn't already present
+ # Otherwise it will update the Overflow value
+ if (!($yaml.custom_attributes)) {
+ $yaml.custom_attributes = @{Overflow = $IsOverflow }
+ $isDirty = $true
+ } elseif ($yaml.custom_attributes.Overflow -ne $IsOverflow) {
+ $yaml.custom_attributes.Overflow = $IsOverflow
+ $isDirty = $true
+ }
+
+ if ($isDirty) {
+ Write-Host "$loglead : Setting Overflow Attribute to $IsOverflow"
+ Write-Host "$loglead : Saving to path: $Path"
+ ConvertTo-Yaml -Data $yaml -Outfile $Path -Force
+ } else {
+ Write-Host "$loglead : Nothing to change in the config file"
+ }
+
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Installation/Public/Add-OverflowCustomAttribute.tests.ps1 b/Modules/Alkami.DevOps.Installation/Public/Add-OverflowCustomAttribute.tests.ps1
new file mode 100644
index 0000000..e9e3131
--- /dev/null
+++ b/Modules/Alkami.DevOps.Installation/Public/Add-OverflowCustomAttribute.tests.ps1
@@ -0,0 +1,71 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ""
+
+
+Describe "Add-OverflowCustomAttribute" {
+ Mock -ModuleName $moduleForMock -CommandName Get-Content
+ Mock -ModuleName $moduleForMock -CommandName Test-Path
+ Mock -ModuleName $moduleForMock -CommandName Write-Warning
+ Mock -ModuleName $moduleForMock -CommandName Write-Host
+ Mock -ModuleName $moduleForMock -CommandName Get-LogLeadName
+ Mock -ModuleName $moduleForMock -CommandName Import-Module
+ Mock -ModuleName $moduleForMock -CommandName ConvertTo-Yaml
+ Mock -ModuleName $moduleForMock -CommandName Get-NewRelicYamlPath -MockWith { "C:\Program Files\New Relic\newrelic-infra\newrelic-infra.yml" }
+
+ Context "Updates custom attributes" {
+
+ It "Overflow value was false" {
+ Mock -ModuleName $moduleForMock -CommandName ConvertFrom-Yaml -MockWith { return @{ custom_attributes = @{ Overflow = $true } } }
+ Add-OverflowCustomAttribute -IsOverflow $false
+ Assert-MockCalled -CommandName ConvertTo-Yaml -Times 1 -Exactly -Scope It -ModuleName $moduleForMock -ParameterFilter { $data.custom_attributes.Overflow -eq $false }
+ }
+ It "Overflow value was true" {
+ Mock -ModuleName $moduleForMock -CommandName ConvertFrom-Yaml -MockWith { return @{ custom_attributes = @{ Overflow = $false } } }
+ Add-OverflowCustomAttribute -IsOverflow $true
+ Assert-MockCalled -CommandName ConvertTo-Yaml -Times 1 -Exactly -Scope It -ModuleName $moduleForMock -ParameterFilter { $data.custom_attributes.Overflow -eq $true }
+ }
+ }
+
+ Context "Adds custom attributes and Overflow when only custom_attributes is present" {
+
+ Mock -ModuleName $moduleForMock -CommandName ConvertFrom-Yaml -MockWith { return @{ custom_attributes = @{ Pod = 17 } } }
+
+ It "Overflow value was true" {
+ Add-OverflowCustomAttribute -IsOverflow $true
+ Assert-MockCalled -CommandName ConvertTo-Yaml -Times 1 -Exactly -Scope It -ModuleName $moduleForMock -ParameterFilter { $data.custom_attributes.Overflow -eq $true }
+ }
+ It "Overflow value was false" {
+ Add-OverflowCustomAttribute -IsOverflow $false
+ Assert-MockCalled -CommandName ConvertTo-Yaml -Times 1 -Exactly -Scope It -ModuleName $moduleForMock -ParameterFilter { $data.custom_attributes.Overflow -eq $false }
+ }
+ }
+
+ Context "Adds custom attributes and Overflow when custom_attributes and Overflow are not present" {
+
+ Mock -ModuleName $moduleForMock -CommandName ConvertFrom-Yaml -MockWith { return @{ } }
+
+ It "Overflow value was false" {
+ Add-OverflowCustomAttribute -IsOverflow $false
+ Assert-MockCalled -CommandName ConvertTo-Yaml -Times 1 -Exactly -Scope It -ModuleName $moduleForMock -ParameterFilter { $data.custom_attributes.Overflow -eq $false }
+ }
+ It "Overflow Value was true" {
+ Add-OverflowCustomAttribute -IsOverflow $true
+ Assert-MockCalled -CommandName ConvertTo-Yaml -Times 1 -Exactly -Scope It -ModuleName $moduleForMock -ParameterFilter { $data.custom_attributes.Overflow -eq $true }
+ }
+ }
+
+ Context "No file changes if no file changes are needed" {
+
+ Mock -ModuleName $moduleForMock -CommandName ConvertFrom-Yaml -MockWith { return @{ custom_attributes = @{ Pod = 17; Overflow = $false } } }
+
+ It "No file change if IsOverflow is the same value in file" {
+ Add-OverflowCustomAttribute -IsOverflow $false
+ Assert-MockCalled -CommandName ConvertTo-Yaml -Times 0 -Exactly -Scope It -ModuleName $moduleForMock
+ }
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Installation/Public/Copy-NewRelicCustomInstrumentationFiles.ps1 b/Modules/Alkami.DevOps.Installation/Public/Copy-NewRelicCustomInstrumentationFiles.ps1
new file mode 100644
index 0000000..a680ad2
--- /dev/null
+++ b/Modules/Alkami.DevOps.Installation/Public/Copy-NewRelicCustomInstrumentationFiles.ps1
@@ -0,0 +1,22 @@
+function Copy-NewRelicCustomInstrumentationFiles {
+<#
+ .SYNOPSIS
+ Copies the New Relic .NET Agent Custom Instrumentation Files to the NR Extensions Folder
+#>
+ [CmdletBinding()]
+ param(
+
+ )
+ $logLead = (Get-LogLeadName);
+
+ ## Get the custom path from the current folder -> NewRelicCustomInstrumentation
+ $customInstrumentationPath = Join-Path $PSScriptRoot "NewRelicCustomInstrumentation"
+ if (Test-Path $customInstrumentationPath) {
+ $nrExtensionsPath = "C:\ProgramData\New Relic\.NET Agent\Extensions"
+ Write-Output ("$logLead : Copying Custom Instrumentation Files from {0} to {1}" -f $customInstrumentationPath, $nrExtensionsPath)
+
+ Get-ChildItem $customInstrumentationPath | Select-Object -ExpandProperty FullName | Copy-Item -Destination $nrExtensionsPath -Force
+ } else {
+ Write-Warning ("$logLead : Skipping copy of custom instrumentation files from {0}" -f $customInstrumentationPath)
+ }
+}
diff --git a/Modules/Alkami.DevOps.Installation/Public/Get-BadPackages.Tests.ps1 b/Modules/Alkami.DevOps.Installation/Public/Get-BadPackages.Tests.ps1
new file mode 100644
index 0000000..87d89b8
--- /dev/null
+++ b/Modules/Alkami.DevOps.Installation/Public/Get-BadPackages.Tests.ps1
@@ -0,0 +1,109 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ""
+$currentErrorActionPreference = $ErrorActionPreference
+$ErrorActionPreference = "stop"
+
+Describe "Get-BadPackages" {
+
+ Mock -CommandName Write-Host -MockWith {}
+ Mock -CommandName Write-Warning -MockWith {}
+ Mock -CommandName Write-Error -MockWith {}
+
+ $webPackage1 = New-DummyPackageInstallationData -PackageName "web.package1" -PackageVersion "1.0.0" -IsWebOnly
+ $webPackage2 = New-DummyPackageInstallationData -PackageName "web.package2" -PackageVersion "1.0.0" -IsWebOnly
+ $appPackage1 = New-DummyPackageInstallationData -PackageName "app.package1" -PackageVersion "1.0.0" -IsAppOnly
+ $appPackage2 = New-DummyPackageInstallationData -PackageName "app.package2" -PackageVersion "1.0.0" -IsAppOnly
+ $micPackage1 = New-DummyPackageInstallationData -PackageName "mic.package1" -PackageVersion "1.0.0" -IsMicOnly
+ $micPackage2 = New-DummyPackageInstallationData -PackageName "mic.package2" -PackageVersion "1.0.0" -IsMicOnly
+
+ $appPackageArray = @($appPackage1, $appPackage2)
+ $webPackageArray = @($webPackage1, $webPackage2)
+ $micPackageArray = @($micPackage1, $micPackage2)
+
+ $appServerPackageArray = @($appPackage1, $appPackage2, $webPackage1)
+ $webServerPackageArray = @($webPackage1, $webPackage2, $appPackage1)
+ $micServerPackageArray = @($micPackage1, $micPackage2, $webPackage1)
+ $fabServerPackageArray = @($micPackage1, $micPackage2, $webPackage1)
+
+ # Define wrapper objects
+ $packageData = New-PackageMetadataObject
+
+ $debugMetadata = New-Object psobject -property @{
+ WebServerPackages = @()
+ AppServerPackages = @()
+ MicServerPackages = @()
+ FabServerPackages = @()
+
+ WebPackagesToInstallMap = @{}
+ AppPackagesToInstallMap = @{}
+ ClassifiedPackagesMap = @{}
+ }
+
+ # Populate wrapper objects.
+ $packageData.AppPackagesToInstall = $appPackageArray
+ $packageData.WebPackagesToInstall = $webPackageArray
+ $packageData.MicPackagesToInstall = $micPackageArray
+ $debugMetadata.WebServerPackages = $webServerPackageArray
+ $debugMetadata.AppServerPackages = $appServerPackageArray
+ $debugMetadata.MicServerPackages = $micServerPackageArray
+ $debugMetadata.FabServerPackages = $fabServerPackageArray
+
+ $debugMetadata.ClassifiedPackagesMap["web.package1"] = $webPackage1
+ $debugMetadata.ClassifiedPackagesMap["web.package2"] = $webPackage2
+ $debugMetadata.ClassifiedPackagesMap["app.package1"] = $appPackage1
+ $debugMetadata.ClassifiedPackagesMap["app.package2"] = $appPackage2
+ $debugMetadata.ClassifiedPackagesMap["mic.package1"] = $micPackage1
+ $debugMetadata.ClassifiedPackagesMap["mic.package2"] = $micPackage2
+
+ Context "When Debug MetaData Has WebServerPackages" {
+ It "Marks Packages To Be Uninstalled From Web Servers" {
+ $packageData = Get-BadPackages $DebugMetadata $packageData
+
+ $packageData.BadWebPackagesToUninstall | Should -Not -BeNullOrEmpty
+ }
+ }
+
+ Context "When Debug MetaData Has AppServerPackages" {
+ It "Marks Packages To Be Uninstalled From App Servers" {
+ $packageData = Get-BadPackages $DebugMetadata $packageData
+
+ $packageData.BadAppPackagesToUninstall | Should -Not -BeNullOrEmpty
+ }
+
+ It "Sets the ActionType to Uninstall"{
+ $packageData = Get-BadPackages $DebugMetadata $packageData
+
+ $packageData.BadAppPackagesToUninstall.ActionType | Should -BeLikeExactly "Uninstall"
+ }
+
+ It "Sets the ActionReason to Wrong_Host_Type"{
+ $packageData = Get-BadPackages $DebugMetadata $packageData
+
+ $packageData.BadAppPackagesToUninstall.ActionReason | Should -BeLikeExactly "Wrong_Host_Type"
+ }
+ }
+ Context "When Debug MetaData Has MicServerPackages" {
+ It "Marks Packages To Be Uninstalled From Mic Servers" {
+ $packageData = Get-BadPackages $DebugMetadata $packageData
+
+ $packageData.BadMicPackagesToUninstall | Should -Not -BeNullOrEmpty }
+ }
+ Context "When Debug MetaData Has FabServerPackages" {
+ It "Marks Packages To Be Uninstalled From Mic Servers" {
+ $packageData = Get-BadPackages $DebugMetadata $packageData
+
+ $packageData.BadMicPackagesToUninstall | Should -Not -BeNullOrEmpty
+ }
+
+ It "Does Not Mark Packages To Be Uninstalled From Fab Servers" {
+ $packageData = Get-BadPackages $DebugMetadata $packageData
+
+ $packageData.BadFabPackagesToUninstall | Should -BeNullOrEmpty
+ }
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Installation/Public/Get-BadPackages.ps1 b/Modules/Alkami.DevOps.Installation/Public/Get-BadPackages.ps1
new file mode 100644
index 0000000..621c934
--- /dev/null
+++ b/Modules/Alkami.DevOps.Installation/Public/Get-BadPackages.ps1
@@ -0,0 +1,105 @@
+function Get-BadPackages {
+ <#
+ .SYNOPSIS
+ Determine if some packages are on the wrong servers. Utilized by Classify-Packages.
+
+ .PARAMETER DebugMetadata
+ Meta object used to determine what goes where.
+
+ .PARAMETER PackageMetadata
+ Packages object to populate.
+ #>
+ [CmdletBinding()]
+ param(
+ $DebugMetadata,
+ $PackageMetadata
+ )
+ # Produce a list of bad packages on the wrong servers, to be uninstalled.
+ if (!(Test-IsCollectionNullOrEmpty $DebugMetadata.WebServerPackages)) {
+ $packages = @()
+ foreach($package in $DebugMetadata.WebServerPackages) {
+ if($null -ne $package) {
+ ## if the package was found on the web server but it wasn't supposed to be on the web, remove it
+ $packageNameLower = $package.Name.ToLower()
+ $foundPackage = $DebugMetadata.ClassifiedPackagesMap.$packageNameLower
+ if (($null -ne $foundPackage) -and (!$foundPackage.InstallToWeb)) {
+ $packages += $foundPackage
+ $packageMetadata.HasBadPackages = $true
+ }
+ }
+ }
+ $packages.ForEach({
+ Add-Member -InputObject $_ -NotePropertyName "ActionType" -NotePropertyValue "Uninstall" -Force
+ Add-Member -InputObject $_ -NotePropertyName "ActionReason" -NotePropertyValue "Wrong_Host_Type" -Force
+ })
+ $PackageMetadata.BadWebPackagesToUninstall = $packages
+ }
+
+ if (!(Test-IsCollectionNullOrEmpty $DebugMetadata.AppServerPackages)) {
+ $packages = @()
+ foreach ($package in $DebugMetadata.AppServerPackages) {
+ if ($null -ne $package) {
+ ## if the package was found on the app server but it wasn't supposed to be on the app, remove it
+ $packageNameLower = $package.Name.ToLower()
+ $foundPackage = $DebugMetadata.ClassifiedPackagesMap.$packageNameLower
+ if (($null -ne $foundPackage) -and (!$foundPackage.InstallToApp)) {
+ $packages += $foundPackage
+ $PackageMetadata.HasBadPackages = $true
+ }
+ }
+ }
+ $packages.ForEach({
+ Add-Member -InputObject $_ -NotePropertyName "ActionType" -NotePropertyValue "Uninstall" -Force
+ Add-Member -InputObject $_ -NotePropertyName "ActionReason" -NotePropertyValue "Wrong_Host_Type" -Force
+ })
+ $PackageMetadata.BadAppPackagesToUninstall = $packages
+ }
+
+ if (!(Test-IsCollectionNullOrEmpty $DebugMetadata.MicServerPackages)) {
+ $packages = @()
+ foreach($package in $DebugMetadata.MicServerPackages) {
+ if ($null -ne $package) {
+ ## if the package was found on the mic server but it wasn't supposed to be on the mic, remove it
+ $packageNameLower = $package.Name.ToLower()
+ $foundPackage = $DebugMetadata.ClassifiedPackagesMap.$packageNameLower
+ if (($null -ne $foundPackage) -and (!$foundPackage.InstallToMic)) {
+ $packages += $foundPackage
+ $PackageMetadata.HasBadPackages = $true
+ }
+ }
+ }
+ $packages.ForEach({
+ Add-Member -InputObject $_ -NotePropertyName "ActionType" -NotePropertyValue "Uninstall" -Force
+ Add-Member -InputObject $_ -NotePropertyName "ActionReason" -NotePropertyValue "Wrong_Host_Type" -Force
+ })
+ $PackageMetadata.BadMicPackagesToUninstall = $packages
+ }
+
+ if (!(Test-IsCollectionNullOrEmpty $DebugMetadata.FabServerPackages)) {
+ $packages = @()
+ foreach ($package in $DebugMetadata.FabServerPackages) {
+ if ($null -ne $package) {
+ ## if the package was found on the fab server but it wasn't supposed to be on the fab, remove it
+ $packageNameLower = $package.Name.ToLower()
+ $foundPackage = $DebugMetadata.ClassifiedPackagesMap.$packageNameLower
+ if (($null -ne $foundPackage) -and (!$foundPackage.InstallToFab)) {
+ $packages += $foundPackage
+ $PackageMetadata.HasBadPackages = $true
+ }
+ }
+ }
+ $packages.ForEach({
+ Add-Member -InputObject $_ -NotePropertyName "ActionType" -NotePropertyValue "Uninstall" -Force
+ Add-Member -InputObject $_ -NotePropertyName "ActionReason" -NotePropertyValue "Wrong_Host_Type" -Force
+ })
+ $PackageMetadata.BadFabPackagesToUninstall = $packages
+ }
+
+ # Combine the mic/fab uninstalls into mics only. Mics/Fabs are mutually exclusive in the eyes of the deploy, might as well use one var.
+ $PackageMetadata.BadMicPackagesToUninstall = (Select-UniqueServerPackages @($PackageMetadata.BadMicPackagesToUninstall, $PackageMetadata.BadFabPackagesToUninstall))
+ $PackageMetadata.BadFabPackagesToUninstall = @()
+
+ # NOTE: The bad packages lists are added to the web/app/mic uninstall lists below, where the mic uninstalls are separate from the app installs.
+
+ return $PackageMetadata
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Installation/Public/Get-ClientWebSiteInformationFromDatabase.ps1 b/Modules/Alkami.DevOps.Installation/Public/Get-ClientWebSiteInformationFromDatabase.ps1
new file mode 100644
index 0000000..075ed92
--- /dev/null
+++ b/Modules/Alkami.DevOps.Installation/Public/Get-ClientWebSiteInformationFromDatabase.ps1
@@ -0,0 +1,28 @@
+function Get-ClientWebSiteInformationFromDatabase {
+<#
+.SYNOPSIS
+ Fetches Client Website Information from Database.
+#>
+
+ [CmdletBinding()]
+ param()
+ # Todo - pull other machines in OU and determine which is an app server
+ # Execute the below on the remote server
+
+ [hashtable[]]$clients += Get-CatalogsFromMaster
+ $urls = @()
+
+ if ($clients.Count -eq 1) {
+ $ipstsUrl = Get-IPSTSUrlFromClient $clients
+ $urls += @{Client = $clients.Signature; Admin = $clients.AdminSignature; IPSTS = $ipstsUrl}
+ }
+ else {
+ foreach ($client in $clients) {
+ $ipstsUrl = Get-IPSTSUrlFromClient $client
+ $urls += @{Key = $client.Name; Client = $client.Signature; Admin = $client.AdminSignature; IPSTS = $ipstsUrl}
+ }
+ }
+
+ return $urls
+}
+
diff --git a/Modules/Alkami.DevOps.Installation/Public/Get-EnvironmentData.Tests.ps1 b/Modules/Alkami.DevOps.Installation/Public/Get-EnvironmentData.Tests.ps1
new file mode 100644
index 0000000..af91a82
--- /dev/null
+++ b/Modules/Alkami.DevOps.Installation/Public/Get-EnvironmentData.Tests.ps1
@@ -0,0 +1,103 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ""
+$currentErrorActionPreference = $ErrorActionPreference
+$ErrorActionPreference = "stop"
+
+Describe "Get-EnvironmentData" {
+ $fakeDotNetConfigPath = Join-Path -Path $TestDrive -ChildPath "machine.config"
+ # Return each of the settings based on parameter filters
+ Mock -CommandName Write-Host -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Write-Warning -ModuleName $moduleForMock -MockWith {}
+
+ Mock -CommandName Get-UncPath -ModuleName $moduleForMock -MockWith { return $fakeDotNetConfigPath }
+ Mock -CommandName Read-XmlFile -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Invoke-Parallel -ModuleName $moduleForMock -MockWith {}
+
+ Context "When Successfully Getting EnvironmentData from AppSettings" {
+ $packageData = @{}
+ $packageData.DisableMicroserviceNewRelic = $true
+ $appPackage1 = New-DummyPackageInstallationData -PackageName "app.package1" -PackageVersion "1.0.0" -IsFullScale
+ $appPackage2 = New-DummyPackageInstallationData -PackageName "app.package2" -PackageVersion "1.0.0" -IsFullScale
+
+ $packageArray = @($appPackage1, $appPackage2)
+
+ $packageData.AppPackagesToInstall = $packageArray
+
+ # these tests were essentially "does Get-EnvironmentThing work?"
+ # there's no business logic to them
+ # It "It Gets EnvironmentName" {
+ # $data = Get-EnvironmentData $packageData
+
+ # $data.EnvironmentName | Should -Match "UnitTest.Env"
+ # }
+
+ # It "It Gets EnvironmentType" {
+ # $data = Get-EnvironmentData $packageData
+
+ # $data.EnvironmentType | Should -Match "UnitTest.Type"
+ # }
+
+ # It "It Gets EnvironmentNameSafeDesignation" {
+ # $data = Get-EnvironmentData $packageData
+
+ # $data.EnvironmentNameSafeDesignation | Should -Match "UTT"
+ # }
+
+ # It "It Gets EnvironmentHosting" {
+ # $data = Get-EnvironmentData $packageData
+
+ # $data.EnvironmentHosting | Should -Match "local"
+ # }
+ }
+
+ Context "When Getting Data from Tags" {
+ $packageData = @{}
+ $packageData.AppServers = @("APP123TEST.fh.local")
+ $packageData.DisableMicroserviceNewRelic = $true
+ $appPackage1 = New-DummyPackageInstallationData -PackageName "app.package1" -PackageVersion "1.0.0" -IsFullScale
+ $appPackage2 = New-DummyPackageInstallationData -PackageName "app.package2" -PackageVersion "1.0.0" -IsFullScale
+
+ $packageArray = @($appPackage1, $appPackage2)
+
+ $packageData.AppPackagesToInstall = $packageArray
+
+ Mock Get-InstanceTags -MockWith { # return tags
+ $tag1 = @{
+ PSComputerName = "Test.Machine"
+ Key = "alk:env"
+ Value = "dev"
+ }
+
+ $tag2 = @{
+ PSComputerName = "Test.Machine"
+ Key = "alk:designation"
+ Value = "FAKEVALUE"
+ }
+ return @($tag1, $tag2)
+ }
+
+ Mock -CommandName Get-DesignationTagNameByEnvironment -ModuleName $moduleForMock -MockWith {
+ return "designation"
+ }
+
+ # How was this mocked? It's a degree of separation away. It shouldnot have been being used.∫
+ # Mock Get-AppSetting -MockWith {return "UnitTest.Env"} -ParameterFilter {$Key -eq "Environment.Name"}
+ # Mock Get-AppSetting -MockWith {return "UnitTest.Type"} -ParameterFilter {$Key -eq "Environment.Type"}
+ # Mock Get-AppSetting -MockWith {return "local"} -ParameterFilter {$Key -eq "Environment.Hosting"}
+ # No available value locally
+ Mock -CommandName Get-AppSetting -ModuleName $moduleForMock -MockWith { return $null } -ParameterFilter { $Key -eq "Environment.NameSafeDesignation" }
+
+ Mock -CommandName Write-Error -ModuleName $moduleForMock -MockWith {}
+
+ It "It Gets EnvironmentNameSafeDesignation" {
+ $data = Get-EnvironmentData $packageData
+
+ $data.EnvironmentNameSafeDesignation | Should -Match "FAKEVALUE"
+ }
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Installation/Public/Get-EnvironmentData.ps1 b/Modules/Alkami.DevOps.Installation/Public/Get-EnvironmentData.ps1
new file mode 100644
index 0000000..ee3bed6
--- /dev/null
+++ b/Modules/Alkami.DevOps.Installation/Public/Get-EnvironmentData.ps1
@@ -0,0 +1,136 @@
+function Get-EnvironmentData {
+ <#
+ .SYNOPSIS
+ Get basic data about the environment. Called by Get-ServerPackageInformation as part of the Classify_Packages part of the Deploy pipeline
+
+ .PARAMETER PackageMetadata
+ Packages object to populate
+
+ .LINK
+ Get-ServerPackageInformation
+
+ .LINK
+ Test-IsEclairInstalled
+ #>
+
+ [CmdletBinding()]
+ param(
+ [PSObject]$PackageMetadata
+ )
+
+ $loglead = Get-LogLeadName
+
+ $PackageMetadata.ServersToQuery = (
+ @($PackageMetadata.WebServers) +
+ @($PackageMetadata.AppServers) +
+ @($PackageMetadata.MicServers) +
+ @($PackageMetadata.SelectedFabServer)
+ ) | Where-Object { $_ }
+
+ $serverToQuery = $PackageMetadata.ServersToQuery | Select-Object -First 1
+
+ # Read the machine.config into a variable to avoid going across the network to read
+ # it every time we call Get-AppSetting... which we were doing before... :/
+ $dncPath = Get-DotNetConfigPath
+ $dncUncPath = Get-UncPath -ComputerName $serverToQuery -filePath $dncPath -IgnoreLocalPaths
+ $machineConfigContent = Read-XMLFile -xmlPath $dncUncPath
+
+ # Map from $packageMetadata KEY to machine.config KEY
+ $envDataKeyMap = @{
+ EnvironmentName = "Environment.Name"
+ EnvironmentType = "Environment.Type"
+ EnvironmentHosting = "Environment.Hosting"
+ EnvironmentNameSafeDesignation = "Environment.NameSafeDesignation"
+ }
+
+ foreach ($envDataKey in $envDataKeyMap.keys) {
+ Write-Host "$loglead : deployData Key: $envDataKey"
+
+ $mcKey = $envDataKeyMap[$envDataKey]
+ Write-Host "$loglead : Machine config key: $mcKey"
+
+ $mcValue = Get-AppSetting -Key $mcKey -XmlDocument $machineConfigContent -SuppressWarnings
+ Write-Host "$loglead : Machine config value: $mcValue"
+
+ $packageMetadata.$envDataKey = $mcValue
+ }
+
+
+ if ($PackageMetadata.EnvironmentNameSafeDesignation) {
+ Write-Host "$loglead : EnvironmentNameSafeDesignation: $($PackageMetadata.EnvironmentNameSafeDesignation)"
+ } else {
+ Write-Warning "$loglead : NameSafeDesignation MISSING from the machine.config. Getting from tags..."
+
+ $tags = Get-InstanceTags -ServerToTest $serverToQuery
+
+ foreach ($tag in $tags) {
+ if ($tag.Key -eq "alk:env") {
+ $designation = Get-DesignationTagNameByEnvironment $tag.Value
+ }
+ }
+ if ($designation) {
+ foreach ($tag in $tags) {
+ if ($tag.Key -eq "alk:$designation") {
+ $PackageMetadata.EnvironmentNameSafeDesignation = $tag.Value.Replace('.', '-').Trim().ToLower()
+ }
+ }
+ }
+
+ if (!$designation -or !$PackageMetadata.EnvironmentNameSafeDesignation) {
+ Write-Error "$loglead : Did not find a Designation tag. This server ($serverToQuery) is missing both machine.config values and tags. Investigation is required."
+ }
+ }
+
+ if ($PackageMetadata.EnvironmentHosting) {
+ Write-Host "$loglead : EnvironmentHosting: $($PackageMetadata.EnvironmentHosting)"
+ } else {
+ Write-Warning "$loglead : Could not obtain a EnvironmentHosting. This will result in no Infrastructure Migrations being run."
+ }
+
+ #region IsEclairInstalled
+ $sbIsEclairInstalled = {
+ param($server)
+ try {
+ $results = @{
+ Hostname = $server
+ IsEclairInstalled = $false
+ Error = $null
+ Success = $false
+ }
+ $ei = Test-IsEclairInstalled -ComputerName $server
+ $results.IsEclairInstalled = $ei
+ $results.Success = $true
+ } catch {
+ $ex = $_
+ $msg = $_.Exception.Message
+ Write-Warning "$loglead : There was an error testing for Eclair"
+ Write-Warning "$msg"
+ $results.Error = $ex
+ $results.Success = $false
+ }
+ return $results
+ }
+
+ $ieiResults = Invoke-Parallel -objects $PackageMetadata.ServersToQuery -script $sbIsEclairInstalled
+
+ $isEclairInstalledOnAllHosts = $ieiResults.IsEclairInstalled -notcontains $false
+
+ Write-Host "$loglead : Is Eclair installed on all hosts: $isEclairInstalledOnAllHosts"
+ $PackageMetadata.IsEclairInstalledOnAllHosts = $isEclairInstalledOnAllHosts
+
+ # Trim out crap from (RunspaceId, PSSourceJobInstanceId, etc.) ReceiveJob so the Classify Packages tests will pass
+ $trimmedResults = @()
+ foreach($result in $ieiResults)
+ {
+ $hash = @{}
+ foreach($enum in $result.GetEnumerator()) {
+ $hash.add($enum.key, $enum.value)
+ }
+ $trimmedResults += $hash
+ }
+
+ $PackageMetadata.EclairInstallData = $trimmedResults
+
+ #endregion IsEclairInstalled
+ return $PackageMetadata
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Installation/Public/Get-MicroserviceNewRelicMapping.Tests.ps1 b/Modules/Alkami.DevOps.Installation/Public/Get-MicroserviceNewRelicMapping.Tests.ps1
new file mode 100644
index 0000000..c09e63a
--- /dev/null
+++ b/Modules/Alkami.DevOps.Installation/Public/Get-MicroserviceNewRelicMapping.Tests.ps1
@@ -0,0 +1,89 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ""
+$currentErrorActionPreference = $ErrorActionPreference
+$ErrorActionPreference = "stop"
+
+Describe 'Get-MicroserviceNewRelicMapping'{
+ # Silence messages. Comment these out if you're debugging tests.
+ Mock -CommandName Write-Host -MockWith {}
+ Mock -CommandName Write-Warning -MockWith {}
+ Mock -CommandName Write-Error -MockWith {}
+ Mock -CommandName Set-Content -MockWith {}
+ Mock -CommandName Add-Member -MockWith {}
+ Mock -CommandName Join-Path -MockWith {return "TestDrive:\$ChildPath"}
+ Mock -CommandName Test-Path -MockWith {return $true}
+ Mock -CommandName Get-Content -MockWith { return @"
+app.package1
+app.package2
+"@
+ }
+
+ Context 'When Some New Relic Monitoring Should Be Disabled' {
+ $packageData = New-PackageMetadataObject
+ $packageData.DisableMicroserviceNewRelic = $true
+ $appPackage1 = New-DummyPackageInstallationData -PackageName "app.package1" -PackageVersion "1.0.0" -IsFullScale
+ $appPackage2 = New-DummyPackageInstallationData -PackageName "app.package2" -PackageVersion "1.0.0" -IsFullScale
+
+ $packageArray = @($appPackage1, $appPackage2)
+
+ $packageData.AppPackagesToInstall = $packageArray
+
+ It "Sets NR On/Off Status" {
+ $packages = Get-MicroserviceNewRelicMapping -BuildCheckoutDirectory "SomePath" -PackageMetadata $packageData
+
+ $packages | Should -Not -BeNullOrEmpty
+
+ Assert-MockCalled -CommandName Add-Member -Scope Context -Times 1
+ }
+ }
+
+
+ Context 'When No New Relic Monitoring Should Be Disabled' {
+ $packageData = New-PackageMetadataObject
+ $packageData.DisableMicroserviceNewRelic = $false
+ $appPackage1 = New-DummyPackageInstallationData -PackageName "app.package1" -PackageVersion "1.0.0" -IsFullScale
+ $appPackage2 = New-DummyPackageInstallationData -PackageName "app.package2" -PackageVersion "1.0.0" -IsFullScale
+
+ $packageArray = @($appPackage1, $appPackage2)
+
+ $packageData.AppPackagesToInstall = $packageArray
+
+ It "Does Not Modify New Relic Status On Any Packages"{
+ $packages = Get-MicroserviceNewRelicMapping -BuildCheckoutDirectory "SomePath" -PackageMetadata $packageData
+
+ $packages | Should -Not -BeNullOrEmpty
+
+ Assert-MockCalled -CommandName Add-Member -Scope Context -Times 0
+ }
+ }
+
+ Context 'When NewRelicMicroServices.txt is not found' {
+ # No New Relic file:
+ Mock -CommandName Test-Path -MockWith {return $false}
+ Mock -CommandName Write-Warning -MockWith {}
+
+ $packageData = New-PackageMetadataObject
+ $packageData.DisableMicroserviceNewRelic = $true
+ $appPackage1 = New-DummyPackageInstallationData -PackageName "app.package1" -PackageVersion "1.0.0" -IsFullScale
+ $appPackage2 = New-DummyPackageInstallationData -PackageName "app.package2" -PackageVersion "1.0.0" -IsFullScale
+
+ $packageArray = @($appPackage1, $appPackage2)
+
+ $packageData.AppPackagesToInstall = $packageArray
+
+ It "Disables Everything"{
+
+ $packages = Get-MicroserviceNewRelicMapping -BuildCheckoutDirectory "SomePath" -PackageMetadata $packageData
+
+ foreach ($package in $packages) {
+ $package.EnableNewRelic | Should -BeNullOrEmpty
+ }
+ Assert-MockCalled -CommandName Write-Warning -Scope Context -Times 1
+ }
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Installation/Public/Get-MicroserviceNewRelicMapping.ps1 b/Modules/Alkami.DevOps.Installation/Public/Get-MicroserviceNewRelicMapping.ps1
new file mode 100644
index 0000000..78f03e9
--- /dev/null
+++ b/Modules/Alkami.DevOps.Installation/Public/Get-MicroserviceNewRelicMapping.ps1
@@ -0,0 +1,43 @@
+function Get-MicroserviceNewRelicMapping {
+<#
+.SYNOPSIS
+ Determine which microservices should report to New Relic. Utilized by Classify-Packages.
+
+.PARAMETER BuildCheckoutDirectory
+ Directory where the code is checked out by TC.
+
+.PARAMETER PackageMetadata
+ Packages object to populate.
+#>
+
+ [CmdletBinding()]
+ param(
+ $BuildCheckoutDirectory,
+ [PSObject]$PackageMetadata
+ )
+ # For each of the microservice packages, figure out if they will have new relic enabled or disabled.
+ # Build out the config checkout directory.
+ $configCheckoutDirectory = (Join-Path $BuildCheckoutDirectory "config-defaults")
+
+ if($PackageMetadata.DisableMicroserviceNewRelic) {
+ Write-Host "Now determining which microservices need New Relic Enabled/Disabled"
+
+ # Read in the package names to leave new-relic enabled for.
+ $newRelicServicePath = (Join-Path $configCheckoutDirectory "NewRelicMicroServices/NewRelicMicroServices.txt")
+ if(Test-Path $newRelicServicePath) {
+ $newRelicMicroservicesToLeaveEnabled = [array](Get-Content -Path $newRelicServicePath)
+ } else {
+ Write-Warning "Could not find NewRelicMicroServices.txt to leave new relic enabled. Assuming no microservices should have NewRelic enabled."
+ $newRelicMicroservicesToLeaveEnabled = $null;
+ }
+ foreach($package in $PackageMetadata.AppPackagesToInstall) {
+ if($package.IsMicroservice) {
+ $enableNewRelic = ($newRelicMicroservicesToLeaveEnabled -contains $package.Name)
+ Add-Member -InputObject $package -NotePropertyName "EnableNewRelic" -NotePropertyValue $enableNewRelic -Force
+ }
+ }
+ } else {
+ Write-Host "New Relic will be left enabled on microservices."
+ }
+ return $PackageMetadata
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Installation/Public/Get-NewRelicAccountDetails.ps1 b/Modules/Alkami.DevOps.Installation/Public/Get-NewRelicAccountDetails.ps1
new file mode 100644
index 0000000..a0ce238
--- /dev/null
+++ b/Modules/Alkami.DevOps.Installation/Public/Get-NewRelicAccountDetails.ps1
@@ -0,0 +1,64 @@
+function Get-NewRelicAccountDetails {
+<#
+.SYNOPSIS
+ Returns the appropriate New Relic account (not Admin) API key and license key based on environment string
+#>
+
+ [CmdletBinding()]
+ Param(
+ [Parameter(Mandatory = $true)]
+ [Alias("Environment")]
+ [string]$environmentKey
+ )
+
+ $logLead = (Get-LogLeadName);
+
+ $specificAccountDetails = @(
+ # FI specific accounts.
+ @{EnvironmentRegex = "(AWS Production( Entrust)? 5)|(AWS Staging Lane FT\w+)"; APIKey = "5362c58099d6a19871a07f66b1ec50e2c00f66b4b69260e"; LicenseKey = "4e8134a277da93644407dc53931f81d99b69260e"; },
+ @{EnvironmentRegex = "AWS Production( Entrust)? 9"; APIKey = "b84b09aabf2ebe0773009698205220e9d53063bf84d041f"; LicenseKey = "417a21abe2bd84ca46940efc9c93cf75584d041f"; },
+ @{EnvironmentRegex = "(AWS Production( Entrust)? 10)|(AWS Staging Lane OTSStg)"; APIKey = "3d0346ec856cc9b02b3b0fcc481afc989a4f9941b28a80d"; LicenseKey = "d2eb268d151e57550de821438c3d99131b28a80d"; },
+ @{EnvironmentRegex = "(AWS Production( Entrust)? 15)|(AWS Staging Lane MACUStg)"; APIKey = "fcb208ec98958282362c4e7879bfa38e4073c10c073cc2f"; LicenseKey = "77b3cae28080be6a5ebcb7d2f3efdde8d073cc2f"; },
+ # Internal special-purpose accounts.
+ @{EnvironmentRegex = "^AWS Production 0$"; APIKey = "d34b1647dee7875568ebc4b7c97c37216c4c342b37bcce1"; LicenseKey = "b17e86388ca5c4a2c4777cd1b0e1e12a837bcce1"; }
+ )
+
+ $defaultAccountDetails = @(
+ # Legacy QA and Dev account.
+ @{EnvironmentRegex = "((?
+
+
+Describe "Get-NewRelicAccountDetails" {
+
+ Context "Correct NR Account Information is Returned for Various Environment Keys" {
+
+ # Generic environment keys.
+ $defaultDrApiKey = "6a9e9820d3aaf12278c80d0bc1433b90aee1ea5769ac25f"
+ $defaultDrLicenseKey = "b55c8c163b1ede6fef75f6f586a1dbb6869ac25f"
+
+ $defaultProdApiKey = "NRRA-8fbdf2188c09dd34aef478e13e31668e405324a304"
+ $defaultProdLicenseKey = "e0318c558f353e420720d4417ebe4cbd43b7d425"
+
+ $defaultStagingApiKey = "a156af42c6da91536e67f2414bc1c25f3742e41b0cb93ab"
+ $defaultStagingLicenseKey = "3707290484dceca2dc2ecd71e40fb0ff40cb93ab"
+
+ # FI environment keys.
+ $dsfcuApiKey = "b84b09aabf2ebe0773009698205220e9d53063bf84d041f"
+ $dsfcuLicenseKey = "417a21abe2bd84ca46940efc9c93cf75584d041f"
+
+ $ftApiKey = "5362c58099d6a19871a07f66b1ec50e2c00f66b4b69260e"
+ $ftLicenseKey = "4e8134a277da93644407dc53931f81d99b69260e"
+
+ $macuApiKey = "fcb208ec98958282362c4e7879bfa38e4073c10c073cc2f"
+ $macuLicenseKey = "77b3cae28080be6a5ebcb7d2f3efdde8d073cc2f"
+
+ $otsApiKey = "3d0346ec856cc9b02b3b0fcc481afc989a4f9941b28a80d"
+ $otsLicenseKey = "d2eb268d151e57550de821438c3d99131b28a80d"
+
+ $devApiKey = "0cdfe5a47b10db89acf3560e47d4657fbfec081040e50b8"
+ $devLicenseKey = "f88cf2f1ed5fca2600f6a49e63778274c40e50b8"
+
+ $qaApiKey = "955b8bfccbdde190f855238dc55f17ba5d6382ee2492392"
+ $qaLicenseKey = "e941422aebc76c48d195bcdcf86e4f7602492392"
+
+ #Internal environment keys.
+ $salesDemoApiKey = 'd34b1647dee7875568ebc4b7c97c37216c4c342b37bcce1'
+ $salesDemoLicenseKey = 'b17e86388ca5c4a2c4777cd1b0e1e12a837bcce1'
+
+ # Test FirstTech
+ It "Returns Expected POD 5 Values" {
+ $curValues = Get-NewRelicAccountDetails "AWS Production 5"
+ $curValues.APIKey | Should Be $ftApiKey
+ $curValues.LicenseKey | Should Be $ftLicenseKey
+ }
+
+ It "Returns Expected Lane FTStg Values" {
+ $curValues = Get-NewRelicAccountDetails "AWS Staging Lane FTStg"
+ $curValues.APIKey | Should Be $ftApiKey
+ $curValues.LicenseKey | Should Be $ftLicenseKey
+ }
+
+ It "Returns Expected Lane FTDev Values" {
+ $curValues = Get-NewRelicAccountDetails "AWS Staging Lane FTDev"
+ $curValues.APIKey | Should Be $ftApiKey
+ $curValues.LicenseKey | Should Be $ftLicenseKey
+ }
+
+ It "Returns Expected Lane FTTest Values" {
+ $curValues = Get-NewRelicAccountDetails "AWS Staging Lane FTTest"
+ $curValues.APIKey | Should Be $ftApiKey
+ $curValues.LicenseKey | Should Be $ftLicenseKey
+ }
+
+ # Test DSFCU
+ It "Returns Expected POD 9 Values" {
+ $curValues = Get-NewRelicAccountDetails "AWS Production 9"
+ $curValues.APIKey | Should Be $dsfcuApiKey
+ $curValues.LicenseKey | Should Be $dsfcuLicenseKey
+ }
+
+ # Test OTS
+ It "Returns Expected POD 10 Values" {
+ $curValues = Get-NewRelicAccountDetails "AWS Production 10"
+ $curValues.APIKey | Should Be $otsApiKey
+ $curValues.LicenseKey | Should Be $otsLicenseKey
+ }
+
+ It "Returns Expected Lane OTSStg Values" {
+ $curValues = Get-NewRelicAccountDetails "AWS Staging Lane OTSStg"
+ $curValues.APIKey | Should Be $otsApiKey
+ $curValues.LicenseKey | Should Be $otsLicenseKey
+ }
+
+ # Test One of the DR PODs
+ It "Returns Expected DR Values" {
+ $curValues = Get-NewRelicAccountDetails "AWS DR Production 1"
+ $curValues.APIKey | Should Be $defaultDrApiKey
+ $curValues.LicenseKey | Should Be $defaultDrLicenseKey
+ }
+
+ # Test One of the DR PODs With Their Own NR Account (should still use base DR key)
+ It "Returns Expected POD 9 DR Values" {
+ $curValues = Get-NewRelicAccountDetails "AWS DR Production 9"
+ $curValues.APIKey | Should Be $defaultDrApiKey
+ $curValues.LicenseKey | Should Be $defaultDrLicenseKey
+ }
+
+ # Test MACU
+ It "Returns Expected Staging Lane MACUStg Values" {
+ $curValues = Get-NewRelicAccountDetails "AWS Staging Lane MACUStg"
+ $curValues.APIKey | Should Be $macuApiKey
+ $curValues.LicenseKey | Should Be $macuLicenseKey
+ }
+
+ It "Returns Expected AWS Production 15 Values" {
+ $curValues = Get-NewRelicAccountDetails "AWS Production 15"
+ $curValues.APIKey | Should Be $macuApiKey
+ $curValues.LicenseKey | Should Be $macuLicenseKey
+ }
+
+ It "Returns Expected AWS Production 15.0.1 Values" {
+ $curValues = Get-NewRelicAccountDetails "AWS Production 15.0.1"
+ $curValues.APIKey | Should Be $macuApiKey
+ $curValues.LicenseKey | Should Be $macuLicenseKey
+ }
+
+ # Test a Representative Set of "Default" Production Pods
+ $defaultPods = @( "AWS Production 1.1", "AWS Production 1", "AWS Production 2.2", "AWS Production 2.4", "AWS Production 6", "AWS Production 6.1", "AWS Production 7", "AWS Production 7.2", "AWS Production 8", "AWS Production 8.1", "AWS Production 12.2", "AWS Production 12.10", "AWS Production 14.3" )
+
+ It "Returns Expected Default Production Values" {
+ foreach ($pod in $defaultPods) {
+ $curValues = Get-NewRelicAccountDetails $pod
+ $curValues.APIKey | Should Be $defaultProdApiKey
+ $curValues.LicenseKey | Should Be $defaultProdLicenseKey
+ }
+ }
+
+ # Test a representative set of "default" Staging Lanes
+ $defaultLanes = @("AWS Staging Lane LC1", "AWS Staging Lane PS5")
+ It "Returns Expected Default Staging Values" {
+ foreach ($lane in $defaultLanes) {
+ $curValues = Get-NewRelicAccountDetails $lane
+ $curValues.APIKey | Should Be $defaultStagingApiKey
+ $curValues.LicenseKey | Should Be $defaultStagingLicenseKey
+ }
+ }
+
+ # Test that legacy Armor Staging lane names for First Tech no longer report the First Tech New Relic credentials.
+ It "Returns Expected Lane H (Deprecated) Values" {
+ $curValues = Get-NewRelicAccountDetails "AWS Staging Lane H"
+ $curValues.APIKey | Should Be $defaultStagingApiKey
+ $curValues.LicenseKey | Should Be $defaultStagingLicenseKey
+ }
+
+ It "Returns Expected FT Dev (Deprecated) Values" {
+ # This should throw because the name never conformed to conventions.
+ { Get-NewRelicAccountDetails "Staging First Tech Dev" } | Should -Throw
+ }
+
+ It "Returns Expected FT Test (Deprecated) Values" {
+ # This should throw because the name never conformed to conventions.
+ { Get-NewRelicAccountDetails "Staging First Tech Test" } | Should -Throw
+ }
+
+ # Test that legacy Armor Staging Lane name for MACU no longer reports the MACU New Relic credentials.
+ It "Returns Expected Staging Lane M (Deprecated) Values" {
+ $curValues = Get-NewRelicAccountDetails "AWS Staging Lane M"
+ $curValues.APIKey | Should Be $defaultStagingApiKey
+ $curValues.LicenseKey | Should Be $defaultStagingLicenseKey
+ }
+
+ # Test QA[x] matches the Dev New Relic key.
+ It "Returns Expected Dev Values for QA3" {
+ $curValues = Get-NewRelicAccountDetails "QA3"
+ $curValues.APIKey | Should Be $devApiKey
+ $curValues.LicenseKey | Should Be $devLicenseKey
+ }
+
+ It "Returns Expected Dev Values for QA15" {
+ $curValues = Get-NewRelicAccountDetails "QW15"
+ $curValues.APIKey | Should Be $devApiKey
+ $curValues.LicenseKey | Should Be $devLicenseKey
+ }
+
+ It "Returns Expected Dev Values for AWS DEV" {
+ $curValues = Get-NewRelicAccountDetails "AWS DEV TEAM RANDOM"
+ $curValues.APIKey | Should Be $devApiKey
+ $curValues.LicenseKey | Should Be $devLicenseKey
+ }
+
+ It "Returns Expected QA Values for AWS QA" {
+ $curValues = Get-NewRelicAccountDetails "AWS QA REGRESSION BUGAPALOOZA"
+ $curValues.APIKey | Should Be $qaApiKey
+ $curValues.LicenseKey | Should Be $qaLicenseKey
+ }
+
+ # Test QASTg does not match the Dev New Relic key.
+ It "Returns Expected Staging Values for QASTg" {
+ $curValues = Get-NewRelicAccountDetails "AWS Staging Lane QASTG"
+ $curValues.APIKey | Should Be $defaultStagingApiKey
+ $curValues.LicenseKey | Should Be $defaultStagingLicenseKey
+ }
+
+ It "Returns Expected Values for Pod 0 (Sales Demo)" {
+ $curValues = Get-NewRelicAccountDetails "AWS Production 0"
+ $curValues.APIKey | Should Be $salesDemoApiKey
+ $curValues.LicenseKey | Should Be $salesDemoLicenseKey
+ }
+
+ It "Returns Expected Values for Pod 0.1 (Pen Test)" {
+ $curValues = Get-NewRelicAccountDetails "AWS Production 0.1"
+ $curValues.APIKey | Should Be $defaultProdApiKey
+ $curValues.LicenseKey | Should Be $defaultProdLicenseKey
+ }
+
+ # Test Entrust environment keys.
+ It "Returns Expected First Tech Entrust Values" {
+ $curValues = Get-NewRelicAccountDetails "AWS Production Entrust 5-0"
+ $curValues.APIKey | Should Be $ftApiKey
+ $curValues.LicenseKey | Should Be $ftLicenseKey
+ }
+
+ It "Returns Expected Desert Entrust Values" {
+ $curValues = Get-NewRelicAccountDetails "AWS Production Entrust 9-0"
+ $curValues.APIKey | Should Be $dsfcuApiKey
+ $curValues.LicenseKey | Should Be $dsfcuLicenseKey
+ }
+
+ It "Returns Expected OTS Entrust Values" {
+ $curValues = Get-NewRelicAccountDetails "AWS Production Entrust 10-0"
+ $curValues.APIKey | Should Be $otsApiKey
+ $curValues.LicenseKey | Should Be $otsLicenseKey
+ }
+
+ It "Returns Expected MACU Entrust Values" {
+ $curValues = Get-NewRelicAccountDetails "AWS Production Entrust 15-0"
+ $curValues.APIKey | Should Be $macuApiKey
+ $curValues.LicenseKey | Should Be $macuLicenseKey
+ }
+
+ It "Returns Expected Staging Entrust Values" {
+ $curValues = Get-NewRelicAccountDetails "AWS Staging Entrust"
+ $curValues.APIKey | Should Be $defaultStagingApiKey
+ $curValues.LicenseKey | Should Be $defaultStagingLicenseKey
+ }
+
+ It "Returns Expected Default Production Entrust Values" {
+ $curValues = Get-NewRelicAccountDetails "AWS Production Entrust 12-0"
+ $curValues.APIKey | Should Be $defaultProdApiKey
+ $curValues.LicenseKey | Should Be $defaultProdLicenseKey
+ }
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Installation/Public/Get-NewRelicYamlPath.ps1 b/Modules/Alkami.DevOps.Installation/Public/Get-NewRelicYamlPath.ps1
new file mode 100644
index 0000000..f5be874
--- /dev/null
+++ b/Modules/Alkami.DevOps.Installation/Public/Get-NewRelicYamlPath.ps1
@@ -0,0 +1,25 @@
+function Get-NewRelicYamlPath {
+ <#
+ .SYNOPSIS
+ Get New Relic Infrastructure yaml file location
+
+ .DESCRIPTION
+ Fetch a hard-coded yaml file location for the New Relic Infrastructure
+
+ .EXAMPLE
+ Get-NewRelicYamlPath
+ #>
+
+ [CmdletBinding()]
+ param()
+ $logLead = Get-LogLeadName
+ $newRelicYaml = "C:\Program Files\New Relic\newrelic-infra\newrelic-infra.yml"
+
+ if (!(Test-Path -Path $newRelicYaml)) {
+ Write-Warning "$loglead : File does not exist: $newRelicYaml"
+ return
+ }
+
+ return $newRelicYaml
+
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Installation/Public/Get-NewRelicYamlPath.tests.ps1 b/Modules/Alkami.DevOps.Installation/Public/Get-NewRelicYamlPath.tests.ps1
new file mode 100644
index 0000000..45d2642
--- /dev/null
+++ b/Modules/Alkami.DevOps.Installation/Public/Get-NewRelicYamlPath.tests.ps1
@@ -0,0 +1,37 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ""
+
+Describe "Get-NewRelicYamlPath" {
+ Mock -CommandName Get-LogLeadName -MockWith {}
+ Mock -CommandName Test-Path -MockWith { $true }
+
+ Context "Validation" {
+
+ It "Finds the yaml" {
+
+ Mock -CommandName Write-Warning -MockWith {}
+ Get-NewRelicYamlPath | Should -Be "C:\Program Files\New Relic\newrelic-infra\newrelic-infra.yml"
+ }
+
+ It "When a yaml file is not found, It writes a warning" {
+
+ Mock -CommandName Test-Path -MockWith { $false }
+
+ Get-NewRelicYamlPath
+ Assert-MockCalled -CommandName Write-Warning -Scope Context -ParameterFilter { $Message -like "*File does not exist:*" }
+ }
+
+ It "Returns null" {
+
+ Mock -CommandName Write-Warning -MockWith {}
+ Mock -CommandName Test-Path -MockWith { $false }
+
+ Get-NewRelicYamlPath | Should -Be $null
+ }
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Installation/Public/Get-PackageServerMapping.Tests.ps1 b/Modules/Alkami.DevOps.Installation/Public/Get-PackageServerMapping.Tests.ps1
new file mode 100644
index 0000000..8c3f1e8
--- /dev/null
+++ b/Modules/Alkami.DevOps.Installation/Public/Get-PackageServerMapping.Tests.ps1
@@ -0,0 +1,208 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ""
+$currentErrorActionPreference = $ErrorActionPreference
+$ErrorActionPreference = "stop"
+
+Describe 'Get_PackageServerMappings'{
+ Mock -CommandName Write-Host -MockWith {}
+ Mock -CommandName Write-Warning -MockWith {}
+ Mock -CommandName Write-Error -MockWith {}
+ Mock -CommandName Get-MicroserviceNewRelicMapping -MockWith {
+ param(
+ $BuildCheckoutDirectory,
+ [PSObject]$PackageMetadata)
+ return $PackageMetadata
+ }
+ Mock -CommandName ConvertTo-SafeTeamCityMessage -MockWith {}
+
+ Context "When Processing Packages" {
+ $appPackage1 = New-DummyPackageInstallationData -PackageName "app.package1" -PackageVersion "1.0.0" -IsFullScale
+ $appPackage2 = New-DummyPackageInstallationData -PackageName "app.package2" -PackageVersion "1.0.0" -IsFullScale
+ $appPackage3 = New-DummyPackageInstallationData -PackageName "invalid.app.package1" -PackageVersion "1.0.0" -IsFullScale -IsValid $false
+ $appPackage4 = New-DummyPackageInstallationData -PackageName "mic.package1" -PackageVersion "1.0.0" -IsFullScale
+ $webPackage1 = New-DummyPackageInstallationData -PackageName "web.package1" -PackageVersion "1.0.0" -IsFullScale
+ $webPackage2 = New-DummyPackageInstallationData -PackageName "web.package2" -PackageVersion "1.0.0" -IsFullScale
+ $webPackage3 = New-DummyPackageInstallationData -PackageName "invalid.web.package1" -PackageVersion "1.0.0" -IsFullScale -IsValid $false
+
+ $appPackageArray = @($appPackage1, $appPackage2, $appPackage4)
+ $webPackageArray = @($webPackage1, $webPackage2)
+
+ $appPackage3.IsValid = $False
+ $webPackage3.IsValid = $False
+
+ $invalidAppPackageArray = @($appPackage1, $appPackage2, $appPackage3, $appPackage4)
+ $invalidWebPackageArray = @($webPackage1, $webPackage2, $webPackage3)
+
+ # Send back app/mic packages if given app packages
+ Mock -CommandName Get-PackageInstallationData -MockWith {
+ # only the packages matter here, but we get all of them, so accept them in case they're useful for a future mock.
+ param(
+ $ChocoPackages,
+ $Servers,
+ $FilterFeeds,
+ $FilterPowerShellModules,
+ $NugetCredential,
+ $IncludeMissingPackages,
+ $UseV2PackageMetadata
+ )
+
+ $packages = @()
+ foreach ($package in $ChocoPackages){
+ if($package.Name -like "*app*"){
+ $newPackage = New-DummyPackageInstallationData -PackageName $package.Name -PackageVersion $package.Version -IsAppOnly -IsValid $package.IsValid
+ } else {
+ $newPackage = New-DummyPackageInstallationData -PackageName $package.Name -PackageVersion $package.Version -IsMicOnly -IsValid $package.IsValid
+ }
+
+ $packages += $newPackage
+ }
+
+ return $packages
+ } -ParameterFilter {($ChocoPackages[0].Name -like "*app*" -or $ChocoPackages[0].Name -like "*mic*")}
+
+ # Send back app/mic packages if given app packages
+ Mock -CommandName Get-PackageInstallationData -MockWith {
+ # only the packages matter here, but we get all of them, so accept them in case they're useful for a future mock.
+ param(
+ $ChocoPackages,
+ $Servers,
+ $FilterFeeds,
+ $FilterPowerShellModules,
+ $NugetCredential,
+ $IncludeMissingPackages,
+ $UseV2PackageMetadata
+ )
+
+ $packages = @()
+ foreach ($package in $ChocoPackages){
+ $newPackage = New-DummyPackageInstallationData -PackageName $package.Name -PackageVersion $package.Version -IsWebOnly -IsValid $package.IsValid
+ $packages += $newPackage
+ }
+
+ return $packages
+ } -ParameterFilter {$ChocoPackages[0].Name -like "*web*"}
+
+ $packageData = New-PackageMetadataObject
+ $packageData.DisableMicroserviceNewRelic = $true
+ $packageData.Servers = @("fakeServer.1","fakeServer.2")
+
+ $password = ConvertTo-SecureString 'fake' -AsPlainText -Force
+ $fakeCred = New-Object System.Management.Automation.PSCredential('bogus', $password)
+
+ $fakeDebugMetadata = New-Object psobject -property @{
+ WebServerPackages = @($webPackage1,$webPackage2)
+ AppServerPackages = @($appPackage1, $appPackage2)
+ MicServerPackages = @($appPackage1, $appPackage2)
+ FabServerPackages = @()
+
+ WebPackagesToInstallMap = @{}
+ AppPackagesToInstallMap = @{}
+ ClassifiedPackagesMap = @{}
+ }
+
+ It "Populates WebPackagesToInstall" {
+ $packageMetadata,$debugMetadata = Get-PackageServerMapping -AllAppTierPackages $appPackageArray -AllWebTierPackages $webPackageArray `
+ -BuildCheckoutDirectory "fakeDirectory" -DebugMetadata $fakeDebugMetadata `
+ -NugetCredential $fakeCred -PackageMetadata $packageData
+
+ $packageData.WebPackagesToInstall | Should -Not -BeNullOrEmpty
+ }
+
+ It "Populates AppPackagesToInstall" {
+ $packageMetadata,$debugMetadata = Get-PackageServerMapping -AllAppTierPackages $appPackageArray -AllWebTierPackages $webPackageArray `
+ -BuildCheckoutDirectory "fakeDirectory" -DebugMetadata $fakeDebugMetadata `
+ -NugetCredential $fakeCred -PackageMetadata $packageData
+
+ $packageData.AppPackagesToInstall | Should -Not -BeNullOrEmpty
+ }
+
+ It "Populates DebugMetadata ClassifiedPackagesMap" {
+ $packageMetadata,$debugMetadata = Get-PackageServerMapping -AllAppTierPackages $appPackageArray -AllWebTierPackages $webPackageArray `
+ -BuildCheckoutDirectory "fakeDirectory" -DebugMetadata $fakeDebugMetadata `
+ -NugetCredential $fakeCred -PackageMetadata $packageData
+
+ $debugMetadata.ClassifiedPackagesMap | Should -Not -BeNullOrEmpty
+ }
+
+ It "Sets Microservice New Relic Settings" {
+ $packageMetadata,$debugMetadata = Get-PackageServerMapping -AllAppTierPackages $appPackageArray -AllWebTierPackages $webPackageArray `
+ -BuildCheckoutDirectory "fakeDirectory" -DebugMetadata $fakeDebugMetadata `
+ -NugetCredential $fakeCred -PackageMetadata $packageData
+
+ Assert-MockCalled -CommandName Get-MicroserviceNewRelicMapping -Scope Context -Times 1
+ }
+
+ It "Writes Warnings when given invalid App packages"{
+ $packageMetadata,$debugMetadata = Get-PackageServerMapping -AllAppTierPackages $invalidAppPackageArray -AllWebTierPackages $webPackageArray `
+ -BuildCheckoutDirectory "fakeDirectory" -DebugMetadata $fakeDebugMetadata `
+ -NugetCredential $fakeCred -PackageMetadata $packageData
+
+ Assert-MockCalled -CommandName Write-Warning -Scope Context -ParameterFilter {$Message -like "*MISSING_PACKAGES*"}
+ Assert-MockCalled -CommandName Write-Warning -Scope Context -ParameterFilter {$Message -like "*MISSING_APP_PACKAGE*"}
+ }
+
+ It "Writes Warnings when given invalid Web packages"{
+ $packageMetadata,$debugMetadata = Get-PackageServerMapping -AllAppTierPackages $appPackageArray -AllWebTierPackages $invalidWebPackageArray `
+ -BuildCheckoutDirectory "fakeDirectory" -DebugMetadata $fakeDebugMetadata `
+ -NugetCredential $fakeCred -PackageMetadata $packageData
+
+ Assert-MockCalled -CommandName Write-Warning -Scope Context -ParameterFilter {$Message -like "*MISSING_PACKAGES*"}
+ Assert-MockCalled -CommandName Write-Warning -Scope Context -ParameterFilter {$Message -like "*MISSING_WEB_PACKAGE*"}
+ }
+
+ It "Sets Install To App With App Servers" {
+ $packageData.InstallToAppsAndMics = $true
+ $packageData.HasAppServers = $true
+
+ $packageMetadata,$debugMetadata = Get-PackageServerMapping -AllAppTierPackages $appPackageArray -AllWebTierPackages $webPackageArray `
+ -BuildCheckoutDirectory "fakeDirectory" -DebugMetadata $fakeDebugMetadata `
+ -NugetCredential $fakeCred -PackageMetadata $packageData
+
+ $packageData.AppPackagesToInstall | Should -Not -BeNullOrEmpty
+
+ ($packageData.AppPackagesToInstall.InstallToApp -contains $true) | Should -BeTrue
+ }
+
+ It "Doesn't Set Install To App Without App Servers" {
+ $packageData.InstallToAppsAndMics = $true
+ $packageData.HasAppServers = $false
+
+ $packageMetadata,$debugMetadata = Get-PackageServerMapping -AllAppTierPackages $appPackageArray -AllWebTierPackages $webPackageArray `
+ -BuildCheckoutDirectory "fakeDirectory" -DebugMetadata $fakeDebugMetadata `
+ -NugetCredential $fakeCred -PackageMetadata $packageData
+
+ $packageData.AppPackagesToInstall | Should -Not -BeNullOrEmpty
+
+ ($packageData.AppPackagesToInstall.InstallToApp -contains $true) | Should -BeFalse
+ }
+
+ It "Sets Install To Mic With Mic Servers" {
+ $packageData.InstallToAppsAndMics = $true
+ $packageData.HasMicServers = $true
+
+ $packageMetadata,$debugMetadata = Get-PackageServerMapping -AllAppTierPackages $appPackageArray -AllWebTierPackages $webPackageArray `
+ -BuildCheckoutDirectory "fakeDirectory" -DebugMetadata $fakeDebugMetadata `
+ -NugetCredential $fakeCred -PackageMetadata $packageData
+
+ $packageData.AppPackagesToInstall | Should -Not -BeNullOrEmpty
+ ($packageData.AppPackagesToInstall.InstallToMic -contains $true) | Should -BeTrue
+ }
+
+ It "Doesn't Install To Mic Without Mic Servers" {
+ $packageData.InstallToAppsAndMics = $true
+ $packageData.HasMicServers = $false
+
+ $packageMetadata,$debugMetadata = Get-PackageServerMapping -AllAppTierPackages $appPackageArray -AllWebTierPackages $webPackageArray `
+ -BuildCheckoutDirectory "fakeDirectory" -DebugMetadata $fakeDebugMetadata `
+ -NugetCredential $fakeCred -PackageMetadata $packageData
+
+ $packageData.AppPackagesToInstall | Should -Not -BeNullOrEmpty
+ ($packageData.AppPackagesToInstall.InstallToMic -contains $true) | Should -BeFalse
+ }
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Installation/Public/Get-PackageServerMapping.ps1 b/Modules/Alkami.DevOps.Installation/Public/Get-PackageServerMapping.ps1
new file mode 100644
index 0000000..14df3ec
--- /dev/null
+++ b/Modules/Alkami.DevOps.Installation/Public/Get-PackageServerMapping.ps1
@@ -0,0 +1,176 @@
+function Get-PackageServerMapping {
+ <#
+ .SYNOPSIS
+ The bulk of the Classify logic. Determines what packages go where. Utilized by Classify-Packages.
+
+ .PARAMETER AllAppTierPackages
+ App tier packages to install.
+
+ .PARAMETER AllWebTierPackages
+ Web tier packages to install.
+
+ .PARAMETER BuildCheckoutDirectory
+ Directory where the code is checked out by TC.
+
+ .PARAMETER DebugMetadata
+ Meta object used to determine what goes where.
+
+ .PARAMETER NugetCredential,
+ Credentials to access nuget/proget.
+
+ .PARAMETER PackageMetadata
+ Packages object to populate.
+
+ .PARAMETER UseV2PackageMetadata
+ Whether or not to use the new version of the PackageMetadata functions.
+
+ .PARAMETER DependencyReleaseValue
+ Used for determining what version of orb we're installing, if it's an orb deploy.
+ #>
+ [CmdletBinding()]
+ param(
+ $AllAppTierPackages,
+ $AllWebTierPackages,
+ $BuildCheckoutDirectory,
+ $DebugMetadata,
+ [System.Management.Automation.PSCredential]$NugetCredential,
+ [PSObject]$PackageMetadata,
+ [switch]$UseV2PackageMetadata,
+ [string]$DependencyReleaseValue = ""
+ )
+
+ $loglead = Get-LogLeadName
+
+ # Force the results of these two calls into arrays. Necessary for testing, and it's what we should be getting anyways.
+ # PS tries to unbox single items. No bueno.
+ Write-Host "$loglead Classifying Web Packages Being Installed"
+ $PackageMetadata.WebPackagesToInstall = @(Get-PackageInstallationData -ChocoPackages $AllWebTierPackages -Servers $PackageMetadata.Servers -FilterFeeds -FilterPowerShellModules -NugetCredential $NugetCredential -IncludeMissingPackages -UseV2PackageMetadata:$UseV2PackageMetadata -PackageToServerMap $PackageMetadata.PackageToServers -ErrorAction Continue)
+ Write-Host "$loglead Classifying App Packages Being Installed"
+ $PackageMetadata.AppPackagesToInstall = @(Get-PackageInstallationData -ChocoPackages $AllAppTierPackages -Servers $PackageMetadata.Servers -FilterFeeds -FilterPowerShellModules -NugetCredential $NugetCredential -IncludeMissingPackages -UseV2PackageMetadata:$UseV2PackageMetadata -PackageToServerMap $PackageMetadata.PackageToServers -ErrorAction Continue)
+
+ # The above forced typecasting into an array broke a bunch of stuff because it forced the values to be @($null) rather than just $null
+ # This puts it back the way it was, but we're not removing any of the down the line null checks.
+ if($PackageMetadata.WebPackagesToInstall.Count -eq 1 -and $null -eq $PackageMetadata.WebPackagesToInstall[0])
+ {
+ $PackageMetadata.WebPackagesToInstall = @()
+ }
+ if($PackageMetadata.AppPackagesToInstall.Count -eq 1 -and $null -eq $PackageMetadata.AppPackagesToInstall[0])
+ {
+ $PackageMetadata.AppPackagesToInstall = @()
+ }
+
+ $PackageMetadata = Remove-OutdatedNewHotfixPackages -PackageMetadata $PackageMetadata -DependencyReleaseValue $DependencyReleaseValue
+
+ # Set which microservices should have New Relic enabled/disabled.
+ $PackageMetadata = Get-MicroserviceNewRelicMapping -BuildCheckoutDirectory $BuildCheckoutDirectory -PackageMetadata $PackageMetadata
+
+ # Notify TC of missing packages.
+ if ($PackageMetadata.WebPackagesToInstall.Where({ $_.IsValid -eq $false}).Count -gt 0 -or $PackageMetadata.AppPackagesToInstall.Where({ $_.IsValid -eq $false}).Count -gt 0) {
+ $message = "Packages currently deployed to the system cannot be located in any configured feed. Were they renamed or removed from the feed?"
+ Write-Warning "$loglead MISSING_PACKAGES : $message"
+ Write-Warning "$loglead ORB Platform deploys reinstall Widgets and Providers that are currently installed."
+ Write-Host "##teamcity[buildProblem description='$message' identity='MISSING_PACKAGES']"
+ }
+
+ if ($PackageMetadata.WebPackagesToInstall.Where({ $_.IsValid -eq $false}).Count -gt 0) {
+ $missingWebPackages = $PackageMetadata.WebPackagesToInstall | Where-Object { $_.IsValid -eq $false }
+ foreach ($missingWebPackage in $missingWebPackages) {
+ $missingWebPackageDetail = "$($missingWebPackage.Name)|$($missingWebPackage.Version)"
+ $tcMissingWebPackageDetail = ConvertTo-SafeTeamCityMessage -InputText $missingWebPackageDetail
+ $missingWebPackageName = $missingWebPackage.Name
+ Write-Warning "$loglead MISSING_WEB_PACKAGE : $missingWebPackageDetail"
+ Write-Host "##teamcity[buildProblem description='Missing web package: $tcMissingWebPackageDetail' identity='MISSINGWEB_$missingWebPackageName']"
+ }
+ }
+
+ if ($PackageMetadata.AppPackagesToInstall.Where({ $_.IsValid -eq $false}).Count -gt 0) {
+ $missingAppPackages = $PackageMetadata.AppPackagesToInstall | Where-Object { $_.IsValid -eq $false }
+ foreach ($missingAppPackage in $missingAppPackages) {
+ $missingAppPackageDetail = "$($missingAppPackage.Name)|$($missingAppPackage.Version)"
+ $tcMissingAppPackageDetail = ConvertTo-SafeTeamCityMessage -InputText $missingAppPackageDetail
+ $missingAppPackageName = $missingAppPackage.Name
+ Write-Warning "$loglead MISSING_APP_PACKAGE : $missingAppPackageDetail"
+ Write-Host "##teamcity[buildProblem description='Missing app package: $tcMissingAppPackageDetail' identity='MISSINGAPP_$missingAppPackageName']"
+ }
+ }
+
+ # Determine if app packages are going to app servers, mic servers, or both.
+ if ($PackageMetadata.InstallToAppsAndMics) {
+ foreach ($package in $PackageMetadata.AppPackagesToInstall) {
+ $install = $package.InstallToApp -or $package.InstallToMic
+ $package.InstallToApp = $PackageMetadata.HasAppServers -and $install
+ $package.InstallToMic = $PackageMetadata.HasMicServers -and $install
+ }
+ }
+
+ # Create a map of classified packages, so we can more easily find name->classifiedPackage
+ if (!(Test-IsCollectionNullOrEmpty $PackageMetadata.WebPackagesToInstall)) {
+ Write-Host "$loglead Creating Web Package Map:"
+ foreach ($package in $PackageMetadata.WebPackagesToInstall) {
+ if([string]::IsNullOrWhiteSpace($package.Name)){
+ Write-Warning "$loglead Got an empty Web package name: $package!"
+ } else {
+ $DebugMetadata.ClassifiedPackagesMap[$package.Name.ToLower()] = $package;
+ }
+ }
+ }
+
+ if (!(Test-IsCollectionNullOrEmpty $PackageMetadata.AppPackagesToInstall)) {
+ Write-Host "$loglead Creating App Package Map:"
+ foreach ($package in $PackageMetadata.AppPackagesToInstall) {
+ if([string]::IsNullOrWhiteSpace($package.Name)){
+ Write-Warning "$loglead Got an empty App package name: $package!"
+ } else {
+ $DebugMetadata.ClassifiedPackagesMap[$package.Name.ToLower()] = $package;
+ }
+ }
+ }
+
+ #region Get MigrationOnly Packages
+ # It is ok, per Cole, that migration packages can be specified in either the web or app install boxes in the UI
+ # We do not care that they show up in the uninstall boxes, yet (future work SRE-16946)
+ $webIsMigrationPackage = $PackageMetadata.WebPackagesToInstall.Where({$_.IsMigrationPackage -eq $true})
+ $appIsMigrationPackage = $PackageMetadata.AppPackagesToInstall.Where({$_.IsMigrationPackage -eq $true})
+ $migrationOnlyPackages = @()
+ if (-not (Test-IsCollectionNullOrEmpty -Collection $webIsMigrationPackage)) {
+ foreach ($migrationPackage in $webIsMigrationPackage) {
+ Write-Host "$logLead : Found a migration package in the web inputs: $($migrationPackage.Name)"
+
+ $DebugMetadata.IsMigrationPackage.Web += $migrationPackage
+
+ if ($migrationPackage.Name -notin $migrationOnlyPackages.Name) {
+ $migrationOnlyPackages += $migrationPackage
+ } else {
+ # TODO - This is not aware of versions, this may bite us later, not worth the effort to pre-worry about that today
+ # The circumstances of them putting two different migration versions in the install lists for the same package name is so low, we should just tell them to do better
+ # There is a point of diminishing returns on holding their hands - cbrand 2022-07-12
+ Write-Host "$logLead : Migration package with Name $($migrationPackage.Name) already in `$migrationOnlyPackages collection, not adding a duplicate-by-name"
+ }
+ }
+ } else {
+ Write-Host "$logLead : Did not find any migration packages to be installed in the web inputs"
+ }
+ if (-not (Test-IsCollectionNullOrEmpty -Collection $appIsMigrationPackage)) {
+ foreach ($migrationPackage in $appIsMigrationPackage) {
+ Write-Host "$logLead : Found a migration package in the app inputs: $($migrationPackage.Name)"
+
+ $DebugMetadata.IsMigrationPackage.App += $migrationPackage
+
+ if ($migrationPackage.Name -notin $migrationOnlyPackages.Name) {
+ $migrationOnlyPackages += $migrationPackage
+ } else {
+ # TODO - This is not aware of versions, this may bite us later, not worth the effort to pre-worry about that today
+ # The circumstances of them putting two different migration versions in the install lists for the same package name is so low, we should just tell them to do better
+ # There is a point of diminishing returns on holding their hands - cbrand 2022-07-12
+ Write-Host "$logLead : Migration package with Name $($migrationPackage.Name) already in `$migrationOnlyPackages collection, not adding a duplicate-by-name"
+ }
+ }
+ } else {
+ Write-Host "$logLead : Did not find any migration packages to be installed in the app inputs"
+ }
+
+ $PackageMetadata.MigrationOnlyPackages = $migrationOnlyPackages
+ #endregion Get MigrationOnly Packages
+
+ return $PackageMetadata, $DebugMetadata
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Installation/Public/Get-ServerPackageInformation.Tests.ps1 b/Modules/Alkami.DevOps.Installation/Public/Get-ServerPackageInformation.Tests.ps1
new file mode 100644
index 0000000..1beb6cf
--- /dev/null
+++ b/Modules/Alkami.DevOps.Installation/Public/Get-ServerPackageInformation.Tests.ps1
@@ -0,0 +1,620 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ""
+$currentErrorActionPreference = $ErrorActionPreference
+$ErrorActionPreference = "stop"
+
+# Init functions. Used to reset package objets in each Context.
+function Init-PackageData {
+ $webPackage1 = New-DummyPackageInstallationData -PackageName "web.package1" -PackageVersion "1.1.0" -IsWebOnly
+ $webPackage2 = New-DummyPackageInstallationData -PackageName "web.package2" -PackageVersion "1.1.0" -IsWebOnly
+ $appPackage1 = New-DummyPackageInstallationData -PackageName "app.package1" -PackageVersion "1.1.0" -IsAppOnly
+ $appPackage2 = New-DummyPackageInstallationData -PackageName "app.package2" -PackageVersion "1.1.0" -IsAppOnly
+ $micPackage1 = New-DummyPackageInstallationData -PackageName "mic.package1" -PackageVersion "1.1.0" -IsMicOnly
+ $micPackage2 = New-DummyPackageInstallationData -PackageName "mic.package2" -PackageVersion "1.1.0" -IsMicOnly
+
+ $appPackageArray = @($appPackage1, $appPackage2)
+ $webPackageArray = @($webPackage1, $webPackage2)
+ $micPackageArray = @($micPackage1, $micPackage2)
+
+ $packageData = New-PackageMetadataObject
+
+ $packageData.AppPackagesToInstall = $appPackageArray
+ $packageData.AppPackagesToInstall += $micPackageArray
+ $packageData.WebPackagesToInstall = $webPackageArray
+ $packageData.ServersToQuery = @("fake.appserver.1", "fake.fabserver.1", "fake.micserver.1", "fake.webserver.1")
+ $packageData.AppServers = @("fake.appserver.1")
+ $packageData.FabServers = @("fake.fabserver.1")
+ $packageData.MicServers = @("fake.micserver.1")
+ $packageData.WebServers = @("fake.webserver.1")
+ $packageData.HasAppServers = $true
+ $packageData.HasFabServers = $true
+ $packageData.HasMicServers = $true
+ $packageData.HasWebServers = $true
+
+ return $packageData
+}
+
+function Init-DebugMetadata {
+ $webPackage1 = New-DummyPackageInstallationData -PackageName "web.package1" -PackageVersion "1.1.0" -IsWebOnly
+ $webPackage2 = New-DummyPackageInstallationData -PackageName "web.package2" -PackageVersion "1.1.0" -IsWebOnly
+ $appPackage1 = New-DummyPackageInstallationData -PackageName "app.package1" -PackageVersion "1.1.0" -IsAppOnly
+ $appPackage2 = New-DummyPackageInstallationData -PackageName "app.package2" -PackageVersion "1.1.0" -IsAppOnly
+ $micPackage1 = New-DummyPackageInstallationData -PackageName "mic.package1" -PackageVersion "1.1.0" -IsMicOnly
+ $micPackage2 = New-DummyPackageInstallationData -PackageName "mic.package2" -PackageVersion "1.1.0" -IsMicOnly
+
+ $debugMetadata = New-Object psobject -property @{
+ WebServerPackages = @()
+ AppServerPackages = @()
+ MicServerPackages = @()
+ FabServerPackages = @()
+
+ WebPackagesToInstallMap = @{}
+ AppPackagesToInstallMap = @{}
+ ClassifiedPackagesMap = @{}
+ ExistingInstalledHotfixes = @()
+ }
+
+ $appServerPackageArray = @($appPackage1, $appPackage2, $webPackage1)
+ $webServerPackageArray = @($webPackage1, $webPackage2, $appPackage1)
+ $micServerPackageArray = @($micPackage1, $micPackage2, $webPackage1)
+ $fabServerPackageArray = @($micPackage1, $micPackage2, $webPackage1)
+
+ $debugMetadata.WebServerPackages = $webServerPackageArray
+ $debugMetadata.AppServerPackages = $appServerPackageArray
+ $debugMetadata.MicServerPackages = $micServerPackageArray
+ $debugMetadata.FabServerPackages = $fabServerPackageArray
+
+ $debugMetadata.ClassifiedPackagesMap["app.package1"] = $appPackage1
+ $debugMetadata.ClassifiedPackagesMap["app.package2"] = $appPackage2
+ $debugMetadata.ClassifiedPackagesMap["mic.package1"] = $micPackage1
+ $debugMetadata.ClassifiedPackagesMap["mic.package2"] = $micPackage2
+ $debugMetadata.ClassifiedPackagesMap["web.package1"] = $webPackage1
+ $debugMetadata.ClassifiedPackagesMap["web.package2"] = $webPackage2
+
+ return $debugMetadata
+}
+
+$password = ConvertTo-SecureString 'fake' -AsPlainText -Force
+$fakeCred = New-Object System.Management.Automation.PSCredential('bogus', $password)
+
+Describe "Get-ServerPackageInformation" {
+ #region Mocks
+ Mock -CommandName Write-Host -MockWith {}
+ Mock -CommandName Write-Warning -MockWith {}
+ Mock -CommandName Write-Error -MockWith {}
+
+ #TODO : Check the return value; possibly needs to be massaged. If so, might need a duplicate in a specific Context.
+ Mock -CommandName Get-EnvironmentData -MockWith { return $packageData }
+
+ #TODO : Check the return value; possibly needs to be massaged. If so, might need a duplicate in a specific Context.
+ Mock -CommandName Get-PackageServerMapping -MockWith { return $packageData, $debugMetadata }
+
+ #TODO : Check the return value; possibly needs to be massaged. If so, might need a duplicate in a specific Context.
+ Mock -CommandName Get-BadPackages -MockWith { return $packageData }
+
+ Mock -CommandName Select-InstallPackages -MockWith { return $packageData }
+
+ Mock -CommandName Get-AwsSettings -MockWith {
+ return @{
+ "Region" = "courts-of-chaos-1"
+ "Profile" = "unittest"
+ }
+ }
+
+ # Cheating for the mock; assume you always get a unique list and just return it.
+ # Only works if you ensure the input is faked correctly.
+ Mock -CommandName Select-UniqueServerPackages -MockWith { return $packagesArray }
+
+ Mock -CommandName Get-InstalledPackages -MockWith { $installedPackages = @( @{
+ Version = "1.0.0"
+ Name = "web.package1"
+ IsValid = $false
+ },
+ @{
+ Version = "1.0.0"
+ Name = "web.package2"
+ IsValid = $false
+ }
+ )
+
+ return [pscustomobject]$installedPackages
+ } -ParameterFilter { $ComputerName -like "*web*" }
+
+ Mock -CommandName Get-InstalledPackages -MockWith { $installedPackages = @( @{
+ Version = "1.0.0"
+ Name = "mic.package1"
+ IsValid = $false
+ },
+ @{
+ Version = "1.0.0"
+ Name = "mic.package2"
+ IsValid = $false
+ }
+ )
+
+ return [pscustomobject]$installedPackages
+ } -ParameterFilter { $ComputerName -like "*mic*" -or $ComputerName -like "*fab*" }
+
+ Mock -CommandName Get-InstalledPackages -MockWith { $installedPackages = @( @{
+ Version = "1.0.0"
+ Name = "app.package1"
+ IsValid = $false
+ },
+ @{
+ Version = "1.0.0"
+ Name = "app.package2"
+ IsValid = $false
+ }
+ )
+
+ return [pscustomobject]$installedPackages
+ } -ParameterFilter { $ComputerName -like "*app*" }
+
+ # TODO : Check the return value; Possibly needs to be massaged.
+ # Cheating for the mock; assume you always get a unique list and just return it.
+ # Only works if you ensure the input is faked correctly.
+ Mock -CommandName Remove-PackagesThatAreAlreadyInstalled -MockWith {
+ return $packagesToInstall
+ }
+
+ Mock -CommandName Select-AlkamiAppServers -MockWith {return $true}
+ Mock -CommandName Select-AlkamiMicServers -MockWith {return $true}
+ Mock -CommandName Select-AlkamiWebServers -MockWith {return $true}
+
+ #endregion Mocks
+
+ Context "Checking that Dependencies are used" {
+ $packageData = Init-PackageData
+ $debugMetadata = Init-DebugMetadata
+
+ It "Gets EnvironmentData" {
+ Get-ServerPackageInformation -PackageMetadata $packageData -DebugMetadata $debugMetadata `
+ -BuildCheckoutDirectory "SomePath" -NugetCredential $fakeCred -AwsProfileName "JunkProfile" -DependencyReleaseValue "junkRelease"
+
+ Assert-MockCalled -CommandName Get-EnvironmentData
+ }
+ It "Builds PackageServerMapping" {
+ Get-ServerPackageInformation -PackageMetadata $packageData -DebugMetadata $debugMetadata `
+ -BuildCheckoutDirectory "SomePath" -NugetCredential $fakeCred -AwsProfileName "JunkProfile" -DependencyReleaseValue "junkRelease"
+
+ Assert-MockCalled -CommandName Get-PackageServerMapping
+ }
+ It "Gets EnvironmentData" {
+ Get-ServerPackageInformation -PackageMetadata $packageData -DebugMetadata $debugMetadata `
+ -BuildCheckoutDirectory "SomePath" -NugetCredential $fakeCred -AwsProfileName "JunkProfile" -DependencyReleaseValue "junkRelease"
+
+ Assert-MockCalled -CommandName Get-BadPackages
+ }
+ It "Gets EnvironmentData" {
+ Get-ServerPackageInformation -PackageMetadata $packageData -DebugMetadata $debugMetadata `
+ -BuildCheckoutDirectory "SomePath" -NugetCredential $fakeCred -AwsProfileName "JunkProfile" -DependencyReleaseValue "junkRelease"
+
+ Assert-MockCalled -CommandName Get-AwsSettings
+ }
+ }
+
+ # We force reinstall Widgets & Providers if it's an orb deploy. We don't if it's a package install.
+ Context "When Force Re-installing Packages" {
+
+ $packageData = Init-PackageData
+ $debugMetadata = Init-DebugMetadata
+
+ # Create Pre-installed packages
+ $appReInstallPackage = New-DummyPackageInstallationData -packagename "app.reinstall.package" -packageversion "1.0.0" -isapponly
+ $webReInstallPackage = New-DummyPackageInstallationData -packagename "web.reinstall.package" -packageversion "1.0.0" -iswebonly
+
+ # Create Packages to be uninstalled
+ $appUninstallPackage = New-DummyPackageInstallationData -packagename "app.uninstall.package" -packageversion "1.0.0" -isapponly
+ $webuninstallPackage = New-DummyPackageInstallationData -packagename "web.uninstall.package" -packageversion "1.0.0" -iswebonly
+
+ # Create pre installed Packages to be uninstalled and reinstalled.
+ $appReUninstallPackage = New-DummyPackageInstallationData -packagename "app.re.uninstall.package" -packageversion "1.0.0" -isapponly
+ $webReUninstallPackage = New-DummyPackageInstallationData -packagename "web.re.uninstall.package" -packageversion "1.0.0" -iswebonly
+
+ # Add packages to install/uninstall lists
+ $packagedata.AppPackagesToUninstall += @($appReUninstallPackage)
+ $packagedata.WebPackagesToUninstall += @($webReUninstallPackage)
+
+ $packagedata.AppPackagesToUninstall += @($appUninstallPackage)
+ $packagedata.WebPackagesToUninstall += @($webUninstallPackage)
+
+ $packageData.AppPackagesToInstall += @($appReUninstallPackage)
+ $packageData.WebPackagesToInstall += @($webReUninstallPackage)
+
+ # Make this an orb run
+ $packageData.ForceReinstallPackages = $true
+
+ # Add packages to preinstalled lists
+ $debugMetadata.AppServerPackages += $appReInstallPackage
+ $debugMetadata.WebServerPackages += $webReInstallPackage
+
+ $debugMetadata.AppServerPackages += $appUninstallPackage
+ $debugMetadata.WebServerPackages += $webUninstallPackage
+
+ $debugMetadata.AppServerPackages += $appReUninstallPackage
+ $debugMetadata.WebServerPackages += $webReUninstallPackage
+
+ # Pretend packages to be reinstalled are everywhere
+ Mock -CommandName Select-InstallPackages -MockWith {
+ $appReInstallPackage.ForceSameVersion = $true
+ $webReInstallPackage.ForceSameVersion = $true
+
+ $newPackages = @()
+ $newPackages += $appReInstallPackage
+ $newPackages += $webReInstallPackage
+
+ # TODO: Change this to be uninstall/reinstall packages.
+ $newPackages += $appReUninstallPackage
+ $newPackages += $webReUninstallPackage
+
+ $newPackages += $packages
+
+ return $newPackages
+ }
+
+ Mock -CommandName Get-PackageServerMapping -MockWith{
+ $PackageMetadata.AppPackagesToInstall = $AllAppTierPackages
+ $PackageMetadata.WebPackagesToInstall = $AllWebTierPackages
+
+ return $PackageMetadata, $DebugMetadata
+ }
+
+ Mock -CommandName Add-OldHotfixPackagesToUninstallList -MockWith {
+ return $PackageMetadata, $DebugMetadata
+ }
+
+ $packages, $debugpackagses = Get-ServerPackageInformation -packagemetadata $packageData -debugmetadata $debugmetadata `
+ -buildcheckoutdirectory "somepath" -nugetcredential $fakecred -DependencyReleaseValue "123" -AwsProfileName "JunkProfile"
+
+ It "Calls Add-OldHotfixPackagesToUninstallList" {
+ Assert-MockCalled Add-OldHotfixPackagesToUninstallList
+ }
+
+ # Test App Packages
+ It "Keeps Providers To Be Reinstalled"{
+ $packages.AppPackagesToInstall | Should -Contain $appReInstallPackage
+ }
+
+ # Test Web Packages
+ It "Keeps Widgets To Be Reinstalled" {
+ $packages.WebPackagesToInstall | Should -Contain $WebReInstallPackage
+ }
+
+ It "Does Not Reinstall Packages Which Are Being Uninstalled" {
+ $packages.AppPackagesToInstall | Should -Not -Contain $appUninstallPackage
+ $packages.WebPackagesToInstall | Should -Not -Contain $webUninstallPackage
+ }
+
+ It "Does Reinstall Packages Which Are Being Uninstalled And Reinstalled" {
+ $packages.AppPackagesToInstall | Should -Contain $appReUninstallPackage
+ $packages.WebPackagesToInstall | Should -Contain $webReUninstallPackage
+ }
+ }
+
+ Context "When Told To Install Chocolatey" {
+ $packageData = Init-PackageData
+ $debugMetadata = Init-DebugMetadata
+ $packageData.AppPackagesToInstall += New-DummyPackageInstallationData -PackageName "chocolatey" -PackageVersion "1.0.0" -IsAppOnly
+ $packageData.WebPackagesToInstall += New-DummyPackageInstallationData -PackageName "chocolatey" -PackageVersion "1.0.0" -IsWebOnly
+
+ # This is the function that strips out chocolatey based on allWebTierPackages and allAppTierPackages. Do that here.
+ Mock -CommandName Get-PackageServerMapping -MockWith {
+ $packageData.AppPackagesToInstall = $AllAppTierPackages
+ $packageData.WebPackagesToInstall = $AllWebTierPackages
+
+ return $packageData, $debugMetadata }
+
+ $packages, $debugPackagses = Get-ServerPackageInformation -PackageMetadata $packageData -DebugMetadata $debugMetadata `
+ -BuildCheckoutDirectory "SomePath" -NugetCredential $fakeCred -AwsProfileName "JunkProfile" -DependencyReleaseValue "junkRelease"
+
+ It "Writes a Warning on Apps" {
+ Assert-MockCalled -CommandName Write-Warning -Scope Context -Exactly 1 `
+ -ParameterFilter {$Message -eq "The Chocolatey Package Manager Program package (chocolatey) was passed in to be installed on the App Tier. At this time we explicitly dissallow that. It has been skipped."}
+ }
+ It "Writes a Warning on Webs" {
+ Assert-MockCalled -CommandName Write-Warning -Scope Context -Exactly 1 `
+ -ParameterFilter {$Message -eq "The Chocolatey Package Manager Program package (chocolatey) was passed in to be installed on the Web Tier. At this time we explicitly dissallow that. It has been skipped."}
+ }
+ It "Removes Chocolatey From the Install List on Apps" {
+ $packages.AppPackagesToInstall | ForEach-Object {$_.Name | Should -Not -BeExactly "chocolatey"}
+ }
+ It "Removes Chocolatey from The Install List on Webs" {
+ $packages.WebPackagesToInstall | ForEach-Object {$_.Name | Should -Not -BeExactly "chocolatey"}
+ }
+ }
+
+ # TODO: Need changes from SRE-16772 before we write tests
+ Context "When Different Servers Have Different Package Versions" {
+ $packageData = Init-PackageData
+ $debugMetadata = Init-DebugMetadata
+
+ $packageData.ServersToQuery = @("fake.appserver.1", "fake.appserver.2", "fake.fabserver.1", "fake.fabserver.2", "fake.micserver.1", "fake.micserver.2", "fake.webserver.1", "fake.webserver.2")
+
+ # Need one mock per server based on computer name. So, 8 mocks.
+ Mock -CommandName Get-InstalledPackages -MockWith { $installedPackages = @( @{
+ Version = "1.0.1"
+ Name = "web.package1"
+ IsValid = $false
+ },
+ @{
+ Version = "1.0.1"
+ Name = "web.package2"
+ IsValid = $false
+ }
+ )
+
+ return [pscustomobject]$installedPackages
+ } -ParameterFilter { $ComputerName -like "*fake.webserver.1*" }
+
+ Mock -CommandName Get-InstalledPackages -MockWith { $installedPackages = @( @{
+ Version = "1.0.1"
+ Name = "web.package1"
+ IsValid = $false
+ },
+ @{
+ Version = "1.0.2"
+ Name = "web.package2"
+ IsValid = $false
+ }
+ )
+
+ return [pscustomobject]$installedPackages
+ } -ParameterFilter { $ComputerName -like "*fake.webserver.2*" }
+
+ Mock -CommandName Get-InstalledPackages -MockWith { $installedPackages = @( @{
+ Version = "1.0.1"
+ Name = "mic.package1"
+ IsValid = $false
+ },
+ @{
+ Version = "1.0.1"
+ Name = "mic.package2"
+ IsValid = $false
+ }
+ )
+
+ return [pscustomobject]$installedPackages
+ } -ParameterFilter { $ComputerName -like "*fake.micserver.1*" }
+
+ Mock -CommandName Get-InstalledPackages -MockWith { $installedPackages = @( @{
+ Version = "1.0.1"
+ Name = "mic.package1"
+ IsValid = $false
+ },
+ @{
+ Version = "1.0.2"
+ Name = "mic.package2"
+ IsValid = $false
+ }
+ )
+
+ return [pscustomobject]$installedPackages
+ } -ParameterFilter { $ComputerName -like "*fake.micserver.2*" }
+
+ Mock -CommandName Get-InstalledPackages -MockWith { $installedPackages = @( @{
+ Version = "1.0.1"
+ Name = "mic.package1"
+ IsValid = $false
+ },
+ @{
+ Version = "1.0.1"
+ Name = "mic.package2"
+ IsValid = $false
+ }
+ )
+
+ return [pscustomobject]$installedPackages
+ } -ParameterFilter { $ComputerName -like "*fake.fabserver.1*" }
+
+ Mock -CommandName Get-InstalledPackages -MockWith { $installedPackages = @( @{
+ Version = "1.0.1"
+ Name = "mic.package1"
+ IsValid = $false
+ },
+ @{
+ Version = "1.0.2"
+ Name = "mic.package2"
+ IsValid = $false
+ }
+ )
+
+ return [pscustomobject]$installedPackages
+ } -ParameterFilter { $ComputerName -like "*fake.fabserver.2*" }
+
+ Mock -CommandName Get-InstalledPackages -MockWith { $installedPackages = @( @{
+ Version = "1.0.1"
+ Name = "app.package1"
+ IsValid = $false
+ },
+ @{
+ Version = "1.0.1"
+ Name = "app.package2"
+ IsValid = $false
+ }
+ )
+
+ return [pscustomobject]$installedPackages
+ } -ParameterFilter { $ComputerName -like "*fake.appserver.1*" }
+
+ Mock -CommandName Get-InstalledPackages -MockWith { $installedPackages = @( @{
+ Version = "1.0.1"
+ Name = "app.package1"
+ IsValid = $false
+ },
+ @{
+ Version = "1.0.2"
+ Name = "app.package2"
+ IsValid = $false
+ }
+ )
+
+ return [pscustomobject]$installedPackages
+ } -ParameterFilter { $ComputerName -like "*fake.appserver.2*" }
+
+ $packages, $debugPackagses = Get-ServerPackageInformation -PackageMetadata $packageData -DebugMetadata $debugMetadata `
+ -BuildCheckoutDirectory "SomePath" -NugetCredential $fakeCred -AwsProfileName "JunkProfile" -DependencyReleaseValue "junkRelease"
+
+ it "Reduces Them to Unique Values" {
+ $packages.PackageToVersions | should -not -BeNullOrEmpty
+ foreach ($h in $packages.packagetoversions.getenumerator()) {
+ $versions = $h.value
+ foreach ($version in $versions) {
+ ($versions | Where-Object { $_ -eq $version }).Count | Should Be 1
+ }
+ }
+ }
+ }
+
+ Context "When Already Installed Packages Are To Be Re-installed" {
+ $packageData = Init-PackageData
+ $debugMetadata = Init-DebugMetadata
+
+ # Create pre-installed package
+ $appInstalledPackage = New-DummyPackageInstallationData -packagename "app.package1" -packageversion "1.0.0" -isapponly
+
+ # Create package to install
+ $appPackageV2 = New-DummyPackageInstallationData -PackageName "app.package1" -PackageVersion "2.1.0" -IsAppOnly
+
+ # Add package to preinstalled list
+ $debugMetadata.AppServerPackages += $appInstalledPackage
+
+ # Add package to list to be installed
+ $packageData.AppPackagesToInstall += @($appPackageV2)
+
+ $packages, $debugPackagses = Get-ServerPackageInformation -PackageMetadata $packageData -DebugMetadata $debugMetadata `
+ -BuildCheckoutDirectory "SomePath" -NugetCredential $fakeCred -AwsProfileName "JunkProfile" -DependencyReleaseValue "junkRelease"
+
+ It "Calls Remove-PackagesThatAreAlreadyInstalled" {
+
+ Assert-MockCalled -CommandName Remove-PackagesThatAreAlreadyInstalled -Scope Context
+ }
+
+ It "Installs The Version From The Install List" {
+ $packages.AppPackagesToInstall | Should -Contain $appPackageV2
+ }
+
+ It "Does Not Install The Existing Version" {
+ $packages.AppPackagesToInstall | Should -Not -Contain $appInstalledPackage
+ }
+ }
+
+ Context "When Uninstalling Packages" {
+ $packageData = Init-PackageData
+ $debugMetadata = Init-DebugMetadata
+
+ $appUninstallPackage = New-DummyPackageInstallationData -PackageName "app.uninstall.package" -PackageVersion "1.0.0" -IsAppOnly
+ $micUninstallPackage = New-DummyPackageInstallationData -PackageName "mic.uninstall.package" -PackageVersion "1.0.0" -IsMicOnly
+ $webUninstallPackage = New-DummyPackageInstallationData -PackageName "web.uninstall.package" -PackageVersion "1.0.0" -IsWebOnly
+
+ # Starts off in the same array, split inside the function we're testing.
+ $packageData.AppPackagesToUninstall += @($appUninstallPackage)
+ $packageData.AppPackagesToUninstall += @($micUninstallPackage)
+
+ $packageData.WebPackagesToUninstall += @($webUninstallPackage)
+
+ $debugMetadata.AppServerPackages += $appUninstallPackage
+ $debugMetadata.MicServerPackages += $micUninstallPackage
+ $debugMetadata.WebServerPackages += $webUninstallPackage
+
+ $packages, $debugPackagses = Get-ServerPackageInformation -PackageMetadata $packageData -DebugMetadata $debugMetadata `
+ -BuildCheckoutDirectory "SomePath" -NugetCredential $fakeCred -AwsProfileName "JunkProfile" -DependencyReleaseValue "junkRelease"
+
+ It "Removes Packages from App Servers" {
+ $packages.AppPackagesToUninstall | Should -Not -BeNullOrEmpty
+
+ $packages.AppPackagesToUninstall | Should -Match $packageData.AppPackagesToUninstall
+ }
+
+ It "Removes Packages from Mic Servers" {
+ $packages.MicPackagesToUninstall | Should -Not -BeNullOrEmpty
+
+ $packages.MicPackagesToUninstall | Should -Match $packageData.MicPackagesToUninstall
+ }
+
+ It "Removes Packages from Web Servers" {
+ $packages.WebPackagesToUninstall | Should -Not -BeNullOrEmpty
+
+ $packages.WebPackagesToUninstall | Should -Match $packageData.WebPackagesToUninstall
+ }
+
+ It "Correctly Labels Has App Uninstalls" {
+ $packages.HasAppUninstalls | Should -BeTrue
+ }
+ It "Correctly Labels Has Mic Uninstalls" {
+ $packages.HasMicUninstalls | Should -BeTrue
+ }
+ It "Correctly Labels Has Web Uninstalls" {
+ $packages.HasWebUninstalls | Should -BeTrue
+ }
+ }
+
+ Context "When Packages Are Found On The Wrong Server Type" {
+ $packageData = Init-PackageData
+ $debugMetadata = Init-DebugMetadata
+
+ $appUninstallPackage = New-DummyPackageInstallationData -PackageName "app.uninstall.package" -PackageVersion "1.0.0" -IsAppOnly
+ $micUninstallPackage = New-DummyPackageInstallationData -PackageName "mic.uninstall.package" -PackageVersion "1.0.0" -IsMicOnly
+ $webUninstallPackage = New-DummyPackageInstallationData -PackageName "web.uninstall.package" -PackageVersion "1.0.0" -IsWebOnly
+
+ Mock -CommandName Get-BadPackages -MockWith {
+ $packageData.BadMicPackagesToUninstall += @($micUninstallPackage)
+ $packageData.BadAppPackagesToUninstall += @($webUninstallPackage)
+ $packageData.BadWebPackagesToUninstall += @($appUninstallPackage)
+ return $packageData
+ }
+
+ $packages, $debugPackagses = Get-ServerPackageInformation -PackageMetadata $packageData -DebugMetadata $debugMetadata `
+ -BuildCheckoutDirectory "SomePath" -NugetCredential $fakeCred -AwsProfileName "JunkProfile" -DependencyReleaseValue "junkRelease"
+
+ It "Removes Bad Packages from App Servers" {
+ $packages.AppPackagesToUninstall | Should -Not -BeNullOrEmpty
+
+ $packages.AppPackagesToUninstall | Should -Match $packageData.AppPackagesToUninstall
+ }
+
+ It "Removes Bad Packages from Mic Servers" {
+ $packages.MicPackagesToUninstall | Should -Not -BeNullOrEmpty
+
+ $packages.MicPackagesToUninstall | Should -Match $packageData.MicPackagesToUninstall
+ }
+
+ It "Removes Bad Packages from Web Servers" {
+ $packages.WebPackagesToUninstall | Should -Not -BeNullOrEmpty
+
+ $packages.WebPackagesToUninstall | Should -Match $packageData.WebPackagesToUninstall
+ }
+ }
+
+ Context "When Getting Install Information" {
+ $packageData = Init-PackageData
+ $debugMetadata = Init-DebugMetadata
+
+ $packages, $debugPackagses = Get-ServerPackageInformation -PackageMetadata $packageData -DebugMetadata $debugMetadata `
+ -BuildCheckoutDirectory "SomePath" -NugetCredential $fakeCred -AwsProfileName "JunkProfile" -DependencyReleaseValue "junkRelease"
+
+ It "Correctly Labels Has App Installs" {
+ $packages.HasAppInstalls | Should -BeTrue
+ }
+
+ It "Correctly Labels Has Mic Installs" {
+ $packages.HasMicInstalls | Should -BeTrue
+ }
+
+ It "Correctly Labels Has Web Installs" {
+ $packages.HasWebInstalls | Should -BeTrue
+ }
+
+ It "Returns Package Metadata" {
+ $packages | Should -Not -BeNullOrEmpty
+ }
+
+ It "Returns DebugMetadata" {
+ $debugPackagses | Should -Not -BeNullOrEmpty
+ }
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Installation/Public/Get-ServerPackageInformation.ps1 b/Modules/Alkami.DevOps.Installation/Public/Get-ServerPackageInformation.ps1
new file mode 100644
index 0000000..e30f6d4
--- /dev/null
+++ b/Modules/Alkami.DevOps.Installation/Public/Get-ServerPackageInformation.ps1
@@ -0,0 +1,314 @@
+function Get-ServerPackageInformation {
+ <#
+ .SYNOPSIS
+ The bulk of the Classify logic. Determines what versions of which packages get installed. Utilized by Classify-Packages.
+
+ .PARAMETER PackageMetadata
+ Packages object to populate.
+
+ .PARAMETER DebugMetadata
+ Meta object used to determine what goes where.
+
+ .PARAMETER BuildCheckoutDirectory
+ Directory where the code is checked out by TC. Passed through.
+
+ .PARAMETER AwsProfileName
+ Profile name to get information from AWS.
+
+ .PARAMETER UseV2PackageMetadata
+ Whether or not to use the new version of the PackageMetadata functions. Passed through.
+
+ .PARAMETER DependencyReleaseValue
+ The value of the orb release being classified. Passed through.
+
+ #>
+ [CmdletBinding()]
+ param(
+ [Parameter (Mandatory = $true)]
+ [PSObject]$PackageMetadata,
+ [Parameter (Mandatory = $true)]
+ [PSObject]$DebugMetadata,
+ [Parameter (Mandatory = $true)]
+ [string]$BuildCheckoutDirectory,
+ [Parameter (Mandatory = $true)]
+ [string]$AwsProfileName,
+ [Parameter (Mandatory = $true)]
+ [System.Management.Automation.PSCredential]$NugetCredential,
+ [switch]$UseV2PackageMetadata,
+ [Parameter (Mandatory = $true)]
+ [string]$DependencyReleaseValue
+ )
+
+ # These were set outside of the region this function came from, but are used only in this function. Nothing happens to the
+ # underlying data between when they were set and when this function was called, so declare them here instead.
+ # TODO: Consider moving these values to DebugMetadata
+ $webInstallNames = $PackageMetadata.WebPackagesToInstall.Name
+ $webUninstallNames = $PackageMetadata.WebPackagesToUninstall.Name
+
+ $appInstallNames = $PackageMetadata.AppPackagesToInstall.Name
+ $appUninstallNames = $PackageMetadata.AppPackagesToUninstall.Name
+
+ Write-Host "##teamcity[blockOpened name='Gather Information']"
+
+ $PackageMetadata = Get-EnvironmentData -packageMetadata $PackageMetadata
+
+ # Construct a map of server -> packages installed on that server.
+ $serverToPackages = @{}
+ foreach ($server in $PackageMetadata.ServersToQuery) {
+ # TODO ~ Fix this to not need the !StartsWith part
+ # ~ Relies on the package classification being right. We can just remove all SREModule types.
+ $serverToPackages[$server] = (Get-InstalledPackages -ComputerName $server).Where({ !$_.Name.StartsWith("Alkami.Installer") })
+ }
+
+ #region Filter existing packages by servertype
+ # Get the packages from each type of server.
+ if ($PackageMetadata.HasWebServers) {
+ $packages = @()
+ foreach ($server in $PackageMetadata.WebServers) {
+ $packages += @($serverToPackages[$server])
+ }
+ $DebugMetadata.WebServerPackages = @(Select-UniqueServerPackages $packages)
+ }
+
+ if ($PackageMetadata.HasAppServers) {
+ $packages = @()
+ foreach ($server in $PackageMetadata.AppServers) {
+ $packages += @($serverToPackages[$server])
+ }
+ $DebugMetadata.AppServerPackages = @(Select-UniqueServerPackages $packages)
+ }
+
+ if ($PackageMetadata.HasMicServers) {
+ $packages = @()
+ foreach ($server in $PackageMetadata.MicServers) {
+ $packages += @($serverToPackages[$server])
+ }
+ $DebugMetadata.MicServerPackages = @(Select-UniqueServerPackages $packages)
+ }
+
+ if ($PackageMetadata.HasFabServers) {
+ $packages = @()
+ foreach ($server in $PackageMetadata.FabServers) {
+ $packages += @($serverToPackages[$server])
+ }
+ $DebugMetadata.FabServerPackages = @(Select-UniqueServerPackages $packages)
+ }
+ #endregion Filter existing packages by servertype
+
+ #region Find all packages with multi-server-existing-version-mismatch for true-up
+ # Create a list of package name -> versions installed on each server.
+ # This gives a view of all of the versions of a particular package installed.
+ foreach ($serverPackages in $serverToPackages.Values) {
+ foreach ($package in $serverPackages) {
+ $packageNameLower = $package.Name.ToLower()
+ if (!$PackageMetadata.PackageToVersions.ContainsKey($packageNameLower)) {
+ $PackageMetadata.PackageToVersions[$packageNameLower] = @($package.Version)
+ } else {
+ $PackageMetadata.PackageToVersions[$packageNameLower] = $PackageMetadata.PackageToVersions[$packageNameLower] + $package.Version
+ }
+ }
+ }
+ # Limit the versions down to unique versions installed.
+ $packageToVersionsKeys = @() + $PackageMetadata.PackageToVersions.Keys; # So we aren't "changing" the keys of the map and breaking enumeration.
+ foreach ($key in $packageToVersionsKeys) {
+ $PackageMetadata.PackageToVersions[$key] = ($PackageMetadata.PackageToVersions[$key] | Select-Object -Unique)
+ }
+ #endregion Find all packages with multi-server-existing-version-mismatch for true-up
+
+ # Create a list of package names to servers the package is installed on.
+ # Also, when a FAB name is encountered, add the entire list of FAB servers.
+ foreach ($server in $serverToPackages.Keys) {
+ $serversToAdd = @($server)
+ if ($server -like "fab*") {
+ $serversToAdd = @($PackageMetadata.FabServers)
+ }
+ $serverPackages = $serverToPackages[$server]
+ foreach ($package in $serverPackages) {
+ $lowerName = $package.Name.ToLower()
+ if (!$PackageMetadata.PackageToServers.ContainsKey($lowerName)) {
+ $PackageMetadata.PackageToServers[$lowerName] = $serversToAdd
+ } else {
+ $PackageMetadata.PackageToServers[$lowerName] += $serversToAdd
+ }
+ }
+ }
+
+ #region Filter packages down to unique packages across the entire environment
+ $allWebTierPackages = (Select-UniqueServerPackages $DebugMetadata.WebServerPackages)
+ $allAppTierPackages = (Select-UniqueServerPackages (
+ @($DebugMetadata.AppServerPackages) +
+ @($DebugMetadata.MicServerPackages) +
+ @($DebugMetadata.FabServerPackages)
+ ))
+
+ # Create a map of the packages getting installed from the job parameters. We need to know which packages came from the deploy params, and which from the servers.
+ if (!(Test-IsCollectionNullOrEmpty $PackageMetadata.WebPackagesToInstall)) {
+ foreach ($package in $PackageMetadata.WebPackagesToInstall) {
+ $DebugMetadata.WebPackagesToInstallMap[$package.Name.ToLower()] = $package
+ }
+ }
+ if (!(Test-IsCollectionNullOrEmpty $PackageMetadata.AppPackagesToInstall)) {
+ foreach ($package in $PackageMetadata.AppPackagesToInstall) {
+ $DebugMetadata.AppPackagesToInstallMap[$package.Name.ToLower()] = $package
+ }
+ }
+ #endregion Filter packages down to unique packages across the entire environment
+
+ #region Determine $all___TierPackageInstalls
+ if ($PackageMetadata.ForceReinstallPackages) {
+ # It's a full deploy. (This is an implicit reproduction of the .IsOrbDeploy flag. The two should probably be consolidated at some point.)
+ # Overwrite the packages from the environment with packages intended to be installed.
+ # This will leave us with a list of things we want installed, plus the things we might force-reinstall.
+ $allWebTierPackages = (Select-InstallPackages $allWebTierPackages $PackageMetadata.WebPackagesToInstall)
+ $allAppTierPackages = (Select-InstallPackages $allAppTierPackages $PackageMetadata.AppPackagesToInstall)
+ } else {
+ # Otherwise it's an element deploy.
+ # Filter down the list of packages to the ones that are being deployed.
+ $allWebTierPackages = $PackageMetadata.WebPackagesToInstall
+ $allAppTierPackages = $PackageMetadata.AppPackagesToInstall
+ }
+ #endregion Determine $all___TierPackageInstalls
+
+ #region Exclude chocolatey if present
+ if ($null -ne ($allWebTierPackages | Where-Object { $_.Name -eq "chocolatey" })) {
+ Write-Warning "The Chocolatey Package Manager Program package (chocolatey) was passed in to be installed on the Web Tier. At this time we explicitly dissallow that. It has been skipped."
+ }
+ if ($null -ne ($allAppTierPackages | Where-Object { $_.Name -eq "chocolatey" })) {
+ Write-Warning "The Chocolatey Package Manager Program package (chocolatey) was passed in to be installed on the App Tier. At this time we explicitly dissallow that. It has been skipped."
+ }
+
+ # Remove chocolatey from the install lists.
+ $allWebTierPackages = $allWebTierPackages | Where-Object { $_.Name -ne "chocolatey" }
+ $allAppTierPackages = $allAppTierPackages | Where-Object { $_.Name -ne "chocolatey" }
+
+ #endregion Exclude chocolatey if present
+
+
+ #region Remove PackagesToUninstall from all***TierPackages
+ #TODO: Independent filter here or add to Select-InstallPackages called from Line 636-637 ?
+ #TODO: OR Add to Get-PackageInstallationData in Alkami.Powershell.Choco called from 675-677 ?
+ #endregion Remove PackagesToUninstall from all***TierPackages
+
+ $buildMappingArgs = @{
+ AllAppTierPackages = $allAppTierPackages
+ AllWebTierPackages = $allWebTierPackages
+ BuildCheckoutDirectory = $BuildCheckoutDirectory
+ DebugMetadata = $DebugMetadata
+ NugetCredential = $NugetCredential
+ PackageMetadata = $PackageMetadata
+ UseV2PackageMetadata = $UseV2PackageMetadata
+ DependencyReleaseValue = $DependencyReleaseValue
+ }
+
+ $PackageMetadata,$DebugMetadata = Get-PackageServerMapping @buildMappingArgs
+
+ $PackageMetadata = Get-BadPackages -DebugMetadata $DebugMetadata -PackageMetadata $PackageMetadata
+
+ # Strip out empty/null entries. Follow up/remove with SRE-17113. Suggestion for debugging; move this block around and see where it fails
+ [array]$PackageMetadata.WebPackagesToInstall = $PackageMetadata.WebPackagesToInstall.Where({$null -ne $_})
+ [array]$PackageMetadata.AppPackagesToInstall = $PackageMetadata.AppPackagesToInstall.Where({$null -ne $_})
+ [array]$PackageMetadata.MicPackagesToInstall = $PackageMetadata.MicPackagesToInstall.Where({$null -ne $_})
+
+ #region Doublecheck force-reinstall
+ # Keep only the packages that need to be force reinstalled, or that came from the install parameters.
+ if ($PackageMetadata.ForceReinstallPackages) {
+ $PackageMetadata.WebPackagesToInstall = $PackageMetadata.WebPackagesToInstall | Where-Object {
+ # Where package exists on a server, and will be deleted (ie: widget), but isn't in either of our lists.
+ (($_.ForceSameVersion -and ($webUninstallNames -inotcontains $_.Name -and $webInstallNames -inotcontains $_.Name))) -or
+ # Packages we're installing for some reason decided upon above.
+ ($DebugMetadata.WebPackagesToInstallMap.ContainsKey($_.Name.ToLower())) }
+ $PackageMetadata.AppPackagesToInstall = $PackageMetadata.AppPackagesToInstall | Where-Object {
+ (($_.ForceSameVersion -and ($appUninstallNames -inotcontains $_.Name -and $appInstallNames -inotcontains $_.Name))) -or
+ # Packages we're installing for some reason decided upon above.
+ ($DebugMetadata.AppPackagesToInstallMap.ContainsKey($_.Name.ToLower())) }
+ }
+ #endregion Doublecheck force-reinstall
+
+ #region Remove packages from the install lists that do not need to be needlessly re-installed.
+ Write-Host ("##teamcity[blockOpened name='Removing Already-Installed Packages']")
+ # Web/App package arrays here are getting null entries from *mumble mumble* somewhere...
+ # cut out the nulls before passing it along.
+ if (!(Test-IsCollectionNullOrEmpty $PackageMetadata.WebPackagesToInstall)) {
+ $packagesToInstall = @()
+ foreach($package in $PackageMetadata.WebPackagesToInstall){
+ if($null -ne $package){
+ $packagesToInstall += $package
+ }
+ }
+
+ $PackageMetadata.WebPackagesToInstall = (Remove-PackagesThatAreAlreadyInstalled -packagesToInstall $packagesToInstall -packageMetadata $PackageMetadata -debugMetadata $DebugMetadata)
+ }
+ if (!(Test-IsCollectionNullOrEmpty $PackageMetadata.AppPackagesToInstall)) {
+ $packagesToInstall = @()
+ foreach($package in $PackageMetadata.AppPackagesToInstall){
+ if($null -ne $package){
+ $packagesToInstall += $package
+ }
+ }
+ $PackageMetadata.AppPackagesToInstall = (Remove-PackagesThatAreAlreadyInstalled -packagesToInstall $packagesToInstall -packageMetadata $PackageMetadata -debugMetadata $DebugMetadata)
+ }
+
+ # $PackageMetadata.WebPackagesToInstall = (Remove-PackagesThatAreAlreadyInstalled -packagesToInstall $PackageMetadata.WebPackagesToInstall -packageMetadata $PackageMetadata -debugMetadata $DebugMetadata)
+ # $PackageMetadata.AppPackagesToInstall = (Remove-PackagesThatAreAlreadyInstalled -packagesToInstall $PackageMetadata.AppPackagesToInstall -packageMetadata $PackageMetadata -debugMetadata $DebugMetadata)
+ Write-Host ("##teamcity[blockClosed name='Removing Already-Installed Packages']")
+ #endregion Remove packages from the install lists that do not need to be needlessly re-installed.
+
+ #region Gather AWS settings from a sample server
+ # Had to re-obtain this value here when functionalizing classify.
+ $serverToQuery = $packageMetadata.ServersToQuery | Select-Object -First 1
+
+ # Grab data from first server available
+ $awsSettings = Get-AwsSettings -serverToTest $serverToQuery -profileName $AwsProfileName
+
+ $PackageMetadata.AwsSettings = $awsSettings
+
+ #endregion Gather AWS settings from a sample server
+
+ #region Separate App-Mic/Fab installs and uninstalls.
+ # Separate out the mic/fab installs from the app installs.
+ [array]$PackageMetadata.MicPackagesToInstall = $PackageMetadata.AppPackagesToInstall | Where-Object { $_.InstallToMic -or $_.InstallToFab }
+ [array]$PackageMetadata.AppPackagesToInstall = $PackageMetadata.AppPackagesToInstall | Where-Object { $_.InstallToApp }
+
+ $newAppUninstalls = @()
+ $newMicUninstalls = @()
+
+ foreach($package in $PackageMetadata.AppPackagesToUninstall) {
+ $packageInstalledOnServers = $PackageMetadata.PackageToServers[$package.Name]
+ $installedOnAppServers = $null -ne (Select-AlkamiAppServers -servers $packageInstalledOnServers)
+ $installedOnMicServers = ($null -ne (Select-AlkamiMicServers -servers $packageInstalledOnServers)) -or
+ ($null -ne (Select-AlkamiFabServers -servers $packageInstalledOnServers))
+
+ if($installedOnAppServers) {
+ $newAppUninstalls += $package
+ }
+ if($installedOnMicServers) {
+ $newMicUninstalls += $package
+ }
+ }
+ $PackageMetadata.AppPackagesToUninstall = $newAppUninstalls
+ $PackageMetadata.MicPackagesToUninstall = $newMicUninstalls
+
+ # Add the bad packages to uninstall to the uninstall lists.
+ # This must happen underneath the app/mic uninstall separation, so we don't remove a valid package from a mic/app server.
+ # This preserves explicit package uninstalls, but allows for specific removal of bad packages.
+ if (!(Test-IsCollectionNullOrEmpty $PackageMetadata.BadWebPackagesToUninstall)) { $PackageMetadata.WebPackagesToUninstall += $PackageMetadata.BadWebPackagesToUninstall; }
+ if (!(Test-IsCollectionNullOrEmpty $PackageMetadata.BadAppPackagesToUninstall)) { $PackageMetadata.AppPackagesToUninstall += $PackageMetadata.BadAppPackagesToUninstall; }
+ if (!(Test-IsCollectionNullOrEmpty $PackageMetadata.BadMicPackagesToUninstall)) { $PackageMetadata.MicPackagesToUninstall += $PackageMetadata.BadMicPackagesToUninstall; }
+
+ #endregion Separate Mic/Fab installs/uninstalls.
+
+ $packageMetadata, $debugMetadata = Add-OldHotfixPackagesToUninstallList -DependencyReleaseValue $DependencyReleaseValue -PackageMetadata $packageMetadata -DebugMetadata $debugMetadata
+
+ #region Final determination of what types of installs we are doing
+ $PackageMetadata.HasWebInstalls = !(Test-IsCollectionNullOrEmpty $PackageMetadata.WebPackagesToInstall)
+ $PackageMetadata.HasWebUninstalls = !(Test-IsCollectionNullOrEmpty $PackageMetadata.WebPackagesToUninstall)
+ $PackageMetadata.HasAppInstalls = !(Test-IsCollectionNullOrEmpty $PackageMetadata.AppPackagesToInstall)
+ $PackageMetadata.HasAppUninstalls = !(Test-IsCollectionNullOrEmpty $PackageMetadata.AppPackagesToUninstall)
+ $PackageMetadata.HasMicInstalls = !(Test-IsCollectionNullOrEmpty $PackageMetadata.MicPackagesToInstall)
+ $PackageMetadata.HasMicUninstalls = !(Test-IsCollectionNullOrEmpty $PackageMetadata.MicPackagesToUninstall)
+ #endregion Final determination of what types of installs we are doing
+
+ return $PackageMetadata, $DebugMetadata
+
+ Write-Host ("##teamcity[blockClosed name='Gather Information']")
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Installation/Public/Install-NewRelicDotNetAgent.ps1 b/Modules/Alkami.DevOps.Installation/Public/Install-NewRelicDotNetAgent.ps1
new file mode 100644
index 0000000..db40b36
--- /dev/null
+++ b/Modules/Alkami.DevOps.Installation/Public/Install-NewRelicDotNetAgent.ps1
@@ -0,0 +1,158 @@
+function Install-NewRelicDotNetAgent {
+<#
+.SYNOPSIS
+ Installs the New Relic .NET Agent from the Default Chocolatey Feed
+.PARAMETER EnvironmentKey
+ Value used to obtain the License Key for the Chocolatey upgrade
+.PARAMETER Source
+ Where the package is located in
+.PARAMETER Version
+ Specified package version number of New Relic .Net
+#>
+
+ [CmdletBinding()]
+ [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingInvokeExpression', '', Scope = 'Function', Justification = "This is not evaluating user-supplied input")]
+ Param(
+ [Parameter(Mandatory = $true)]
+ [Alias("Environment")]
+ [string]$EnvironmentKey,
+
+ [Parameter(Mandatory = $false)]
+ [string]$Source = $null,
+
+ [Parameter(Mandatory = $false)]
+ [string]$Version = $null
+ )
+
+ $logLead = (Get-LogLeadName)
+ $newRelicDotnetAgentPackageName = "newrelic-dotnet"
+ $installedAppsRegistryKey = "HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*"
+ $alkamiFeedMatch = "http?://packagerepo.orb.alkamitech.com/nuget/choco.*"
+
+ $packageSource = $Source
+ if ([string]::IsNullOrWhiteSpace($packageSource)) {
+
+ Write-Host "$logLead : Package Source not provided. Iterating over configured feeds to look for the package to install."
+ [array]$sources = (Get-ChocolateySources) | Where-Object {
+ $_.Source -like $alkamiFeedMatch -and [bool]$_.IsSDK -eq $false -and [bool]$_.Disabled -eq $false
+ }
+
+ Write-Verbose "$logLead : Found $($sources.Count) active feeds matching $alkamiFeedMatch"
+
+ if (Test-IsCollectionNullOrEmpty -Collection $sources) {
+
+ Write-Error "$logLead : NOT FOUND - Alkami choco feed for $newRelicDotnetAgentPackageName"
+ return
+
+ } else {
+
+ foreach ($feedSource in $sources) {
+
+ Write-Verbose "$logLead : Checking source with name $($feedSource.Name) and Feed URL $($feedSource.Source)"
+ $highestAvailableChoco = Get-ChocoState -exact -packageName $newRelicDotnetAgentPackageName -source $feedSource.Source -ErrorAction Continue
+ if ($null -eq $highestAvailableChoco) {
+
+ Write-Warning "$logLead : Package $newRelicDotnetAgentPackageName not found at feed $($feedSource.Source)"
+
+ } else {
+
+ Write-Host "$logLead : Package $newRelicDotnetAgentPackageName found at $($feedSource.Source). Using this source"
+ $packageSource = $feedSource.Source
+ continue
+ }
+ }
+
+ if ($null-eq $packageSource) {
+
+ Write-Error "$logLead : No configured feed has package $newRelicDotnetAgentPackageName available. Execution cannot continue."
+ return
+ }
+ }
+ } else {
+
+ Write-Host "$logLead : Using provided Package Source - $packageSource"
+ }
+
+ $licenseKey = (Get-NewRelicAccountDetails $EnvironmentKey).LicenseKey
+ Write-Verbose ("$logLead : Using license key {0}" -f $licenseKey)
+
+ $localChoco = Get-ChocoState -local -exact -packageName $newRelicDotnetAgentPackageName
+ Write-Verbose "$logLead : LocalChoco : $localChoco"
+
+ $doInstall = $false
+ if (Test-StringIsNullOrWhiteSpace -Value $Version) {
+
+ #TODO: Add trap for failure to find any version of NRDNA in the feed
+ if (Test-IsCollectionNullOrEmpty -Collection $highestAvailableChoco) {
+ Write-Error "$logLead : PACKAGE NOT FOUND - Package [$newRelicDotnetAgentPackageName] in feed [$packageSource]"
+ return
+ } else {
+ Write-Host "$loglead : No specific version specified, upgrading/installing latest available"
+ Write-Verbose "$loglead : Highest version found in feed: $highestAvailableChoco"
+ }
+ [System.Version]$highestAvailableChocoVersion = ((Select-Object -InputObject $highestAvailableChoco -First 1).Version)
+ [System.Version]$versionToInstall = $highestAvailableChocoVersion
+ } else {
+ try {
+ $versionObject = Get-VersionPSObject -Version $Version -ErrorAction Stop
+ Write-Host "$loglead : Version specified : $Version"
+ [System.Version]$versionToInstall = $versionObject.Version
+ } catch {
+ Write-Warning "$loglead : Version specified not in accordance to correct format : $Version"
+ throw $_
+ }
+ }
+
+ if ($null -eq $versionToInstall) {
+ $errorMessage = "$loglead : Version number is empty, exiting..."
+ throw $errorMessage
+ }
+
+ $isAgentInstalledViaChoco = !(Test-IsCollectionNullOrEmpty -Collection $localChoco)
+
+ Write-Verbose "$logLead : Is .Net agent Installed via Choco package? $isAgentInstalledViaChoco"
+
+ if ( !$isAgentInstalledViaChoco ) {
+ $installedApps = Get-ItemProperty -Path $installedAppsRegistryKey `
+ | Select-Object DisplayName, DisplayVersion, InstallDate `
+ | Where-Object { $_.DisplayName -match "New Relic .NET" }
+ Write-Verbose "$logLead : Value from registry key : $installedApps"
+
+ $isAgentInstalled = ![string]::IsNullOrWhiteSpace($installedApps)
+
+ if ( !($isAgentInstalled) ) {
+ $doInstall = $true
+ Write-Host "$logLead : Determined no .Net Agent is installed."
+ }
+ } else {
+ [System.Version]$preInstallChocoVersion = ((Select-Object -InputObject $localChoco -First 1).Version)
+ if ($versionToInstall -gt $preInstallChocoVersion) {
+ $doInstall = $true
+ Write-Host "$logLead : Determined .Net Agent is installed via Choco and requires an update."
+ Write-Host "$logLead : Installed : $preInstallChocoVersion"
+ Write-Host "$logLead : Version to install : $versionToInstall"
+ } elseif ($versionToInstall -lt $preInstallChocoVersion) {
+ $doInstall = $true
+ Write-Host "$logLead : Determined .Net Agent is installed via Choco and requires a downgrade."
+ Write-Host "$logLead : Installed : $preInstallChocoVersion"
+ Write-Host "$logLead : Version to install : $versionToInstall"
+ Uninstall-NewRelicDotNetAgent
+ }
+ }
+
+ if ($doInstall) {
+ Write-Host "$logLead : Install package : $newRelicDotnetAgentPackageName"
+ Write-Host "$logLead : Package version : $versionToInstall"
+ $command = "choco upgrade -y $newRelicDotnetAgentPackageName --version $versionToInstall --no-progress --failstderr --params `"'license_key=$licenseKey'`" --source $packageSource"
+ Write-Host "Command: $command"
+ Invoke-Expression -Command $command
+ } else {
+ Write-Host "$logLead : No Update Was Performed"
+ }
+
+ #TODO: Move these inside the $doInstall if-block?
+ # These currently run all the time if the new early returns don't get hit
+ # Even if we write-host "No Update Was Performed"
+ Copy-NewRelicCustomInstrumentationFiles
+ Set-NewRelicConfigurationValues
+}
diff --git a/Modules/Alkami.DevOps.Installation/Public/Install-NewRelicDotNetAgent.tests.ps1 b/Modules/Alkami.DevOps.Installation/Public/Install-NewRelicDotNetAgent.tests.ps1
new file mode 100644
index 0000000..0f9f2c8
--- /dev/null
+++ b/Modules/Alkami.DevOps.Installation/Public/Install-NewRelicDotNetAgent.tests.ps1
@@ -0,0 +1,477 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ""
+
+# NOTE: This test file is a hot mess. It is a product of trying to combine User Story changes
+# with mockability refactors and suffers as a result. I believe it does what it "should"
+# do, with valid PASS/FAIL assertions and criteria; HOWEVER, it should be simplified and
+# cleaned up, at a later date. Consider this my apology to future-us.
+# trowton - 2020-10-29
+# NOTE: I got tired of this always failing for no good reason, so I made it sorta-good-enough-pass-for-now
+# I did not walk the actual code for "good" - I also apologize to future us - cbrand 2021-05-06
+# TODO: Double check various Contexts to ensure all external calls, especially network calls
+# are mocked - Get-ChocoState, especially. Get-ChocoSources should also be mocked in all
+# Contexts
+# NOTE: Mocks are not scoped to It - only Describe/Context - Mocks at It level will
+# impact other, later Its in the same Context, or Describe if Contexts are not used
+
+# FAKES AND DUMMIES
+$nrdnAgentPackageName = "newrelic-dotnet"
+$nrdnAgentDowngradeVersion = "0.9.9.9"
+$nrdnAgentPriorVersion = "1.0.0.0"
+$nrdnAgentNewVersion = "1.1.0.0"
+
+$priorVersionProperties = @{ Name = $nrdnAgentPackageName; Version = $nrdnAgentPriorVersion; Feed = $null; Tags = $null; IsService = $null; StartMode = $null }
+$testFake_PriorPackage = New-Object -TypeName PSObject -Prop $priorVersionProperties
+
+$newVersionProperties = @{ Name = $nrdnAgentPackageName; Version = $nrdnAgentNewVersion; Feed = $null; Tags = $null; IsService = $null; StartMode = $null }
+$testFake_NewPackage = New-Object -TypeName PSObject -Prop $newVersionProperties
+
+$infraAgentDisplayName = "New Relic Infrastructure Agent"
+$nrdnAgentDisplayName = "New Relic .NET Agent (64-bit)"
+$fakeDisplayName = "Fake .NET App"
+
+# Registry FAKES
+$infraAgentRegistryProps = @{ DisplayName = $infraAgentDisplayName; DisplayVersion = "9.9.9"; InstallDate = "20000101" }
+$nrdnAgentRegistryProps = @{ DisplayName = $nrdnAgentDisplayName; DisplayVersion = "42.42.42.42"; InstallDate = "20200101" }
+$dummyAppRegistryProps = @{ DisplayName = $fakeDisplayName; DisplayVersion = "8.8.8"; InstallDate = "20010101" }
+$infraAgentFakeApp = New-Object -TypeName PSObject -Prop $infraAgentRegistryProps
+$nrdnAgentFakeApp = New-Object -TypeName PSObject -Prop $nrdnAgentRegistryProps
+$dummyAppFakeApp = New-Object -TypeName PSObject -Prop $dummyAppRegistryProps
+$fakeInstalledApps_NRDNA_TRUE = @($infraAgentFakeApp, $nrdnAgentFakeApp, $dummyAppFakeApp)
+$fakeInstalledApps_NRDNA_FALSE = @($infraAgentFakeApp, $dummyAppFakeApp)
+
+# ChocolateySource FAKE
+$fakeFeedSource = "https://packagerepo.orb.alkamitech.com/nuget/choco.FAKE_FEED_FOR_UNIT_TEST"
+$fakeFeed = @{ Source = $fakeFeedSource; IsSDK = $false; Disabled = $false }
+$fakeFeedList = @($fakeFeed)
+Describe -Name "Code_Paths" -Fixture {
+ # TODO: This being missing causes some stuff to try to install on local tests
+ # TODO: This needs to be mocked more properly
+ # dummy for catching in case it's not mocked as it should be
+ Mock -CommandName Get-ChocoState -ModuleName $moduleForMock -MockWith {
+ param ([Alias("local")][switch]$LocalOnly, $exact, $packageName, $source)
+ if ($localonly) {
+ return $testFake_PriorPackage
+ } else {
+ return $testFake_NewPackage
+ }
+ }
+
+ #Mock -CommandName Write-Verbose -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Get-LogLeadName -ModuleName $moduleForMock -MockWith {}
+
+ Mock -CommandName Get-NewRelicAccountDetails -ModuleName $moduleForMock -MockWith {
+ return @{LicenseKey = "FAKE-KEY-FOR-UNIT-TEST" }
+ }
+ Mock -CommandName Get-ItemProperty -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Invoke-Expression -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Test-StringIsNullOrWhiteSpace -ModuleName $moduleForMock -MockWith { $true }
+ Mock -CommandName Copy-NewRelicCustomInstrumentationFiles -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Set-NewRelicConfigurationValues -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Uninstall-NewRelicDotNetAgent -ModuleName $moduleForMock -MockWith {}
+
+ Context -Name "Parameters" -Fixture {
+ # ARRANGE
+ Mock -CommandName Write-Host -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Write-Warning -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Write-Error -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Get-VersionPSObject -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Get-ChocolateySources -ModuleName $moduleForMock -MockWith {
+ # $alkamiFeedMatch = "https://packagerepo.orb.alkamitech.com/nuget/choco.*";
+ # $sdkFeedMatch = "https://feeds.alkamitech.com/nuget/*";
+ # $fakeFeedList = ($fakeFeedList | Sort-Object -Property @{Expression={ [int]($_.Source -like $alkamiFeedMatch) * 2 + [int]($_.Source -like $sdkFeedMatch)}} -Descending);
+ return $fakeFeedList
+ }
+
+ It -Name "Choco_Source_Provided" -Test {
+ # ACT
+ Install-NewRelicDotNetAgent -environmentKey "FAKE ENV FOR UNIT TEST" -Source "FAKE SOURCE FOR UNIT TEST"
+
+ # ASSERT
+ Assert-MockCalled -CommandName Get-ChocolateySources -Times 0 -Exactly -Scope It
+ }
+
+ It -Name "Choco_Source_Not_Provided" -Test {
+ # ACT
+ Install-NewRelicDotNetAgent -environmentKey "FAKE ENV FOR UNIT TEST"
+
+ # ASSERT
+ Assert-MockCalled -CommandName Get-ChocolateySources -Times 1 -Exactly -Scope It
+ }
+
+ It -Name "Choco_Source_Not_Provided_Valid_Feed_Found" -Test {
+ # ACT
+ Install-NewRelicDotNetAgent -environmentKey "FAKE ENV FOR UNIT TEST"
+
+ # ASSERT
+ Assert-MockCalled -CommandName Get-ChocolateySources -Times 1 -Exactly -Scope It
+ Assert-MockCalled -CommandName Write-Error -Times 0 -Exactly -ParameterFilter {
+ $Message -match "NOT FOUND - Alkami choco feed"
+ }
+ Assert-MockCalled -CommandName Invoke-Expression -ModuleName $moduleForMock -Times 1 -Exactly -Scope It
+ # !These are ALWAYS called, even if Agent is installed via choco and is current!
+ Assert-MockCalled -CommandName Copy-NewRelicCustomInstrumentationFiles -ModuleName $moduleForMock -Times 1 -Exactly -Scope It
+ Assert-MockCalled -CommandName Set-NewRelicConfigurationValues -ModuleName $moduleForMock -Times 1 -Exactly -Scope It
+
+ }
+
+ It -Name "Version_Not_Provided" -Test {
+ # ACT
+ Install-NewRelicDotNetAgent -environmentKey "FAKE ENV FOR UNIT TEST"
+
+ # ASSERT
+ Assert-MockCalled -CommandName Invoke-Expression -ModuleName $moduleForMock -Times 1 -Exactly -Scope It -ParameterFilter {
+ $Command -match "choco upgrade -y newrelic-dotnet --version 1.1.0.0 --no-progress"
+ }
+ # This value contains a version, which is populated from line 55 where Get-ChocoState is mocked to the object
+ # $newVersionProperties on line 30
+ # This happens when a $Version is not provided
+
+ Assert-MockCalled -CommandName Copy-NewRelicCustomInstrumentationFiles -ModuleName $moduleForMock -Times 1 -Exactly -Scope It
+ Assert-MockCalled -CommandName Set-NewRelicConfigurationValues -ModuleName $moduleForMock -Times 1 -Exactly -Scope It
+ }
+
+ It -Name "Version_Provided" -Test {
+
+ # ARRANGE
+ Mock -CommandName Test-StringIsNullOrWhiteSpace -ModuleName $moduleForMock -MockWith { $false }
+ Mock -CommandName Get-VersionPSObject -ModuleName $moduleForMock -MockWith { @{ Name = $nrdnAgentPackageName; Version = "1.2.4.8"; } }
+ # ACT
+ Install-NewRelicDotNetAgent -environmentKey "FAKE ENV FOR UNIT TEST" -Version "1.2.4.8"
+
+ # ASSERT
+ Assert-MockCalled -CommandName Test-StringIsNullOrWhiteSpace -ModuleName $moduleForMock -Times 1 -Exactly -Scope It -ParameterFilter {
+ $Value -eq "1.2.4.8"
+ }
+ Assert-MockCalled -CommandName Invoke-Expression -ModuleName $moduleForMock -Times 1 -Exactly -Scope It -ParameterFilter {
+ $Command -match "choco upgrade -y newrelic-dotnet --version 1.2.4.8"
+ }
+ Assert-MockCalled -CommandName Copy-NewRelicCustomInstrumentationFiles -ModuleName $moduleForMock -Times 1 -Exactly -Scope It
+ Assert-MockCalled -CommandName Set-NewRelicConfigurationValues -ModuleName $moduleForMock -Times 1 -Exactly -Scope It
+ }
+
+ It -Name "Version_Bad_Format" -Test {
+
+ # ARRANGE
+ $badVersionProperties = @{ Version = "INVALIDVERSION"; }
+ Mock -CommandName Test-StringIsNullOrWhiteSpace -ModuleName $moduleForMock -MockWith { $false }
+ Mock -CommandName Get-VersionPSObject -ModuleName $moduleForMock -MockWith { $badVersionProperties }
+
+ # ACT
+ { Install-NewRelicDotNetAgent -environmentKey "FAKE ENV FOR UNIT TEST" -Version "INVALIDVERSION" } | Should -Throw
+
+ # ASSERT
+ Assert-MockCalled -CommandName Test-StringIsNullOrWhiteSpace -ModuleName $moduleForMock -Times 1 -Exactly -Scope It -ParameterFilter {
+ $Value -eq "INVALIDVERSION"
+ }
+ Assert-MockCalled -CommandName Write-Warning -ModuleName $moduleForMock -Times 1 -Exactly -Scope It -ParameterFilter {
+ $Message -match "Version specified not in accordance to correct format"
+ }
+ Assert-MockCalled -CommandName Invoke-Expression -ModuleName $moduleForMock -Times 0 -Exactly -Scope It
+ Assert-MockCalled -CommandName Copy-NewRelicCustomInstrumentationFiles -ModuleName $moduleForMock -Times 0 -Exactly -Scope It
+ Assert-MockCalled -CommandName Set-NewRelicConfigurationValues -ModuleName $moduleForMock -Times 0 -Exactly -Scope It
+ }
+
+ It -Name "Version_Null" -Test {
+
+ # ARRANGE
+ $badVersionProperties = @{ Version = $null; }
+ Mock -CommandName Test-StringIsNullOrWhiteSpace -ModuleName $moduleForMock -MockWith { $false }
+ Mock -CommandName Get-VersionPSObject -ModuleName $moduleForMock -MockWith { $badVersionProperties }
+
+ # ACT
+ { Install-NewRelicDotNetAgent -environmentKey "FAKE ENV FOR UNIT TEST" -Version $null } | Should -Throw
+
+ # ASSERT
+ Assert-MockCalled -CommandName Test-StringIsNullOrWhiteSpace -ModuleName $moduleForMock -Times 1 -Exactly -Scope It
+ Assert-MockCalled -CommandName Write-Warning -ModuleName $moduleForMock -Times 1 -Exactly -Scope It -ParameterFilter {
+ $Message -match "Version specified not in accordance to correct format"
+ }
+ Assert-MockCalled -CommandName Invoke-Expression -ModuleName $moduleForMock -Times 0 -Exactly -Scope It
+ Assert-MockCalled -CommandName Copy-NewRelicCustomInstrumentationFiles -ModuleName $moduleForMock -Times 0 -Exactly -Scope It
+ Assert-MockCalled -CommandName Set-NewRelicConfigurationValues -ModuleName $moduleForMock -Times 0 -Exactly -Scope It
+ }
+
+ It -Name "Choco_Source_Not_Provided_No_Feed_Found" -Test {
+ # ARRANGE
+ Mock -CommandName Test-StringIsNullOrWhiteSpace -ModuleName $moduleForMock -MockWith { $true }
+ Mock -CommandName Get-ChocolateySources -ModuleName $moduleForMock -MockWith {
+ return @()
+ }
+
+ # ACT
+ Install-NewRelicDotNetAgent -environmentKey "FAKE ENV FOR UNIT TEST"
+
+ # ASSERT
+ Assert-MockCalled -CommandName Get-ChocolateySources -Times 1 -Exactly -Scope It
+ Assert-MockCalled -CommandName Write-Error -Times 1 -Exactly -ParameterFilter {
+ $Message -match "NOT FOUND - Alkami choco feed"
+ }
+ Assert-MockCalled -CommandName Invoke-Expression -ModuleName $moduleForMock -Times 0 -Exactly -Scope It
+ # !These are ALWAYS called, even if Agent is installed via choco and is current!
+ Assert-MockCalled -CommandName Copy-NewRelicCustomInstrumentationFiles -ModuleName $moduleForMock -Times 0 -Exactly -Scope It
+ Assert-MockCalled -CommandName Set-NewRelicConfigurationValues -ModuleName $moduleForMock -Times 0 -Exactly -Scope It
+ }
+ }
+
+ Context -Name "When different Versions are passed thru Get-VersionPSObject" {
+ Mock -CommandName Write-Host -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Write-Warning -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Write-Error -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Get-VersionPSObject -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Get-ChocolateySources -ModuleName $moduleForMock -MockWith { return $fakeFeedList }
+ Mock -CommandName Test-StringIsNullOrWhiteSpace -ModuleName $moduleForMock -MockWith { $false }
+
+ It -Name "It throws when given an invalid version number" -Test {
+ # ARRANGE
+ Mock -CommandName Get-VersionPSObject -ModuleName $moduleForMock -MockWith { throw } -ParameterFilter { $Version -eq "hello" }
+
+ # ACT
+ { Install-NewRelicDotNetAgent -environmentKey "FAKE ENV FOR UNIT TEST" -Version "hello" } | Should -Throw
+
+ # ASSERT
+ Assert-MockCalled -CommandName Get-VersionPSObject -Times 1 -Exactly -Scope It
+ Assert-MockCalled -CommandName Write-Warning -ModuleName $moduleForMock -Times 1 -Exactly -Scope It -ParameterFilter {
+ $Message -match "Version specified not in accordance to correct format : hello"
+ }
+ }
+
+ It -Name "It doesn't throw when given a valid version number" -Test {
+ # ARRANGE
+ Mock -CommandName Get-VersionPSObject -ModuleName $moduleForMock -MockWith { @{ Name = $nrdnAgentPackageName; Version = "9.88.44.0"; } } -ParameterFilter { $Version -eq "9.88.44.0" }
+
+ # ACT
+ { Install-NewRelicDotNetAgent -environmentKey "FAKE ENV FOR UNIT TEST" -Version "9.88.44.0" } | Should -Not -Throw
+
+ # ASSERT
+ Assert-MockCalled -CommandName Get-VersionPSObject -Times 1 -Exactly -Scope It
+ Assert-MockCalled -CommandName Write-Host -ModuleName $moduleForMock -Times 1 -Exactly -Scope It -ParameterFilter {
+ $Object -match "Version specified : 9.88.44.0"
+ }
+ }
+ }
+
+ Context -Name "Installed_Current_Version" -Fixture {
+ Mock -CommandName Get-ChocolateySources -ModuleName $moduleForMock -MockWith {
+ return $fakeFeedList
+ }
+
+ It -Name "Via_Choco" -Test {
+ # ARRANGE
+ Mock -CommandName Write-Host -ModuleName $moduleForMock -MockWith {}
+ # Newest version installed via choco
+ Mock -CommandName Get-ChocoState -ModuleName $moduleForMock -MockWith {
+ param ([switch]$local, [switch]$exact, $packageName, $source)
+ if ($local) {
+ return $testFake_NewPackage
+ } else {
+ return $testFake_NewPackage
+ }
+ }
+ # Baseline - already installed via choco
+ Mock -CommandName Test-IsCollectionNullOrEmpty -ModuleName $moduleForMock -MockWith {
+ return $false
+ }
+ # Baseline - install version matches highest available version
+ Mock -CommandName Select-Object -ModuleName $moduleForMock -MockWith {
+ return $newVersionProperties
+ } -ParameterFilter { $InputObject -eq $testFake_NewPackage -and $First -eq 1 }
+
+ # ACT
+ Install-NewRelicDotNetAgent -environmentKey "FAKE ENV FOR UNIT TEST"
+
+ # ASSERT
+ Assert-MockCalled -CommandName Get-NewRelicAccountDetails -ModuleName $moduleForMock -Times 1 -Exactly -Scope It
+ Assert-MockCalled -CommandName Write-Host -ModuleName $moduleForMock -ParameterFilter {
+ $Object -match "Agent is installed via Choco and requires an update"
+ } -Times 0 -Exactly
+ Assert-MockCalled -CommandName Invoke-Expression -ModuleName $moduleForMock -Times 0 -Exactly -Scope It
+ # !These are ALWAYS called, even if Agent is installed via choco and is current!
+ Assert-MockCalled -CommandName Copy-NewRelicCustomInstrumentationFiles -ModuleName $moduleForMock -Times 1 -Exactly -Scope It
+ Assert-MockCalled -CommandName Set-NewRelicConfigurationValues -ModuleName $moduleForMock -Times 1 -Exactly -Scope It
+ }
+ }
+
+ Context -Name "Not_Installed" -Fixture {
+ Mock -CommandName Get-ChocolateySources -ModuleName $moduleForMock -MockWith {
+ return $fakeFeedList
+ }
+
+ It -Name "Anywhere" -Test {
+ # ARRANGE
+ Mock -CommandName Write-Host -ModuleName $moduleForMock -MockWith {}
+ # Not installed via choco
+ Mock -CommandName Get-ChocoState -ModuleName $moduleForMock -MockWith {
+ return $null
+ } -ParameterFilter { $local }
+ Mock -CommandName Get-ChocoState -ModuleName $moduleForMock -MockWith {
+ return $testFake_NewPackage
+ } -ParameterFilter { -not $local }
+ # Not installed outside choco
+ Mock -CommandName Get-ItemProperty -ModuleName $moduleForMock -MockWith {
+ return $fakeInstalledApps_NRDNA_FALSE
+ }
+
+ # ACT
+ Install-NewRelicDotNetAgent -environmentKey "FAKE ENV FOR UNIT TEST"
+
+ # ASSERT
+ Assert-MockCalled -CommandName Get-ChocoState -ModuleName $moduleForMock -ParameterFilter {
+ $local
+ } -Times 1 -Exactly -Scope It
+ Assert-MockCalled -CommandName Get-ChocoState -ModuleName $moduleForMock -ParameterFilter {
+ -not $local
+ } -Times 1 -Exactly -Scope It
+ Assert-MockCalled -CommandName Get-ItemProperty -ModuleName $moduleForMock -Times 1 -Exactly -Scope It
+ Assert-MockCalled -CommandName Write-Host -ModuleName $moduleForMock -ParameterFilter {
+ $Object -match "Determined no .Net Agent is installed"
+ } -Times 1 -Exactly -Scope It
+ Assert-MockCalled -CommandName Write-Host -ModuleName $moduleForMock -ParameterFilter {
+ $Object -match "Install package :"
+ } -Times 1 -Exactly -Scope It
+ Assert-MockCalled -CommandName Invoke-Expression -ModuleName $moduleForMock -ParameterFilter {
+ $Command -match "choco upgrade"
+ } -Times 1 -Exactly -Scope It
+ # !These are ALWAYS called, even if Agent is installed via choco and is current!
+ Assert-MockCalled -CommandName Copy-NewRelicCustomInstrumentationFiles -ModuleName $moduleForMock -Times 1 -Exactly -Scope It
+ Assert-MockCalled -CommandName Set-NewRelicConfigurationValues -ModuleName $moduleForMock -Times 1 -Exactly -Scope It
+ }
+ }
+
+ Context -Name "Installed" -Fixture {
+ Mock -CommandName Get-ChocolateySources -ModuleName $moduleForMock -MockWith {
+ return $fakeFeedList
+ }
+
+ Mock -CommandName Write-Host -ModuleName $moduleForMock -MockWith {}
+ It -Name "Outside_of_Choco" -Test {
+ # ARRANGE
+ # Not installed via choco
+ Mock -CommandName Get-ChocoState -ModuleName $moduleForMock -MockWith {
+ return $null
+ } -ParameterFilter { $local }
+ Mock -CommandName Get-ChocoState -ModuleName $moduleForMock -MockWith {
+ return $testFake_NewPackage
+ } -ParameterFilter { -not $local }
+ # Installed outside choco
+ Mock -CommandName Get-ItemProperty -ModuleName $moduleForMock -MockWith {
+ return $fakeInstalledApps_NRDNA_TRUE
+ }
+
+ # ACT
+ Install-NewRelicDotNetAgent -environmentKey "FAKE ENV FOR UNIT TEST"
+
+ # ASSERT
+ Assert-MockCalled -CommandName Get-ItemProperty -ModuleName $moduleForMock -Times 1 -Exactly -Scope It
+ Assert-MockCalled -CommandName Invoke-Expression -ModuleName $moduleForMock -ParameterFilter {
+ $Command -match "choco upgrade"
+ } -Times 0 -Exactly -Scope It
+ Assert-MockCalled -CommandName Write-Host -ModuleName $moduleForMock -ParameterFilter {
+ $Object -match "No Update Was Performed"
+ } -Times 1 -Exactly -Scope It
+ # !These are ALWAYS called, even if Agent is installed via choco and is current!
+ Assert-MockCalled -CommandName Copy-NewRelicCustomInstrumentationFiles -ModuleName $moduleForMock -Times 1 -Exactly -Scope It
+ Assert-MockCalled -CommandName Set-NewRelicConfigurationValues -ModuleName $moduleForMock -Times 1 -Exactly -Scope It
+ }
+ }
+
+ Context -Name "Installed_Prior_Version" -Fixture {
+ Mock -CommandName Get-ChocolateySources -ModuleName $moduleForMock -MockWith {
+ return $fakeFeedList
+ }
+
+ Mock -CommandName Write-Host -ModuleName $moduleForMock -MockWith {}
+ It -Name "Via_Choco" -Test {
+ # ARRANGE
+ # Not installed via choco
+ Mock -CommandName Get-ChocoState -ModuleName $moduleForMock -MockWith {
+ return $testFake_PriorPackage
+ } -ParameterFilter { $local }
+ Mock -CommandName Get-ChocoState -ModuleName $moduleForMock -MockWith {
+ return $testFake_NewPackage
+ } -ParameterFilter { -not $local }
+ # Installed outside choco
+ Mock -CommandName Get-ItemProperty -ModuleName $moduleForMock -MockWith {}
+
+ # ACT
+ Install-NewRelicDotNetAgent -environmentKey "FAKE ENV FOR UNIT TEST"
+
+ # ASSERT
+ Assert-MockCalled -CommandName Invoke-Expression -ModuleName $moduleForMock -ParameterFilter {
+ $Command -match "choco upgrade"
+ } -Times 1 -Exactly -Scope It
+ Assert-MockCalled -CommandName Write-Host -ModuleName $moduleForMock -ParameterFilter {
+ $Object -match "Install package :"
+ } -Times 1 -Exactly -Scope It
+ # !These are ALWAYS called, even if Agent is installed via choco and is current!
+ Assert-MockCalled -CommandName Copy-NewRelicCustomInstrumentationFiles -ModuleName $moduleForMock -Times 1 -Exactly -Scope It
+ Assert-MockCalled -CommandName Set-NewRelicConfigurationValues -ModuleName $moduleForMock -Times 1 -Exactly -Scope It
+ }
+ }
+
+ Context -Name "Package_Not_Found_In_Feed" -Fixture {
+ Mock -CommandName Get-ChocolateySources -ModuleName $moduleForMock -MockWith {
+ return $fakeFeedList
+ }
+
+ Mock -CommandName Write-Host -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Write-Error -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Write-Warning -ModuleName $moduleForMock -MockWith {}
+ # Newest version installed via choco
+ Mock -CommandName Get-ChocoState -ModuleName $moduleForMock -MockWith {
+ return $testFake_NewPackage
+ } -ParameterFilter { $local }
+ Mock -CommandName Get-ChocoState -ModuleName $moduleForMock -MockWith {
+ return $null
+ } -ParameterFilter { -not $local }
+
+ It -Name "Error_And_Return" -Test {
+ # ACT
+ Install-NewRelicDotNetAgent -environmentKey "FAKE ENV FOR UNIT TEST"
+
+ # ASSERT
+ Assert-MockCalled -CommandName Write-Error -Times 1 -Exactly -ParameterFilter {
+ $Message -match "PACKAGE NOT FOUND - Package"
+ }
+ Assert-MockCalled -CommandName Invoke-Expression -ModuleName $moduleForMock -Times 0 -Exactly -Scope It
+ # !These are ALWAYS called, even if Agent is installed via choco and is current!
+ Assert-MockCalled -CommandName Copy-NewRelicCustomInstrumentationFiles -ModuleName $moduleForMock -Times 0 -Exactly -Scope It
+ Assert-MockCalled -CommandName Set-NewRelicConfigurationValues -ModuleName $moduleForMock -Times 0 -Exactly -Scope It
+
+ }
+
+ }
+
+ Context -Name "Version_Downgrade" -Fixture {
+ Mock -CommandName Get-ChocoState -ModuleName $moduleForMock -MockWith {
+ return $testFake_PriorPackage
+ }
+ Mock -CommandName Test-StringIsNullOrWhiteSpace -ModuleName $moduleForMock -MockWith { $false }
+ Mock -CommandName Get-ChocolateySources -ModuleName $moduleForMock -MockWith { return $fakeFeedList }
+ Mock -CommandName Get-VersionPSObject -ModuleName $moduleForMock -MockWith { @{ Name = $nrdnAgentPackageName; Version = $nrdnAgentDowngradeVersion; } }
+ Mock -CommandName Write-Host -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Write-Error -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Write-Warning -ModuleName $moduleForMock -MockWith {}
+
+
+ It -Name "Downgrade_Uninstall" -Test {
+
+ # ARRANGE
+ Mock -CommandName Test-StringIsNullOrWhiteSpace -ModuleName $moduleForMock -MockWith { $false }
+
+ # ACT
+ Install-NewRelicDotNetAgent -Version $nrdnAgentDowngradeVersion -environmentKey "FAKE ENV FOR UNIT TEST"
+
+ # ASSERT
+ Assert-MockCalled -CommandName Uninstall-NewRelicDotNetAgent -ModuleName $moduleForMock -Times 1 -Exactly -Scope It
+ Assert-MockCalled -CommandName Invoke-Expression -ModuleName $moduleForMock -Times 1 -Exactly -Scope It
+ Assert-MockCalled -CommandName Copy-NewRelicCustomInstrumentationFiles -ModuleName $moduleForMock -Times 1 -Exactly -Scope It
+ Assert-MockCalled -CommandName Set-NewRelicConfigurationValues -ModuleName $moduleForMock -Times 1 -Exactly -Scope It
+ }
+ }
+}
diff --git a/Modules/Alkami.DevOps.Installation/Public/Install-NewRelicInfrastructure.ps1 b/Modules/Alkami.DevOps.Installation/Public/Install-NewRelicInfrastructure.ps1
new file mode 100644
index 0000000..1881213
--- /dev/null
+++ b/Modules/Alkami.DevOps.Installation/Public/Install-NewRelicInfrastructure.ps1
@@ -0,0 +1,44 @@
+function Install-NewRelicInfrastructure {
+<#
+.SYNOPSIS
+
+Installs the New Relic Infrastructure Monitor
+
+.PARAMETER serverRole
+
+The role of the Server "App,Web,DB,Report,Redis,Entrust"
+
+.PARAMETER pod
+
+The pod the server is in
+
+.PARAMETER environmentKey
+
+The environment. This usually comes from the job in teamcity
+
+#>
+ [CmdletBinding()]
+ Param(
+
+ [Parameter(Mandatory = $true)]
+ [string]$serverRole,
+
+ [Parameter(Mandatory = $true)]
+ [string]$pod,
+
+ [Parameter(Mandatory = $false)]
+ [Alias("Environment")]
+ [string]$environmentKey
+ )
+
+ $logLead = (Get-LogLeadName);
+
+ Write-Verbose ("$logLead : Choco Installing New Relic Infra")
+
+ choco upgrade newrelic-infra -y --no-progress
+
+ Write-Host ("$logLead : Choco Installation completed")
+
+ Set-InfrastructureConfiguration $serverRole $pod $environmentKey
+ Start-Service newrelic-infra
+}
diff --git a/Modules/Alkami.DevOps.Installation/Public/Install-NewRelicServerMonitor.ps1 b/Modules/Alkami.DevOps.Installation/Public/Install-NewRelicServerMonitor.ps1
new file mode 100644
index 0000000..c734383
--- /dev/null
+++ b/Modules/Alkami.DevOps.Installation/Public/Install-NewRelicServerMonitor.ps1
@@ -0,0 +1,33 @@
+function Install-NewRelicServerMonitor {
+<#
+.SYNOPSIS
+
+Installs the New Relic Server Monitor
+#>
+
+ [CmdletBinding()]
+ Param(
+ [Parameter(Mandatory = $false)]
+ [Alias("Environment")]
+ [string]$environmentKey
+ )
+
+ $logLead = (Get-LogLeadName);
+
+ $msiName = "NewRelicServerMonitor_x64.msi"
+ $64bitMSIInstallerUrl = "https://download.newrelic.com/windows_server_monitor/release/NewRelicServerMonitor_x64_3.3.6.0.msi"
+
+ $folderDateFormat = Get-Date -Format ddMMyyyy
+ $outputFolder = ("C:\Temp\Deploy\NRServerMonitor_{0}" -f $folderDateFormat)
+
+ $downloadedMsi = Get-PackageForInstallation $loglead $outputFolder $msiName $64bitMSIInstallerUrl
+
+ $licenseKey = (Get-NewRelicAccountDetails $environmentKey).LicenseKey
+ Write-Verbose ("$logLead : Using license key {0}" -f $licenseKey)
+
+ $installParams = ("/i {0} /qn NR_LICENSE_KEY=`"{1}`"" -f $downloadedMsi, $licenseKey)
+ Write-Verbose ("$logLead : Running msiexec.exe with parameters {0}" -f $installParams)
+
+ $installProcess = Start-Process msiexec.exe -ArgumentList $installParams -NoNewWindow -PassThru -Wait
+ Write-Output ("$logLead : Installation completed with exit code {0}" -f $installProcess.ExitCode)
+}
diff --git a/Modules/Alkami.DevOps.Installation/Public/Install-ORB.ps1 b/Modules/Alkami.DevOps.Installation/Public/Install-ORB.ps1
new file mode 100644
index 0000000..54d7b24
--- /dev/null
+++ b/Modules/Alkami.DevOps.Installation/Public/Install-ORB.ps1
@@ -0,0 +1,116 @@
+function Install-ORB {
+<#
+.SYNOPSIS
+
+Provides an entry point to install ORB on a Web or App Tier Server. This function is primarily utilized by automation which may not know what tier it may be installing.
+.DESCRIPTION
+
+Provides an entry point to install ORB on a web or app tier server. In order to leverage this function the machine must be properly instrumented with a ServerRole envirornmental variable, if it is a
+new machine. The ServerRole system environmental variable value should be set to one of either Web or App. If your machines have not been tagged during provisioning you may add the variable manually,
+or call the Install-ORBAppServer / Install-ORBWebServer functions directly with the appropriate parameters
+.PARAMETER clientUrl
+
+Alias: ClientSiteUrl
+Provide the Client Site URL without preceeding protocol or trailing slash. For example, instead of https://orb.alkamitech.com/, provide orb.alkamitech.com. This parameter is only used when installing on a web tier server.
+.PARAMETER adminUrl
+
+Alias: AdminSiteUrl
+Provide the Admin Site URL without preceeding protocol or trailing slash. For example, instead of https://admin-orb.alkamitech.com/, provide admin-orb.alkamitech.com. This parameter is only used when installing on a web tier server.
+.PARAMETER ipstsUrl
+
+Alias: IPSTSSiteUrl
+Provide the IPSTS Site URL without preceeding protocol or trailing slash. For example, instead of https://orb-ip.alkamitech.com/, provide orb-ip.alkamitech.com. This parameter is only used when installing on a web tier server.
+.PARAMETER skipSiteAndAppWarmup
+
+Alias: SkipWarmup
+Will skip the warmup of sites or services when set
+.PARAMETER doCombineAdminAppPools
+
+Alias: CombineAdminAppPools
+Will configure admin websites to use an app pool named 'Admin' when set
+.PARAMETER doCombineClientAppPools
+
+Alias: CombineClientAppPools
+Will configure client websites to use an app pool named 'WebClient' when set
+.PARAMETER doCombineIPSTSAppPools
+
+Alias: CombineIPSTSAppPools
+Will configure IPSTS websites to use an app pool named 'IPSTS' when set
+.EXAMPLE
+
+Install-ORB -ClientSiteUrl orb.alkamitech.com -AdminSiteUrl admin-orb.alkamitech.com -IpstsSiteUrl orb-ip.alkamitech.com -SecretServerUserName foobar -SecretServerPassword barfoo -SecretServerFolders "POD6,Common"
+.EXAMPLE
+
+Install-ORB -Broadcasters "Server1,Server2,Server3" -SkipWarmup
+#>
+
+ [CmdletBinding()]
+ Param(
+ [Parameter(Mandatory = $false)]
+ [Alias("ClientSiteUrl")]
+ [string]$clientUrl,
+
+ [Parameter(Mandatory = $false)]
+ [Alias("AdminSiteUrl")]
+ [string]$adminUrl,
+
+ [Parameter(Mandatory = $false)]
+ [Alias("IPSTSSiteUrl")]
+ [string]$ipstsUrl,
+
+ [Parameter(Mandatory = $false)]
+ [Alias("SecretServerUserName")]
+ [string]$secretUserName,
+
+ [Parameter(Mandatory = $false)]
+ [Alias("SecretServerPassword")]
+ [string]$secretPassword,
+
+ [Parameter(Mandatory = $false)]
+ [Alias("SecretServerFolders")]
+ [string]$secretFolderNames,
+
+ [Parameter(Mandatory = $false)]
+ [Alias("SecretServerDomain")]
+ [string]$secretDomain = "corp.alkamitech.com",
+
+
+ [Parameter(Mandatory = $false)]
+ [Alias("SkipWarmup")]
+ [switch]$skipSiteAndAppWarmup,
+
+ [Parameter(Mandatory = $false)]
+ [Alias("CombineAdminAppPools")]
+ [switch]$doCombineAdminAppPools,
+
+ [Parameter(Mandatory = $false)]
+ [Alias("CombineClientAppPools")]
+ [switch]$doCombineClientAppPools,
+
+ [Parameter(Mandatory = $false)]
+ [Alias("CombineIPSTSAppPools")]
+ [switch]$doCombineIPSTSAppPools
+ )
+
+ $logLead = (Get-LogLeadName);
+
+ Write-Verbose ("$logLead : Received Arguments:");
+ Write-Verbose (" ClientSiteUrl: $clientUrl");
+ Write-Verbose (" AdminSiteUrl: $adminUrl");
+ Write-Verbose (" IpstsSiteUrl: $ipstsUrl");
+ Write-Verbose (" SecretServerUserName: $secretUserName");
+ Write-Verbose (" SecretServerPassword: REDACTED");
+ Write-Verbose (" SecretServerFolders: $secretFolderNames");
+ Write-Verbose (" SecretServerDomain: $secretDomain");
+
+ if (Test-IsAppServer) {
+ Install-ORBAppServer -SecretServerUserName $secretUserName -SecretServerPassword $secretPassword -SecretServerFolders $secretFolderNames -SecretServerDomain $secretDomain -SkipAppWarmup:$skipSiteAndAppWarmup;
+ }
+ elseif (Test-IsWebServer) {
+ Install-ORBWebServer -ClientSiteUrl $clientUrl -AdminSiteUrl $adminUrl -IpstsSiteUrl $ipstsUrl -SecretServerUserName $secretUserName -SecretServerPassword $secretPassword -SecretServerFolders $secretFolderNames -SecretServerDomain $secretDomain -SkipSiteWarmup:$skipSiteAndAppWarmup -CombineAdminAppPools:$doCombineAdminAppPools -CombineClientAppPools:$doCombineClientAppPools -CombineIPSTSAppPools:$doCombineIPSTSAppPools;
+ }
+ else {
+ Write-Warning "Could not automatically determine if this server is a web or app tier machine.";
+ Write-Output "You may still call Install-ORBAppServer or Install-ORBWebServer directly";
+ }
+}
diff --git a/Modules/Alkami.DevOps.Installation/Public/Install-ORBAppServer.ps1 b/Modules/Alkami.DevOps.Installation/Public/Install-ORBAppServer.ps1
new file mode 100644
index 0000000..4c588f0
--- /dev/null
+++ b/Modules/Alkami.DevOps.Installation/Public/Install-ORBAppServer.ps1
@@ -0,0 +1,94 @@
+function Install-ORBAppServer {
+<#
+.SYNOPSIS
+ Installs an ORB App Server.
+#>
+
+ [CmdletBinding()]
+ Param(
+ [Parameter(Mandatory = $false)]
+ [Alias("SecretServerUserName")]
+ [string]$secretUserName,
+
+ [Parameter(Mandatory = $false)]
+ [Alias("SecretServerPassword")]
+ [string]$secretPassword,
+
+ [Parameter(Mandatory = $false)]
+ [Alias("SecretServerFolders")]
+ [string]$secretFolderNames,
+
+ [Parameter(Mandatory = $false)]
+ [Alias("SecretServerDomain")]
+ [string]$secretDomain = "corp.alkamitech.com",
+
+ [Parameter(Mandatory = $false)]
+ [Alias("SkipWarmup")]
+ [switch]$skipAppWarmup,
+
+ [Parameter(Mandatory = $false)]
+ [Alias("PodGMSAAccount")]
+ [string]$podGMSAAccountParent
+ )
+
+ if ( ( Test-IsAppServer ) -eq $false ) {
+ $title = "You Are Attempting to Install the ORB App Tier on a Machine Identified as a non-App Tier Server";
+ $message = "Do you want to quit?";
+
+ $yes = New-Object System.Management.Automation.Host.ChoiceDescription "&Yes", "Breaks execution."
+ $no = New-Object System.Management.Automation.Host.ChoiceDescription "&No", "Continues execution."
+ $options = [System.Management.Automation.Host.ChoiceDescription[]]($yes, $no)
+ $result = $host.ui.PromptForChoice($title, $message, $options, 0)
+
+ if ( $result -eq 0 ) {
+ Write-Host "Cancelling installation."
+ return
+ }
+ }
+
+ $logLead = (Get-LogLeadName)
+
+ Write-Output ("$logLead : Configuring Machine as App Tier Server for POD {0}" -f [Environment]::GetEnvironmentVariable("POD", "Machine"))
+
+ if (![String]::IsNullOrEmpty($secretUserName) -and ![String]::IsNullOrEmpty($secretPassword) -and ![String]::IsNullOrEmpty($secretFolderNames) -and ![String]::IsNullOrEmpty($secretDomain)) {
+ Read-AppTierSecrets $secretUserName $secretPassword $secretFolderNames $secretDomain
+ }
+
+ if (![String]::IsNullOrEmpty($podGMSAAccountParent)) {
+ Set-AppTierGMSAAccounts $podGMSAAccountParent
+ }
+
+ if (($appTierApplications | Where-Object {$_.User.EndsWith("$") -and $_.IsGMSAAccount}).Count -gt 0 -or ((Get-AppTierServices) | Where-Object {$_.User.EndsWith("$") -and $_.IsGMSAAccount}).Count -gt 0) {
+ Test-AppTierGMSAAccounts
+ }
+
+ Set-AppTierDefaultWebSite
+ Set-AppTierFolderAndFilePermissions
+ New-MachineConfigConnectionString $true
+ New-MachineConfigConnectionString $false
+
+ Write-Output "$logLead : Running the CreateSymLinks Script"
+ New-OrbSymLinks
+
+ Write-Output "$logLead : Creating AppPools, Services, Configs, etc"
+
+ New-AppTierWebApplications
+ New-AppTierWindowsServices
+ New-AppTierHostFileEntries
+ New-MachineConfigMachineKeys
+
+ Rename-NewLogConfig
+ Set-DefaultTLSVersion
+
+ Write-Output "$logLead : Calling Disable-Nag"
+ Disable-Nag
+
+ Set-RapidFailSettings
+
+ if ($skipAppWarmup) {
+ return
+ }
+
+ Write-Output "$logLead : Warming up services..."
+ Ping-AlkamiServices
+}
diff --git a/Modules/Alkami.DevOps.Installation/Public/Install-ORBWebServer.ps1 b/Modules/Alkami.DevOps.Installation/Public/Install-ORBWebServer.ps1
new file mode 100644
index 0000000..8ea8255
--- /dev/null
+++ b/Modules/Alkami.DevOps.Installation/Public/Install-ORBWebServer.ps1
@@ -0,0 +1,120 @@
+function Install-ORBWebServer {
+<#
+.SYNOPSIS
+ Installs an ORB Web Server.
+#>
+
+ # Todo: Set parameter groups and verification
+ [CmdletBinding()]
+ Param(
+ [Parameter(Mandatory = $false)]
+ [Alias("ClientSiteUrl")]
+ [string]$clientUrl,
+
+ [Parameter(Mandatory = $false)]
+ [Alias("AdminSiteUrl")]
+ [string]$adminUrl,
+
+ [Parameter(Mandatory = $false)]
+ [Alias("IpstsSiteUrl")]
+ [string]$ipstsUrl,
+
+ [Parameter(Mandatory = $false)]
+ [Alias("SecretServerUserName")]
+ [string]$secretUserName,
+
+ [Parameter(Mandatory = $false)]
+ [Alias("SecretServerPassword")]
+ [string]$secretPassword,
+
+ [Parameter(Mandatory = $false)]
+ [Alias("SecretServerFolders")]
+ [string]$secretFolderNames,
+
+ [Parameter(Mandatory = $false)]
+ [Alias("SecretServerDomain")]
+ [string]$secretDomain = "corp.alkamitech.com",
+
+ [Parameter(Mandatory = $false)]
+ [Alias("AppTierVIP")]
+ [string]$appTierVipPrefix,
+
+ [Parameter(ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true, Mandatory = $false)]
+ [Alias("SkipWarmup")]
+ [switch]$skipSiteWarmup,
+
+ [Parameter(ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true, Mandatory = $false)]
+ [Alias("CombineAdminAppPools")]
+ [switch]$doCombineAdminAppPools,
+
+ [Parameter(ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true, Mandatory = $false)]
+ [Alias("CombineClientAppPools")]
+ [switch]$doCombineClientAppPools,
+
+ [Parameter(ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true, Mandatory = $false)]
+ [Alias("CombineIPSTSAppPools")]
+ [switch]$doCombineIPSTSAppPools,
+
+ [Parameter(ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true, Mandatory = $false)]
+ [Alias("CombineAdminWebSites")]
+ [switch]$doCombineAdminWebSites,
+
+ [Parameter(ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true, Mandatory = $false)]
+ [Alias("CombineClientWebSites")]
+ [switch]$doCombineClientWebSites,
+
+ [Parameter(ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true, Mandatory = $false)]
+ [Alias("CombineIPSTSWebSites")]
+ [switch]$doCombineIPSTSWebSites
+ )
+
+ if (Test-IsAppServer) {
+ $title = "You Are Attempting to Install the ORB Web Tier on a Machine Identified as an App Tier Server"
+ $message = "Do you want to quit?"
+
+ $yes = New-Object System.Management.Automation.Host.ChoiceDescription "&Yes", "Breaks execution."
+ $no = New-Object System.Management.Automation.Host.ChoiceDescription "&No", "Continues execution."
+ $options = [System.Management.Automation.Host.ChoiceDescription[]]($yes, $no)
+ $result = $host.ui.PromptForChoice($title, $message, $options, 0)
+
+ switch ($result) {
+ 0 { return }
+ }
+ }
+
+ $logLead = (Get-LogLeadName);
+
+ Write-Output ("$logLead : Configuring Machine as Web Tier Server for POD {0}" -f [Environment]::GetEnvironmentVariable("POD", "Machine"));
+
+ if (![String]::IsNullOrEmpty($secretUserName) -and ![String]::IsNullOrEmpty($secretPassword) -and ![String]::IsNullOrEmpty($secretFolderNames) -and ![String]::IsNullOrEmpty($secretDomain)) {
+ Read-WebTierSecrets $secretUserName $secretPassword $secretFolderNames $secretDomain;
+ }
+
+ Set-WebTierDefaultWebSite;
+ Set-WebTierFolderAndFilePermissions;
+ Set-ServerResponseHeaders;
+ Set-ServerMIMETypes;
+ New-WebTierMachineConfigAppSettings;
+ New-MachineConfigMachineKeys;
+
+ Write-Output "$logLead : Running New-OrbSymLinks"
+ New-OrbSymLinks
+
+ New-WebTierWebSites $clientUrl $adminUrl $ipstsUrl -CombineAdminAppPools:$doCombineAdminAppPools.IsPresent -CombineClientAppPools:$doCombineClientAppPools.IsPresent -CombineIPSTSAppPools:$doCombineIPSTSAppPools.IsPresent -CombineAdminWebSites:$doCombineAdminWebSites.IsPresent -CombineClientWebSites:$doCombineClientWebSites.IsPresent -CombineIPSTSWebSites:$doCombineIPSTSWebSites.IsPresent;
+
+ if (!([String]::IsNullOrEmpty($appTierVipPrefix))) {
+ New-VIPsHostFileEntries $appTierVipPrefix;
+ }
+
+ Rename-NewLogConfig;
+
+ Set-RapidFailSettings;
+
+ if ($skipSiteWarmup) {
+ return
+ }
+
+ Start-IISAndServices;
+ Write-Output "$logLead : Warming up sites...";
+ Ping-AlkamiWebSites;
+}
diff --git a/Modules/Alkami.DevOps.Installation/Public/New-DummyPackageInstallationData.ps1 b/Modules/Alkami.DevOps.Installation/Public/New-DummyPackageInstallationData.ps1
new file mode 100644
index 0000000..43fdb7b
--- /dev/null
+++ b/Modules/Alkami.DevOps.Installation/Public/New-DummyPackageInstallationData.ps1
@@ -0,0 +1,127 @@
+function New-DummyPackageInstallationData {
+ <#
+ .SYNOPSIS
+ Create a new PackageMetadata object for unit tests. Utilized by Classify-Packages tests.
+ .PARAMETER PackageName
+ Name of the package being created.
+ .PARAMETER PackageVersion
+ Version of the package being created.
+ .PARAMETER IsWebOnly
+ Indicates that the package is only installed on Web servers.
+ .PARAMETER IsAppOnly
+ Indicates that the package is only installed on App servers.
+ .PARAMETER IsMicOnly
+ Indicates that the package is only installed on Mic servers.
+ .PARAMETER IsFullScale
+ Indicates that the package is only installed everywhere.
+ .PARAMETER IsHotFix
+ Indicates that the package is a Hotfix package.
+
+ #>
+ [CmdletBinding()]
+ Param (
+ [Parameter(Mandatory = $true, ParameterSetName = "FullScale")]
+ [Parameter(Mandatory = $true, ParameterSetName = "Web")]
+ [Parameter(Mandatory = $true, ParameterSetName = "App")]
+ [Parameter(Mandatory = $true, ParameterSetName = "Mic")]
+ [string]$PackageName,
+ [Parameter(Mandatory = $true, ParameterSetName = "FullScale")]
+ [Parameter(Mandatory = $true, ParameterSetName = "Web")]
+ [Parameter(Mandatory = $true, ParameterSetName = "App")]
+ [Parameter(Mandatory = $true, ParameterSetName = "Mic")]
+ [string]$PackageVersion,
+ [Parameter(Mandatory = $true, ParameterSetName = "Web")]
+ [switch]$IsWebOnly,
+ [Parameter(Mandatory = $true, ParameterSetName = "App")]
+ [switch]$IsAppOnly,
+ [Parameter(Mandatory = $true, ParameterSetName = "Mic")]
+ [switch]$IsMicOnly,
+ [Parameter(Mandatory = $true, ParameterSetName = "FullScale")]
+ [switch]$IsFullScale,
+ [Parameter(Mandatory = $false)]
+ [switch]$IsHotfix,
+ [bool]$IsValid = $true
+ )
+
+ $tags = @()
+
+ $tier = 2
+ $installToWeb = $false
+ $installToApp = $false
+ $installToMic = $false
+
+ if ($IsWebOnly) {
+ $tags = @(
+ "ORB",
+ "Widget"
+ )
+ $installToWeb = $true
+ }
+ if ($IsAppOnly) {
+ $tags = @(
+ "ORB",
+ "Generic",
+ "SSO",
+ "Provider"
+ )
+ $installToApp = $true
+ }
+ if ($IsFullScale) {
+ $tags = @(
+ "Service",
+ "Infrastructure"
+ )
+ $installToWeb = $true
+ $installToApp = $true
+ $installToMic = $true
+ $tier = 0
+ }
+ if ($IsMicOnly) {
+ $tags = @(
+ "Service",
+ "Provider"
+ )
+ $installToMic = $true
+ }
+
+ $objectTemplate = @{
+ Tags = $tags
+ Name = $PackageName
+ IsReliableService = $false
+ Version = $PackageVersion
+ HasInfrastructureMigration = $false
+ Upgrade = $false
+ IsService = $IsService -or $IsFullScale
+ InstallToAppTier = $installToApp
+ InstallToMicTier = $installToMic
+ InstallToWebTier = $installToWeb
+ IsMicroservice = $IsService -or $IsFullScale
+ IsFullScaleMicroservice = $IsFullScale
+ ForceSameVersion = $true
+ PreventRollback = $false
+ IsDbms = $false
+ Feed = @{
+ Name = "imaginaryfeed"
+ Source = "https://imaginaryfeed/nuget/feed"
+ IsDefault = $false
+ Priority = "0"
+ Disabled = $false
+ IsSDK = $false
+ }
+ IsInstaller = $false
+ IsInfrastructure = $IsFullScale
+ StartMode = $null
+ NewRelicAppName = $null
+ IsSDK = $false
+ IsValid = $IsValid
+ IsHotfix = $IsHotfix
+ InstallToWeb = $installToWeb
+ InstallToFab = $false
+ InstallToMic = $installToMic
+ InstallToApp = $installToApp
+ HasAlkamiManifest = $true
+ Tier = $tier
+ }
+
+ return (New-Object -TypeName PSObject -Prop $objectTemplate)
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Installation/Public/New-PackageMetadataObject.ps1 b/Modules/Alkami.DevOps.Installation/Public/New-PackageMetadataObject.ps1
new file mode 100644
index 0000000..147e1d8
--- /dev/null
+++ b/Modules/Alkami.DevOps.Installation/Public/New-PackageMetadataObject.ps1
@@ -0,0 +1,83 @@
+function New-PackageMetadataObject {
+ <#
+ .SYNOPSIS
+ Creates a (mostly) empty metadata object. Utilized by Classify-Packages.
+
+ .PARAMETER ServerFilter
+ Limits the deployment to a specific list of servers.
+
+ .PARAMETER ServersString
+ List of servers.
+ #>
+
+ [CmdletBinding()]
+ param(
+ $ServerFilter,
+ $ServersString
+ )
+
+ $packageMetadata = New-Object psobject -property @{
+ WebPackagesToInstall = @()
+ AppPackagesToInstall = @()
+ MicPackagesToInstall = @()
+ WebPackagesToUninstall = @()
+ AppPackagesToUninstall = @()
+ MicPackagesToUninstall = @()
+
+ AwsSettings = @{ }
+
+ HasWebInstalls = $false
+ HasAppInstalls = $false
+ HasMicInstalls = $false
+
+ HasWebUninstalls = $false
+ HasAppUninstalls = $false
+ HasMicUninstalls = $false
+
+ ServerFilter = @()
+ ServerFilterRaw = $ServerFilter
+ OriginalServerList = @()
+ OriginalServerListRaw = $ServersString
+
+ Servers = @()
+ ServersToQuery = @()
+
+ WebServers = @()
+ AppServers = @()
+ MicServers = @()
+ FabServers = @()
+ HasWebServers = $false
+ HasAppServers = $false
+ HasMicServers = $false
+ HasFabServers = $false
+ SelectedFabServer = ""
+
+ HasBadPackages = $false
+ BadWebPackagesToUninstall = @()
+ BadAppPackagesToUninstall = @()
+ BadMicPackagesToUninstall = @()
+ BadFabPackagesToUninstall = @()
+
+ EnvironmentLabel = ""
+ EnvironmentName = ""
+ EnvironmentNameSafeDesignation = ""
+ EnvironmentHosting = ""
+ EnvironmentType = ""
+ InstalledOrbVersion = ""
+ IsDisasterRecovery = ""
+ ForceReinstallPackages = ""
+ InstallToAppsAndMics = ""
+ PackageToVersions = @{ }
+ IsRollingDeploy = $false
+ DisableMicroserviceNewRelic = $false
+
+ PackageToServers = @{}
+ MigrationOnlyPackages = @()
+
+ IsEclairInstalledOnAllHosts = $false
+ EclairInstallData = @()
+
+ }
+
+ return $packageMetadata
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Installation/Public/New-WebTierWebSites.ps1 b/Modules/Alkami.DevOps.Installation/Public/New-WebTierWebSites.ps1
new file mode 100644
index 0000000..fa38924
--- /dev/null
+++ b/Modules/Alkami.DevOps.Installation/Public/New-WebTierWebSites.ps1
@@ -0,0 +1,91 @@
+function New-WebTierWebSites {
+<#
+.SYNOPSIS
+ Upserts Web Tier Websites.
+#>
+
+ [CmdletBinding()]
+ Param(
+ [string]$clientUrl,
+ [string]$adminUrl,
+ [string]$ipstsUrl,
+
+ [Parameter(Mandatory = $false)]
+ [Alias("CombineAdminAppPools")]
+ [bool]$doCombineAdminAppPools,
+
+ [Parameter(Mandatory = $false)]
+ [Alias("CombineClientAppPools")]
+ [bool]$doCombineClientAppPools,
+
+ [Parameter(Mandatory = $false)]
+ [Alias("CombineIPSTSAppPools")]
+ [bool]$doCombineIPSTSAppPools,
+
+ [Parameter(Mandatory = $false)]
+ [Alias("CombineAdminWebSites")]
+ [bool]$doCombineAdminWebSites,
+
+ [Parameter(Mandatory = $false)]
+ [Alias("CombineClientWebSites")]
+ [bool]$doCombineClientWebSites,
+
+ [Parameter(Mandatory = $false)]
+ [Alias("CombineIPSTSWebSites")]
+ [bool]$doCombineIPSTSWebSites
+ )
+
+ $logLead = (Get-LogLeadName);
+
+ Write-Verbose ("$logLead : CombineAdminAppPools is {0}" -f $doCombineAdminAppPools)
+ Write-Verbose ("$logLead : CombineClientAppPools is {0}" -f $doCombineClientAppPools)
+ Write-Verbose ("$logLead : CombineIPSTSAppPools is {0}" -f $doCombineIPSTSAppPools)
+
+ Write-Verbose ("$logLead : CombineAdminWebSites is {0}" -f $doCombineAdminWebSites)
+ Write-Verbose ("$logLead : CombineClientWebSites is {0}" -f $doCombineClientWebSites)
+ Write-Verbose ("$logLead : CombineIPSTSWebSites is {0}" -f $doCombineIPSTSWebSites)
+
+ if (!([String]::IsNullOrEmpty($clientUrl))) {
+ if ($doCombineClientWebSites) {
+ New-ClientWebBinding $clientUrl -CombineClientAppPools $doCombineClientAppPools
+ # New-WebTierWebApplications "WebClient"
+ }
+ else {
+ New-ClientWebSite $clientUrl -CombineClientAppPools $doCombineClientAppPools
+ # New-WebTierWebApplications $clientUrl
+ }
+ }
+
+ if (!([String]::IsNullOrEmpty($adminUrl))) {
+ if ($doCombineAdminWebSites) {
+ New-AdminWebBinding $adminUrl -CombineAdminAppPools $doCombineAdminAppPools
+ }
+ else {
+ New-AdminWebSite $adminUrl -CombineAdminAppPools $doCombineAdminAppPools
+ }
+ }
+
+ if (!([String]::IsNullOrEmpty($ipstsUrl))) {
+ if ($doCombineIPSTSWebSites) {
+ New-IPSTSWebBinding $ipstsUrl -CombineIPSTSAppPools $doCombineIPSTSAppPools
+ }
+ else {
+ New-IPSTSWebSite $ipstsUrl -CombineIPSTSAppPools $doCombineIPSTSAppPools
+ }
+ }
+
+ if ([String]::IsNullOrEmpty($ipstsUrl) -and
+ [String]::IsNullOrEmpty($adminUrl) -and
+ [String]::IsNullOrEmpty($clientUrl)) {
+ $clients = Get-ClientWebSiteInformationFromDatabase
+
+ foreach ($client in $clients) {
+ New-ClientWebBinding $client.Client -CombineClientAppPools $doCombineClientAppPools
+ # New-WebTierWebApplications $client.Client
+ New-AdminWebBinding $client.Admin -CombineAdminAppPools $doCombineAdminAppPools
+ New-IPSTSWebBinding $client.IPSTS -CombineIPSTSAppPools $doCombineIPSTSAppPools
+ }
+ }
+}
+
+Set-Alias -name Create-WebTierWebSites -value New-WebTierWebSites;
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Installation/Public/Read-AppTierSecrets.ps1 b/Modules/Alkami.DevOps.Installation/Public/Read-AppTierSecrets.ps1
new file mode 100644
index 0000000..8a89333
--- /dev/null
+++ b/Modules/Alkami.DevOps.Installation/Public/Read-AppTierSecrets.ps1
@@ -0,0 +1,62 @@
+function Read-AppTierSecrets {
+<#
+.SYNOPSIS
+ Reads App Tier Secrets.
+#>
+
+ [CmdletBinding()]
+ Param(
+ [string]$secretUserName,
+ [string]$secretPassword,
+ [string]$secretFolder,
+ [string]$secretDomain
+ )
+
+ $logLead = (Get-LogLeadName);
+ $hasCerts = $false
+
+ # Create a temporary download folder for certificates
+ $randomFolderName = [System.IO.Path]::GetRandomFileName().Split('.') | Select-Object -First 1
+ $downloadFolder = Join-Path $PSScriptRoot $randomFolderName
+
+ if (!([System.IO.Directory]::Exists($downloadFolder))) {
+ Write-Verbose ("$logLead : Creating temporary download folder {0}" -f $downloadFolder)
+ New-Item $downloadFolder -ItemType Directory -Force | Out-Null
+ }
+
+ # Pull Secrets
+ Write-Output ("$logLead : Getting AppServer Secrets for Folder {0} using user {1}" -f $secretFolder, $secretUserName)
+ $secrets = Get-SecretsForPod $secretUserName $secretPassword $secretDomain $secretFolder
+
+ $savedCertificates = @()
+
+ # Have to explicitly call GetEnumerator because of the way PS handles Dictionaries to HashTables
+ foreach ($secret in $secrets.GetEnumerator()) {
+ [System.Reflection.Assembly]::LoadWithPartialName("System.IO.Compression.FileSystem") | Out-Null
+
+ if ($secret.Value.SecretType -eq [Alkami.Ops.SecretServer.Enum.SecretType]::Certificate) {
+ $cert = [Alkami.Ops.SecretServer.Model.Certificate]$secret.Value
+ Save-CertificatesToDisk $cert ([ref]$savedCertificates) $downloadFolder
+ $hasCerts = $true
+ }
+ elseif ($secret.Value.SecretType -eq [Alkami.Ops.SecretServer.Enum.SecretType]::User) {
+ Set-ServiceAccountValue ([Alkami.Ops.SecretServer.Model.User]$secret.Value)
+ }
+ elseif ($secret.Value.SecretType -eq [Alkami.Ops.SecretServer.Enum.SecretType]::ConnectionString -and $masterConnectionString -eq "REPLACEME") {
+ $secretConnectionString = ([Alkami.Ops.SecretServer.Model.ConnectionString]$secret.Value).RawConnectionString
+ Write-Output ("$logLead : Setting master connection string to {0}" -f $secretConnectionString)
+ $global:masterConnectionString = $secretConnectionString
+ }
+ }
+
+ if ($hasCerts) {
+ Read-AppTierCertificates $downloadFolder $savedCertificates
+ }
+
+ if (Test-Path $downloadFolder) {
+ Write-Verbose ("$logLead : Removing temporary download folder {0}" -f $downloadFolder)
+ Remove-Item $downloadFolder -Recurse -Force
+ }
+}
+
+Set-Alias -name Load-AppTierSecrets -value Read-AppTierSecrets;
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Installation/Public/Read-WebTierSecrets.ps1 b/Modules/Alkami.DevOps.Installation/Public/Read-WebTierSecrets.ps1
new file mode 100644
index 0000000..457c7f0
--- /dev/null
+++ b/Modules/Alkami.DevOps.Installation/Public/Read-WebTierSecrets.ps1
@@ -0,0 +1,72 @@
+function Read-WebTierSecrets {
+<#
+.SYNOPSIS
+ Reads Web Tier Secrets.
+#>
+
+ [CmdletBinding()]
+ Param(
+ [string]$secretUserName,
+ [string]$secretPassword,
+ [string]$secretFolder,
+ [string]$secretDomain
+ )
+
+ $logLead = (Get-LogLeadName);
+ $hasCerts = $false
+
+ # Create a temporary download folder for certificates
+ $randomFolderName = [System.IO.Path]::GetRandomFileName().Split('.') | Select-Object -First 1
+ $downloadFolder = Join-Path $PSScriptRoot $randomFolderName
+
+ if (!([System.IO.Directory]::Exists($downloadFolder))) {
+ Write-Verbose ("$logLead : Creating temporary download folder {0}" -f $downloadFolder)
+ New-Item $downloadFolder -ItemType Directory -Force | Out-Null
+ }
+
+ # Pull Secrets
+ Write-Output ("$logLead : Getting WebServer Secrets for Folder {0} using user {1}" -f $secretFolder, $secretUserName)
+ $secrets = Get-SecretsForPod $secretUserName $secretPassword $secretDomain $secretFolder
+
+ $savedCertificates = @()
+
+ # Have to explicitly call GetEnumerator because of the way PS handles Dictionaries to HashTables
+ foreach ($secret in $secrets.GetEnumerator()) {
+ [System.Reflection.Assembly]::LoadWithPartialName("System.IO.Compression.FileSystem") | Out-Null
+
+ if ($secret.Value.SecretType -eq [Alkami.Ops.SecretServer.Enum.SecretType]::Certificate) {
+ $cert = [Alkami.Ops.SecretServer.Model.Certificate]$secret.Value
+ Save-CertificatesToDisk $cert ([ref]$savedCertificates) $downloadFolder
+ $hasCerts = $true
+ }
+ elseif ($secret.Value.SecretType -eq [Alkami.Ops.SecretServer.Enum.SecretType]::User) {
+ if ($secret.Value.SecretName -like "*localreport*") {
+ Write-Output ("$logLead : Setting ReportServer local reports user to {0}" -f $secret.Value.UserName)
+ ($webTierAppSettings | Where-Object {$_.Name -eq "ReportServerUserName"}).Value = $secret.Value.UserName
+ ($webTierAppSettings | Where-Object {$_.Name -eq "ReportServerPassword"}).Value = $secret.Value.Password
+ }
+ elseif ($secret.Value.SecretName -like "*adminreport*") {
+ Write-Output ("$logLead : Setting ReportServer admin reports user to {0}" -f $secret.Value.UserName)
+ ($webTierAppSettings | Where-Object {$_.Name -eq "ReportUserName"}).Value = $secret.Value.UserName
+ ($webTierAppSettings | Where-Object {$_.Name -eq "ReportPassword"}).Value = $secret.Value.Password
+ }
+ }
+ elseif ($secret.Value.SecretType -eq [Alkami.Ops.SecretServer.Enum.SecretType]::ConnectionString -and $masterConnectionString -eq "REPLACEME") {
+ $secretConnectionString = ([Alkami.Ops.SecretServer.Model.ConnectionString]$secret.Value).ConnectionStringBuilder
+ Write-Output ("$logLead : Setting ReportServer URL to server {0}" -f $secretConnectionString.DataSource)
+ ($webTierAppSettings | Where-Object {$_.Name -eq "ReportServer"}).Value = ("http://" + $secretConnectionString.DataSource)
+ ($webTierAppSettings | Where-Object {$_.Name -eq "ReportServerUrl"}).Value = ("http://" + $secretConnectionString.DataSource + "/Pages/ReportViewer.aspx")
+ }
+ }
+
+ if ($hasCerts) {
+ Read-WebTierCertificates $downloadFolder $savedCertificates
+ }
+
+ if (Test-Path $downloadFolder) {
+ Write-Verbose ("$logLead : Removing temporary download folder {0}" -f $downloadFolder)
+ Remove-Item $downloadFolder -Recurse -Force
+ }
+}
+
+Set-Alias -name Load-WebTierSecrets -value Read-WebTierSecrets;
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Installation/Public/Remove-OutdatedNewHotfixPackages.ps1 b/Modules/Alkami.DevOps.Installation/Public/Remove-OutdatedNewHotfixPackages.ps1
new file mode 100644
index 0000000..3f4ea64
--- /dev/null
+++ b/Modules/Alkami.DevOps.Installation/Public/Remove-OutdatedNewHotfixPackages.ps1
@@ -0,0 +1,113 @@
+function Remove-OutdatedNewHotfixPackages{
+ <#
+ .SYNOPSIS
+ Removes hotfix packages supplied by the user if they are outdated given the existing or new orb version.
+
+ .PARAMETER DependencyReleaseValue
+ The value of the orb release being classified. Only populated for Orb installs.
+
+ .PARAMETER PackageMetadata
+ Packages object to populate.
+ #>
+
+ [CmdletBinding()]
+ [OutputType([PSObject])]
+ Param(
+ [Parameter(Mandatory = $false)]
+ [string]$DependencyReleaseValue = "",
+
+ [Parameter(Mandatory = $true)]
+ [PSObject] $PackageMetadata
+ )
+ $loglead = Get-LogleadName
+
+ if ($PackageMetadata.ForceReinstallPackages) {
+ if (Test-StringIsNullOrWhiteSpace -Value $DependencyReleaseValue) {
+ Write-Host "##teamcity[message text='DependencyReleaseValue is NullOrWhiteSpace on an ORB deploy!' status='WARNING']"
+ }
+
+ # matches
+ # release dot 4digits dot at-least-one-digit dot at-least-one-digit dot at-least-one-digit dot zip
+ # parens around (\d{4}\.\d+\.\d+\.\d+) "captures" that string that looks like YYYY.N.N.N or 2022.3.0.9
+ # which is our Release Version Number, or OrbVersion, matching what Get-OrbVersion would return
+ $regexOrbReleaseFile = "release\.(\d{4}\.\d+\.\d+\.\d+)\.zip"
+ if ($DependencyReleaseValue -match $regexOrbReleaseFile) {
+ $releaseVersion = $Matches[1]
+ Write-Host "$loglead : Valid release version found in dependency parameters"
+ $PackageMetadata.InstalledOrbVersion = $releaseVersion
+ $releaseVersionIsCanonical = $true
+ } else {
+ Write-Host "##teamcity[message text='DependencyReleaseValue does not match release name format. Fallback to Get-OrbVersion from remote host' status='WARNING']"
+ }
+ } else {
+ Write-Host "$loglead : This is a package deploy, so we're using Get-OrbVersion from a remote host to determine what to do with hotfixes."
+ }
+
+ $firstWebORBServer = $PackageMetadata.WebServers | Select-Object -First 1
+ $firstAppORBServer = $PackageMetadata.AppServers | Select-Object -First 1
+ $firstMicORBServer = $PackageMetadata.MicServers | Select-Object -First 1
+
+ $orbServersToCheck = @($firstWebORBServer, $firstAppORBServer, $firstMicORBServer)
+ foreach ($server in $orbServersToCheck) {
+ if ( -NOT (Test-StringIsNullOrWhiteSpace -Value $server)) {
+ if (-NOT $releaseVersionIsCanonical) {
+ $orbVersion = Get-OrbVersion -ComputerName $server
+ $PackageMetadata.InstalledOrbVersion = $orbVersion
+ Write-Host "$logLead : Found orb version $orbVersion."
+ # Found an orb version, don't do this more than necessary.
+ break;
+ }
+ }
+ }
+
+ $versionIsLessThanTarget = -1
+ $versionIsSameAsTarget = 0
+ $versionIsGreaterThanTarget = 1
+
+ $webHotfixPackagesToRemove = @()
+ $appHotfixPackagesToRemove = @()
+
+ # Check incoming Web packages.
+ foreach($package in $PackageMetadata.WebPackagesToInstall)
+ {
+ if($package.IsHotfix){
+ $compareSemverResult = Compare-SemVer -Version1 $package.HotfixFixedInOrbVersion -Version2 $PackageMetadata.InstalledOrbVersion
+
+ switch ($compareSemverResult) {
+ $versionIsLessThanTarget { $webHotfixPackagesToRemove += $package.Name }
+ $versionIsSameAsTarget { $webHotfixPackagesToRemove += $package.Name }
+ $versionIsGreaterThanTarget { <# Do nothing, this will get reinstalled if it should be reinstalled at this point #> }
+ }
+ }
+ }
+
+ # This is a list of Package NAMES, not packages.
+ foreach($package in $webHotfixPackagesToRemove){
+ Write-Host "##teamcity[message text='Found a hotfix in the supplied WEB install list which is not valid with this version of Orb. It will not be installed: $package .' status='WARNING']"
+
+ [array]$PackageMetadata.WebPackagesToInstall = ([array]$PackageMetadata.WebPackagesToInstall).Where( { $_.Name -ne $package })
+ }
+
+ # Check incoming App packages.
+ foreach($package in $PackageMetadata.AppPackagesToInstall)
+ {
+ if($package.IsHotfix){
+ $compareSemverResult = Compare-SemVer -Version1 $package.HotfixFixedInOrbVersion -Version2 $PackageMetadata.InstalledOrbVersion
+
+ switch ($compareSemverResult) {
+ $versionIsLessThanTarget { $appHotfixPackagesToRemove += $package.Name }
+ $versionIsSameAsTarget { $appHotfixPackagesToRemove += $package.Name }
+ $versionIsGreaterThanTarget { <# Do nothing, this will get reinstalled if it should be reinstalled at this point #> }
+ }
+ }
+ }
+
+ # This is a list of Package NAMES, not packages.
+ foreach($package in $appHotfixPackagesToRemove){
+ Write-Host "##teamcity[message text='Found a hotfix in the supplied APP install list which is not valid with this version of Orb. It will not be installed: $package .' status='WARNING']"
+
+ [array]$PackageMetadata.AppPackagesToInstall = ([array]$PackageMetadata.AppPackagesToInstall).Where( { $_.Name -ne $package })
+ }
+
+ return $PackageMetadata
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Installation/Public/Set-InfrastructureConfiguration.ps1 b/Modules/Alkami.DevOps.Installation/Public/Set-InfrastructureConfiguration.ps1
new file mode 100644
index 0000000..41ece1b
--- /dev/null
+++ b/Modules/Alkami.DevOps.Installation/Public/Set-InfrastructureConfiguration.ps1
@@ -0,0 +1,101 @@
+function Set-InfrastructureConfiguration {
+
+<#
+.SYNOPSIS
+ Writes the expected NewRelic Infrastructure configuration file using environment specific parameters
+
+.DESCRIPTION
+ Writes the expected NewRelic Infrastructure configuration file using environment specific parameters. Sets
+ custom attributes for Pod and ServerRole based on user input. Calculated license key is looked up from
+ function Get-NewRelicAccountDetails using an environment key
+
+.PARAMETER ServerRole
+ [string] The literal value to use for the ServerRole custom attribute
+
+.PARAMETER Pod
+ [string] The literal value to use for the Pod custom attribute
+
+.PARAMETER EnvironmentKey
+ [string] The environment key, such as "AWS Production Pod 5", which is used to determine the appropriate license key for NewRelic
+
+.PARAMETER ConfigurationFilePath
+ [string] The filepath to the NewRelic infrastructure configuration file. Defaults to ""C:\Program Files\New Relic\newrelic-infra\newrelic-infra.yml"
+
+.INPUTS
+ None
+
+.OUTPUTS
+ None
+
+.EXAMPLE
+Set-InfrastructureConfiguration -ServerRole "BitcoinMiner" -Pod "99" -EnvironmentKey "AWS Dev Team 99" -Verbose
+
+VERBOSE: [Set-InfrastructureConfiguration] : Using ServerRole: [BitcoinMiner]
+VERBOSE: [Set-InfrastructureConfiguration] : Using Pod: [99]
+VERBOSE: [Set-InfrastructureConfiguration] : ConfigurationFilePath: [C:\Program Files\New Relic\newrelic-infra\newrelic-infra.yml]
+VERBOSE: [Get-NewRelicAccountDetails] : No specific account found for 'AWS Dev Team 99'; looking for default.
+VERBOSE: [Get-NewRelicAccountDetails] : Returning entry with EnvironmentRegex '((?
+
+ #TODO replace with AWS logic and pull tags instead once we move to AWS
+ #TODO this function name is terrible
+
+ [CmdletBinding()]
+ Param(
+ [Parameter(Mandatory = $true)]
+ [string]$ServerRole,
+
+ [Parameter(Mandatory = $true)]
+ [string]$Pod,
+
+ [Parameter(Mandatory = $true)]
+ [string]$EnvironmentKey
+ )
+
+ $logLead = Get-LogLeadName
+
+ Import-AWSModule
+
+ $ConfigurationFilePath = Get-NewRelicYamlPath
+ # Returning/stopping exactly where the error is
+ if (Test-StringIsNullOrWhitespace $ConfigurationFilePath) { return }
+
+ Write-Verbose "$logLead : Using ServerRole: [$ServerRole]"
+ Write-Verbose "$logLead : Using Pod: [$Pod]"
+ Write-Verbose "$logLead : ConfigurationFilePath: [$ConfigurationFilePath]"
+
+ $licenseKey = (Get-NewRelicAccountDetails $environmentKey).LicenseKey
+ Write-Verbose "$logLead : Using license key: $licenseKey"
+
+ Set-Content $ConfigurationFilePath @"
+license_key: $licenseKey
+disable_cloud_instance_id: true
+custom_attributes:
+ ServerRole: $serverRole
+ Pod: '$Pod'
+"@
+
+ if (Test-IsAWS) {
+ # We already know the instance exists, so we want to see if it has a tag for alk:overflow
+ # It can only return if all three are true, so there will never be two record-sets returned in the response
+ # If the instance has this tag, we will get some set of results back in the response, so we should always set the custom_attribute.Overflow to true
+ # If the instance does not have this tag, we will get no results back in the response, so we should always set the custom_attribute.Overflow to false
+ # We should always set the flag (even if it is to false) to ensure that we are properly reporting server intent to NR
+ $hostname = $env:COMPUTERNAME.ToLower()
+ $Ec2TagFilter = @{Name = "tag:alk:hostname"; Values = "$hostname" },
+ @{Name = "resource-type"; Values = "instance" },
+ @{Name = "tag:alk:overflow"; Values = "true" }
+ $scriptBlock = { Get-EC2Tag -Filter $Ec2TagFilter }
+ $instanceOverflowTag = Invoke-CommandWithRetry -Arguments @($hostname) -ScriptBlock $scriptBlock -MaxRetries 3 -SecondsDelay 1
+
+ if (!(Test-IsCollectionNullOrEmpty $instanceOverflowTag)) {
+ Add-OverflowCustomAttribute -IsOverflow $true
+ } else {
+ Add-OverflowCustomAttribute -IsOverflow $false
+ }
+ }
+
+ Write-Host "$logLead : NewRelic Infrastructure configuration saved to $ConfigurationFilePath"
+}
diff --git a/Modules/Alkami.DevOps.Installation/Public/Set-InfrastructureConfiguration.tests.ps1 b/Modules/Alkami.DevOps.Installation/Public/Set-InfrastructureConfiguration.tests.ps1
new file mode 100644
index 0000000..8f94cc1
--- /dev/null
+++ b/Modules/Alkami.DevOps.Installation/Public/Set-InfrastructureConfiguration.tests.ps1
@@ -0,0 +1,138 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ""
+
+Describe "Set-InfrastructureConfiguration" {
+
+ Context "Function Tests" {
+
+
+ It "Uses the Supplied ServerRole in the Output File" {
+
+ $testConfiguration = "TestDrive:\newrelic-infra.yml"
+ Mock -CommandName Get-NewRelicYamlPath -MockWith { "TestDrive:\newrelic-infra.yml" }
+ Mock -CommandName Get-NewRelicAccountDetails -ModuleName $moduleForMock {
+
+ return New-Object PSObject -Property @{
+ LicenseKey = "abcdefg123456";
+ }
+ }
+
+ Set-InfrastructureConfiguration -ServerRole "BitcoinMiner" -Pod "99" -EnvironmentKey "abcdefg123456" `
+
+ $content = Get-Content $testConfiguration -Raw
+ $content | Should -Match " ServerRole: BitcoinMiner"
+ }
+
+ It "Uses the Supplied Pod in the Output File" {
+
+ $testConfiguration = "TestDrive:\newrelic-infra.yml"
+ Mock -CommandName Get-NewRelicYamlPath -MockWith { "TestDrive:\newrelic-infra.yml" }
+ Mock -CommandName Get-NewRelicAccountDetails -ModuleName $moduleForMock {
+
+ return New-Object PSObject -Property @{
+ LicenseKey = "abcdefg123456";
+ }
+ }
+
+ Set-InfrastructureConfiguration -ServerRole "Web" -Pod "98.70" -EnvironmentKey "abcdefg123456" `
+
+ $content = Get-Content $testConfiguration -Raw
+ $content | Should -Match " Pod: '98\.70'"
+ }
+
+ It "Does not use the Supplied Pod in the Output File" {
+
+ $testConfiguration = "TestDrive:\newrelic-infra.yml"
+ Mock -CommandName Get-NewRelicYamlPath -MockWith { "TestDrive:\newrelic-infra.yml" }
+ Mock -CommandName Get-NewRelicAccountDetails -ModuleName $moduleForMock {
+
+ return New-Object PSObject -Property @{
+ LicenseKey = "abcdefg123456";
+ }
+ }
+
+ Set-InfrastructureConfiguration -ServerRole "Web" -Pod "98670" -EnvironmentKey "abcdefg123456" `
+
+ $content = Get-Content $testConfiguration -Raw
+ $content | Should -Not -Match " Pod: '98\.70'"
+ }
+
+ It "Does not use the Supplied Pod in the Output File with no single quotes" {
+
+ $testConfiguration = "TestDrive:\newrelic-infra.yml"
+ Mock -CommandName Get-NewRelicYamlPath -MockWith { "TestDrive:\newrelic-infra.yml" }
+ Mock -CommandName Get-NewRelicAccountDetails -ModuleName $moduleForMock {
+
+ return New-Object PSObject -Property @{
+ LicenseKey = "abcdefg123456";
+ }
+ }
+
+ Set-InfrastructureConfiguration -ServerRole "Web" -Pod "98.70" -EnvironmentKey "abcdefg123456" `
+
+ $content = Get-Content $testConfiguration -Raw
+ $content | Should -Not -Match " Pod: 98\.70"
+ }
+
+ It "Uses the Supplied LicenseKey in the Output File" {
+
+ $testConfiguration = "TestDrive:\newrelic-infra.yml"
+ Mock -CommandName Get-NewRelicYamlPath -MockWith { "TestDrive:\newrelic-infra.yml" }
+ Mock -CommandName Get-NewRelicAccountDetails -ModuleName $moduleForMock {
+
+ return New-Object PSObject -Property @{
+ LicenseKey = "hunter2";
+ }
+ }
+
+ Set-InfrastructureConfiguration -ServerRole "Web" -Pod "187" -EnvironmentKey "abcdefg123456" `
+
+ $content = Get-Content $testConfiguration -Raw
+ $content | Should -Match "^license_key: hunter2"
+ }
+
+ It "Uses 4 Preceeding Spaces for All Indented Lines" {
+
+ $testConfiguration = "TestDrive:\newrelic-infra.yml"
+ Mock -CommandName Get-NewRelicYamlPath -MockWith { "TestDrive:\newrelic-infra.yml" }
+ Mock -CommandName Get-NewRelicAccountDetails -ModuleName $moduleForMock {
+
+ return New-Object PSObject -Property @{
+ LicenseKey = "fakelicense";
+ }
+ }
+
+ Set-InfrastructureConfiguration -ServerRole "FakeRole" -Pod "FakePod" -EnvironmentKey "abcdefg123456" `
+
+ $leadingWhiteSpaceContent = Get-Content $testConfiguration | Where-Object { $_ -match "^\s" }
+
+ foreach ($contentLine in $leadingWhiteSpaceContent) {
+
+ $contentLine | Should -Match "^ " -Because "YAML Indented Lines Must Start with 4 Spaces, Not Tabs"
+ $contentLine | Should -Not -Match "^\t" -Because "YAML Indented Lines Must Start with 4 Spaces, Not Tabs"
+ }
+ }
+
+ It "Includes the Disable Cloud Instance ID Attribute" {
+
+ $testConfiguration = "TestDrive:\newrelic-infra.yml"
+ Mock -CommandName Get-NewRelicYamlPath -MockWith { "TestDrive:\newrelic-infra.yml" }
+ Mock -CommandName Get-NewRelicAccountDetails -ModuleName $moduleForMock {
+
+ return New-Object PSObject -Property @{
+ LicenseKey = "fakelicense2";
+ }
+ }
+
+ Set-InfrastructureConfiguration -ServerRole "FakeRole2" -Pod "FakePod2" -EnvironmentKey "abcdefg1234562" `
+
+ $content = Get-Content $testConfiguration -Raw
+ $content | Should -Match "disable_cloud_instance_id: true" -Because "NewRelic Infrastructure May Use the Instance ID instead of Hostname if Not Present"
+ }
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Installation/Public/Set-NewRelicConfigurationValues.Tests.ps1 b/Modules/Alkami.DevOps.Installation/Public/Set-NewRelicConfigurationValues.Tests.ps1
new file mode 100644
index 0000000..af1de94
--- /dev/null
+++ b/Modules/Alkami.DevOps.Installation/Public/Set-NewRelicConfigurationValues.Tests.ps1
@@ -0,0 +1,290 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ""
+
+#region Set-NewRelicConfigurationValues
+
+Describe "Set-NewRelicConfigurationValues" {
+
+ $global:modifiedXml = $null
+
+ Mock -ModuleName $moduleForMock Save-XmlFile {
+
+ Param(
+ [string]$path,
+ [xml]$xml
+ )
+
+ $global:modifiedXml = $xml
+ }
+ Mock -ModuleName $moduleForMock Write-Host
+ Mock -ModuleName $moduleForMock Write-Warning
+
+ # XML With Wrong Values for both Log Levels and Slow SQL
+ [xml]$global:nrXmlWrongValues = @'
+
+
+
+
+
+
+
+
+
+
+'@
+
+ Context "When the Values are Not Alkami Defaults" {
+
+ # Mock Read-XmlFile for the Test
+ Mock -ModuleName $moduleForMock Read-XmlFile {
+ return $global:nrXmlWrongValues
+ }
+
+ Set-NewRelicConfigurationValues
+
+ It "Sets the Log Level to Error" {
+
+ Assert-MockCalled Read-XmlFile -ModuleName $moduleForMock -times 1
+ $modifiedXml.OuterXml.ToString() | Should -Match ''
+ }
+
+ It "Sets explainsEnabled to False" {
+
+ $modifiedXml.OuterXml.ToString() | Should -Match ''
+ }
+
+ It "Sets SlowSql to False" {
+
+ $modifiedXml.OuterXml.ToString() | Should -Match ''
+ }
+
+ It "Sets applicationLogging to False" {
+
+ $modifiedXml.OuterXml.ToString() | Should -Match ''
+ }
+
+ It "Sets applicationLoggingforwarding to False" {
+
+ $modifiedXml.OuterXml.ToString() | Should -Match '
+ #
+ #
+ #
+ #
+ #'@
+
+ $global:modifiedXml = $null
+
+ # Mock Read-XmlFile for the Test
+ Mock -ModuleName $moduleForMock Read-XmlFile { return $nrXmlWrongValues }
+
+ Set-NewRelicConfigurationValues -LogLevel "FATAL"
+
+ It "Sets the Log Level to the Provided Value" {
+
+ $modifiedXml.OuterXml.ToString() | Should -Match ''
+ }
+ }
+
+ Context "When the transactionTracer Node is Missing" {
+
+ $global:modifiedXml = $null
+
+ # XML With Missing Slow SQL Node
+ [xml]$global:nrXmlNoSlowSql = @'
+
+
+
+
+
+
+
+
+'@
+ # Mock Read-XmlFile for the Test
+ Mock -ModuleName $moduleForMock Read-XmlFile { return $nrXmlNoSlowSql }
+
+ Set-NewRelicConfigurationValues
+
+ It "Adds the Missing transactionTracer Node" {
+
+ $modifiedXml.OuterXml.ToString() | Should -Match ''
+ }
+ }
+
+
+ Context "When the applicationLogging Node is Missing" {
+
+ $global:modifiedXml = $null
+
+ # XML With Missing Slow SQL Node
+ [xml]$nrXmlNoSlowSql = @'
+
+
+
+
+'@
+ # Mock Read-XmlFile for the Test
+ Mock -ModuleName $moduleForMock Read-XmlFile { return $nrXmlNoSlowSql }
+
+ Set-NewRelicConfigurationValues
+
+ It "Adds the Missing applicationLogging Node" {
+
+ $modifiedXml.OuterXml.ToString() | Should -Match ''
+ }
+ }
+
+ Context "When the applicationLoggingforwarding Node is Missing" {
+
+ $global:modifiedXml = $null
+
+ # XML With Missing Slow SQL Node
+ [xml]$nrXmlNoSlowSql = @'
+
+
+
+
+'@
+ # Mock Read-XmlFile for the Test
+ Mock -ModuleName $moduleForMock Read-XmlFile { return $nrXmlNoSlowSql }
+
+ Set-NewRelicConfigurationValues
+
+ It "Adds the Missing applicationLoggingforwarding Node" {
+
+ $modifiedXml.OuterXml.ToString() | Should -Match '
+
+
+
+'@
+ # Mock Read-XmlFile for the Test
+ Mock -ModuleName $moduleForMock Read-XmlFile { return $nrXmlNoSlowSql }
+
+ Set-NewRelicConfigurationValues
+
+ It "Adds the Missing applicationLogginglocalDecorating Node" {
+
+ $modifiedXml.OuterXml.ToString() | Should -Match '
+
+
+
+
+
+
+
+'@
+ # Mock Read-XmlFile for the Test
+ Mock -ModuleName $moduleForMock Read-XmlFile { return $nrXmlNoSlowSql }
+
+ Set-NewRelicConfigurationValues
+
+ It "Adds the Missing SlowSql Node" {
+
+ $modifiedXml.OuterXml.ToString() | Should -Match ''
+ }
+ }
+
+ Context "When the NewRelic Configuration File Is Invalid" {
+
+ $tempPath = [System.IO.Path]::GetTempFileName()
+
+ It "Writes a Warning When the File Is Not Found" {
+
+ { (Set-NewRelicConfigurationValues -NewRelicConfigPath $tempPath 3>&1) -match "The New Relic Config File Could Not be Found"
+ } | Should Be $true
+ }
+
+ It "Writes a Warning When the Config is Invalid XML" {
+
+ $global:badXml = "Hello World!"
+ Mock -ModuleName $moduleForMock Read-XmlFile { return $global:badXml }
+
+ { (Set-NewRelicConfigurationValues 3>&1) -match "The New Relic Config File Could Not be Read Or Is Invalid" } | Should Be $true
+ }
+
+ It "Writes a Warning When the Logging Node Does Not Exist" {
+
+ $global:modifiedXml = $null
+
+ # XML With Missing Slow SQL Node
+ [xml]$nrXmlNoLogging = @'
+
+
+
+
+'@
+ # Mock Read-XmlFile for the Test
+ Mock -ModuleName $moduleForMock Read-XmlFile { return $nrXmlNoLogging }
+
+ { (Set-NewRelicConfigurationValues 3>&1) -match "Log Node was not found" } | Should Be $true
+ }
+ }
+
+ Context "When No Changes Are Required" {
+
+ [xml]$nrNoChangesNeeded = @'
+
+
+
+
+
+
+
+
+
+
+'@
+
+ It "Does Not Save the Configuration File" {
+
+ # Mock Read-XmlFile for the Test
+ Mock -ModuleName $moduleForMock Read-XmlFile { return $nrNoChangesNeeded }
+
+ # Mock Save-XmlFile for the Test
+ Mock -ModuleName $moduleForMock Save-XmlFile { }
+
+ Set-NewRelicConfigurationValues
+ Assert-MockCalled Save-XmlFile -ModuleName $moduleForMock -Times 0 -Exactly -Scope It
+ }
+ }
+}
+
+#endregion Set-NewRelicConfigurationValues
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Installation/Public/Set-NewRelicConfigurationValues.ps1 b/Modules/Alkami.DevOps.Installation/Public/Set-NewRelicConfigurationValues.ps1
new file mode 100644
index 0000000..ea378ab
--- /dev/null
+++ b/Modules/Alkami.DevOps.Installation/Public/Set-NewRelicConfigurationValues.ps1
@@ -0,0 +1,215 @@
+function Set-NewRelicConfigurationValues {
+<#
+.SYNOPSIS
+ Sets the LogLevel and SlowSql Configuration for the New Relic agent to recommended defaults
+#>
+
+ [CmdletBinding()]
+ Param(
+ [Parameter(Mandatory = $false)]
+ [Alias("LogLevel")]
+ [string]$loggingLevel = "error",
+
+ [Parameter(Mandatory = $false)]
+ [Alias("NewRelicConfigPath")]
+ [string]$nrConfigPath = "C:\ProgramData\New Relic\.NET Agent\newrelic.config"
+ )
+
+ $logLead = Get-LogLeadName
+
+ try {
+ Write-Verbose ("$logLead : Looking for New Relic configuration in {0}" -f $nrConfigPath)
+
+ [Xml]$newRelicConfig = Read-XmlFile $nrConfigPath -ErrorAction SilentlyContinue
+
+ if ([String]::IsNullOrEmpty($newRelicConfig)) {
+ Write-Warning ("$logLead : The New Relic Config File Could Not be Found")
+ return
+ }
+
+ }
+ catch {
+ Write-Warning "$logLead : The New Relic Config File Could Not be Read Or Is Invalid"
+
+ return
+ }
+
+ $xmlNameSpace = @{ nr = 'urn:newrelic-config'; }
+
+ Write-Verbose "$logLead : Looking for NewRelic Configuration Node"
+
+ $configurationNode = Select-Xml `
+ -Xml $newRelicConfig `
+ -Xpath '//nr:configuration' `
+ -Namespace $xmlNamespace `
+ -ErrorAction SilentlyContinue
+
+ if ($null -eq $configurationNode) {
+ Write-Warning "$logLead : The New Relic configuration node could not be found. Check the New Relic Agent installation"
+
+ return
+ }
+
+ Write-Verbose "$logLead : Looking for New Relic Service SSL attr"
+ $serviceNode = Select-Xml `
+ -Xml $newRelicConfig `
+ -Xpath '//nr:configuration/nr:service' `
+ -Namespace $xmlNamespace `
+ -ErrorAction SilentlyContinue
+
+ $sslAttr = $null
+ if ($null -eq $serviceNode) {
+ Write-Warning "$logLead : Service Node was not found -- this should never be the case. Check the New Relic Agent installation"
+ } else {
+ $sslAttr = $serviceNode.Node.Attributes["ssl"]
+ }
+
+ Write-Verbose "$logLead : Looking for NewRelic Log Level Node"
+ $loggingNode = Select-Xml `
+ -Xml $newRelicConfig `
+ -Xpath '//nr:configuration/nr:log/@level' `
+ -Namespace $xmlNamespace `
+ -ErrorAction SilentlyContinue
+
+ Write-Verbose "$logLead : Looking for NewRelic SlowSql Node"
+ $slowSqlNode = Select-Xml `
+ -Xml $newRelicConfig `
+ -Xpath '//nr:configuration/nr:slowSql/@enabled' `
+ -Namespace $xmlNamespace `
+ -ErrorAction SilentlyContinue
+
+ Write-Verbose "$logLead : Looking for NewRelic transactionTracer Node"
+ $transactionTracer = Select-Xml `
+ -Xml $newRelicConfig `
+ -Xpath '//nr:configuration/nr:transactionTracer/@explainEnabled' `
+ -Namespace $xmlNamespace `
+ -ErrorAction SilentlyContinue
+
+ Write-Verbose "$logLead : Looking for NewRelic applicationLogging Node"
+ $applicationLogging = Select-Xml `
+ -Xml $newRelicConfig `
+ -Xpath '//nr:configuration/nr:applicationLogging' `
+ -Namespace $xmlNamespace `
+ -ErrorAction SilentlyContinue
+
+ $applicationLoggingEnabled = Select-Xml `
+ -Xml $newRelicConfig `
+ -Xpath '//nr:configuration/nr:applicationLogging/@enabled' `
+ -Namespace $xmlNamespace `
+ -ErrorAction SilentlyContinue
+
+ $applicationLoggingForwardingEnabled = Select-Xml `
+ -Xml $newRelicConfig `
+ -Xpath '//nr:configuration/nr:applicationLogging/nr:forwarding/@enabled' `
+ -Namespace $xmlNamespace `
+ -ErrorAction SilentlyContinue
+
+ $applicationLoggingLocalDecorating = Select-Xml `
+ -Xml $newRelicConfig `
+ -Xpath '//nr:configuration/nr:applicationLogging/nr:localDecorating/@enabled' `
+ -Namespace $xmlNamespace `
+ -ErrorAction SilentlyContinue
+
+ $configFileIsDirty = $false
+
+ if ($null -eq $loggingNode) {
+ Write-Warning "$logLead : Log Node was not found -- this should never be the case. Check the New Relic Agent installation"
+
+ return
+ } ElseIf ($loggingNode.ToString() -notmatch $loggingLevel) {
+ Write-Host "$logLead : Updating the New Relic Logging Configuration"
+
+ $configFileIsDirty = $true
+
+ $loggingNode.Node.Value = $loggingLevel;
+ }
+
+ if ($null -ne $sslAttr) {
+ Write-Host "$logLead : The Service/Ssl Attr Exists and Will Be Removed"
+
+ $serviceNode.Node.Attributes.Remove($sslAttr)
+
+ $configFileIsDirty = $true
+ }
+
+ if ($null -eq $slowSqlNode) {
+ Write-Host "$logLead : The SlowSQL Node Does Not Exist and Will be Created"
+
+ $configFileIsDirty = $true
+
+ $tempDoc = New-Object System.Xml.XmlDocument
+ $tempDoc.LoadXml('')
+
+ $newSqlNode = $newRelicConfig.ImportNode($tempDoc.DocumentElement, $true)
+ $newRelicConfig.DocumentElement.AppendChild($newSqlNode) | Out-Null
+ } Elseif ($slowSqlNode.Node.Value -notmatch "false") {
+ Write-Host "$logLead : Updating the New Relic SlowSQL Node Configuration"
+
+ $configFileIsDirty = $true
+
+ $slowSqlNode.Node.Value = "false"
+ }
+
+ if ($null -eq $transactionTracer) {
+ Write-Host "$logLead : The transactionTracer Node Does Not Exist and Will be Created"
+
+ $configFileIsDirty = $true
+
+ $tempDoc = New-Object System.Xml.XmlDocument
+ $tempDoc.LoadXml(' ')
+
+ $newSqlNode = $newRelicConfig.ImportNode($tempDoc.DocumentElement, $true)
+ $newRelicConfig.DocumentElement.AppendChild($newSqlNode) | Out-Null
+ } ElseIf ($transactionTracer.Node.Value -notmatch "false") {
+ Write-Host "$logLead : Updating the New Relic transactionTracer Node Configuration"
+
+ $configFileIsDirty = $true
+
+ $transactionTracer.Node.Value = "false"
+ }
+
+ if ($null -eq $applicationLogging) {
+ Write-Host "$logLead : The applicationLogging Node Does Not Exist and Will be Created"
+
+ $configFileIsDirty = $true
+
+ $tempDoc = New-Object System.Xml.XmlDocument
+ $tempDoc.LoadXml('')
+
+ $newSqlNode = $newRelicConfig.ImportNode($tempDoc.DocumentElement, $true)
+ $newRelicConfig.DocumentElement.AppendChild($newSqlNode) | Out-Null
+ } else {
+
+ if ($applicationLoggingEnabled.Node.Value -notmatch "false") {
+ Write-Host "$logLead : Updating the New Relic applicationLogging Node Configuration"
+
+ $configFileIsDirty = $true
+
+ $applicationLoggingEnabled.Node.Value = "false"
+ }
+ if ($applicationLoggingForwardingEnabled.Node.Value -notmatch "false") {
+ Write-Host "$logLead : Updating the New Relic applicationLoggingforwarding Node Configuration"
+
+ $configFileIsDirty = $true
+
+ $applicationLoggingForwardingEnabled.Node.Value = "false"
+ }
+ if ($applicationLoggingLocalDecorating.Node.Value -notmatch "false") {
+ Write-Host "$logLead : Updating the New Relic applicationLogginglocalDecorating Node Configuration"
+
+ $configFileIsDirty = $true
+
+ $applicationLoggingLocalDecorating.Node.Value = "false"
+ }
+ }
+
+
+ if ($configFileIsDirty) {
+ Write-Host ("$logLead : Saving the Modified New Relic Configuration File" -f $nrConfigPath)
+
+ $utfNoBOM = New-Object System.Text.UTF8Encoding($false)
+ Save-XMLFile $nrConfigPath $newRelicConfig.OuterXml.Replace('xmlns=""', [String]::Empty) $utfNoBOM
+ } else {
+ Write-Host "$logLead : No Changes Required to the New Relic Config"
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Installation/Public/Set-NewRelicDeployment.ps1 b/Modules/Alkami.DevOps.Installation/Public/Set-NewRelicDeployment.ps1
new file mode 100644
index 0000000..13b9b41
--- /dev/null
+++ b/Modules/Alkami.DevOps.Installation/Public/Set-NewRelicDeployment.ps1
@@ -0,0 +1,131 @@
+function Set-NewRelicDeployment {
+ <#
+.SYNOPSIS
+ Posts a Deployment to a New Relic application via the API for either:
+ All non-microservices that New Relic knows about in a given Environment
+ OR
+ All specified microservices that New Relic knows about in a given Environment.
+
+.PARAMETER EnvironmentKey
+ Name of the environment to update.
+
+.PARAMETER AppVersion
+ Version of the service that is being deployed. Only valid for legacy services. Providing this and a set of microservices will throw.
+
+.PARAMETER DeployUser
+ The user performing the deployment. Optional.
+
+.PARAMETER Microservices
+ A hash table of microservices and their versions.
+
+.EXAMPLE
+ Set-NewRelicDeployment -EnvironmentKey "AWS CICD CI1" -AppVersion 1.0 -DeployUser "testUser"
+
+ $microservices = @{"Alkami.Microservice.Sample" = "1.0"}
+
+ Set-NewRelicDeployment -EnvironmentKey "AWS CICD CI1" -DeployUser "testUser" -micoserviceNames $microservices
+
+#>
+ [CmdletBinding()]
+ Param(
+ [Parameter(Mandatory = $true)]
+ [Alias("Environment")]
+ [string]$EnvironmentKey,
+
+ [Parameter(Mandatory = $false,
+ ParameterSetName = "StandardServices")]
+ [Alias("Version")]
+ [string]$AppVersion,
+
+ [Parameter(Mandatory = $false)]
+ [Alias("DeploymentEngineer")]
+ [string]$DeployUser = "Alkami",
+
+ [Parameter(Mandatory = $false,
+ ParameterSetName = "Microservices")]
+ [Alias("SpecifiedMicroservices")]
+ [hashtable]$Microservices
+ )
+
+ $logLead = (Get-LogLeadName)
+ Write-Host ("$logLead : Pulling list of Applications from NewRelic")
+
+ $apiKey = (Get-NewRelicAccountDetails $EnvironmentKey).APIKey
+
+ $postFailed = @()
+
+ try {
+ $applications = (Get-NewRelicObjects -apiKey $apiKey -initialUrl "https://api.newrelic.com/v2/applications.json" -ObjectKey "applications" -FilterKey "name" -FilterValue $EnvironmentKey).applications
+ } catch {
+ # If an error gets thrown below me on the stack, we should just abort with an error message
+ Write-Error "$logLead : Error thrown while getting NewRelic objects, can not process deployments. Exiting function with no action performed.`r`n$_"
+ return
+ }
+
+ if (![string]::IsNullOrWhiteSpace($EnvironmentKey) -and (Test-IsCollectionNullOrEmpty $applications)) {
+ Write-Warning "$logLead : No results at all returned from NewRelic for [$EnvironmentKey]. Cannot continue, returning early."
+ return
+ }
+
+ $filteredApplications = $applications.Where({$_.name -match "^$($EnvironmentKey)[^.]"})
+ if (Test-IsCollectionNullOrEmpty $filteredApplications) {
+ Write-Warning "$logLead : No results returned from NewRelic that match the filter for [$EnvironmentKey]. Cannot continue, returning early."
+ return
+ }
+
+ Write-Host "$logLead : Posting Deployment to NewRelic Applications for Environment [$EnvironmentKey]"
+
+ # If provided with microservice names, loop through and send them to new-relic, if they already exist.
+ if ($Microservices) {
+ foreach ($microservice in $Microservices.GetEnumerator()) {
+ $foundMicroserviceViaFilter = $false
+ $AppVersion = $microservice.value
+ $microserviceName = $microservice.key
+
+ $matchedApplications = $filteredApplications.Where({$_.name -match $microserviceName})
+ foreach($matchedApplication in $matchedApplications) {
+ $foundMicroserviceViaFilter = $true
+
+ Write-Verbose "$logLead : Looking to update $($matchedApplication.Name)"
+ if (Submit-DeploymentToNewRelic -ApiKey $apiKey -ApplicationId $matchedApplication.id -AppVersion $AppVersion -DeployUser $DeployUser -EnvironmentKey $EnvironmentKey) {
+ Write-Verbose "$logLead : Updated $($matchedApplication.Name)"
+ } else {
+ Write-Verbose "$logLead : Failed to update $($matchedApplication.Name)"
+ $postFailed += $matchedApplication.Name
+ }
+ }
+ if (!$foundMicroserviceViaFilter -and $microserviceName -like "*Alkami.M*") {
+ Write-Warning "$microserviceName was specified to be updated in New Relic, but New Relic does not know about it for [$EnvironmentKey]. There is likely no entry for [$EnvironmentKey $microserviceName]"
+ }
+ }
+ } else {
+ if([string]::IsNullOrWhiteSpace($AppVersion)) {
+ throw "$logLead : Somehow we got to a New Relic API call with no AppVersion value. That shouldn't happen. Please investigate."
+ } else {
+ Write-Host "$logLead : Looking for non-service accounts to be updated in NR"
+
+ $matchedApplications = $filteredApplications.Where({$_.name -notlike "*Alkami.M*" })
+
+ if (Test-IsCollectionNullOrEmpty $matchedApplications) {
+ Write-Warning "$logLead : No applications found for [$EnvironmentKey] in NewRelic"
+ } else {
+ foreach($matchedApplication in $matchedApplications) {
+ Write-Verbose "$logLead : Looking to update $($matchedApplication.Name)"
+ if (Submit-DeploymentToNewRelic -ApiKey $apiKey -ApplicationId $matchedApplication.id -AppVersion $AppVersion -DeployUser $DeployUser -EnvironmentKey $EnvironmentKey) {
+ Write-Verbose "$logLead : Updated $($matchedApplication.Name)"
+ } else {
+ Write-Verbose "$logLead : Failed to update $($matchedApplication.Name)"
+ $postFailed += $matchedApplication.Name
+ }
+ }
+ }
+ }
+ }
+
+ if (!(Test-IsCollectionNullOrEmpty $postFailed)) {
+ foreach($failed in $postFailed) {
+ Write-Warning "$logLead : Failed to update deployment for [$failed]"
+ }
+ throw "$logLead : Not all Post Requests were successful. Review the log output for details"
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Installation/Public/Set-NewRelicDeployment.tests.ps1 b/Modules/Alkami.DevOps.Installation/Public/Set-NewRelicDeployment.tests.ps1
new file mode 100644
index 0000000..d7c5669
--- /dev/null
+++ b/Modules/Alkami.DevOps.Installation/Public/Set-NewRelicDeployment.tests.ps1
@@ -0,0 +1,194 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ""
+
+Describe "Set-NewRelicDeployment" {
+# by using variables for everything, we can eliminate typos and ensure we still match the appropriate/expected actions
+#region variable setup
+ $EnvironmentKey = "Unit Test Environment"
+ $AlternateEnvironmentKey = "Fake Pod 12"
+ $TestUser = "TestUser"
+ $SampleWcfApplicationId = 1234
+ $SamplePositiveMicroservice1Id = 5678
+ $SamplePositiveMicroservice2Id = 1010
+ $SampleNegativeMicroservice1Id = 9876
+ $SampleNegativeMicroservice2Id = 8765
+ $SampleMicroserviceName1 = "Alkami.Microservice Test 1"
+ $SampleMicroserviceName2 = "Alkami.Microservice Test 2"
+ $SampleMicroserviceName3 = "Alkami.Microservice Test 3"
+ $SampleMicroserviceName4 = "Alkami.Microservice Test 4"
+#endregion variable setup
+
+#region mock setup
+ Mock -ModuleName $moduleForMock -CommandName Get-LogLeadName -MockWith { return "[UUT]" }
+ Mock -ModuleName $moduleForMock -CommandName Write-Host -MockWith { }
+ Mock -ModuleName $moduleForMock -CommandName Write-Verbose -MockWith { }
+ Mock -ModuleName $moduleForMock -CommandName Write-Warning -MockWith { }
+
+ Mock -ModuleName $moduleForMock -CommandName Get-NewRelicAccountDetails -MockWith { return @{
+ EnvironmentRegex = $EnvironmentKey
+ APIKey = "Unit Test Api Key"
+ LicenseKey = "Unit Test License Key"
+ } }
+
+ Mock -ModuleName $moduleForMock -CommandName Submit-DeploymentToNewRelic -MockWith { Write-warning "$applicationVersion"; return $true }
+ Mock -ModuleName $moduleForMock -CommandName Submit-DeploymentToNewRelic -ParameterFilter { $ApplicationId -eq $SampleWcfApplicationId } -MockWith { return $true }
+ Mock -ModuleName $moduleForMock -CommandName Submit-DeploymentToNewRelic -ParameterFilter { $ApplicationId -eq $SamplePositiveMicroservice1Id } -MockWith { return $true }
+ Mock -ModuleName $moduleForMock -CommandName Submit-DeploymentToNewRelic -ParameterFilter { $ApplicationId -eq $SamplePositiveMicroservice2Id } -MockWith { return $true }
+ Mock -ModuleName $moduleForMock -CommandName Submit-DeploymentToNewRelic -ParameterFilter { $ApplicationId -eq $SampleNegativeMicroservice1Id } -MockWith { return $true }
+ Mock -ModuleName $moduleForMock -CommandName Submit-DeploymentToNewRelic -ParameterFilter { $ApplicationId -eq $SampleNegativeMicroservice2Id } -MockWith { return $false }
+
+ Mock -ModuleName $moduleForMock -CommandName Write-Warning -ParameterFilter { $message.IndexOf("$SampleMicroserviceName3 was specified to be updated in New Relic") -gt -1 } -MockWith { }
+
+ Mock -ModuleName $moduleForMock -CommandName Get-NewRelicObjects -MockWith {
+ return @{ applications = @(
+ @{
+ id = $SampleWcfApplicationId;
+ name = "$EnvironmentKey Sample WCF Application";
+ },
+ @{
+ id = $SamplePositiveMicroservice1Id;
+ name = "$EnvironmentKey $SampleMicroserviceName1";
+ },
+ @{
+ id = $SamplePositiveMicroservice2Id;
+ name = "$EnvironmentKey $SampleMicroserviceName2";
+ },
+ @{
+ id = $SampleNegativeMicroservice1Id;
+ name = "$AlternateEnvironmentKey $SampleMicroserviceName3";
+ },
+ @{
+ id = $SampleNegativeMicroservice2Id;
+ name = "$AlternateEnvironmentKey.1 $SampleMicroserviceName4";
+ }
+ )};
+ }
+#endregion mock setup
+
+ Context "When Microservices Are Not Provided As A Parameter, Post For Services" {
+ Set-NewRelicDeployment -EnvironmentKey $EnvironmentKey -AppVersion "1.1" -DeployUser $TestUser
+
+ It "Posts to non-MS Which Do Exist In New Relic" {
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Submit-DeploymentToNewRelic -Exactly -Times 1 -Scope Context -ParameterFilter { $ApplicationId -eq $SampleWcfApplicationId }
+ }
+
+ It "Does Not Post to MS Which Do Exist In New Relic" {
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Submit-DeploymentToNewRelic -Exactly -Times 0 -Scope Context -ParameterFilter { $ApplicationId -eq $SamplePositiveMicroservice1Id }
+ }
+
+ # We already asserted above that it posts to the one we want, now ensure it only posted the expected amount of times
+ It "Does Not Post An Update To Additional Services Which Do Exist In New Relic" {
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Submit-DeploymentToNewRelic -Exactly -Times 1 -Scope Context
+ }
+ }
+
+ Context "When Making the Application Query" {
+ It "Throws if AppVersion is not supplied" {
+ { Set-NewRelicDeployment -EnvironmentKey $EnvironmentKey -DeployUser $TestUser } | Should -throw
+ }
+
+ It "Does not throw when required parameters are provided" {
+ {
+ Set-NewRelicDeployment -EnvironmentKey $EnvironmentKey -AppVersion "1.1" -DeployUser $TestUser
+ } | Should -Not -Throw
+ }
+
+ It "Throws when too many parameters are provided for input" {
+ $microservices = @{
+ $SampleMicroserviceName1 = "1.1"
+ $SampleMicroserviceName2 = "1.1"
+ }
+ {
+ Set-NewRelicDeployment -EnvironmentKey $EnvironmentKey -AppVersion "1.1" -DeployUser $TestUser -microservices $microservices
+ } | Should -Throw
+ }
+ }
+
+ Context "When 2 Microservices Are Provided As A Parameter and all belong to the environment" {
+ $microservices = @{
+ $SampleMicroserviceName1 = "1.1"
+ $SampleMicroserviceName2 = "1.1"
+ }
+
+ It "Does not throw when MS are provided for input" {
+ {
+ Set-NewRelicDeployment -EnvironmentKey $EnvironmentKey -DeployUser $TestUser -microservices $microservices
+ } | Should -Not -Throw
+ }
+
+ It "Does Not Report a Warning To Provided Microservices Which Exist In New Relic - 1" {
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Submit-DeploymentToNewRelic -Exactly -Times 1 -Scope Context -ParameterFilter { $ApplicationId -eq $SamplePositiveMicroservice1Id }
+ }
+
+ It "Does Not Report a Warning To Provided Microservices Which Exist In New Relic - 2" {
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Submit-DeploymentToNewRelic -Exactly -Times 1 -Scope Context -ParameterFilter { $ApplicationId -eq $SamplePositiveMicroservice2Id }
+ }
+
+ It "Does Not Post An Update To Non-Microservices Which Exist In New Relic" {
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Submit-DeploymentToNewRelic -Exactly -Times 0 -Scope Context -ParameterFilter { $ApplicationId -eq $SampleWcfApplicationId }
+ }
+ }
+
+ Context "When 4 Microservices Are Provided As A Parameter but some don't belong to the environment" {
+ $microservices = @{
+ $SampleMicroserviceName1 = "1.1"
+ $SampleMicroserviceName2 = "1.1"
+ $SampleMicroserviceName3 = "1.1"
+ $SampleMicroserviceName4 = "1.1"
+ }
+
+ It "Does not throw when proper arguments are provided" {
+ {
+ Set-NewRelicDeployment -EnvironmentKey $EnvironmentKey -DeployUser $TestUser -Microservices $microservices
+ } | Should -Not -Throw
+ }
+
+ It "Posts An Update To Provided Microservices Which Exist In New Relic - 1" {
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Submit-DeploymentToNewRelic -Exactly -Times 1 -Scope Context -ParameterFilter { $ApplicationId -eq $SamplePositiveMicroservice1Id }
+ }
+
+ It "Posts An Update To Provided Microservices Which Exist In New Relic - 2" {
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Submit-DeploymentToNewRelic -Exactly -Times 1 -Scope Context -ParameterFilter { $ApplicationId -eq $SamplePositiveMicroservice2Id }
+ }
+
+ It "Does Not Post An Update To Provided Microservices Which Do Not Exist In New Relic" {
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Submit-DeploymentToNewRelic -Exactly -Times 0 -Scope Context -ParameterFilter { $ApplicationId -eq $SampleNegativeMicroservice1Id }
+ }
+
+ It "Reports A Warning For Provided Microservices Which Do Not Exist In New Relic - 1" {
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Submit-DeploymentToNewRelic -Exactly -Times 0 -Scope Context -ParameterFilter { $ApplicationId -eq $SampleNegativeMicroservice1Id }
+ }
+
+ It "Reports A Warning For Provided Microservices Which Do Not Exist In New Relic - 2" {
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Write-Warning -Exactly -Times 1 -Scope Context -ParameterFilter { $message.IndexOf("$SampleMicroserviceName3 was specified to be updated in New Relic") -gt -1 }
+ }
+ }
+
+ Context "When 4 microservices are provided as a parameter but some don't belong to the (alternate) environment" {
+ $microservices = @{
+ $SampleMicroserviceName1 = "1.1"
+ $SampleMicroserviceName2 = "1.1"
+ $SampleMicroserviceName3 = "1.1"
+ $SampleMicroserviceName4 = "1.1"
+ }
+
+ It "Does not throw when proper arguments are provided" {
+ {
+ Set-NewRelicDeployment -EnvironmentKey $AlternateEnvironmentKey -DeployUser $TestUser -microservices $microservices
+ } | Should -Not -Throw
+ }
+
+ It "Only posts to the specified environment - 1" {
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Submit-DeploymentToNewRelic -Exactly -Times 1 -Scope Context -ParameterFilter { $ApplicationId -eq $SampleNegativeMicroservice1Id }
+ }
+
+ It "Only posts to the specified environment - 2" {
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Submit-DeploymentToNewRelic -Exactly -Times 0 -Scope Context -ParameterFilter { $ApplicationId -eq $SampleNegativeMicroservice2Id }
+ }
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Installation/Public/Set-RapidFailSettings.ps1 b/Modules/Alkami.DevOps.Installation/Public/Set-RapidFailSettings.ps1
new file mode 100644
index 0000000..ae74678
--- /dev/null
+++ b/Modules/Alkami.DevOps.Installation/Public/Set-RapidFailSettings.ps1
@@ -0,0 +1,47 @@
+function Set-RapidFailSettings {
+<#
+.SYNOPSIS
+ Sets the Rapid-Fail Protection settings for all AppPools configured
+ on the current server for use with AppPool-Shutdown-Alert.exe
+#>
+
+ [CmdletBinding()]
+ Param(
+ [Parameter(Mandatory = $false)]
+ [Alias("exePath")]
+ [string]$autoShutdownExePath = "C:\Tools\AppPool-Shutdown-Alert\AppPool-Shutdown-Alert.exe"
+ )
+
+ try{
+ Add-Type -Path (Get-ChildItem -Path "C:\Windows\assembly\" -Include "Microsoft.Web.Administration.dll" -Recurse).FullName
+ $iis = New-Object Microsoft.Web.Administration.ServerManager
+
+ if(!(Test-Path $autoShutdownExePath))
+ {
+ Write-Warning("Path $autoShutdownExePath does not already exist.")
+ Write-Warning("Verify that AppPool-Shutdown-Alert has been installed with chocolatey or this alert will not work. It will create the path above.")
+ Write-Warning("Run 'choco install AppPool-Shutdown-Alert -y' on the target server to install it.")
+ Write-Warning("Application Pool settings will still be changed.`n`n")
+ }
+
+ foreach ($applicationPool in $iis.ApplicationPools)
+ {
+ Write-Host("Current Application Pool: $($applicationPool.Name)")
+ Write-Host("AutoShutdownExe is set to: $($applicationPool.Failure.AutoShutdownExe)")
+ Write-Host("Setting AutoShutdownExe to: $autoShutdownExePath")
+ Write-Host("Setting AutoShutdownParams to: $($applicationPool.Name)`n")
+
+ $applicationPool.Failure.AutoShutdownExe = $autoShutdownExePath
+ $applicationPool.Failure.AutoShutdownParams = $applicationPool.Name
+ }
+ }
+ catch
+ {
+ Write-Error("An error occurred while trying to set Application Pool settings:")
+ Write-Error $_
+ }
+
+ $iis.CommitChanges()
+
+ Write-Host("Application Pool changes committed successfully.`n")
+}
diff --git a/Modules/Alkami.DevOps.Installation/Public/Set-ServiceAccountValue.ps1 b/Modules/Alkami.DevOps.Installation/Public/Set-ServiceAccountValue.ps1
new file mode 100644
index 0000000..53e9512
--- /dev/null
+++ b/Modules/Alkami.DevOps.Installation/Public/Set-ServiceAccountValue.ps1
@@ -0,0 +1,50 @@
+function Set-ServiceAccountValue {
+<#
+.SYNOPSIS
+ Sets a value to the Service Account.
+#>
+
+ [CmdletBinding()]
+ Param(
+ [Alkami.Ops.SecretServer.Model.User]$serviceAccount
+ )
+
+ $logLead = (Get-LogLeadName);
+
+ foreach ($application in $appTierApplications) {
+ if ($serviceAccount.SecretName -like ("*{0}*" -f $application.Name)) {
+ if ($serviceAccount.UserName.EndsWith("$")) {
+ Write-Output ("$logLead : Setting application {0} to install with GMSA user {1}" -f $application.Name, $serviceAccount.UserName)
+ $application.IsGMSAAccount = $true
+ $application.Password = "GMSA"
+ }
+ else {
+ Write-Output ("$logLead : Setting application {0} to install with user {1}" -f $application.Name, $serviceAccount.UserName)
+ $application.IsGMSAAccount = $false
+ $application.Password = $serviceAccount.Password
+ }
+
+ $application.User = $serviceAccount.UserName
+ continue
+ }
+ }
+
+ foreach ($appTierService in (Get-AppTierServices)) {
+ if ($serviceAccount.SecretName -like ("*{0}*" -f $appTierService.FriendlyName)) {
+ if ($serviceAccount.UserName.EndsWith("$")) {
+ Write-Output ("$logLead : Setting Windows Service {0} to install with GMSA user {1}" -f $appTierService.FriendlyName, $serviceAccount.UserName)
+ $appTierService.IsGMSAAccount = $true
+ $appTierService.Password = "GMSA"
+ }
+ else {
+ Write-Output ("$logLead : Setting application {0} to install with user {1}" -f $appTierService.FriendlyName, $serviceAccount.UserName)
+ $appTierService.IsGMSAAccount = $false
+ $appTierService.Password = $serviceAccount.Password
+ }
+
+ $appTierService.User = $serviceAccount.UserName
+ continue
+ }
+ }
+}
+
diff --git a/Modules/Alkami.DevOps.Installation/Public/Submit-DeploymentToNewRelic.ps1 b/Modules/Alkami.DevOps.Installation/Public/Submit-DeploymentToNewRelic.ps1
new file mode 100644
index 0000000..17fa499
--- /dev/null
+++ b/Modules/Alkami.DevOps.Installation/Public/Submit-DeploymentToNewRelic.ps1
@@ -0,0 +1,65 @@
+function Submit-DeploymentToNewRelic {
+<#
+.SYNOPSIS
+ Posts a Deployment to a New Relic application via the API
+
+.PARAMETER ApiKey
+ New Relic Api Key
+
+.PARAMETER ApplicationId
+ New Relic supplied Application Id
+
+.PARAMETER AppVersion
+ Version of the application being updated.
+
+.PARAMETER DeployUser
+ Name of person doing the deploy
+
+.PARAMETER EnvironmentKey
+ Name of the Environment being deployed to
+#>
+ [CmdletBinding()]
+ Param(
+ [Parameter(Mandatory = $true)]
+ [string] $ApiKey,
+ [Parameter(Mandatory = $true)]
+ [string] $ApplicationId,
+ [Parameter(Mandatory = $true)]
+ [string] $AppVersion,
+ [Parameter(Mandatory = $true)]
+ [string] $DeployUser,
+ [Parameter(Mandatory = $true)]
+ [string] $EnvironmentKey
+ )
+
+ $postParams = @{
+ "deployment[revision]" = $appVersion;
+ "deployment[description]" = "Deployment of $($appVersion) to $($environmentKey)";
+ "deployment[changelog]" = "Update to $($appVersion)";
+ "deployment[user]" = $deployUser
+ }
+
+ $postUri = ("https://api.newrelic.com/v2/applications/{0}/deployments.xml" -f $applicationId)
+ Write-Verbose ("$logLead : Posting Deployment Version {0} to URI {1}" -f $appVersion, $postUri)
+
+ try {
+ $postResponse = Invoke-WebRequest -UseBasicParsing -Uri $postUri -Method Post -Headers @{"X-Api-Key" = $apiKey } -Body $postParams
+
+ if ($postResponse.StatusCode -notmatch "20\d") {
+ Write-Host "$logLead : Posting to Newrelic returned a status code other than 20x for Uri PostUrl=$postUri StatusCode=$($postResponse.StatusCode) StatusDescription=$($postResponse.StatusDescription)"
+ return $false
+ }
+ } catch [System.Net.WebException] {
+ $errorMessage = $_.Exception.Message
+ $contentResponse = (New-Object System.IO.StreamReader($_.Exception.Response.GetResponseStream())).ReadToEnd()
+ Write-Warning "$logLead : Could not complete deployment to NewRelic. Error message: [$errorMessage]. Content of response was [$contentResponse]"
+ return $false
+ } catch {
+ $errorMessage = $_.Exception.Message
+ Write-Warning "$logLead : Could not complete deployment to NewRelic. Error message: [$errorMessage]"
+
+ return $false
+ }
+
+ return $true
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Installation/Public/Uninstall-NewRelicDotNetAgent.ps1 b/Modules/Alkami.DevOps.Installation/Public/Uninstall-NewRelicDotNetAgent.ps1
new file mode 100644
index 0000000..974f2ed
--- /dev/null
+++ b/Modules/Alkami.DevOps.Installation/Public/Uninstall-NewRelicDotNetAgent.ps1
@@ -0,0 +1,19 @@
+function Uninstall-NewRelicDotNetAgent {
+ <#
+.SYNOPSIS
+ Uninstalls the New Relic .NET Agent
+#>
+
+ [CmdletBinding()]
+ Param()
+
+ $logLead = Get-LogLeadName
+ $newRelicDotnetAgentPackageName = "newrelic-dotnet"
+
+ # This uninstall command skips the uninstall.ps1 powershell file included in the package
+ # It instead runs the auto-installer with the added arguements to display no UI, thus requiring no user interaction
+ $command = "choco uninstall $newRelicDotnetAgentPackageName -yf --skip-scripts --uninstall-arguments=''/qn''"
+ Write-Host "$loglead : Uninstalling : $newRelicDotnetAgentPackageName"
+ Write-Host "$loglead : Uninstall Command: $command"
+ Invoke-Expression -Command $command
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Installation/Public/Update-Borg.Tests.ps1 b/Modules/Alkami.DevOps.Installation/Public/Update-Borg.Tests.ps1
new file mode 100644
index 0000000..1cb46e5
--- /dev/null
+++ b/Modules/Alkami.DevOps.Installation/Public/Update-Borg.Tests.ps1
@@ -0,0 +1,60 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ""
+$currentErrorActionPreference = $ErrorActionPreference
+$ErrorActionPreference = "stop"
+
+Describe "Update-Borg" {
+ Mock -CommandName Write-Host -MockWith {}
+ Mock -CommandName Write-Error -MockWith {}
+
+ $packageData = New-PackageMetadataObject
+ $packageData.DisableMicroserviceNewRelic = $false
+ $appPackage1 = New-DummyPackageInstallationData -PackageName "app.package1" -PackageVersion "1.0.0" -IsFullScale
+ $appPackage2 = New-DummyPackageInstallationData -PackageName "app.package2" -PackageVersion "1.0.0" -IsFullScale
+
+ $packageArray = @($appPackage1, $appPackage2)
+
+ $packageData.AppPackagesToInstall = $packageArray
+ $packageData.EnvironmentLabel = "UnitTest"
+
+ Mock -CommandName Update-PlatformElementInventory -MockWith {
+ # No third case necessary; This function doesn't return. Just does its thing silently.
+ write-host "params: $borgUri, $ApiKey"
+
+ if ($BorgUri -like "*Fake*"){
+ throw "Got a fake URI"
+ } elseif ($ApiKey -like "*Fake*"){
+ throw "Got a fake API key"
+ }
+ }
+
+ Context "When Given Invalid Borg Connection Info" {
+ It "Writes an Error on a bad URI" {
+ { Update-Borg -BorgApiKey "ValidKey" -BorgUri "FakeUri" -PackageMetadata $packageData } | Should -Not -Throw
+ }
+
+ It "Writes an Error given an empty URI"{
+ Update-Borg -BorgApiKey "ValidKey" -BorgUri "" -PackageMetadata $packageData
+
+ Assert-MockCalled -CommandName Write-Error -Scope Context -Times 1
+ }
+
+ It "Writes an Error on a bad API Key"{
+ { Update-Borg -BorgApiKey "FakeKey" -BorgUri "ValidUri" -PackageMetadata $packageData } | Should -Not -Throw
+
+ }
+ }
+
+ Context "When Given Valid Info"{
+ It "Updates Borg" {
+ Update-Borg -BorgApiKey "ValidKey" -BorgUri "ValidUri" -PackageMetadata $packageData
+
+ Assert-MockCalled -CommandName Write-Host -Times 1 -ParameterFilter {$object -eq "BoRG successfully updated."}
+ }
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Installation/Public/Update-Borg.ps1 b/Modules/Alkami.DevOps.Installation/Public/Update-Borg.ps1
new file mode 100644
index 0000000..1e70ab7
--- /dev/null
+++ b/Modules/Alkami.DevOps.Installation/Public/Update-Borg.ps1
@@ -0,0 +1,46 @@
+function Update-Borg{
+<#
+ .SYNOPSIS
+ Update Borg with information about packages being installed. Utilized by Classify-Packages.
+
+ .PARAMETER BorgApiKey
+ API key for borg
+ .PARAMETER BorgUri
+ URI of the Borg API
+ .PARAMETER PackageMetadata
+ Object containing package information to be sent to Borg.
+
+#>
+ [CmdletBinding()]
+ param(
+ [string]$BorgApiKey,
+ [string]$BorgUri,
+ [PSObject]$PackageMetadata
+ )
+ $borgElementsToAdd = Select-UniqueServerPackages @($PackageMetadata.WebPackagesToInstall, $PackageMetadata.AppPackagesToInstall, $PackageMetadata.MicPackagesToInstall)
+ $borgElementsToRemove = Select-UniqueServerPackages @($PackageMetadata.WebPackagesToUninstall, $PackageMetadata.AppPackagesToUninstall, $PackageMetadata.MicPackagesToUninstall)
+
+ try {
+ if (![string]::IsNullOrWhiteSpace($BorgUri)) {
+ Write-Host "Updating BoRG [$BorgUri] Inventory"
+
+ $splatParams = @{
+ ElementsToAdd = $borgElementsToAdd
+ ElementsToRemove = $borgElementsToRemove
+ EnvironmentTypeName = $PackageMetadata.EnvironmentLabel
+ PlatformVersionName = "Unspecified"
+ ElementTierName = "Unknown"
+ BorgUri = $BorgUri
+ ApiKey = $BorgApiKey
+ }
+
+ Update-PlatformElementInventory @splatParams
+
+ Write-Host "BoRG successfully updated."
+ } else {
+ Write-Error "BorgUri is not configured! Check deployment params in TC."
+ }
+ } catch {
+ Write-Error "An exception occurred while updating BoRG. Error: $($_.Exception.Message)"
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Installation/Public/Write-PackageList.ps1 b/Modules/Alkami.DevOps.Installation/Public/Write-PackageList.ps1
new file mode 100644
index 0000000..0bbed44
--- /dev/null
+++ b/Modules/Alkami.DevOps.Installation/Public/Write-PackageList.ps1
@@ -0,0 +1,88 @@
+function Write-PackageList {
+ <#
+ .SYNOPSIS
+ Dump packages to console. Utilized by Classify-Packages.
+
+ .PARAMETER HasBadPackages
+ Bool detailing whether or not the package list includes packages that are currently installed in the wrong place.
+
+ .PARAMETER PackageMetadata
+ Packages with data to dump.
+
+ #>
+ [CmdletBinding()]
+ param(
+ $HasBadPackages,
+ $PackageMetadata
+ )
+ #region Write Packages To Uninstall
+ # Write out list of packages being removed from the wrong servers.
+ if ($HasBadPackages) {
+ Write-Host ("##teamcity[blockOpened name='Packages Being Removed From Incorrect Servers']")
+ foreach ($package in $PackageMetadata.BadWebPackagesToUninstall) {
+ Write-Host "Removing $($package.Name) from Web servers."
+ }
+ foreach ($package in $PackageMetadata.BadAppPackagesToUninstall) {
+ Write-Host "Removing $($package.Name) from App servers."
+ }
+ foreach ($package in $PackageMetadata.BadMicPackagesToUninstall) {
+ Write-Host "Removing $($package.Name) from Mic/Fab servers."
+ }
+ Write-Host ("##teamcity[blockClosed name='Packages Being Removed From Incorrect Servers']")
+ }
+ #endregion Write Packages To Uninstall
+
+ #region Write Packages To Install
+ if ($PackageMetadata.HasWebInstalls -or $PackageMetadata.HasWebUninstalls -or
+ $PackageMetadata.HasAppInstalls -or $PackageMetadata.HasAppUninstalls -or
+ $PackageMetadata.HasMicInstalls -or $PackageMetadata.HasMicUninstalls) {
+ Write-Host ("##teamcity[blockOpened name='Packages to Install']")
+
+ # Write out web packages.
+ if ($PackageMetadata.HasWebInstalls) {
+ Write-Host ("##teamcity[blockOpened name='Web Packages to Install']")
+ Write-InstallPackageMetadataToConsole -Packages $PackageMetadata.WebPackagesToInstall -isWeb
+ Write-Host ("##teamcity[blockClosed name='Web Packages to Install']")
+ }
+ if ($PackageMetadata.HasWebUninstalls) {
+ Write-Host ("##teamcity[blockOpened name='Web Packages to Uninstall']")
+ foreach ($package in $PackageMetadata.WebPackagesToUninstall) {
+ Write-Host $package.Name
+ }
+ Write-Host ("##teamcity[blockClosed name='Web Packages to Uninstall']")
+ }
+
+ # Write out app packages.
+ if ($PackageMetadata.HasAppInstalls) {
+ Write-Host ("##teamcity[blockOpened name='App Packages to Install']")
+ Write-InstallPackageMetadataToConsole -Packages $PackageMetadata.AppPackagesToInstall
+ Write-Host ("##teamcity[blockClosed name='App Packages to Install']")
+ }
+ if ($PackageMetadata.HasAppUninstalls) {
+ Write-Host ("##teamcity[blockOpened name='App Packages to Uninstall']")
+ foreach ($package in $PackageMetadata.AppPackagesToUninstall) {
+ Write-Host $package.Name
+ }
+ Write-Host ("##teamcity[blockClosed name='App Packages to Uninstall']")
+ }
+
+ # Write out mic packages.
+ if ($PackageMetadata.HasMicInstalls) {
+ Write-Host ("##teamcity[blockOpened name='Mic Packages to Install']")
+ Write-InstallPackageMetadataToConsole -Packages $PackageMetadata.MicPackagesToInstall
+ Write-Host ("##teamcity[blockClosed name='Mic Packages to Install']")
+ }
+ if ($PackageMetadata.HasMicUninstalls) {
+ Write-Host ("##teamcity[blockOpened name='Mic Packages to Uninstall']")
+ foreach ($package in $PackageMetadata.MicPackagesToUninstall) {
+ Write-Host $package.Name
+ }
+ Write-Host ("##teamcity[blockClosed name='Mic Packages to Uninstall']")
+ }
+
+ Write-Host ("##teamcity[blockClosed name='Packages to Install']")
+ } else {
+ Write-Host "No packages to install or uninstall."
+ }
+ #endregion Write Packages To Install
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Installation/tools/chocolateyInstall.ps1 b/Modules/Alkami.DevOps.Installation/tools/chocolateyInstall.ps1
new file mode 100644
index 0000000..b01306e
--- /dev/null
+++ b/Modules/Alkami.DevOps.Installation/tools/chocolateyInstall.ps1
@@ -0,0 +1,37 @@
+[CmdletBinding()]
+Param()
+process {
+ $myCurrentPath = $PSScriptRoot;
+ Write-Verbose "Installing the Module from $myCurrentPath";
+
+ $parentPath = (Split-Path $myCurrentPath);
+ $systemModulePath = "C:\Program Files\WindowsPowerShell\Modules\";
+ $myModulePath = (Join-Path $parentPath "module");
+
+ $metadata = ([Xml](Get-Content (Join-Path $parentPath "*.nuspec"))).package.metadata;
+
+ $id = $metadata.id;
+
+ $version = $metadata.version -replace '-pre.+','';
+
+ $targetModulePath = (Join-Path $systemModulePath $id);
+ $targetModuleVersionPath = (Join-Path $targetModulePath $version);
+
+ if (Test-Path $targetModulePath) {
+ ## If the target folder already existed, remove it, because we are re-installing this package, obviously
+ if (Test-Path $targetModuleVersionPath) {
+ Write-Warning "Found an already existing module at [$targetModuleVersionPath]!!"
+ Remove-Item $targetModuleVersionPath -Recurse -Force;
+ }
+
+ ## Clear previous children for name conflicts
+ (Get-ChildItem $targetModulePath) | ForEach-Object {
+ Write-Information "Removing module located at [$_]";
+ Remove-Item $_.FullName -Recurse -Force;
+ }
+ }
+
+ Write-Host "Copying module $id to [$targetModuleVersionPath]";
+ Copy-Item $myModulePath -Destination $targetModuleVersionPath -Recurse -Force;
+
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Installation/tools/chocolateyUninstall.ps1 b/Modules/Alkami.DevOps.Installation/tools/chocolateyUninstall.ps1
new file mode 100644
index 0000000..7c36766
--- /dev/null
+++ b/Modules/Alkami.DevOps.Installation/tools/chocolateyUninstall.ps1
@@ -0,0 +1,25 @@
+[CmdletBinding()]
+Param()
+process {
+ $myCurrentPath = $PSScriptRoot;
+ Write-Verbose "Uninstalling the Module from $myCurrentPath";
+
+ $parentPath = (Split-Path $myCurrentPath);
+ $systemModulePath = "C:\Program Files\WindowsPowerShell\Modules\";
+ $myModulePath = (Join-Path $parentPath "module");
+
+ $metadata = ([Xml](Get-Content (Join-Path $parentPath "*.nuspec"))).package.metadata;
+
+ $id = $metadata.id;
+
+ $version = $metadata.version -replace '-pre.+','';
+
+ $targetModulePath = (Join-Path $systemModulePath $id);
+ $targetModuleVersionPath = (Join-Path $targetModulePath $version);
+
+ if (Test-Path $targetModuleVersionPath) {
+ Write-Information "Removing module at [$targetModuleVersionPath]!!"
+ Remove-Item $targetModuleVersionPath -Recurse -Force;
+ }
+}
+
diff --git a/Modules/Alkami.DevOps.Inventory/Alkami.DevOps.Inventory.nuspec b/Modules/Alkami.DevOps.Inventory/Alkami.DevOps.Inventory.nuspec
new file mode 100644
index 0000000..2dbcab2
--- /dev/null
+++ b/Modules/Alkami.DevOps.Inventory/Alkami.DevOps.Inventory.nuspec
@@ -0,0 +1,34 @@
+
+
+
+ Alkami.DevOps.Inventory
+ $version$
+ Alkami Platform Modules - DevOps - Inventory
+ Alkami Technologies
+ Alkami Technologies
+ https://extranet.alkamitech.com/display/SRE/Alkami.DevOps.Inventory
+ https://www.alkami.com/files/alkamilogo75x75.png
+ http://alkami.com/files/orblicense.html
+ false
+ Installs the DevOps Inventory module for use with PowerShell.
+
+ PowerShell
+ Copyright (c) 2018 Alkami Technologies
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Modules/Alkami.DevOps.Inventory/Alkami.DevOps.Inventory.psd1 b/Modules/Alkami.DevOps.Inventory/Alkami.DevOps.Inventory.psd1
new file mode 100644
index 0000000..46b04b5
--- /dev/null
+++ b/Modules/Alkami.DevOps.Inventory/Alkami.DevOps.Inventory.psd1
@@ -0,0 +1,19 @@
+@{
+ RootModule = 'Alkami.DevOps.Inventory.psm1'
+ ModuleVersion = '1.7.1'
+ GUID = '03fd4b8d-43a9-42a3-bf94-783fb89fae76'
+ Author = 'SRE,dsage'
+ CompanyName = 'Alkami Technologies, Inc.'
+ Copyright = '(c) 2018 Alkami Technologies, Inc.. All rights reserved.'
+ Description = 'A set of cmdlets used to pull machine details'
+ RequiredModules = 'Alkami.PowerShell.Common','Alkami.PowerShell.IIS','Alkami.PowerShell.Configuration','Alkami.DevOps.Common','Alkami.PowerShell.AD','Alkami.PowerShell.Services','Alkami.PowerShell.Choco'
+ FunctionsToExport = 'Get-AlkamiInstallationDriveInventory','Get-ApplicationInventory','Get-AppSettingsInventory','Get-AppSettingsInventoryConfigPaths','Get-CertificateInventory','Get-ChocolateyInventory','Get-ComputerUptime','Get-ConnectionStringInventory','Get-DotNetTempFilesCreationTime','Get-EnvironmentalVariables','Get-FileSystemInventory','Get-IISInventory','Get-IISResetHistory','Get-LocalSecurityPolicyInventory','Get-MachineInventory','Get-MemoryInventory','Get-ModuleInventory','Get-OrbInventory','Get-PlatformElementInventory','Get-PlatformInventory','Get-ProcessorInventory','Get-RestartHistory','Get-ServiceFabricInventory','Get-SystemWebSettingsInventory','Get-TimeConfiguration','Get-WindowsFeatureInventory','Get-WindowsServiceInventory','New-PlatformElementDetailPSObject','New-PlatformElementDetails','Remove-PlatformElementDetailById','Remove-PlatformElementDetails','Save-MachineManifest','Send-DeploymentManifestToS3','Sync-PlatformElementInventory','Update-PlatformElementInventory'
+ PrivateData = @{
+ PSData = @{
+ Tags = @('powershell', 'module', 'inventory', 'sre')
+ ProjectUri = 'https://extranet.alkamitech.com/display/SRE/Alkami.DevOps.Inventory'
+ IconUri = 'https://www.alkami.com/files/alkamilogo75x75.png'
+ }
+ }
+ HelpInfoURI = 'https://extranet.alkamitech.com/display/SRE/Alkami.DevOps.Inventory'
+}
diff --git a/Modules/Alkami.DevOps.Inventory/Alkami.DevOps.Inventory.pssproj b/Modules/Alkami.DevOps.Inventory/Alkami.DevOps.Inventory.pssproj
new file mode 100644
index 0000000..ffdfea3
--- /dev/null
+++ b/Modules/Alkami.DevOps.Inventory/Alkami.DevOps.Inventory.pssproj
@@ -0,0 +1,82 @@
+
+
+ Debug
+ 2.0
+ {54d03dc1-d279-4278-842e-db0500e33196}
+ Exe
+ MyApplication
+ MyApplication
+ Alkami.DevOps.Inventory
+
+
+ true
+ full
+ false
+ bin\Debug\
+ DEBUG;TRACE
+ prompt
+ 4
+
+
+ pdbonly
+ true
+ bin\Release\
+ TRACE
+ prompt
+ 4
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Inventory/AlkamiManifest.xml b/Modules/Alkami.DevOps.Inventory/AlkamiManifest.xml
new file mode 100644
index 0000000..af4f5f6
--- /dev/null
+++ b/Modules/Alkami.DevOps.Inventory/AlkamiManifest.xml
@@ -0,0 +1,12 @@
+
+
+ 1.0
+
+ Alkami
+ Alkami.DevOps.Inventory
+ SREModule
+
+
+ Production
+
+
diff --git a/Modules/Alkami.DevOps.Inventory/Private/Add-CertificatesToInventoryDictionary.ps1 b/Modules/Alkami.DevOps.Inventory/Private/Add-CertificatesToInventoryDictionary.ps1
new file mode 100644
index 0000000..0640a7f
--- /dev/null
+++ b/Modules/Alkami.DevOps.Inventory/Private/Add-CertificatesToInventoryDictionary.ps1
@@ -0,0 +1,32 @@
+function Add-CertificatesToInventoryDictionary {
+<#
+.SYNOPSIS
+ Adds Certificates to the Inventory Dictionary.
+#>
+
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory = $true)]
+ [Object[]]$certificates
+ )
+
+ $logLead = (Get-LogLeadName);
+ $dictionary = New-Object System.Collections.Specialized.OrderedDictionary
+
+ foreach ($cert in $certificates | Group-Object {$_.Thumbprint}) {
+
+ $uniqueCert = $cert.Group | Select-Object -First 1
+ $certPropertyHash = Get-CertificatePropertyHash $uniqueCert
+
+ if ($cert.Count -gt 1) {
+
+ $thumbprint = $uniqueCert.Thumbprint
+ Write-Warning "$logLead : Certificate With Thumbprint $thumbprint Has a Duplicate Record"
+ $certPropertyHash.Add("HasDuplicate", $true)
+ }
+
+ $dictionary.Add($uniqueCert.Thumbprint.ToString(), $certPropertyHash)
+ }
+
+ return $dictionary
+}
diff --git a/Modules/Alkami.DevOps.Inventory/Private/Get-CertificatePropertyHash.ps1 b/Modules/Alkami.DevOps.Inventory/Private/Get-CertificatePropertyHash.ps1
new file mode 100644
index 0000000..987d1db
--- /dev/null
+++ b/Modules/Alkami.DevOps.Inventory/Private/Get-CertificatePropertyHash.ps1
@@ -0,0 +1,49 @@
+function Get-CertificatePropertyHash {
+<#
+.SYNOPSIS
+ Fetches a Certificate's Property Hash.
+#>
+
+ [CmdletBinding()]
+ [OutputType([System.Collections.Hashtable])]
+ param(
+ [Parameter(Mandatory = $true)]
+ [System.Security.Cryptography.X509Certificates.X509Certificate2]$certificate
+ )
+
+ $logLead = (Get-LogLeadName);
+ $currentDate = (Get-Date);
+ $certIsExpired = ($certificate.NotAfter -gt $currentDate)
+
+ $usersWithPermsArray = $null
+ if ($certificate.HasPrivateKey) {
+
+ Write-Verbose "$logLead : Searching for Private Key Details for Certificate $($certificate.Thumbprint)"
+ $ACLs = (Get-PrivateKeyPermissions $certificate)
+ $usersWithPermsArray = ($ACLs | Select-Object -ExpandProperty IdentityReference)
+
+ $usersWithPerms = New-Object System.Collections.Specialized.OrderedDictionary
+
+ for ($i = 0; $i -lt $usersWithPermsArray.Count; $i++) {
+
+ $usersWithPerms.Add($i.ToString(), $usersWithPermsArray[$i])
+ }
+ }
+
+ return @{
+
+ SubjectName = $certificate.SubjectName.Name;
+ DnsNameList = $certificate.DnsNameList.Unicode;
+ Subject = $certificate.Subject;
+ Issuer = $certificate.Issuer;
+ FriendlyName = $certificate.FriendlyName;
+ HasPrivateKey = $certificate.HasPrivateKey;
+ NotBefore = $certificate.NotBefore;
+ NotAfter = $certificate.NotAfter;
+ SerialNumber = $certificate.SerialNumber;
+ Thumbprint = $certificate.Thumbprint;
+ IsExpired = $certIsExpired;
+ IsCurrent = ($certificate.NotBefore -lt $currentDate -and !$certIsExpired );
+ UsersWithPrivateKeyRights = $usersWithPerms;
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Inventory/Private/VariableDeclarations.ps1 b/Modules/Alkami.DevOps.Inventory/Private/VariableDeclarations.ps1
new file mode 100644
index 0000000..a671e92
--- /dev/null
+++ b/Modules/Alkami.DevOps.Inventory/Private/VariableDeclarations.ps1
@@ -0,0 +1,41 @@
+[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')]
+param()
+
+try {
+ Add-Type -Path (Get-ChildItem -Path "C:\Windows\assembly\" -Include "Microsoft.Web.Administration.dll" -Recurse).FullName
+}
+catch {
+ try {
+ # Just in case we have IIS Express and IIS loaded (dev machines)
+ [System.Reflection.Assembly]::LoadFile("C:\Windows\system32\inetsrv\Microsoft.Web.Administration.dll")
+ } catch {
+ # Do nothing in case this is a brand new server
+ Write-Warning "[Alkami.DevOps.Inventory] : Unable to Load Assembly Microsoft.Web.Administration. Some functions may not work as expected."
+ }
+}
+
+$inventoryFilterSetOptions = @(
+ @{ FilterName = "Uptime"; FunctionName = "Get-ComputerUptime"; SectionVariable = "SystemData"; }
+ @{ FilterName = "EnvironmentVariables"; FunctionName = "Get-EnvironmentalVariables"; SectionVariable = "SystemData"; }
+ @{ FilterName = "Memory"; FunctionName = "Get-MemoryInventory"; SectionVariable = "SystemData"; }
+ @{ FilterName = "Modules"; FunctionName = "Get-ModuleInventory"; SectionVariable = "SystemData"; }
+ @{ FilterName = "Processors"; FunctionName = "Get-ProcessorInventory"; SectionVariable = "SystemData"; }
+ @{ FilterName = "RestartHistory"; FunctionName = "Get-RestartHistory"; SectionVariable = "SystemData"; }
+ @{ FilterName = "IISResetHistory"; FunctionName = "Get-IISResetHistory"; SectionVariable = "SystemData"; }
+ @{ FilterName = "DotNetTempFiles"; FunctionName = "Get-DotNetTempFilesCreationTime"; SectionVariable = "SystemData"; }
+ @{ FilterName = "Time"; FunctionName = "Get-TimeConfiguration"; SectionVariable = "SystemData"; }
+ @{ FilterName = "Services"; FunctionName = "Get-WindowsServiceInventory"; SectionVariable = "SystemData"; }
+ @{ FilterName = "Features"; FunctionName = "Get-WindowsFeatureInventory"; SectionVariable = "SystemData"; }
+ @{ FilterName = "FileSystem"; FunctionName = "Get-FileSystemInventory"; SectionVariable = "SystemData"; }
+ @{ FilterName = "Applications"; FunctionName = "Get-ApplicationInventory"; SectionVariable = "SystemData"; }
+ @{ FilterName = "OrbVersion"; FunctionName = "Get-OrbInventory"; SectionVariable = "ConfigData"; }
+ @{ FilterName = "AppSettings"; FunctionName = "Get-AppSettingsInventory"; SectionVariable = "ConfigData"; }
+ @{ FilterName = "ConnectionStrings"; FunctionName = "Get-ConnectionStringInventory"; SectionVariable = "ConfigData"; }
+ @{ FilterName = "IIS"; FunctionName = "Get-IISInventory"; SectionVariable = "ConfigData"; }
+ @{ FilterName = "Chocolatey"; FunctionName = "Get-ChocolateyInventory"; SectionVariable = "ConfigData"; }
+ @{ FilterName = "ServiceFabric"; FunctionName = "Get-ServiceFabricInventory"; SectionVariable = "ConfigData"; }
+ @{ FilterName = "Certificates"; FunctionName = "Get-CertificateInventory"; SectionVariable = "ConfigData"; }
+ @{ FilterName = "SecurityPolicy"; FunctionName = "Get-LocalSecurityPolicyInventory"; SectionVariable = "ConfigData"; }
+ @{ FilterName = "SystemWebSettings"; FunctionName = "Get-SystemWebSettingsInventory"; SectionVariable = "ConfigData"; }
+ @{ FilterName = "AlkamiInstallationDrive"; FunctionName = "Get-AlkamiInstallationDriveInventory"; SectionVariable = "ConfigData"; }
+)
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Inventory/Public/Get-AlkamiInstallationDriveInventory.ps1 b/Modules/Alkami.DevOps.Inventory/Public/Get-AlkamiInstallationDriveInventory.ps1
new file mode 100644
index 0000000..3ef5b00
--- /dev/null
+++ b/Modules/Alkami.DevOps.Inventory/Public/Get-AlkamiInstallationDriveInventory.ps1
@@ -0,0 +1,19 @@
+function Get-AlkamiInstallationDriveInventory {
+<#
+.SYNOPSIS
+ Get the drive letter (and colon) that describes where Alkami Platform software is installed and return the value in a dictionary format.
+
+.DESCRIPTION
+ Using Environment Variables, determine where the Alkami Platform software is installed.
+ If present and in the correct format, ENV:ALKAMI_INSTALLATION_DRIVE is used. Otherwise,
+ ENV:SystemDrive is used.
+
+#>
+ [CmdletBinding()]
+ [OutputType([System.Collections.Hashtable])]
+ param ()
+
+ $driveLetter = Get-AlkamiInstallationDrive
+ $returnValue = @{AlkamiInstallationDrive = $driveLetter}
+ return $returnValue
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Inventory/Public/Get-AppSettingsInventory.ps1 b/Modules/Alkami.DevOps.Inventory/Public/Get-AppSettingsInventory.ps1
new file mode 100644
index 0000000..bca396c
--- /dev/null
+++ b/Modules/Alkami.DevOps.Inventory/Public/Get-AppSettingsInventory.ps1
@@ -0,0 +1,101 @@
+function Get-AppSettingsInventory {
+<#
+.SYNOPSIS
+ Returns an OrderedDictionary that Represents the App Settings Inventory.
+#>
+
+ [CmdletBinding()]
+ Param()
+
+ $logLead = (Get-LogLeadName);
+ $providerStopWatch = [System.Diagnostics.StopWatch]::StartNew()
+
+ $appSettingsDictionary = New-Object System.Collections.Specialized.OrderedDictionary
+ $appSettingsDetails = New-Object System.Collections.Specialized.OrderedDictionary
+
+ [array]$configs = (Get-AppSettingsInventoryConfigPaths)
+
+ Write-Verbose "$logLead : Found these config paths for processing [$($configs -join ',')]"
+
+ Write-Verbose "$logLead : [$($providerStopWatch.Elapsed)] : Processing App Configs"
+
+ try {
+ foreach ($config in $configs) {
+ Write-Verbose "$logLead : Collecting inventory from $($config)"
+
+ # Filter out Choco configs which are not for the parent package
+ if ($config.DirectoryName -match "chocolatey") {
+
+ $packageName = ($config.Directory.FullName).Split("\") | Where-Object {$_.ToString().StartsWith("Alkami")} | Select-Object -First 1
+ if ($null -ne $packageName -and $config.FullName -notmatch $packageName -and $config.Name -notmatch "machine.config") {
+
+ Write-Verbose ("$logLead : [$($providerStopWatch.Elapsed)] : {0} does not match package name {1} and will be skipped" -f $config.Name, $packageName)
+ continue;
+ }
+ }
+
+ if ($config.DirectoryName -match "ORB") {
+
+ # Handle Legacy App Configs Which May Conflict on Name
+ $parentKey = ($config.DirectoryName.Split("\") | Select-Object -Last 1)
+ }
+ else {
+
+ # The machine.config and choco configs should be unique by name
+ $parentKey = $config.Name.Replace(".exe.config", "")
+ }
+
+ Write-Verbose "$logLead : found `$parentKey to be [$parentKey]"
+
+ $appSettingsDetails[$parentKey] = New-Object System.Collections.Specialized.OrderedDictionary
+ $appSettingsDetails[$parentKey]["FilePath"] = $config.FullName
+
+ [xml]$configXml = Read-XmlFile $config.FullName
+ if ($null -eq $configXml) {
+ Write-Verbose "$logLead : The Content of the Specified File [$($config.FullName)] is Null, or Could Not be Cast to XML, or could not be found"
+ $appSettingsDetails[$parentKey]["ERROR"] = "The Content of the Specified File is Null, or Could Not be Cast to XML, or could not be found"
+ } else {
+ $appSettings = $configXml.SelectSingleNode("//appSettings")
+
+ if ($null -eq $appSettings -or !$appSettings.HasChildNodes) {
+ Write-Verbose ("$logLead : [$($providerStopWatch.Elapsed)] : No AppSettings Found in {0}" -f $config.Name)
+ continue;
+ }
+
+
+ foreach ($appSetting in ($appSettings.ChildNodes | Where-Object {$_.NodeType -ne "Comment"})) {
+ try {
+ if ($appSetting.key -match "password" -or $appSetting.value -match "password") {
+ if ([string]::IsNullOrEmpty($appSetting.value)) {
+ $appSetting.Value = "EMPTY"
+ }
+ elseif ($appSetting.value -match "REPLACEME") {
+ $appSetting.Value = "UNSET"
+ }
+ else {
+ $appSetting.Value = "HIDDEN"
+ }
+ }
+
+ $appSettingsDetails[$parentKey][$appSetting.key] = $appSetting.value
+ } catch {
+ Write-Verbose "could not capture value from [$($appSetting.OuterXml)]"
+ }
+ }
+ }
+ }
+ }
+ catch {
+ Write-Verbose $_.Exception.ToString()
+ $appSettingsDetails["Error"] = $_.Exception.ToString()
+ }
+
+ Write-Verbose "$logLead : [$($providerStopWatch.Elapsed)] : Done Processing App Configs"
+
+ $appSettingsDictionary.Add("AppSettings", $appSettingsDetails)
+
+ Write-Verbose "$logLead : [$($providerStopWatch.Elapsed)] : Provider Complete"
+ $providerStopWatch.Stop()
+
+ return $appSettingsDictionary
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Inventory/Public/Get-AppSettingsInventory.tests.ps1 b/Modules/Alkami.DevOps.Inventory/Public/Get-AppSettingsInventory.tests.ps1
new file mode 100644
index 0000000..6f62ba3
--- /dev/null
+++ b/Modules/Alkami.DevOps.Inventory/Public/Get-AppSettingsInventory.tests.ps1
@@ -0,0 +1,118 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ""
+
+Describe "Get-AppSettingsInventory" {
+
+ Context "When the app setting values are good" {
+
+ ## Good file values
+ $GoodValues = @'
+
+
+
+
+
+
+
+'@
+
+ $global:TempFile = New-TemporaryFile
+
+ Set-Content -Path $global:TempFile.FullName -Value $GoodValues
+
+ Mock -ModuleName $moduleForMock -CommandName Get-AppSettingsInventoryConfigPaths {
+ return @(Get-Item $global:TempFile.FullName)
+ }
+
+
+ It "Works as expected" {
+ $a = (Get-AppSettingsInventory)
+ $a.Values.Values[1] | Should Be "value1"
+ $a.Values.Values[2] | Should Be "value2"
+ ## Keys is an ICollection, and it doesn't know how to index otherwise.
+ ## By forcing it to an array before I index it, I can read the values I expect by ID
+ ([string[]]$a.Values.Values.Keys)[1] | Should Be "key1"
+ ([string[]]$a.Values.Values.Keys)[2] | Should Be "key2"
+
+ ## There's a filename key and the two of our object
+ ([string[]]$a.Values.Values.Keys).Length | Should Be 3
+ }
+
+ Remove-Item $global:TempFile.FullName -Force
+ }
+
+ Context "When the app setting values are empty" {
+
+ ## Good file values
+ $GoodValues = @'
+
+
+
+
+
+'@
+
+ $global:TempFile = New-TemporaryFile
+
+ Set-Content -Path $global:TempFile.FullName -Value $GoodValues
+
+ Mock -ModuleName $moduleForMock -CommandName Get-AppSettingsInventoryConfigPaths {
+ return @(Get-Item $global:TempFile.FullName)
+ }
+
+
+ It "Works as expected" {
+ $a = (Get-AppSettingsInventory)
+ ## There's a filename key even when there are no KVP
+ ([string[]]$a.Values.Values.Keys).Length | Should Be 1
+ }
+
+ Remove-Item $global:TempFile.FullName -Force
+ }
+
+ Context "When the app setting values are good but have a clear entry" {
+
+ ## Good file values
+ $GoodValues = @'
+
+
+
+
+
+
+
+
+'@
+
+ $global:TempFile = New-TemporaryFile
+
+ Set-Content -Path $global:TempFile.FullName -Value $GoodValues
+
+ Mock -ModuleName $moduleForMock -CommandName Get-AppSettingsInventoryConfigPaths {
+ return @(Get-Item $global:TempFile.FullName)
+ }
+
+
+ It "Works as expected" {
+ $a = (Get-AppSettingsInventory)
+ $a.Values.Values[1] | Should Be "value1"
+ $a.Values.Values[2] | Should Be "value2"
+ ## Keys is an ICollection, and it doesn't know how to index otherwise.
+ ## By forcing it to an array before I index it, I can read the values I expect by ID
+ ([string[]]$a.Values.Values.Keys)[1] | Should Be "key1"
+ ([string[]]$a.Values.Values.Keys)[2] | Should Be "key2"
+
+ ## There's a filename key and the two of our object
+ ([string[]]$a.Values.Values.Keys).Length | Should Be 3
+ }
+
+ Remove-Item $global:TempFile.FullName -Force
+ }
+
+}
+
diff --git a/Modules/Alkami.DevOps.Inventory/Public/Get-AppSettingsInventoryConfigPaths.ps1 b/Modules/Alkami.DevOps.Inventory/Public/Get-AppSettingsInventoryConfigPaths.ps1
new file mode 100644
index 0000000..801dcb0
--- /dev/null
+++ b/Modules/Alkami.DevOps.Inventory/Public/Get-AppSettingsInventoryConfigPaths.ps1
@@ -0,0 +1,37 @@
+function Get-AppSettingsInventoryConfigPaths {
+<#
+.SYNOPSIS
+ Collects the anticipated config paths for AppSettings Inventorying
+
+.DESCRIPTION
+ Collects the existing config files from
+ * C:\ProgramData\Chocolatey\lib
+ * Get-ChildItem (Get-OrbPath)
+ * Get-ChildItem (Get-DotNetConfigPath -use64Bit:$true)
+#>
+ [CmdletBinding()]
+ Param()
+
+ $logLead = (Get-LogLeadName)
+
+ $providerStopWatch = [System.Diagnostics.StopWatch]::StartNew()
+
+ [array]$configs = @()
+ $chocoInstallPath = Get-ChocolateyInstallPath
+
+ Write-Verbose "$logLead : [$($providerStopWatch.Elapsed)] : Reading Choco App Configs"
+ $configs += Get-ChildItem "$chocoInstallPath\lib" -File -Recurse -Filter Alkami*.exe.config
+ Write-Verbose "$logLead : [$($providerStopWatch.Elapsed)] : Done Reading Choco App Configs"
+
+ Write-Verbose "$logLead : [$($providerStopWatch.Elapsed)] : Reading ORB App|Web Configs"
+ $configs += Get-ChildItem (Get-OrbPath) -File -Recurse -Filter *.config -Depth 1 `
+ | Where-Object {$_.Directory.Attributes.ToString() -notmatch "ReparsePoint" -and $_.Name -notmatch "log4net"}
+ Write-Verbose "$logLead : [$($providerStopWatch.Elapsed)] : Done Reading ORB App|Web Configs"
+
+ Write-Verbose "$logLead : [$($providerStopWatch.Elapsed)] : Reading Machine Config"
+ $configs += Get-ChildItem (Get-DotNetConfigPath) -File
+ $providerStopWatch.Stop()
+ Write-Verbose "$logLead : [$($providerStopWatch.Elapsed)] : Done Reading Machine Config"
+
+ return $configs
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Inventory/Public/Get-ApplicationInventory.ps1 b/Modules/Alkami.DevOps.Inventory/Public/Get-ApplicationInventory.ps1
new file mode 100644
index 0000000..92a2f04
--- /dev/null
+++ b/Modules/Alkami.DevOps.Inventory/Public/Get-ApplicationInventory.ps1
@@ -0,0 +1,44 @@
+function Get-ApplicationInventory {
+<#
+.SYNOPSIS
+ Returns an OrderedDictionary that Represents the Application Inventory.
+#>
+
+ [CmdletBinding()]
+ Param()
+
+ $logLead = (Get-LogLeadName);
+ $providerStopWatch = [System.Diagnostics.StopWatch]::StartNew()
+
+ $applicationDictionary = New-Object System.Collections.Specialized.OrderedDictionary
+ $applicationDetails = New-Object System.Collections.Specialized.OrderedDictionary
+
+ try {
+
+ Write-Verbose "$logLead : [$($providerStopWatch.Elapsed)] : Reading App Uninstall Details"
+ $uninstallDetails = Get-ItemProperty -Path HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*
+ Write-Verbose "$logLead : [$($providerStopWatch.Elapsed)] : App Uninstall Details Retrieved"
+
+ foreach ($app in $uninstallDetails) {
+
+ $appName = $app | Select-Object -ExpandProperty PSChildName
+ $applicationDetails[$appName] = New-Object System.Collections.Specialized.OrderedDictionary
+ $applicationDetails[$appName]["Name"] = $app.DisplayName
+ $applicationDetails[$appName]["Version"] = $app.DisplayVersion
+ $applicationDetails[$appName]["Publisher"] = $app.Publisher
+ $applicationDetails[$appName]["InstallDate"] = $app.InstallDate
+ $applicationDetails[$appName]["UninstallString"] = $app.UninstallString
+ }
+ }
+ catch {
+
+ $applicationDetails["Error"] = $_.Exception.ToString()
+ }
+
+ $applicationDictionary.Add("Software", $applicationDetails)
+
+ Write-Verbose "$logLead : [$($providerStopWatch.Elapsed)] : Provider Complete"
+ $providerStopWatch.Stop()
+
+ return $applicationDictionary
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Inventory/Public/Get-CertificateInventory.ps1 b/Modules/Alkami.DevOps.Inventory/Public/Get-CertificateInventory.ps1
new file mode 100644
index 0000000..72d0340
--- /dev/null
+++ b/Modules/Alkami.DevOps.Inventory/Public/Get-CertificateInventory.ps1
@@ -0,0 +1,53 @@
+function Get-CertificateInventory {
+<#
+.SYNOPSIS
+ Returns an OrderedDictionary that Represents the Certificate Inventory.
+#>
+
+ [CmdletBinding()]
+ Param()
+
+ $logLead = (Get-LogLeadName);
+ $providerStopWatch = [System.Diagnostics.StopWatch]::StartNew()
+
+ $certDictionary = New-Object System.Collections.Specialized.OrderedDictionary
+ $storeDictionary = New-Object System.Collections.Specialized.OrderedDictionary
+
+ try {
+
+ Write-Verbose "$logLead : [$($providerStopWatch.Elapsed)] : Getting Machine Certificates"
+ $personalCerts = Get-ChildItem -Path Cert:\LocalMachine\My
+ $iaCerts = Get-ChildItem -Path Cert:\LocalMachine\CA
+ $rootCerts = Get-ChildItem -Path Cert:\LocalMachine\Root
+ $trustedPeopleCerts = Get-ChildItem -Path Cert:\LocalMachine\TrustedPeople
+ Write-Verbose "$logLead : [$($providerStopWatch.Elapsed)] : Machine Certificates Received"
+
+ Write-Verbose "$logLead : [$($providerStopWatch.Elapsed)] : Working on Personal Certs"
+ $personalCertDictionary = Add-CertificatesToInventoryDictionary $personalCerts
+
+ Write-Verbose "$logLead : [$($providerStopWatch.Elapsed)] : Working on IA Certs"
+ $iaCertDictionary = Add-CertificatesToInventoryDictionary $iaCerts
+
+ Write-Verbose "$logLead : [$($providerStopWatch.Elapsed)] : Working on Root Certs"
+ $rootCertDictionary = Add-CertificatesToInventoryDictionary $rootCerts
+
+ Write-Verbose "$logLead : [$($providerStopWatch.Elapsed)] : Working on Trusted People Certs"
+ $trustedPeopleCertDictionary = Add-CertificatesToInventoryDictionary $trustedPeopleCerts
+
+ $storeDictionary.Add("Personal", $personalCertDictionary)
+ $storeDictionary.Add("IA", $iaCertDictionary)
+ $storeDictionary.Add("Root", $rootCertDictionary)
+ $storeDictionary.Add("TrustedPeople", $trustedPeopleCertDictionary)
+ }
+ catch {
+
+ $storeDictionary["Error"] = $_.Exception.ToString()
+ }
+
+ $certDictionary.Add("Certificates", $storeDictionary)
+
+ Write-Verbose "$logLead : [$($providerStopWatch.Elapsed)] : Provider Complete"
+ $providerStopWatch.Stop()
+
+ return $certDictionary
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Inventory/Public/Get-ChocolateyInventory.ps1 b/Modules/Alkami.DevOps.Inventory/Public/Get-ChocolateyInventory.ps1
new file mode 100644
index 0000000..5426980
--- /dev/null
+++ b/Modules/Alkami.DevOps.Inventory/Public/Get-ChocolateyInventory.ps1
@@ -0,0 +1,102 @@
+function Get-ChocolateyInventory {
+ <#
+ .SYNOPSIS
+ Get a PSObject containing categorized packages which represents the assumed list of what is installed on a machine, per BoRG
+
+ .EXAMPLE
+ Get-ChocolateyInventory
+
+ .PARAMETER WithTags
+ Switch indicating whether or not to include package tags
+ #>
+
+ [CmdletBinding()]
+ Param(
+ [Parameter(Mandatory = $false)]
+ [switch]$WithTags
+ )
+
+ $logLead = (Get-LogLeadName);
+ $providerStopWatch = [System.Diagnostics.StopWatch]::StartNew()
+
+ $chocoDictionary = New-Object System.Collections.Specialized.OrderedDictionary
+ $chocoDetails = New-Object System.Collections.Specialized.OrderedDictionary
+ $chocoPackages = New-Object System.Collections.Specialized.OrderedDictionary
+ $chocoSettings = New-Object System.Collections.Specialized.OrderedDictionary
+ $chocoSources = New-Object System.Collections.Specialized.OrderedDictionary
+ $requiredChocoPackages = New-Object System.Collections.Specialized.OrderedDictionary
+
+ Write-Verbose "$logLead : [$($providerStopWatch.Elapsed)] : Reading Choco Sources"
+ $sources = Get-ChocolateySources
+ Write-Verbose "$logLead : [$($providerStopWatch.Elapsed)] : Done Reading Choco Sources"
+
+ Write-Verbose "$logLead : [$($providerStopWatch.Elapsed)] : Reading Choco Packages"
+ $localChocos = Get-ChocoState -l -getServiceInfo
+ Set-ChocoPackageSourceFeeds $localChocos
+ if ($WithTags.IsPresent) {
+ Write-Verbose "$logLead : [$($providerStopWatch.Elapsed)] : Retrieving tags"
+ Set-ChocoPackageTags $localChocos
+ Write-Verbose "$logLead : [$($providerStopWatch.Elapsed)] : Done retrieving tags"
+ }
+ Write-Verbose "$logLead : [$($providerStopWatch.Elapsed)] : Done Reading Choco Packages"
+
+ Write-Verbose "$logLead : [$($providerStopWatch.Elapsed)] : Reading Miscellaneous Choco Info"
+ $chocoVersion = choco --version -r
+ $chocoFeatures = choco features -r
+ Write-Verbose "$logLead : [$($providerStopWatch.Elapsed)] : Done Reading Miscellaneous Choco Info"
+
+ # Handle Version
+ $chocoDictionary["ChocolateyVersion"] = $chocoVersion
+
+ # Handle Features
+ foreach ($feature in $chocoFeatures) {
+
+ $featureSplit = $feature.Split("|")
+ $chocoSettings.Add($featureSplit[0], $featureSplit[1])
+ }
+
+ # Handle Sources
+ foreach ($source in $sources) {
+
+ $chocoSources.Add($source.Name, ($source | Select-Object Source, IsDefault, Priority, Disabled, IsSdk))
+ }
+
+ $chocoInstallPath = Get-ChocolateyInstallPath
+ $chocoFolder = Join-Path $chocoInstallPath "lib"
+ # Handle Packages
+ foreach ($package in $localChocos) {
+ # I dislike putting this here, but I don't think it's worth extracting. Maybe.
+ $packageFolder = Join-Path $chocoFolder "$($package.Name)"
+ $nuspecPath = Join-Path $packageFolder "$($package.Name).nuspec"
+ [xml]$nuspecContent = Read-XMLFile -xmlPath $nuspecPath
+ $nuspecVersion = $nuspecContent.Package.Metadata.Version
+
+ $packageResult = @{
+ Name = $package.Name
+ IsSDK = $package.Feed.IsSDK;
+ IsService = $package.IsService;
+ SourceName = $package.Feed.Name;
+ SourceUrl = $package.Feed.Source;
+ StartMode = $package.StartMode;
+ Tags = $package.Tags;
+ Version = $package.Version;
+ NuSpecVersion = $nuspecVersion;
+ }
+
+ $chocoPackages.Add($package.Name, (New-Object PSObject -Property $packageResult))
+ }
+
+ # This property is legacy from SRE-13518 and can probably go if you're doing future work
+ # We left it in case someone does $x.requiredChocoPackages.Name or somesuch, so it's not _$null_
+ $chocoDetails.Add("requiredChocoPackages", $requiredChocoPackages)
+ $chocoDetails.Add("Packages", $chocoPackages)
+ $chocoDetails.Add("Settings", $chocoSettings)
+ $chocoDetails.Add("Sources", $chocoSources)
+
+ $chocoDictionary.Add("Chocolatey", $chocoDetails)
+
+ Write-Verbose "$logLead : [$($providerStopWatch.Elapsed)] : Provider Complete"
+ $providerStopWatch.Stop()
+
+ return $chocoDictionary
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Inventory/Public/Get-ComputerUptime.ps1 b/Modules/Alkami.DevOps.Inventory/Public/Get-ComputerUptime.ps1
new file mode 100644
index 0000000..7d7d753
--- /dev/null
+++ b/Modules/Alkami.DevOps.Inventory/Public/Get-ComputerUptime.ps1
@@ -0,0 +1,42 @@
+function Get-ComputerUptime {
+<#
+.SYNOPSIS
+ Returns an OrderedDictionary that Represents the Computer Up Time.
+#>
+
+ [CmdletBinding()]
+ Param()
+
+ $logLead = (Get-LogLeadName);
+ $providerStopWatch = [System.Diagnostics.StopWatch]::StartNew()
+
+ $uptimeDictionary = New-Object System.Collections.Specialized.OrderedDictionary
+ $uptimeDetails = New-Object System.Collections.Specialized.OrderedDictionary
+
+ try {
+
+ Write-Verbose "$logLead : [$($providerStopWatch.Elapsed)] : Getting Computer Uptime"
+ $uptimeSeconds = Get-CIMInstance -Namespace "root\CIMV2" -Class Win32_PerfFormattedData_PerfOS_System -Property SystemUpTime | Select-Object -ExpandProperty SystemUpTime
+ Write-Verbose "$logLead : [$($providerStopWatch.Elapsed)] : Computer Uptime Retrieved"
+
+ $uptimeTimeSpan = New-TimeSpan -Seconds $uptimeSeconds
+ $bootTime = (Get-Date).AddSeconds(-1 * $uptimeSeconds)
+
+ $uptimeDetails["UptimeString"] = ("{0:00}d {1:00}h {2:00}m {3:00}s" -f $uptimeTimeSpan.Days,
+ $uptimeTimeSpan.Hours, $uptimeTimeSpan.Minutes, $uptimeTimeSpan.Seconds);
+ $uptimeDetails["UptimeSeconds"] = $uptimeSeconds;
+ $uptimeDetails["LastBootTime"] = ("{0} {1}" -f $bootTime.ToShortDateString(), $bootTime.ToLongTimeString());
+ $uptimeDetails["LastBootTimeTicks"] = $bootTime.Ticks
+ }
+ catch {
+
+ $uptimeDetails["Error"] = $_.Exception.ToString()
+ }
+
+ $uptimeDictionary.Add("Uptime", $uptimeDetails)
+
+ Write-Verbose "$logLead : [$($providerStopWatch.Elapsed)] : Provider Complete"
+ $providerStopWatch.Stop()
+
+ return $uptimeDictionary
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Inventory/Public/Get-ConnectionStringInventory.ps1 b/Modules/Alkami.DevOps.Inventory/Public/Get-ConnectionStringInventory.ps1
new file mode 100644
index 0000000..2b93f09
--- /dev/null
+++ b/Modules/Alkami.DevOps.Inventory/Public/Get-ConnectionStringInventory.ps1
@@ -0,0 +1,61 @@
+function Get-ConnectionStringInventory {
+<#
+.SYNOPSIS
+ Returns an OrderedDictionary that Represents the Connection String Inventory.
+#>
+
+ [CmdletBinding()]
+ Param()
+
+ $logLead = (Get-LogLeadName);
+ $providerStopWatch = [System.Diagnostics.StopWatch]::StartNew()
+
+ $connectionStringDictionary = New-Object System.Collections.Specialized.OrderedDictionary
+ $connectionStringDetails = New-Object System.Collections.Specialized.OrderedDictionary
+
+ Write-Verbose "$logLead : [$($providerStopWatch.Elapsed)] : Getting Machine Config Data"
+
+ [xml]$machineConfigRaw = Read-MachineConfig
+ $connectionStrings = $machineConfigRaw.SelectSingleNode("//connectionStrings")
+
+ Write-Verbose "$logLead : [$($providerStopWatch.Elapsed)] : Machine Config Data Retrieved"
+
+ try {
+
+ foreach ($connectionString in $connectionStrings.ChildNodes) {
+
+ try {
+
+ $conn = New-Object System.Data.SqlClient.SqlConnectionStringBuilder($connectionString.connectionString) -ErrorAction SilentlyContinue
+ }
+ catch {
+
+ # Non-SQL Connection Strings, like Redis, will error here. And that's OK
+ Write-Host "$logLead : Non-SQL Connection Strings, like Redis, will error here. And that's OK."
+ }
+
+ if ($null -ne $conn -and !([String]::IsNullOrEmpty($conn.Password))) {
+
+ $cleansedConnectionString = $conn.ToString().Replace($conn.Password, "HIDDEN")
+ }
+ else {
+
+ $cleansedConnectionString = ($connectionString.connectionString -replace "password\=[^;]+", "password=HIDDEN")
+ }
+
+ $connectionStringDetails[$connectionString.name] = New-Object System.Collections.Specialized.OrderedDictionary
+ $connectionStringDetails[$connectionString.name]["ConnectionString"] = $cleansedConnectionString
+ }
+ }
+ catch {
+
+ $connectionStringDetails["Error"] = $_.Exception.ToString()
+ }
+
+ $connectionStringDictionary.Add("ConnectionStrings", $connectionStringDetails)
+
+ Write-Verbose "$logLead : [$($providerStopWatch.Elapsed)] : Provider Complete"
+ $providerStopWatch.Stop()
+
+ return $connectionStringDictionary
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Inventory/Public/Get-DotNetTempFilesCreationTime.ps1 b/Modules/Alkami.DevOps.Inventory/Public/Get-DotNetTempFilesCreationTime.ps1
new file mode 100644
index 0000000..fd9d85c
--- /dev/null
+++ b/Modules/Alkami.DevOps.Inventory/Public/Get-DotNetTempFilesCreationTime.ps1
@@ -0,0 +1,44 @@
+function Get-DotNetTempFilesCreationTime {
+<#
+.SYNOPSIS
+ Gets the first time a .NET Temporary File was created
+#>
+ [CmdletBinding()]
+ Param(
+ [Parameter(Mandatory = $false)]
+ [Alias("AppPoolName")]
+ [string]$Name = ""
+ )
+ $logLead = (Get-LogLeadName);
+ $providerStopWatch = [System.Diagnostics.StopWatch]::StartNew()
+
+
+ $DotNetTempFilesCreationTimeDictonary = New-Object System.Collections.Specialized.OrderedDictionary
+ $DotNetTempFilesCreationTimeDetails = New-Object System.Collections.Specialized.OrderedDictionary
+
+ try {
+
+ Write-Verbose "$logLead : [$($providerStopWatch.Elapsed)] : Getting ASP.NET Temporary files"
+ $targetItems = Get-ChildItem -recurse "C:\Windows\Microsoft.NET\Framework*\v*\Temporary ASP.NET Files\$Name*";
+ Write-Verbose "$logLead : [$($providerStopWatch.Elapsed)] : Retrived ASP.NET Temporary files"
+
+ $LastWriteTimes = @()
+ foreach ($targetItem in $targetItems) {
+ Write-Verbose ("$logLead : Extracting Create time {0} for file {1}" -f $targetItem.creationTime, $targetItem.Name);
+ $LastWriteTimes += $targetItem.creationTime
+ }
+
+ $DotNetTempFilesCreationTimeDetails["Date"] = ($LastWriteTimes | sort-object)[0]
+
+ } catch {
+
+ $DotNetTempFilesCreationTimeDetails["Error"] = $_.Exception.ToString()
+ }
+
+ $DotNetTempFilesCreationTimeDictonary.Add("DotNetTemps", $DotNetTempFilesCreationTimeDetails)
+
+ Write-Verbose "$logLead : [$($providerStopWatch.Elapsed)] : Provider Complete"
+ $providerStopWatch.Stop()
+
+ return $DotNetTempFilesCreationTimeDictonary
+}
diff --git a/Modules/Alkami.DevOps.Inventory/Public/Get-EnvironmentalVariables.ps1 b/Modules/Alkami.DevOps.Inventory/Public/Get-EnvironmentalVariables.ps1
new file mode 100644
index 0000000..5ef06e7
--- /dev/null
+++ b/Modules/Alkami.DevOps.Inventory/Public/Get-EnvironmentalVariables.ps1
@@ -0,0 +1,36 @@
+function Get-EnvironmentalVariables {
+<#
+.SYNOPSIS
+ Returns an OrderedDictionary that Represents the Environmental Variables.
+#>
+
+ [CmdletBinding()]
+ Param()
+
+ $logLead = (Get-LogLeadName);
+ $providerStopWatch = [System.Diagnostics.StopWatch]::StartNew()
+
+ $environmentVariablesDictionary = New-Object System.Collections.Specialized.OrderedDictionary
+ $environmentVariableDetails = New-Object System.Collections.Specialized.OrderedDictionary
+
+ Write-Verbose "$logLead : [$($providerStopWatch.Elapsed)] : Getting Environmental Variables"
+
+ try {
+
+ Get-ChildItem ENV: | ForEach-Object {
+
+ $environmentVariableDetails.Add($_.Name, $_.Value)
+ }
+ }
+ catch {
+
+ $environmentVariableDetails["Error"] = $_.Exception.Message
+ }
+
+ $environmentVariablesDictionary.Add("EnvironmentalVariables", $environmentVariableDetails)
+
+ Write-Verbose "$logLead : [$($providerStopWatch.Elapsed)] : Provider Complete"
+ $providerStopWatch.Stop()
+
+ return $environmentVariablesDictionary
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Inventory/Public/Get-FileSystemInventory.ps1 b/Modules/Alkami.DevOps.Inventory/Public/Get-FileSystemInventory.ps1
new file mode 100644
index 0000000..c1d5e8b
--- /dev/null
+++ b/Modules/Alkami.DevOps.Inventory/Public/Get-FileSystemInventory.ps1
@@ -0,0 +1,51 @@
+function Get-FileSystemInventory {
+<#
+.SYNOPSIS
+ Returns an OrderedDictionary that Represents the FileSystem Inventory.
+#>
+
+ [CmdletBinding()]
+ Param()
+
+ $logLead = (Get-LogLeadName);
+ $providerStopWatch = [System.Diagnostics.StopWatch]::StartNew()
+
+ $fileSystemDictionary = New-Object System.Collections.Specialized.OrderedDictionary
+ $fileSystemDetails = New-Object System.Collections.Specialized.OrderedDictionary
+
+ Set-Variable megaByteDivisor -Option Constant -Value 1048576
+
+ try {
+
+ Write-Verbose "$logLead : [$($providerStopWatch.Elapsed)] : Getting File System Details"
+ $diskInfo = Get-CIMInstance -Namespace "root\CIMV2" -Class Win32_LogicalDisk -Property DeviceID, Name, FileSystem, VolumeName, Size, FreeSpace
+ Write-Verbose "$logLead : [$($providerStopWatch.Elapsed)] : File System Details Retrieved"
+
+ foreach ($disk in $diskInfo) {
+ $deviceId = $disk.DeviceID
+ $fileSystemDetails[$deviceId] = New-Object System.Collections.Specialized.OrderedDictionary
+ $fileSystemDetails[$deviceId]['Mount'] = $disk.CimInstanceProperties['Name'].Value
+ $fileSystemDetails[$deviceId]['Type'] = ($disk.CimInstanceProperties['FileSystem'].Value)
+ $fileSystemDetails[$deviceId]['Name'] = $disk.CimInstanceProperties['VolumeName'].Value
+ $fileSystemDetails[$deviceId]['SizeMB'] = $disk.CimInstanceProperties['Size'].Value / $megaByteDivisor
+ $fileSystemDetails[$deviceId]['AvailableMB'] = $disk.CimInstanceProperties['FreeSpace'].Value / $megaByteDivisor
+ $fileSystemDetails[$deviceId]['UsedMB'] = $fileSystemDetails[$deviceId]['SizeMB'] - $fileSystemDetails[$deviceId]['AvailableMB']
+ $fileSystemDetails[$deviceId]['UsedPercent'] = `
+ if ($fileSystemDetails[$deviceId]['SizeMB'] -ne 0) {
+ [System.Math]::Round($fileSystemDetails[$deviceId]['UsedMB'] * 100 / $fileSystemDetails[$deviceId]['SizeMB'], 2)
+ } else {
+ 0
+ }
+ }
+ } catch {
+
+ $fileSystemDetails["Error"] = $_.Exception.ToString()
+ }
+
+ $fileSystemDictionary.Add('FileSystem', $fileSystemDetails)
+
+ Write-Verbose "$logLead : [$($providerStopWatch.Elapsed)] : Provider Complete"
+ $providerStopWatch.Stop()
+
+ return $fileSystemDictionary
+}
diff --git a/Modules/Alkami.DevOps.Inventory/Public/Get-IISInventory.ps1 b/Modules/Alkami.DevOps.Inventory/Public/Get-IISInventory.ps1
new file mode 100644
index 0000000..6d9452c
--- /dev/null
+++ b/Modules/Alkami.DevOps.Inventory/Public/Get-IISInventory.ps1
@@ -0,0 +1,176 @@
+function Get-IISInventory {
+<#
+.SYNOPSIS
+ Returns an OrderedDictionary that Represents the IIS Inventory.
+#>
+
+ [CmdletBinding()]
+ Param()
+
+ $logLead = (Get-LogLeadName);
+ $providerStopWatch = [System.Diagnostics.StopWatch]::StartNew()
+
+ $mgr = New-Object Microsoft.Web.Administration.ServerManager
+ $iisDictionary = New-Object System.Collections.Specialized.OrderedDictionary
+ $iisDetails = New-Object System.Collections.Specialized.OrderedDictionary
+ $appPoolDetails = New-Object System.Collections.Specialized.OrderedDictionary
+ $webSiteDetails = New-Object System.Collections.Specialized.OrderedDictionary
+
+ Write-Verbose "$logLead : [$($providerStopWatch.Elapsed)] : Parsing IIS Details"
+
+ try {
+
+ foreach ($appPool in $mgr.ApplicationPools) {
+
+ $appPoolName = $appPool.Name
+
+ $appPoolDetails[$appPoolName] = New-Object System.Collections.Specialized.OrderedDictionary
+ $appPoolDetails[$appPoolName]["CLRVersion"] = $appPool.ManagedRuntimeVersion
+ $appPoolDetails[$appPoolName]["Enable32BitAppOnWin64"] = $appPool.Enable32BitAppOnWin64
+ $appPoolDetails[$appPoolName]["ManagedPipelineMode"] = $appPool.ManagedPipelineMode
+ $appPoolDetails[$appPoolName]["QueueLength"] = $appPool.QueueLength
+ $appPoolDetails[$appPoolName]["StartMode"] = $appPool.StartMode
+ $appPoolDetails[$appPoolName]["State"] = $appPool.State
+ $appPoolDetails[$appPoolName]["AutoStart"] = $appPool.AutoStart
+
+ $appPoolDetails.$appPoolName["CPU"] = New-Object System.Collections.Specialized.OrderedDictionary
+ $appPoolDetails.$appPoolName["CPU"]["Limit"] = $appPool.Cpu.Limit
+ $appPoolDetails.$appPoolName["CPU"]["LimitAction"] = $appPool.Cpu.Action
+ $appPoolDetails.$appPoolName["CPU"]["LimitInterval"] = ($appPool.Cpu.ResetInterval).ToString()
+ $appPoolDetails.$appPoolName["CPU"]["AffinityEnabled"] = $appPool.Cpu.SmpAffinitized
+ $appPoolDetails.$appPoolName["CPU"]["AffinityMask"] = $appPool.Cpu.SmpProcessorAffinityMask
+ $appPoolDetails.$appPoolName["CPU"]["AffinityMask64"] = $appPool.Cpu.SmpProcessorAffinityMask2
+
+ $appPoolDetails.$appPoolName["ProcessModel"] = New-Object System.Collections.Specialized.OrderedDictionary
+ $appPoolDetails.$appPoolName["ProcessModel"]["EventLogEntry"] = $appPool.ProcessModel.LogEventOnProcessModel
+ $appPoolDetails.$appPoolName["ProcessModel"]["IdentityType"] = $appPool.ProcessModel.IdentityType
+ $appPoolDetails.$appPoolName["ProcessModel"]["UserName"] = $appPool.ProcessModel.UserName
+ $appPoolDetails.$appPoolName["ProcessModel"]["IdleTimeout"] = ($appPool.ProcessModel.IdleTimeout).ToString()
+ $appPoolDetails.$appPoolName["ProcessModel"]["IdleTimeoutAction"] = $appPool.ProcessModel.IdleTimeoutAction
+ $appPoolDetails.$appPoolName["ProcessModel"]["LoadUserProfile"] = $appPool.ProcessModel.LoadUserProfile
+ $appPoolDetails.$appPoolName["ProcessModel"]["MaximumWorkerProcesses"] = $appPool.ProcessModel.MaxProcesses
+ $appPoolDetails.$appPoolName["ProcessModel"]["PingEnabled"] = $appPool.ProcessModel.PingingEnabled
+ $appPoolDetails.$appPoolName["ProcessModel"]["PingMaxResponseTime"] = ($appPool.ProcessModel.PingInterval).ToString()
+ $appPoolDetails.$appPoolName["ProcessModel"]["PingPeriod"] = ($appPool.ProcessModel.PingResponseTime).ToString()
+ $appPoolDetails.$appPoolName["ProcessModel"]["ShutdownTimeLimit"] = ($appPool.ProcessModel.ShutdownTimeLimit).ToString()
+ $appPoolDetails.$appPoolName["ProcessModel"]["StartupTimeLimit"] = ($appPool.ProcessModel.StartupTimeLimit).ToString()
+
+ $appPoolDetails.$appPoolName["ProcessOrphaning"] = New-Object System.Collections.Specialized.OrderedDictionary
+ $appPoolDetails.$appPoolName["ProcessOrphaning"]["OrphanWorkerProcess"] = $appPool.Failure.OrphanWorkerProcess
+ $appPoolDetails.$appPoolName["ProcessOrphaning"]["Executable"] = $appPool.Failure.OrphanActionExe
+ $appPoolDetails.$appPoolName["ProcessOrphaning"]["ExecutableParams"] = $appPool.Failure.OrphanActionParams
+
+ $appPoolDetails.$appPoolName["RapidFailProtection"] = New-Object System.Collections.Specialized.OrderedDictionary
+ $appPoolDetails.$appPoolName["RapidFailProtection"]["ReponseType"] = $appPool.Failure.LoadBalancerCapabilities
+ $appPoolDetails.$appPoolName["RapidFailProtection"]["Enabled"] = $appPool.Failure.RapidFailProtection
+ $appPoolDetails.$appPoolName["RapidFailProtection"]["FailureInterval"] = ($appPool.Failure.RapidFailProtectionInterval).ToString()
+ $appPoolDetails.$appPoolName["RapidFailProtection"]["MaximumFailures"] = $appPool.Failure.RapidFailProtectionMaxCrashes
+ $appPoolDetails.$appPoolName["RapidFailProtection"]["Executable"] = $appPool.Failure.AutoShutdownExe
+ $appPoolDetails.$appPoolName["RapidFailProtection"]["ExecutableParams"] = $appPool.Failure.AutoShutdownParams
+
+ $appPoolDetails.$appPoolName["Recycling"] = New-Object System.Collections.Specialized.OrderedDictionary
+ $appPoolDetails.$appPoolName["Recycling"]["DisallowOverlappedRecycle"] = $appPool.Recycling.DisallowOverlappingRotation
+ $appPoolDetails.$appPoolName["Recycling"]["DisableConfigRecycling"] = $appPool.Recycling.DisallowRotationOnConfigChange
+ $appPoolDetails.$appPoolName["Recycling"]["RecyclingEventLogging"] = $appPool.Recycling.LogEventOnRecycle
+ $appPoolDetails.$appPoolName["Recycling"]["PrivateMemoryLimit"] = $appPool.Recycling.PeriodicRestart.PrivateMemory
+ $appPoolDetails.$appPoolName["Recycling"]["RegularTimeInterval"] = ($appPool.Recycling.PeriodicRestart.Time).ToString()
+ $appPoolDetails.$appPoolName["Recycling"]["RequestLimit"] = $appPool.Recycling.PeriodicRestart.Requests
+ $appPoolDetails.$appPoolName["Recycling"]["SpecificTimes"] = $appPool.Recycling.PeriodicRestart.Schedule
+ $appPoolDetails.$appPoolName["Recycling"]["VirtualMemoryLimit"] = $appPool.Recycling.PeriodicRestart.Memory
+ }
+
+ foreach ($site in $mgr.Sites) {
+
+ $siteName = $site.Name
+
+ $webSiteDetails[$siteName] = New-Object System.Collections.Specialized.OrderedDictionary
+ $webSiteDetails[$siteName]["Id"] = $site.Id
+ $webSiteDetails[$siteName]["Name"] = $site.Name
+ $webSiteDetails[$siteName]["State"] = $site.State
+ $webSiteDetails[$siteName]["IsLocallyStored"] = $site.IsLocallyStored
+ $webSiteDetails[$siteName]["ServerAutoStart"] = $site.ServerAutoStart
+ $webSiteDetails[$siteName]["DefaultAppPool"] = $site.ApplicationDefaults.ApplicationPoolName
+
+ $applicationHeader = "Applications"
+ $webSiteDetails[$siteName][$applicationHeader] = New-Object System.Collections.Specialized.OrderedDictionary
+
+ foreach ($webApp in $site.Applications) {
+
+ if ($webApp.Path -eq "/") {
+
+ $webAppName = $webApp.Path
+ }
+ else {
+
+ $webAppName = (($webApp.Path).Split("/") | Select-Object -Skip 1 -First 1)
+ }
+
+ $webSiteDetails[$siteName][$applicationHeader][$webAppName] = New-Object System.Collections.Specialized.OrderedDictionary
+ $webSiteDetails[$siteName][$applicationHeader][$webAppName]["ApplicationPool"] = $webApp.ApplicationPoolName
+ $webSiteDetails[$siteName][$applicationHeader][$webAppName]["EnabledProtocols"] = $webApp.EnabledProtocols
+ $webSiteDetails[$siteName][$applicationHeader][$webAppName]["IsLocallyStored"] = $webApp.IsLocallyStored
+ $webSiteDetails[$siteName][$applicationHeader][$webAppName]["PhysicalPath"] = $webApp.VirtualDirectories[0].PhysicalPath
+ $webSiteDetails[$siteName][$applicationHeader][$webAppName]["Path"] = $webApp.Path
+ $webSiteDetails[$siteName][$applicationHeader][$webAppName]["PreloadEnabled"] = $webApp.Attributes["preloadEnabled"].Value
+ }
+
+ $bindingHeader = "Bindings"
+ $webSiteDetails[$siteName][$bindingHeader] = New-Object System.Collections.Specialized.OrderedDictionary
+
+ foreach ($binding in $site.Bindings) {
+
+ $bindingInfo = $binding.BindingInformation
+
+ $webSiteDetails[$siteName][$bindingHeader][$bindingInfo] = New-Object System.Collections.Specialized.OrderedDictionary
+ $webSiteDetails[$siteName][$bindingHeader][$bindingInfo]["HostName"] = $binding.Host
+ $webSiteDetails[$siteName][$bindingHeader][$bindingInfo]["Protocol"] = $binding.Protocol
+ $webSiteDetails[$siteName][$bindingHeader][$bindingInfo]["Address"] = $binding.Endpoint.Address.IPAddressToString
+ $webSiteDetails[$siteName][$bindingHeader][$bindingInfo]["Port"] = $binding.Endpoint.Port
+ $webSiteDetails[$siteName][$bindingHeader][$bindingInfo]["SslFlags"] = $binding.SslFlags
+ $webSiteDetails[$siteName][$bindingHeader][$bindingInfo]["CertificateStoreName"] = $binding.CertificateStoreName
+ $webSiteDetails[$siteName][$bindingHeader][$bindingInfo]["CertificateHash"] = `
+ if ($binding.CertificateHash) {
+ ([System.BitConverter]::ToString($binding.CertificateHash).Replace('-',''))
+ }
+ }
+
+ $logHeader = "Log"
+ $webSiteDetails[$siteName][$logHeader] = New-Object System.Collections.Specialized.OrderedDictionary
+ $webSiteDetails[$siteName][$logHeader]["Enabled"] = $site.LogFile.Enabled
+ $webSiteDetails[$siteName][$logHeader]["Period"] = ($site.LogFile.Period).ToString()
+ $webSiteDetails[$siteName][$logHeader]["Rollover"] = $site.LogFile.LocalTimeRollover
+ $webSiteDetails[$siteName][$logHeader]["Format"] = $site.LogFile.LogFormat
+ $webSiteDetails[$siteName][$logHeader]["Directory"] = $site.LogFile.Directory
+ $webSiteDetails[$siteName][$logHeader]["Flags"] = ($site.LogFile.LogExtFileFlags).ToString()
+
+ $reqLogHeader = "FailedRequestLog"
+ $webSiteDetails[$siteName][$reqLogHeader] = New-Object System.Collections.Specialized.OrderedDictionary
+ $webSiteDetails[$siteName][$reqLogHeader]["Enabled"] = $site.TraceFailedRequestsLogging.Enabled
+ $webSiteDetails[$siteName][$reqLogHeader]["MaxLogFiles"] = $site.TraceFailedRequestsLogging.MaxLogFiles
+ $webSiteDetails[$siteName][$reqLogHeader]["Directory"] = $site.TraceFailedRequestsLogging.Directory
+ $webSiteDetails[$siteName][$reqLogHeader]["MaxSizeKB"] = $site.TraceFailedRequestsLogging.Attributes["maxLogfileSizeKb"].Value
+
+ $limitsHeader = "Limits"
+ $webSiteDetails[$siteName][$limitsHeader] = New-Object System.Collections.Specialized.OrderedDictionary
+ $webSiteDetails[$siteName][$limitsHeader]["ConnectionTimeout"] = ($site.Limits.ConnectionTimeout).ToString()
+ $webSiteDetails[$siteName][$limitsHeader]["MaxBandwidth"] = $site.Limits.MaxBandwidth
+ $webSiteDetails[$siteName][$limitsHeader]["MaxConnections"] = $site.Limits.MaxConnections
+ $webSiteDetails[$siteName][$limitsHeader]["MaxUrlSegments"] = $site.Limits.MaxUrlSegments
+
+ }
+ }
+ catch {
+
+ $iisDictionary["Error"] = $_.Exception.ToString()
+ }
+
+ $iisDetails.Add("AppPools", $appPoolDetails)
+ $iisDetails.Add("Sites", $webSiteDetails)
+
+ $iisDictionary.Add("IIS", $iisDetails)
+
+ Write-Verbose "$logLead : [$($providerStopWatch.Elapsed)] : Provider Complete"
+ $providerStopWatch.Stop()
+
+ return $iisDictionary
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Inventory/Public/Get-IISResetHistory.ps1 b/Modules/Alkami.DevOps.Inventory/Public/Get-IISResetHistory.ps1
new file mode 100644
index 0000000..6675596
--- /dev/null
+++ b/Modules/Alkami.DevOps.Inventory/Public/Get-IISResetHistory.ps1
@@ -0,0 +1,54 @@
+function Get-IISResetHistory {
+<#
+.SYNOPSIS
+ Returns an OrderedDictionary that Represents the IIS Reset History.
+#>
+
+ [CmdletBinding()]
+ Param(
+ $ID = 3201
+ )
+
+ $logLead = (Get-LogLeadName);
+ $providerStopWatch = [System.Diagnostics.StopWatch]::StartNew()
+
+ $resetDictionary = New-Object System.Collections.Specialized.OrderedDictionary
+ $IISResetHistoryDetails = New-Object System.Collections.Specialized.OrderedDictionary
+
+ try {
+
+ Write-Verbose "$logLead : [$($providerStopWatch.Elapsed)] : Getting Event Log Entries for Event ID $ID"
+ $dateLimit = (Get-Date) - (New-TimeSpan -Day 90)
+ $resetEvents = Get-WinEvent -FilterHashtable @{
+ LogName = 'System'
+ Id = $ID
+ StartTime = $dateLimit
+ }
+
+ foreach ($event in $resetEvents) {
+
+ $eventId = [string]$event.RecordId
+ Write-Verbose "$logLead : [$($providerStopWatch.Elapsed)] : Processing RecordId $eventId"
+
+ $IISResetHistoryDetails[$eventId] = New-Object System.Collections.Specialized.OrderedDictionary
+ $IISResetHistoryDetails[$eventId]["Date"] = $event.TimeCreated;
+ $IISResetHistoryDetails[$eventId]["Process"] = $event.Properties[0].Value;
+ $IISResetHistoryDetails[$eventId]["Reason"] = $event.Properties[2].Value;
+ $IISResetHistoryDetails[$eventId]["Action"] = $event.Properties[4].Value;
+ $IISResetHistoryDetails[$eventId]["Comment"] = $event.Properties[5].Value;
+ $IISResetHistoryDetails[$eventId]["User"] = $event.Properties[6].Value;
+ $IISResetHistoryDetails[$eventId]["Message"] = $event.Message;
+ }
+ $IISResetHistoryDetails = ($IISResetHistoryDetails | sort-object )[0]
+ } catch {
+
+ $IISResetHistoryDetails["Error"] = $_.Exception.ToString()
+ }
+
+ $resetDictionary.Add("IISResetHistory", $IISResetHistoryDetails)
+
+ Write-Verbose "$logLead : [$($providerStopWatch.Elapsed)] : Provider Complete"
+ $providerStopWatch.Stop()
+
+ return $resetDictionary
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Inventory/Public/Get-LocalSecurityPolicyInventory.ps1 b/Modules/Alkami.DevOps.Inventory/Public/Get-LocalSecurityPolicyInventory.ps1
new file mode 100644
index 0000000..47c55c4
--- /dev/null
+++ b/Modules/Alkami.DevOps.Inventory/Public/Get-LocalSecurityPolicyInventory.ps1
@@ -0,0 +1,64 @@
+function Get-LocalSecurityPolicyInventory {
+<#
+.SYNOPSIS
+ Returns an OrderedDictionary that Represents the Local Security Policy Inventory.
+#>
+
+ [CmdletBinding()]
+ Param()
+
+ $logLead = (Get-LogLeadName);
+ $providerStopWatch = [System.Diagnostics.StopWatch]::StartNew()
+
+ $userNameHasLogonAsServiceRights = @()
+ $userNameHasSystemProfileRights = @()
+ $localSecurityPolicyDictionary = New-Object System.Collections.Specialized.OrderedDictionary
+ $localSecurityPolicyDetails = New-Object System.Collections.Specialized.OrderedDictionary
+
+ Write-Verbose "$logLead : [$($providerStopWatch.Elapsed)] : Reading Logon As Service rights (SeServiceLogonRight)"
+ $SeServiceLogonAsServiceRightResult = Get-SecurityPolicySetting "SeServiceLogonRight"
+ Write-Verbose "$logLead : [$($providerStopWatch.Elapsed)] : Done reading Logon As Service rights (SeServiceLogonRight)"
+
+ Write-Verbose "$logLead : [$($providerStopWatch.Elapsed)] : Reading User Profile System Performance Rights (SeSystemProfilePrivilege)"
+ $SeSystemProfilePrivilegeResult = Get-SecurityPolicySetting "SeSystemProfilePrivilege"
+ Write-Verbose "$logLead : [$($providerStopWatch.Elapsed)] : Done reading User Profile System Performance Rights (SeSystemProfilePrivilege)"
+
+ Write-Verbose "$logLead : [$($providerStopWatch.Elapsed)] : Processing Policies"
+
+ try {
+
+ foreach ($sid in $SeServiceLogonAsServiceRightResult.Split(",")) {
+ $userNameHasLogonAsServiceRights += Get-UsernameFromSid ($sid.Replace("*", ""))
+ }
+
+ if (Test-IsCollectionNullOrEmpty $userNameHasLogonAsServiceRights) {
+ Write-Verbose ("$logLead : [$($providerStopWatch.Elapsed)] : No users accounts found to have Logon as Service rights")
+ continue;
+ }
+
+ foreach ($sid in $SeSystemProfilePrivilegeResult.Split(",")) {
+ $userNameHasSystemProfileRights += Get-UsernameFromSid ($sid.Replace("*", ""))
+ }
+
+ if (Test-IsCollectionNullOrEmpty $userNameHasSystemProfileRights) {
+ Write-Verbose ("$logLead : [$($providerStopWatch.Elapsed)] : No users accounts found to have System Profile Privilege rights")
+ continue;
+ }
+
+ $localSecurityPolicyDetails["localSecurityPolicyDetailsKey"] = New-Object System.Collections.Specialized.OrderedDictionary
+ $localSecurityPolicyDetails["localSecurityPolicyDetailsKey"]["userNameHasLogonAsServiceRights"] = $userNameHasLogonAsServiceRights
+ $localSecurityPolicyDetails["localSecurityPolicyDetailsKey"]["userNameHasSystemProfileRights"] = $userNameHasSystemProfileRights
+
+ }
+ catch {
+ $localSecurityPolicyDetails["Error"] = $_.Exception.ToString()
+ }
+
+ Write-Verbose "$logLead : [$($providerStopWatch.Elapsed)] : Done Processing Policies"
+ $localSecurityPolicyDictionary.Add("LocalSecurityPolices", $localSecurityPolicyDetails)
+ Write-Verbose "$logLead : [$($providerStopWatch.Elapsed)] : Provider Complete"
+
+ $providerStopWatch.Stop()
+
+ return $localSecurityPolicyDictionary
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Inventory/Public/Get-MachineInventory.ps1 b/Modules/Alkami.DevOps.Inventory/Public/Get-MachineInventory.ps1
new file mode 100644
index 0000000..54fd30a
--- /dev/null
+++ b/Modules/Alkami.DevOps.Inventory/Public/Get-MachineInventory.ps1
@@ -0,0 +1,245 @@
+function Get-MachineInventory {
+<#
+.SYNOPSIS
+ Returns an Object that Represents the State of a Machine
+
+.DESCRIPTION
+ Executes multiple collectors which describe the state of a machine. Can execute on an array of
+ machine names or PSSessions and allows for an optional filter to be included. May be returned as a PSObject, JSON, or XML
+
+.PARAMETER TargetComputerNames
+ [string[]] One or more remote computers to profile
+
+.PARAMETER PsSessions
+[PSSession[]] One or more PSSessions to Use
+
+.PARAMETER Filter
+ [string[]] A filter which limits the number of profilers to execute
+
+.PARAMETER JsonOutput
+ [switch] Specifies to return the result as JSON
+
+.PARAMETER XmlOutput
+ [switch] Specifies to return the result as XML
+
+.INPUTS
+ None
+
+.OUTPUTS
+ An object with details about the machine state. May be a PSObject (default), JSON, or XML depending on arguments
+
+.EXAMPLE
+ Get-MachineInventory
+
+[Get-MachineInventory] : Collecting data from 1 target(s)
+WARNING: [ALKA01VMA167] : Function Get-AppSettingsInventory Took 00:00:02.2702503 to Execute
+[Get-MachineInventory] : Received 1 result(s)
+[Get-MachineInventory] : Adding Results for ALKA01VMA167
+[Get-MachineInventory] : Inventory Collection Complete After 00:00:07.9261976
+
+Name Value
+---- -----
+ALKA01VMA167 {OS, Version, Architecture, GenerationTime...}
+
+
+.EXAMPLE
+ Get-MachineInventory -Filter Uptime, FileSystem
+
+[Get-MachineInventory] : Collecting data from 1 target(s)
+[Get-MachineInventory] : Received 1 result(s)
+[Get-MachineInventory] : Adding Results for ALK-DELL1323
+
+Name Value
+---- -----
+ALK-DELL1323 {OS, Version, Architecture, GenerationTime...}
+
+.EXAMPLE
+ Get-MachineInventory -Filter Uptime -MachineNames @("localhost", "ALKA01VMA163.fh.local")
+
+[Get-MachineInventory] : Collecting data from 2 target(s)
+[Get-MachineInventory] : Received 2 result(s)
+[Get-MachineInventory] : Adding Results for ALKA01VMA163
+[Get-MachineInventory] : Adding Results for ALK-DELL1323
+
+Name Value
+---- -----
+ALKA01VMA163 {OS, Version, Architecture, GenerationTime...}
+ALK-DELL1323 {OS, Version, Architecture, GenerationTime...}
+#>
+ [CmdletBinding(DefaultParameterSetName = 'FilterOnlyParameterSet')]
+ [OutputType([string], ParameterSetName=("MachineNameParameterJson","SessionParameterJson","FilterParameterSetJson"))]
+ [OutputType([System.Xml.XmlDocument], ParameterSetName=("MachineNameParameterXml","SessionParameterXml","FilterParameterSetXml"))]
+ [OutputType([System.Collections.Specialized.OrderedDictionary], ParameterSetName=("MachineNameParameterSet","SessionParameterSet"))]
+ param(
+
+ [Parameter(ParameterSetName = 'MachineNameParameterSet', Mandatory = $true)]
+ [Parameter(ParameterSetName = 'MachineNameParameterJson', Mandatory = $true)]
+ [Parameter(ParameterSetName = 'MachineNameParameterXml', Mandatory = $true)]
+ [Alias("ComputerNames", "Servers", "MachineNames")]
+ [string[]]$TargetComputerNames,
+
+ [Parameter(ParameterSetName='SessionParameterSet', Mandatory=$true)]
+ [Parameter(ParameterSetName='SessionParameterJson', Mandatory=$true)]
+ [Parameter(ParameterSetName='SessionParameterXml', Mandatory=$true)]
+ [Alias("Sessions")]
+ [System.Management.Automation.Runspaces.PSSession[]]$PsSessions,
+
+ [Parameter(ParameterSetName = 'MachineNameParameterJson', Mandatory = $true)]
+ [Parameter(ParameterSetName = 'SessionParameterJson', Mandatory = $true)]
+ [Parameter(ParameterSetName = 'FilterParameterSetJson', Mandatory = $false)]
+ [Alias("JsonOutput")]
+ [switch]$AsJson,
+
+ [Parameter(ParameterSetName = 'MachineNameParameterXml', Mandatory = $true)]
+ [Parameter(ParameterSetName = 'SessionParameterXml', Mandatory = $true)]
+ [Parameter(ParameterSetName = 'FilterParameterSetXml', Mandatory = $false)]
+ [Alias("XMLOutput")]
+ [switch]$AsXML
+ )
+ DynamicParam {
+ # Define the Paramater Attributes
+ $ParameterName = "Filter"
+ $RuntimeParameterDictionary = New-Object System.Management.Automation.RuntimeDefinedParameterDictionary
+ $AttributeCollection = New-Object System.Collections.ObjectModel.Collection[System.Attribute]
+
+ $ParameterAttribute = New-Object System.Management.Automation.ParameterAttribute
+ $ParameterAttribute.Mandatory = $false
+ $ParameterAttribute.ParameterSetName = "__AllParameterSets"
+ $AttributeCollection.Add($ParameterAttribute)
+
+ # Generate and add the ValidateSet
+ $arrSet = $inventoryFilterSetOptions | ForEach-Object { $_.FilterName }
+ $ValidateSetAttribute = New-Object System.Management.Automation.ValidateSetAttribute($arrSet)
+ $AttributeCollection.Add($ValidateSetAttribute)
+
+ # Create the dynamic parameter
+ $RuntimeParameter = New-Object System.Management.Automation.RuntimeDefinedParameter($ParameterName, [string[]], $AttributeCollection)
+ $RuntimeParameterDictionary.Add($ParameterName, $RuntimeParameter)
+ return $RuntimeParameterDictionary
+ }
+
+ begin {
+ # Bind the Filter dynamic parameter to a variable
+ $logLead = Get-LogLeadName
+ Write-Host "$logLead : Executing using ParameterSet: [$($PSCmdlet.ParameterSetName)]"
+
+ $filterCollection = $PsBoundParameters[$ParameterName]
+ Write-Host "$logLead : Executing with filters: [$filterCollection]"
+
+ # We'll Silently Continue for Any Provider Errors -- They Get Added to the Results
+ $originalErrorActionPreference = $ErrorActionPreference
+ $ErrorActionPreference = "SilentlyContinue"
+
+ $functionStopWatch = [System.Diagnostics.StopWatch]::StartNew()
+
+ $inventoryResult = New-Object System.Collections.Specialized.OrderedDictionary
+ }
+
+ process {
+ try {
+ $clearSessions = $true
+ if ($null -eq $PsSessions -or $PsSessions.Count -eq 0) {
+ $sessions = @()
+ foreach ($target in $TargetComputerNames) {
+ Write-Host "$logLead : Creating Session for $target"
+ $sessions += New-PSSession -ComputerName $target -ErrorVariable err -ErrorAction SilentlyContinue
+
+ if($err.Count -gt 0) {
+ Write-Warning "$logLead : New-PSSession encountered an error : $err"
+ }
+ }
+ } else {
+ $clearSessions = $false
+ $sessions = $PsSessions
+ }
+
+ $scriptBlock = {
+
+ $logLead = "[$env:COMPUTERNAME]"
+ $ErrorActionPreference = "Continue"
+
+ $machineData = New-Object System.Collections.Specialized.OrderedDictionary
+ $uniqueSections = $Using:inventoryFilterSetOptions | ForEach-Object { $_.SectionVariable } | Sort-Object | Select-Object -Unique
+
+ $VerbosePreference=$Using:VerbosePreference;
+
+ foreach ($section in $uniqueSections) {
+ Write-Verbose "$logLead : Creating Variable for $section Data"
+ New-Variable -Name $section -Value (New-Object System.Collections.Specialized.OrderedDictionary)
+ }
+
+ # Always include Platform Inventory as Top Level Properties
+ $machineData += Get-PlatformInventory
+
+ # Use the entire filter set if no filter was set as a parameter
+ [array]$filteredInventories = Test-IsNull $Using:filterCollection ($Using:inventoryFilterSetOptions | ForEach-Object { $_.FilterName }) -Strict
+ Write-Verbose "$logLead : Using Final Inventory Filter: [$filteredInventories]"
+
+ foreach ($inventory in ($filteredInventories | Sort-Object)) {
+
+ $matchingFilter = $Using:inventoryFilterSetOptions | Where-Object {$_.FilterName -eq $inventory}
+
+ $masterProviderStopWatch = [System.Diagnostics.StopWatch]::StartNew()
+
+ Write-Host "$logLead : Executing FunctionName [$($matchingFilter.FunctionName)]"
+ $sb = [ScriptBlock]::Create($matchingFilter.FunctionName)
+ ((Get-Variable -Name $matchingFilter.SectionVariable).Value) += (Invoke-Command $sb)
+
+ $masterProviderStopWatch.Stop()
+
+ if ($masterProviderStopWatch.ElapsedMilliseconds -ge 1000) {
+
+ Write-Warning "$logLead : Function $($matchingFilter.FunctionName) Took $($masterProviderStopWatch.Elapsed) to Execute"
+ }
+ }
+
+ foreach ($uniqueSection in $uniqueSections) {
+ $section = (Get-Variable -Name $uniqueSection -ValueOnly)
+ if ($section.Count -gt 0) {
+
+ Write-Verbose "$logLead : Adding $uniqueSection Section to Results"
+ $machineData.Add("$uniqueSection", $section)
+ }
+ }
+
+ return $machineData
+ }
+
+ Write-Host "$logLead : Collecting data from $($sessions.Count) target(s)"
+ [array]$results = Invoke-Command -ScriptBlock $scriptBlock -Session $sessions
+
+ Write-Host "$logLead : Received $($results.Count) result(s)"
+
+ # Add Result Sets
+ foreach ($result in $results) {
+ Write-Host "$logLead : Adding Results for $($result.Hostname)"
+ $inventoryResult.Add($result.Hostname, $result)
+ }
+ } finally {
+ Write-Host "$logLead : Setting ErrorActionPreference to $originalErrorActionPreference"
+ $ErrorActionPreference = $originalErrorActionPreference
+
+ if ($clearSessions) {
+ if ($sessions.Count -gt 0) {
+ Write-Host "$logLead : Cleaning Up PSSessions"
+ Remove-PSSession $sessions;
+ } else {
+ Write-Error "$logLead : WinRM cannot connect to any computers in the input list."
+ }
+ }
+ }
+
+ $functionStopWatch.Stop()
+ Write-Host "$logLead : Inventory Collection Complete After $($functionStopWatch.Elapsed)"
+
+ if ($AsJson) {
+ return ($inventoryResult | ConvertTo-Json -Depth 8)
+ }
+
+ if ($AsXml) {
+ return ($inventoryResult | ConvertTo-Xml -Depth 8 -As Document)
+ }
+
+ return $inventoryResult
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Inventory/Public/Get-MemoryInventory.ps1 b/Modules/Alkami.DevOps.Inventory/Public/Get-MemoryInventory.ps1
new file mode 100644
index 0000000..c5cce8a
--- /dev/null
+++ b/Modules/Alkami.DevOps.Inventory/Public/Get-MemoryInventory.ps1
@@ -0,0 +1,43 @@
+function Get-MemoryInventory {
+<#
+.SYNOPSIS
+ Returns an OrderedDictionary that Represents the Memory Inventory.
+#>
+
+ [CmdletBinding()]
+ Param()
+
+ $logLead = (Get-LogLeadName);
+ $providerStopWatch = [System.Diagnostics.StopWatch]::StartNew()
+
+ $memoryDictionary = New-Object System.Collections.Specialized.OrderedDictionary
+ $memoryDetails = New-Object System.Collections.Specialized.OrderedDictionary
+
+ Set-Variable megaByteDivisor -Option Constant -Value 1048576
+
+ try {
+
+ Write-Verbose "$logLead : [$($providerStopWatch.Elapsed)] : Getting Memory Details"
+ $memory = Get-CIMInstance -Namespace "root\CIMV2" -Class Win32_PerfRawData_PerfOS_Memory `
+ -Property CommitLimit, AvailableMBytes, CommittedBytes, CacheBytes, PoolNonpagedBytes, PoolPagedBytes
+ Write-Verbose "$logLead : [$($providerStopWatch.Elapsed)] : Memory Details Retrieved"
+
+ $memoryDetails["TotalMBytes"] = ($memory.CommitLimit / $megaByteDivisor)
+ $memoryDetails["AvailableMBytes"] = $memory.AvailableMBytes
+ $memoryDetails["CommittedMBytes"] = [int]($memory.CommittedBytes / $megaByteDivisor)
+ $memoryDetails["SystemCacheMBytes"] = [int]($memory.CacheBytes / $megaByteDivisor)
+ $memoryDetails["NonPagedPoolMBytes"] = [int]($memory.PoolNonpagedBytes / $megaByteDivisor)
+ $memoryDetails["PagedPoolMBytes"] = [int]($memory.PoolPagedBytes / $megaByteDivisor)
+
+ $memoryDictionary.Add("Memory" , $memoryDetails)
+ }
+ catch {
+
+ $memoryDetails["Error"] = $_.Exception.ToString()
+ }
+
+ Write-Verbose "$logLead : [$($providerStopWatch.Elapsed)] : Provider Complete"
+ $providerStopWatch.Stop()
+
+ return $memoryDictionary
+}
diff --git a/Modules/Alkami.DevOps.Inventory/Public/Get-ModuleInventory.ps1 b/Modules/Alkami.DevOps.Inventory/Public/Get-ModuleInventory.ps1
new file mode 100644
index 0000000..4822dce
--- /dev/null
+++ b/Modules/Alkami.DevOps.Inventory/Public/Get-ModuleInventory.ps1
@@ -0,0 +1,44 @@
+function Get-ModuleInventory {
+<#
+.SYNOPSIS
+ Returns an OrderedDictionary that Represents the Module Inventory.
+#>
+
+ [CmdletBinding()]
+ Param()
+
+ $logLead = (Get-LogLeadName);
+ $providerStopWatch = [System.Diagnostics.StopWatch]::StartNew()
+
+ $moduleDictionary = New-Object System.Collections.Specialized.OrderedDictionary
+ $moduleDetails = New-Object System.Collections.Specialized.OrderedDictionary
+
+ try {
+
+ Write-Verbose "$logLead : [$($providerStopWatch.Elapsed)] : Getting Available Modules"
+ $localModules = Get-Module -ListAvailable -Verbose:$false | Select-Object ModuleType, Name, Version, ExportedCommands, ModuleBase, Description
+ Write-Verbose "$logLead : [$($providerStopWatch.Elapsed)] : Module Inventory Complete"
+
+ foreach ($module in $localModules) {
+
+ $moduleDetails[$module.Name] = New-Object System.Collections.Specialized.OrderedDictionary
+ $moduleDetails[$module.Name]["ModuleType"] = $module.ModuleType
+ $moduleDetails[$module.Name]["Name"] = $module.Name
+ $moduleDetails[$module.Name]["Version"] = $module.Version
+ $moduleDetails[$module.Name]["ExportedCommands"] = ($module.ExportedCommands.Values | Select-Object -ExpandProperty Name)
+ $moduleDetails[$module.Name]["ModuleBase"] = $module.ModuleBase
+ $moduleDetails[$module.Name]["Description"] = $module.Description
+ }
+ }
+ catch {
+
+ $moduleDetails["Error"] = $_.Exception.ToString()
+ }
+
+ $moduleDictionary.Add("Modules", $moduleDetails)
+
+ Write-Verbose "$logLead : [$($providerStopWatch.Elapsed)] : Provider Complete"
+ $providerStopWatch.Stop()
+
+ return $moduleDictionary
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Inventory/Public/Get-OrbInventory.ps1 b/Modules/Alkami.DevOps.Inventory/Public/Get-OrbInventory.ps1
new file mode 100644
index 0000000..1f86009
--- /dev/null
+++ b/Modules/Alkami.DevOps.Inventory/Public/Get-OrbInventory.ps1
@@ -0,0 +1,40 @@
+function Get-OrbInventory {
+<#
+.SYNOPSIS
+ Returns a hash table that Represents the ORB Inventory.
+#>
+
+ [CmdletBinding()]
+ [OutputType([System.Collections.Hashtable])]
+ Param()
+
+ $logLead = (Get-LogLeadName);
+ $providerStopWatch = [System.Diagnostics.StopWatch]::StartNew()
+
+ Write-Verbose "$logLead : [$($providerStopWatch.Elapsed)] : Getting Orb Version Details"
+ $orbVersion = Get-OrbVersion
+ Write-Verbose "$logLead : [$($providerStopWatch.Elapsed)] : Orb Version Details Retrieved"
+
+ Write-Verbose "$logLead : [$($providerStopWatch.Elapsed)] : Getting ServerType Details"
+ $IsWebServer = Test-IsWebServer
+ $IsAppServer = Test-IsAppServer
+ $IsMicServer = Test-IsMicroServer
+ $IsFabServer = Test-IsServiceFabricServer
+ $IsAWSServer = Test-IsAws
+ Write-Verbose "$logLead : [$($providerStopWatch.Elapsed)] : ServerType Details Retrieved"
+
+
+ $orbInfo = @{
+ orbVersion = $orbVersion;
+ isWebServer = $IsWebServer;
+ isAppServer = $IsAppServer;
+ isMicServer = $IsMicServer;
+ isFabServer = $IsFabServer;
+ isAws = $IsAWSServer;
+ }
+
+ Write-Verbose "$logLead : [$($providerStopWatch.Elapsed)] : Provider Complete"
+ $providerStopWatch.Stop()
+
+ return $orbInfo
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Inventory/Public/Get-PlatformElementInventory.ps1 b/Modules/Alkami.DevOps.Inventory/Public/Get-PlatformElementInventory.ps1
new file mode 100644
index 0000000..43109f4
--- /dev/null
+++ b/Modules/Alkami.DevOps.Inventory/Public/Get-PlatformElementInventory.ps1
@@ -0,0 +1,48 @@
+function Get-PlatformElementInventory {
+ <#
+ .SYNOPSIS
+ Get all PlatformElementDetail data in json from a BoRG Uri based on the EnvironmentName
+
+ .EXAMPLE
+ Get-PlatformElementInventory -BorgUri "http://uri.to.borg.com" -EnvironmentName "AWS Sandbox Box 0.2 MIC"
+
+ .PARAMETER BorgUri
+ The Uri to the BoRG REST endpoint
+
+ .PARAMETER EnvironmentName
+ Filters based on the provided Environment Name. If not provided, the local machine.config Environment.Name appSetting value will be used.
+ #>
+
+ [CmdletBinding()]
+ Param(
+ [Parameter(Mandatory = $true)]
+ [string]$BorgUri,
+
+ [Parameter(Mandatory = $false)]
+ [string]$EnvironmentName
+ )
+
+ $packages = New-Object System.Collections.ArrayList
+
+ #Get environment name locally if not provided
+ if ([String]::IsNullOrEmpty($environmentName)) {
+ $environmentName = Get-AppSetting -appSettingKey "Environment.Name"
+ }
+
+ $Uri = "$BorgUri/odata/PlatformElementDetails?`$filter=IsCurrent eq true and EnvironmentType/Name eq '$environmentName'&`$expand=Element(`$select=Name;`$expand=ElementTier(`$select=Name)),ElementVersion(`$select=Name)&`$select=Element,ElementVersion"
+
+ $platformElementInventory = Invoke-RestMethod -Uri $Uri -Method GET -ContentType "application/json"
+
+ if ($platformElementInventory.value.Length -gt 0) {
+ foreach ($item in $platformElementInventory.value) {
+ $name = $item.Element.Name
+ $version = $item.ElementVersion.Name
+ $tier = $item.Element.ElementTier.Name
+ $properties = @{ Name = $name; Version = $version; Tier = $tier; Feed = $null; Tags = $null; IsService = $null; StartMode = $null }
+ $pkg = New-Object -TypeName PSObject -Prop $properties
+ ($packages.Add($pkg)) | Out-Null
+ }
+ }
+
+ return $packages
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Inventory/Public/Get-PlatformInventory.ps1 b/Modules/Alkami.DevOps.Inventory/Public/Get-PlatformInventory.ps1
new file mode 100644
index 0000000..3bd7477
--- /dev/null
+++ b/Modules/Alkami.DevOps.Inventory/Public/Get-PlatformInventory.ps1
@@ -0,0 +1,51 @@
+function Get-PlatformInventory {
+<#
+.SYNOPSIS
+ Get information about the Platform
+
+.LINK
+ Get-MachineInventory
+#>
+ [CmdletBinding()]
+ [OutputType([System.Collections.Hashtable])]
+ Param()
+
+ $logLead = (Get-LogLeadName);
+ $providerStopWatch = [System.Diagnostics.StopWatch]::StartNew()
+
+ Write-Host "$logLead : [$($providerStopWatch.Elapsed)] : Getting OS Details"
+ $platformInfo = Get-CIMInstance Win32_OperatingSystem -Namespace "root\CIMV2" -Property Caption, Version, OSArchitecture, Organization, SizeStoredInPagingFiles
+ Write-Host "$logLead : [$($providerStopWatch.Elapsed)] : OS Details Retrieved"
+
+ Write-Host "$logLead : [$($providerStopWatch.Elapsed)] : Getting System Details"
+ $systemInfo = Get-CIMInstance Win32_ComputerSystem -Namespace "root\CIMV2" -Property DNSHostName, Domain
+ Write-Host "$logLead : [$($providerStopWatch.Elapsed)] : System Details Retrieved"
+
+ $psVersionInfo = $PSVersionTable.PSVersion
+
+ $platformInfo = @{
+ GenerationTime = (Get-Date).DateTime;
+ OS = "Windows";
+ PlatformFamily = "Windows";
+ Platform = $platformInfo.Caption;
+ Version = $platformInfo.Version;
+ Architecture = $platformInfo.OSArchitecture;
+ Organization = $platformInfo.Organization;
+ PageFileSize = $platformInfo.SizeStoredInPagingFiles;
+ Hostname = $systemInfo.DNSHostName;
+ DotNetVersion = (Get-DotNetVersion).FriendlyVersion;
+ FQDN = (Get-FullyQualifiedServerName);
+ PowerShell = @{
+ Version = $psVersionInfo.ToString();
+ Major = $psVersionInfo.Major;
+ Minor = $psVersionInfo.Minor;
+ Build = $psVersionInfo.Build;
+ Revision = $psVersionInfo.Revision;
+ }
+ }
+
+ Write-Host "$logLead : [$($providerStopWatch.Elapsed)] : Provider Complete"
+ $providerStopWatch.Stop()
+
+ return $platformInfo
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Inventory/Public/Get-ProcessorInventory.ps1 b/Modules/Alkami.DevOps.Inventory/Public/Get-ProcessorInventory.ps1
new file mode 100644
index 0000000..e630c18
--- /dev/null
+++ b/Modules/Alkami.DevOps.Inventory/Public/Get-ProcessorInventory.ps1
@@ -0,0 +1,60 @@
+function Get-ProcessorInventory {
+<#
+.SYNOPSIS
+ Get information about the CPU(s)
+
+.LINK
+ Get-MachineInventory
+#>
+ [CmdletBinding()]
+ [OutputType([System.Collections.Specialized.OrderedDictionary])]
+ Param()
+
+ $logLead = Get-LogLeadName
+ $providerStopWatch = [System.Diagnostics.StopWatch]::StartNew()
+
+ $cpuDictionary = New-Object System.Collections.Specialized.OrderedDictionary
+ $cpuDetails = New-Object System.Collections.Specialized.OrderedDictionary
+ $cpuIndex = 0
+ $totalCoreCount = 0
+
+ try {
+ Write-Host "$logLead : [$($providerStopWatch.Elapsed)] : Getting Processor Details"
+ $processors = Get-CIMInstance -Namespace "root\CIMV2" -Class Win32_Processor `
+ -Property Manufacturer, Family, Revision, NumberOfLogicalProcessors, Description, MaxClockSpeed
+ Write-Host "$logLead : [$($providerStopWatch.Elapsed)] : Processor Details Retrieved"
+
+ foreach ($processor in $processors) {
+
+ $indexId = Test-IsNull $processor.DeviceId ([string]$cpuIndex) -Strict
+
+ $coreCount = $processor.NumberOfCores
+ $totalCoreCount += $coreCount
+
+ $cpuDetails[$indexId] = New-Object System.Collections.Specialized.OrderedDictionary
+ $cpuDetails[$indexId]["Manufacturer"] = $processor.Manufacturer
+ $cpuDetails[$indexId]["Family"] = [string]$processor.Family
+ $cpuDetails[$indexId]["Revision"] = [string]$processor.Revision
+ $cpuDetails[$indexId]["CoreCount"] = $coreCount
+ $cpuDetails[$indexId]["LogicalCores"] = $processor.NumberOfLogicalProcessors
+ $cpuDetails[$indexId]["Description"] = $processor.Description
+ $cpuDetails[$indexId]["ClockSpeed"] = [string]$processor.MaxClockSpeed
+
+ $cpuIndex += 1
+ Write-Host "$logLead : [$($providerStopWatch.Elapsed)] : $indexId Iteration Complete"
+ }
+
+ $cpuDetails["Sockets"] = $cpuIndex
+ $cpuDetails["PhysicalCores"] = Test-IsNull $totalCoreCount $null -Strict
+ }
+ catch {
+ $cpuDetails["Error"] = $_.Exception.ToString()
+ }
+
+ $cpuDictionary.Add("Processor" , $cpuDetails)
+
+ Write-Host "$logLead : [$($providerStopWatch.Elapsed)] : Provider Complete"
+ $providerStopWatch.Stop()
+
+ return $cpuDictionary
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Inventory/Public/Get-RestartHistory.ps1 b/Modules/Alkami.DevOps.Inventory/Public/Get-RestartHistory.ps1
new file mode 100644
index 0000000..b9f6f75
--- /dev/null
+++ b/Modules/Alkami.DevOps.Inventory/Public/Get-RestartHistory.ps1
@@ -0,0 +1,52 @@
+function Get-RestartHistory {
+<#
+.SYNOPSIS
+ Returns an OrderedDictionary that Represents the Restart History.
+#>
+
+ [CmdletBinding()]
+ Param()
+
+ $logLead = (Get-LogLeadName);
+ $providerStopWatch = [System.Diagnostics.StopWatch]::StartNew()
+
+ $historyDictionary = New-Object System.Collections.Specialized.OrderedDictionary
+ $rebootHistoryDetails = New-Object System.Collections.Specialized.OrderedDictionary
+
+ try {
+
+ Write-Verbose "$logLead : [$($providerStopWatch.Elapsed)] : Getting Event Log Entries for Event ID 1074"
+ $dateLimit = (Get-Date) - (New-TimeSpan -Day 90)
+ $restartEvents = Get-WinEvent -FilterHashtable @{
+ LogName = 'System'
+ Id = 1074
+ StartTime = $dateLimit
+ }
+
+ foreach ($event in $restartEvents) {
+
+ $eventId = [string]$event.RecordId
+ Write-Verbose "$logLead : [$($providerStopWatch.Elapsed)] : Processing RecordId $eventId"
+
+ $rebootHistoryDetails[$eventId] = New-Object System.Collections.Specialized.OrderedDictionary
+ $rebootHistoryDetails[$eventId]["Date"] = $event.TimeCreated;
+ $rebootHistoryDetails[$eventId]["Process"] = $event.Properties[0].Value;
+ $rebootHistoryDetails[$eventId]["Reason"] = $event.Properties[2].Value;
+ $rebootHistoryDetails[$eventId]["Action"] = $event.Properties[4].Value;
+ $rebootHistoryDetails[$eventId]["Comment"] = $event.Properties[5].Value;
+ $rebootHistoryDetails[$eventId]["User"] = $event.Properties[6].Value;
+ $rebootHistoryDetails[$eventId]["Message"] = $event.Message;
+ }
+ }
+ catch {
+
+ $rebootHistoryDetails["Error"] = $_.Exception.ToString()
+ }
+
+ $historyDictionary.Add("RestartHistory", $rebootHistoryDetails)
+
+ Write-Verbose "$logLead : [$($providerStopWatch.Elapsed)] : Provider Complete"
+ $providerStopWatch.Stop()
+
+ return $historyDictionary
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Inventory/Public/Get-ServiceFabricInventory.ps1 b/Modules/Alkami.DevOps.Inventory/Public/Get-ServiceFabricInventory.ps1
new file mode 100644
index 0000000..2aebcb2
--- /dev/null
+++ b/Modules/Alkami.DevOps.Inventory/Public/Get-ServiceFabricInventory.ps1
@@ -0,0 +1,67 @@
+function Get-ServiceFabricInventory {
+ <#
+ .SYNOPSIS
+ Get a PSObject containing categorized packages which represents the assumed list of what is installed on a Fab machine
+
+ .EXAMPLE
+ Get-ServiceFabricInventory
+
+ .PARAMETER ComputerName
+ The name of the computer to inventory
+ #>
+
+ [CmdletBinding()]
+ Param(
+ [Parameter(Mandatory = $false)]
+ [string]$ComputerName = "localhost"
+ )
+
+ $logLead = (Get-LogLeadName);
+ $providerStopWatch = [System.Diagnostics.StopWatch]::StartNew()
+
+ $SFDictionary = New-Object System.Collections.Specialized.OrderedDictionary
+ $SFDetails = New-Object System.Collections.Specialized.OrderedDictionary
+ $serviceFabricPackages = New-Object System.Collections.Specialized.OrderedDictionary
+ $requiredServiceFabricPackages = New-Object System.Collections.Specialized.OrderedDictionary
+
+ if (!(Test-IsServiceFabricServer)) {
+ Write-Warning "$logLead : Not a service fabric node. Inventory collection will be skipped"
+ $SFDictionary.Add("ServiceFabric", $null )
+ return $SFDictionary
+ }
+
+ try {
+ Import-Module serviceFabric
+ } catch {
+ Write-Warning "$logLead : $($_.exception.message)"
+ $SFDictionary.Add("ServiceFabric", $null )
+ return $SFDictionary
+ }
+
+ Connect-AlkamiServiceFabricCluster -hostname $ComputerName | Out-Null
+
+ Write-Verbose "$logLead : [$($providerStopWatch.Elapsed)] : Reading Service Fabric Packages"
+ $localServiceFabricPackages = (Get-AlkamiServiceFabricApplications);
+ Write-Verbose "$logLead : [$($providerStopWatch.Elapsed)] : Done Reading Service Fabric Packages"
+
+ Write-Verbose "$logLead : [$($providerStopWatch.Elapsed)] : Determing environment name"
+ $environmentName = Get-AppSetting "Environment.Name"
+ $envName = (Format-AlkamiEnvironmentName -name $environmentName);
+ Write-Verbose "$logLead : [$($providerStopWatch.Elapsed)] : Done determing environment name"
+
+ # Filter the packages down to the specific environment, and then record the packages.
+ Write-Verbose "$logLead : [$($providerStopWatch.Elapsed)] : Filtering packages"
+ $environmentPackages = $localServiceFabricPackages | Where-Object { ($null -eq $_.Environment) -or ($_.Environment -eq $envName)}
+ foreach ($package in $environmentPackages) {
+ $serviceFabricPackages.Add("$($package.ServiceFabricApplicationName)", $package);
+ }
+
+ $SFDetails.Add("requiredServiceFabricPackages", $requiredServiceFabricPackages)
+ $SFDetails.Add("Packages", $serviceFabricPackages)
+ $SFDictionary.Add("ServiceFabric", $SFDetails)
+
+ Write-Verbose "$logLead : [$($providerStopWatch.Elapsed)] : Provider Complete"
+ $providerStopWatch.Stop()
+
+ return $SFDictionary
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Inventory/Public/Get-SystemWebSettingsInventory.ps1 b/Modules/Alkami.DevOps.Inventory/Public/Get-SystemWebSettingsInventory.ps1
new file mode 100644
index 0000000..a173d76
--- /dev/null
+++ b/Modules/Alkami.DevOps.Inventory/Public/Get-SystemWebSettingsInventory.ps1
@@ -0,0 +1,52 @@
+function Get-SystemWebSettingsInventory {
+<#
+.SYNOPSIS
+ Returns an OrderedDictionary that Represents the System Web Settings Inventory.
+#>
+
+ [CmdletBinding()]
+ Param()
+
+ $logLead = (Get-LogLeadName);
+ $providerStopWatch = [System.Diagnostics.StopWatch]::StartNew()
+ $systemWebSettingsDetails = @{
+ httpRuntime = @{};
+ processModel = @{}
+ machineKey = @{};
+ }
+
+
+ $desiredSystemWebNodes = @{
+ "httpRuntime" = @{"minFreeThreads" = 0; "minLocalRequestFreeThreads" = 0};
+ "processModel" = @{"autoConfig" = ""; "maxWorkerThreads" = 0; "maxIoThreads" = 0; "minWorkerThreads" = 0; "minIoThreads" = 0};
+ "machineKey" = @{"validationKey" = ""; "decryptionKey" = ""; "decryption" = ""};
+ }
+
+ $systemWebSettingsDictionary = New-Object System.Collections.Specialized.OrderedDictionary
+
+ Write-Verbose "$logLead : [$($providerStopWatch.Elapsed)] : Getting Machine Config Data"
+
+ [xml]$machineConfigRaw = Read-MachineConfig
+
+ Write-Verbose "$logLead : [$($providerStopWatch.Elapsed)] : Machine Config Data Retrieved"
+
+ try {
+
+ foreach ($nodeKey in $desiredSystemWebNodes.keys) {
+ $tempXmlNode = ([xml]$machineConfigRaw).SelectNodes("//system.web/$nodeKey")
+ foreach ($keyname in $desiredSystemWebNodes.$nodeKey.keys) {
+ $systemWebSettingsDetails.$nodeKey[$keyname] = $tempXmlNode.$keyname
+ }
+ }
+ } catch {
+
+ $systemWebSettingsDetails["Error"] = $_.Exception.ToString()
+ }
+
+ $systemWebSettingsDictionary.Add("systemWebSettings", $systemWebSettingsDetails)
+
+ Write-Verbose "$logLead : [$($providerStopWatch.Elapsed)] : Provider Complete"
+ $providerStopWatch.Stop()
+
+ return $systemWebSettingsDictionary
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Inventory/Public/Get-TimeConfiguration.ps1 b/Modules/Alkami.DevOps.Inventory/Public/Get-TimeConfiguration.ps1
new file mode 100644
index 0000000..7bb9f92
--- /dev/null
+++ b/Modules/Alkami.DevOps.Inventory/Public/Get-TimeConfiguration.ps1
@@ -0,0 +1,43 @@
+function Get-TimeConfiguration {
+<#
+.SYNOPSIS
+ Returns an OrderedDictionary that Represents the Time Configuration.
+#>
+
+ [CmdletBinding()]
+ Param()
+
+ $logLead = (Get-LogLeadName);
+ $providerStopWatch = [System.Diagnostics.StopWatch]::StartNew()
+
+ $timeSettingDictionary = New-Object System.Collections.Specialized.OrderedDictionary
+ $timeDetails = New-Object System.Collections.Specialized.OrderedDictionary
+
+ Write-Verbose "$logLead : [$($providerStopWatch.Elapsed)] : Getting Time Configuration Details"
+
+ try {
+
+ $dateTime = Get-Date
+ $timeZone = Get-TimeZone
+
+ $timeDetails = New-Object PSObject -Property @{
+ "CurrentTime" = $dateTime.ToLongTimeString()
+ "CurrentDate" = $dateTime.Date
+ "CurrentTicks" = $dateTime.Ticks
+ "TimeZoneId" = $timeZone.Id
+ "TimeZoneName" = $timeZone.StandardName
+ "BaseUtcOffset" = $timeZone.BaseUtcOffset
+ }
+ }
+ catch {
+
+ $timeDetails["Error"] = $_.Exception.Message
+ }
+
+ $timeSettingDictionary.Add("TimeConfiguration", $timeDetails)
+
+ Write-Verbose "$logLead : [$($providerStopWatch.Elapsed)] : Provider Complete"
+ $providerStopWatch.Stop()
+
+ return $timeSettingDictionary
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Inventory/Public/Get-WindowsFeatureInventory.ps1 b/Modules/Alkami.DevOps.Inventory/Public/Get-WindowsFeatureInventory.ps1
new file mode 100644
index 0000000..cd9f9a8
--- /dev/null
+++ b/Modules/Alkami.DevOps.Inventory/Public/Get-WindowsFeatureInventory.ps1
@@ -0,0 +1,53 @@
+function Get-WindowsFeatureInventory {
+<#
+.SYNOPSIS
+ Returns an OrderedDictionary that Represents the Windows Feature Inventory.
+#>
+
+ [CmdletBinding()]
+ param()
+ $logLead = (Get-LogLeadName);
+ $providerStopWatch = [System.Diagnostics.StopWatch]::StartNew()
+
+ $featureDictionary = New-Object System.Collections.Specialized.OrderedDictionary
+ $featureDetails = New-Object System.Collections.Specialized.OrderedDictionary
+
+ try {
+
+ if ((Get-CIMInstance -Namespace "root\CIMV2" -Class Win32_OperatingSystem -Property Caption).Caption -notmatch "Server") {
+
+ $logError = "Windows Feature Inventory Not Supported on Client Operating Systems"
+ Write-Warning "$logLead : $logError"
+ throw $logError
+ }
+
+ Write-Verbose "$logLead : [$($providerStopWatch.Elapsed)] : Reading Windows Feature and Role Details"
+
+ Import-Module ServerManager -Verbose:$false | Out-Null
+ $features = Get-WindowsFeature | Where-Object {$_.Installed}
+
+ Write-Verbose "$logLead : [$($providerStopWatch.Elapsed)] : Windows Feature and Role Data Retrieved"
+
+ foreach ($feature in $features) {
+
+ $featureDetails[$feature.Name] = New-Object System.Collections.Specialized.OrderedDictionary
+ $featureDetails[$feature.Name]["Name"] = $feature.Name
+ $featureDetails[$feature.Name]["DisplayName"] = $feature.DisplayName
+ $featureDetails[$feature.Name]["FeatureType"] = $feature.FeatureType
+ $featureDetails[$feature.Name]["Path"] = $feature.Path
+ $featureDetails[$feature.Name]["Depth"] = $feature.Depth
+ $featureDetails[$feature.Name]["Parent"] = $feature.Parent
+ }
+ }
+ catch {
+
+ $featureDetails["Error"] = $_.Exception.ToString()
+ }
+
+ $featureDictionary.Add("Features", $featureDetails)
+
+ Write-Verbose "$logLead : [$($providerStopWatch.Elapsed)] : Provider Complete"
+ $providerStopWatch.Stop()
+
+ return $featureDictionary
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Inventory/Public/Get-WindowsServiceInventory.ps1 b/Modules/Alkami.DevOps.Inventory/Public/Get-WindowsServiceInventory.ps1
new file mode 100644
index 0000000..ab81ea0
--- /dev/null
+++ b/Modules/Alkami.DevOps.Inventory/Public/Get-WindowsServiceInventory.ps1
@@ -0,0 +1,34 @@
+function Get-WindowsServiceInventory {
+<#
+.SYNOPSIS
+ Returns an OrderedDictionary that Represents the Windows Service Inventory.
+#>
+
+ [CmdletBinding()]
+ Param()
+
+ $logLead = (Get-LogLeadName);
+ $providerStopWatch = [System.Diagnostics.StopWatch]::StartNew()
+
+ $serviceDictionary = New-Object System.Collections.Specialized.OrderedDictionary
+
+ try {
+
+ Write-Verbose "$logLead : [$($providerStopWatch.Elapsed)] : Reading Windows Service Information"
+ $services = Get-CIMInstance -Namespace "root\CIMV2" -Class Win32_Service `
+ -Property Name, ProcessId, State, StartMode, Status, PathName, StartName, Description, DisplayName `
+ | Select-Object -Property Name, ProcessId, State, StartMode, Status, PathName, StartName, Description, DisplayName
+ Write-Verbose "$logLead : [$($providerStopWatch.Elapsed)] : Windows Service Information Retrieved"
+
+ $serviceDictionary.Add("Services", $services)
+ }
+ catch {
+
+ $serviceDictionary["Error"] = $_.Exception.ToString()
+ }
+
+ Write-Verbose "$logLead : [$($providerStopWatch.Elapsed)] : Provider Complete"
+ $providerStopWatch.Stop()
+
+ return $serviceDictionary
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Inventory/Public/New-PlatformElementDetailPSObject.ps1 b/Modules/Alkami.DevOps.Inventory/Public/New-PlatformElementDetailPSObject.ps1
new file mode 100644
index 0000000..1107347
--- /dev/null
+++ b/Modules/Alkami.DevOps.Inventory/Public/New-PlatformElementDetailPSObject.ps1
@@ -0,0 +1,55 @@
+function New-PlatformElementDetailPSObject {
+ <#
+ .SYNOPSIS
+ Convenience function to create and return a PSOBject required by the BoRG REST API for PlatformElementDetails updates
+
+ .EXAMPLE
+ $detailObj = New-PlatformElementDetailPSObject `
+ -EnvironmentTypeName "AWS Sandbox 0.2" `
+ -PlatformVersionName "R2019.07.1.731" `
+ -ElementTierName "Web" `
+ -ElementName "Alkami.Ops.Common" `
+ -ElementVersionName "3.0.3"
+
+ .PARAMETER EnvironmentTypeName
+ The Environment label
+
+ .PARAMETER PlatformVersionName
+ The Platform version value
+
+ .PARAMETER ElementTierName
+ The label of the tier (Web, App, Fab, Mic, etc.)
+
+ .PARAMETER ElementName
+ The name of the actual element
+
+ .PARAMETER ElementVersion
+ The version of the actual element
+ #>
+ [CmdletBinding()]
+ Param(
+ [Parameter(Mandatory = $true)]
+ [string]$EnvironmentTypeName,
+
+ [Parameter(Mandatory = $false)]
+ [string]$PlatformVersionName,
+
+ [Parameter(Mandatory = $false)]
+ [string]$ElementTierName,
+
+ [Parameter(Mandatory = $true)]
+ [string]$ElementName,
+
+ [Parameter(Mandatory = $false)]
+ [string]$ElementVersionName
+ )
+
+ return New-Object PSObject -Property @{
+ "EnvironmentTypeName" = $EnvironmentTypeName
+ "PlatformVersionName" = $PlatformVersionName
+ "ElementName" = $ElementName
+ "ElementTierName" = $ElementTierName
+ "ElementVersionName" = $ElementVersionName
+ "DoNotCopy" = $false
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Inventory/Public/New-PlatformElementDetails.ps1 b/Modules/Alkami.DevOps.Inventory/Public/New-PlatformElementDetails.ps1
new file mode 100644
index 0000000..f282da2
--- /dev/null
+++ b/Modules/Alkami.DevOps.Inventory/Public/New-PlatformElementDetails.ps1
@@ -0,0 +1,40 @@
+function New-PlatformElementDetails {
+ <#
+ .SYNOPSIS
+ Use to persist an array of PlatformElementDetails to BoRG
+
+ .EXAMPLE
+ New-PlatformElementDetails -PlatformElementDetailArray @($obj) -BorgUri "http://uri.to.borg.com"
+
+ .PARAMETER PlatformElementDetailArray
+ An array of PSObjects containing all element details to be persisted. Use New-PlatformElementDetailPSObject for creation of the required PSObjects.
+
+ .PARAMETER BorgUri
+ Uri to the BoRG REST service.
+
+ .PARAMETER ApiKey
+ Borg Api key. Required for write operations.
+ #>
+ [CmdletBinding()]
+ Param(
+ [Parameter(Mandatory = $true)]
+ [PSObject[]]$PlatformElementDetailArray, #Use New-PlatformElementDetailPSObject to create each PSObject
+
+ [Parameter(Mandatory = $true)]
+ [string]$BorgUri,
+
+ [Parameter(Mandatory = $true)]
+ [string]$ApiKey
+ )
+
+ $uri = "$BorgUri/api/PlatformElementDetails/PostCascadeChildren"
+ $headers = @{ "x-api-key" = $ApiKey }
+ $body = ConvertTo-Json $PlatformElementDetailArray
+
+ $response = Invoke-CommandWithRetry -Arguments @($uri,$headers,$body) -Seconds 3 -JitterMin 500 -JitterMax 3000 -ScriptBlock {
+ param($uri,$headers,$body)
+ return (Invoke-RestMethod -Uri $uri -Method POST -Headers $headers -body $body -ContentType "application/json")
+ }
+
+ return ConvertTo-Json $response
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Inventory/Public/Remove-PlatformElementDetailById.ps1 b/Modules/Alkami.DevOps.Inventory/Public/Remove-PlatformElementDetailById.ps1
new file mode 100644
index 0000000..3684922
--- /dev/null
+++ b/Modules/Alkami.DevOps.Inventory/Public/Remove-PlatformElementDetailById.ps1
@@ -0,0 +1,40 @@
+function Remove-PlatformElementDetailById {
+ <#
+ .SYNOPSIS
+ Soft deletes a PlatformElementDetail by Id in BoRG by setting IsCurrent = false
+
+ .EXAMPLE
+ Remove-PlatformElementDetailById -PlatformElementDetailId 1 -BorgUri "http://uri.to.borg.com" -APiKey "b752be2f-9206-4706-a2fb-851162cb29bf"
+
+ .PARAMETER PlatformElementDetailId
+ The id of the element details to be removed.
+
+ .PARAMETER BorgUri
+ Uri to the BoRG REST service.
+
+ .PARAMETER ApiKey
+ Borg Api key. Required for write operations.
+ #>
+ [CmdletBinding()]
+ [OutputType([string])]
+ Param(
+ [Parameter(Mandatory = $true)]
+ [long]$PlatformElementDetailId,
+
+ [Parameter(Mandatory = $true)]
+ [string]$BorgUri,
+
+ [Parameter(Mandatory = $true)]
+ [string]$ApiKey
+ )
+
+ $uri = "$BorgUri/api/platformelementdetails/$PlatformElementDetailId"
+ $headers = @{ "x-api-key" = $ApiKey }
+
+ $response = Invoke-CommandWithRetry -Arguments @($uri, $headers) -Seconds 3 -JitterMin 500 -JitterMax 3000 -ScriptBlock {
+ param($uri, $headers)
+ return (Invoke-RestMethod -Uri $uri -Method PATCH -Headers $headers -ContentType "application/json")
+ }
+
+ return ConvertTo-Json $response
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Inventory/Public/Remove-PlatformElementDetails.ps1 b/Modules/Alkami.DevOps.Inventory/Public/Remove-PlatformElementDetails.ps1
new file mode 100644
index 0000000..8133ba2
--- /dev/null
+++ b/Modules/Alkami.DevOps.Inventory/Public/Remove-PlatformElementDetails.ps1
@@ -0,0 +1,41 @@
+function Remove-PlatformElementDetails {
+ <#
+ .SYNOPSIS
+ Soft deletes PlatformElementDetail(s) in BoRG by setting IsCurrent = false
+
+ .EXAMPLE
+ Remove-PlatformElementDetails -PlatformElementDetailArray @(item1, item2) -BorgUri "http://uri.to.borg.com" -APiKey "b752be2f-9206-4706-a2fb-851162cb29bf"
+
+ .PARAMETER PlatformElementDetailArray
+ An array of PSObjects containing all element details to be deleted. Use New-PlatformElementDetailPSObject for creation of the required PSObjects.
+
+ .PARAMETER BorgUri
+ Uri to the BoRG REST service.
+
+ .PARAMETER ApiKey
+ Borg Api key. Required for write operations.
+ #>
+ [CmdletBinding()]
+ [OutputType([string])]
+ Param(
+ [Parameter(Mandatory = $true)]
+ [PSObject[]]$PlatformElementDetailArray,
+
+ [Parameter(Mandatory = $true)]
+ [string]$BorgUri,
+
+ [Parameter(Mandatory = $true)]
+ [string]$ApiKey
+ )
+
+ $uri = "$BorgUri/api/platformelementdetails"
+ $headers = @{ "x-api-key" = $ApiKey }
+ $body = ConvertTo-Json $PlatformElementDetailArray
+
+ $response = Invoke-CommandWithRetry -Arguments @($uri, $headers, $body) -Seconds 3 -JitterMin 500 -JitterMax 3000 -ScriptBlock {
+ param($uri, $headers, $body)
+ return (Invoke-RestMethod -Uri $uri -Method PATCH -Headers $headers -Body $body -ContentType "application/json")
+ }
+
+ return ConvertTo-Json $response
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Inventory/Public/Save-MachineManifest.ps1 b/Modules/Alkami.DevOps.Inventory/Public/Save-MachineManifest.ps1
new file mode 100644
index 0000000..144a4a1
--- /dev/null
+++ b/Modules/Alkami.DevOps.Inventory/Public/Save-MachineManifest.ps1
@@ -0,0 +1,33 @@
+function Save-MachineManifest {
+<#
+ .SYNOPSIS
+ Saves a json manifest to the specified location. Defaults to c:\temp\manifest if not supplied
+
+ .EXAMPLE
+ Save-MachineManifest
+
+ This will save a manifest for the local machine at c:\temp\manifest
+
+ Save-MachineManifest -Folder "c:\temp\foo"
+
+ This will save a manifest of the local machine to "c:\temp\foo"
+
+ .PARAMETER outputFolder
+ Optional output location
+#>
+
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory = $false)]
+ [Alias("Folder")]
+ [string]$outputFolder = "c:\temp\manifest"
+ )
+
+ if(!(Test-Path $outputFolder))
+ {
+ New-Item -Path $defaultTempFileLocation -ItemType "directory"
+ }
+
+ $filename = "$outputFolder\$env:computername.json"
+ Get-MachineInventory -filter chocolatey -asJson | Out-File -FilePath $filename
+}
diff --git a/Modules/Alkami.DevOps.Inventory/Public/Send-DeploymentManifestToS3.ps1 b/Modules/Alkami.DevOps.Inventory/Public/Send-DeploymentManifestToS3.ps1
new file mode 100644
index 0000000..dcb818d
--- /dev/null
+++ b/Modules/Alkami.DevOps.Inventory/Public/Send-DeploymentManifestToS3.ps1
@@ -0,0 +1,112 @@
+function Send-DeploymentManifestToS3 {
+<#
+ .SYNOPSIS
+ Generates an inventory for the current machine (or accepts one from an existing local file location) and uploads it to s3. If a valid bucket is supplied that will be targeted. Otherwise a bucket name will be generated from machine.config values. If that generates an invalid bucket name it will throw.
+
+ .EXAMPLE
+ Send-DeploymentManifestToS3
+
+ This will upload a local manifest to the bucket generated based on machine.config values.
+
+ Send-DeploymentManifestToS3 -Bucket "alkami-manifest-test"
+
+ This will upload a local manifest to the "alkami-manifest-test" bucket
+
+ Send-DeploymentManifestToS3 -Bucket "alkami-manifest-test" -Filename "C:\Temp\foo1.json"
+
+ This will upload the manifest in the specified file (no validation is performed here) to the "alkami-manifest-test" bucket
+
+ .PARAMETER S3BucketName
+ Optional bucket name. Mostly used for local (non-aws) machine testing.
+
+ .PARAMETER InventoryFilename
+ Optional local manifest file name.
+ .PARAMETER ProfileName
+ [string] Specific AWS CLI Profile to use in AWS API calls
+#>
+
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory = $false)]
+ [Alias("Bucket")]
+ [string]$S3BucketName,
+
+ [Parameter(Mandatory = $false)]
+ [Alias("Filename")]
+ [string]$InventoryFilename,
+
+ [Parameter(Mandatory = $false)]
+ [string]$ProfileName
+ )
+
+ $logLead = (Get-LogLeadName)
+
+ Import-AWSModule # S3
+
+ $defaultTempFileLocation = "c:\temp\manifest"
+ $filename = ""
+ $splatParams = @{}
+
+ if (!([string]::IsNullOrEmpty($ProfileName))) {
+ $splatParams["ProfileName"] = "$ProfileName"
+ }
+
+ if ([string]::IsNullOrEmpty($s3BucketName)) {
+ $envName = Get-AppSetting "Environment.Name"
+ $envType = Get-AppSetting "Environment.Type"
+
+ $envName = $envName.Split(" ")[-1].Replace(".", "-")
+
+ if ($envType -eq "Production") {
+ $envType = "prod"
+ }
+
+ $s3BucketName = "orb-$envType-$envName-script-bucket"
+ }
+
+ if ([string]::IsNullOrEmpty($inventoryFilename)) {
+ if (!(Test-Path $defaultTempFileLocation)) {
+ New-Item -Path $defaultTempFileLocation -ItemType "directory"
+ }
+ $filename = "$defaultTempFileLocation\$env:computername.json"
+ Get-MachineInventory -filter chocolatey -asJson | Out-File -FilePath $filename
+ }
+ elseif (!($null -eq $inventoryFilename)) {
+ $filename = $inventoryFilename
+ }
+ else {
+ throw "$logLead : Inventory source is required"
+ }
+
+ $leafName = Split-Path $filename -leaf
+
+ $leafNameWithoutExtension = $leafName.Split(".")[0]
+
+ $existingManifests = Get-S3Object -BucketName $s3BucketName -KeyPrefix $leafNameWithoutExtension @splatParams
+
+ if ($null -eq $existingManifests) {
+ Write-S3Object -BucketName $s3BucketName -File $filename @splatParams
+ }
+ else {
+
+ if ($existingManifests.Count -gt 1) {
+ $oldManifest = $existingManifests | Where-Object {$_.Key -ne $leafName}
+
+ if ($oldManifest.Count -gt 1) {
+ throw "$loglead : Unexpected manifests found in S3. Please manually inspect the bucket."
+ }
+
+ # Force this delete so that we don't ask for human input.
+ Remove-S3Object -BucketName $s3BucketName -Key $oldManifest.Key -Force @splatParams
+ }
+
+ $fileTimestamp = Get-Date -Format FileDateTimeUniversal
+ $destinationKey = [System.Io.Path]::GetFileNameWithoutExtension($leafName) + "-" + $fileTimestamp + ".json"
+
+ Copy-S3Object -BucketName $s3BucketName -key $leafName -DestinationBucket $s3BucketName -DestinationKey $destinationKey @splatParams
+
+ # This should overwrite the existing one that we just copied to a new renamed file.
+ Write-S3Object -BucketName $s3BucketName -File $filename @splatParams
+ }
+ Write-Host "Manifest has been uploaded to $s3BucketName."
+}
diff --git a/Modules/Alkami.DevOps.Inventory/Public/Sync-PlatformElementInventory.ps1 b/Modules/Alkami.DevOps.Inventory/Public/Sync-PlatformElementInventory.ps1
new file mode 100644
index 0000000..c42fc8c
--- /dev/null
+++ b/Modules/Alkami.DevOps.Inventory/Public/Sync-PlatformElementInventory.ps1
@@ -0,0 +1,85 @@
+function Sync-PlatformElementInventory {
+ <#
+ .SYNOPSIS
+ Fully synchronizes the element inventory of an Environment to BoRG. The final BoRG contents will
+ match the environment contents. Useful for populating BoRG with the element list of a new environment or
+ correcting major deviations.
+
+ WARNING: This function persists ALL discovered elements in an environment, regardless of tier. Elements
+ installed on an incorrect tier can result in a failed MDV during deployments.
+ #>
+ [CmdletBinding()]
+ Param (
+ [Parameter(Mandatory = $true)]
+ [string[]]$Servers,
+
+ [Parameter(Mandatory = $true)]
+ [string]$EnvironmentName,
+
+ [Parameter(Mandatory = $true)]
+ [string]$BorgUri,
+
+ [Parameter(Mandatory = $true)]
+ [string]$ApiKey
+ )
+
+ Write-Host "Starting BoRG full sync for $EnvironmentName..."
+
+ #Pull current BoRG Inventory
+ try {
+ Write-Host "Retrieving BoRG platform element inventory for $EnvironmentName."
+ $borgInventory = (Get-PlatformElementInventory -BorgUri $BorgUri -EnvironmentName $EnvironmentName)
+ } catch {
+ Write-Error "An error occurred Retrieving BoRG inventory: $($_.Exception.Message)"
+ }
+
+ #Pull machine inventory
+ Write-Host "Retrieving machine inventory for $($Servers -join ',')."
+ $machineInventory = (Get-MachineInventory -ComputerNames $Servers -Filter Chocolatey,ServiceFabric)
+
+ #Compare contents of Machines against BoRG and build add/remove collections
+ $borgElementsToAdd = @{}
+ $borgElementsToRemove = @{}
+
+ foreach ($key in $machineInventory.Keys) {
+ #Get element list from inventory
+ $machineTier = $key.SubString(0,3).ToLower()
+ if ($machineTier -eq "fab") {
+ $machineElements = $machineInventory.$key.ConfigData.ServiceFabric.Packages
+ } else {
+ $machineElements = $machineInventory.$key.ConfigData.Chocolatey.Packages
+ }
+
+ Write-Host "Determining elements to add/update for $key."
+
+ foreach ($element in $machineElements.Values) {
+ if ($element.SourceName -match "Alkami|SDK" -and $element.Name -notmatch "PowerShell" -and !$borgElementsToAdd.ContainsKey($element.Name)) {
+ $borgElementsToAdd[$element.Name] = $element
+ }
+ }
+ }
+
+ Write-Host "Determining elements to remove from BoRG."
+ foreach ($element in $borgInventory) {
+ if (!$borgElementsToAdd.ContainsKey($element.Name)) {
+ $borgElementsToRemove[$element.Name] = $element
+ }
+ }
+
+ #Update BoRG
+ Write-Host "Updating BoRG [$BorgUri] Inventory."
+ try {
+ Update-PlatformElementInventory `
+ -ElementsToAdd @($borgElementsToAdd.Values) `
+ -ElementsToRemove @($borgElementsToRemove.Values) `
+ -EnvironmentTypeName $EnvironmentName `
+ -PlatformVersionName "Unspecified" `
+ -ElementTierName "Unknown" `
+ -BorgUri $BorgUri `
+ -ApiKey $ApiKey
+ } catch {
+ Write-Error "An exception occurred while updating BoRG. Error: $($_.Exception.Message)"
+ }
+
+ Write-Host "BoRG full sync for $EnvironmentName completed successfully."
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Inventory/Public/Update-PlatformElementInventory.ps1 b/Modules/Alkami.DevOps.Inventory/Public/Update-PlatformElementInventory.ps1
new file mode 100644
index 0000000..6eaf9f9
--- /dev/null
+++ b/Modules/Alkami.DevOps.Inventory/Public/Update-PlatformElementInventory.ps1
@@ -0,0 +1,72 @@
+function Update-PlatformElementInventory {
+<#
+.SYNOPSIS
+ Adds or Removes the Element name/versions in BoRG to reflect changes.
+#>
+ [CmdletBinding()]
+ Param (
+ [Parameter(Mandatory = $false)]
+ [AllowNull()]
+ [object[]]$ElementsToAdd,
+
+ [Parameter(Mandatory = $false)]
+ [AllowNull()]
+ [object[]]$ElementsToRemove,
+
+ [Parameter(Mandatory = $true)]
+ [string]$EnvironmentTypeName,
+
+ [Parameter(Mandatory = $true)]
+ [string]$PlatformVersionName,
+
+ [Parameter(Mandatory = $true)]
+ [string]$ElementTierName,
+
+ [Parameter(Mandatory = $true)]
+ [string]$BorgUri,
+
+ [Parameter(Mandatory = $true)]
+ [string]$ApiKey
+ )
+
+ #Remove from BoRG
+ $deletePlatformElementInventory = New-Object System.Collections.ArrayList
+
+ foreach ($element in $ElementsToRemove) {
+ $delete = New-PlatformElementDetailPSObject `
+ -EnvironmentTypeName $EnvironmentTypeName `
+ -ElementName $element.Name
+
+ $deletePlatformElementInventory.Add($delete) | Out-Null
+ }
+
+ if ($deletePlatformElementInventory.Count -gt 0) {
+ Write-Host "Deleting [$($deletePlatformElementInventory.Count)] PlatformElementDetail items from BoRG"
+ (Remove-PlatformElementDetails -PlatformElementDetailArray $deletePlatformElementInventory.ToArray() -BorgUri $BorgUri -ApiKey $ApiKey) | Out-Null
+ } else {
+ Write-Host "No PlatformElementDetail items deleted in BoRG"
+ }
+
+ #Add/Update BoRG
+ $addPlatformElementInventory = New-Object System.Collections.ArrayList
+
+ foreach ($element in $ElementsToAdd) {
+ $detail = New-PlatformElementDetailPSObject `
+ -EnvironmentTypeName $EnvironmentTypeName `
+ -PlatformVersionName $PlatformVersionName `
+ -ElementTierName $ElementTierName `
+ -ElementName $element.Name `
+ -ElementVersionName $element.Version
+
+ $addPlatformElementInventory.Add($detail) | Out-Null
+ }
+
+ if ($addPlatformElementInventory.Count -gt 0) {
+ Write-Host "Adding/Updating [$($addPlatformElementInventory.Count)] PlatformElementDetail items in BoRG"
+ (New-PlatformElementDetails -PlatformElementDetailArray $addPlatformElementInventory.ToArray() -BorgUri $BorgUri -ApiKey $ApiKey) | Out-Null
+ } else {
+ Write-Host "No PlatformElementDetail items added to BoRG"
+ }
+
+
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Inventory/tools/chocolateyInstall.ps1 b/Modules/Alkami.DevOps.Inventory/tools/chocolateyInstall.ps1
new file mode 100644
index 0000000..b01306e
--- /dev/null
+++ b/Modules/Alkami.DevOps.Inventory/tools/chocolateyInstall.ps1
@@ -0,0 +1,37 @@
+[CmdletBinding()]
+Param()
+process {
+ $myCurrentPath = $PSScriptRoot;
+ Write-Verbose "Installing the Module from $myCurrentPath";
+
+ $parentPath = (Split-Path $myCurrentPath);
+ $systemModulePath = "C:\Program Files\WindowsPowerShell\Modules\";
+ $myModulePath = (Join-Path $parentPath "module");
+
+ $metadata = ([Xml](Get-Content (Join-Path $parentPath "*.nuspec"))).package.metadata;
+
+ $id = $metadata.id;
+
+ $version = $metadata.version -replace '-pre.+','';
+
+ $targetModulePath = (Join-Path $systemModulePath $id);
+ $targetModuleVersionPath = (Join-Path $targetModulePath $version);
+
+ if (Test-Path $targetModulePath) {
+ ## If the target folder already existed, remove it, because we are re-installing this package, obviously
+ if (Test-Path $targetModuleVersionPath) {
+ Write-Warning "Found an already existing module at [$targetModuleVersionPath]!!"
+ Remove-Item $targetModuleVersionPath -Recurse -Force;
+ }
+
+ ## Clear previous children for name conflicts
+ (Get-ChildItem $targetModulePath) | ForEach-Object {
+ Write-Information "Removing module located at [$_]";
+ Remove-Item $_.FullName -Recurse -Force;
+ }
+ }
+
+ Write-Host "Copying module $id to [$targetModuleVersionPath]";
+ Copy-Item $myModulePath -Destination $targetModuleVersionPath -Recurse -Force;
+
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Inventory/tools/chocolateyUninstall.ps1 b/Modules/Alkami.DevOps.Inventory/tools/chocolateyUninstall.ps1
new file mode 100644
index 0000000..7c36766
--- /dev/null
+++ b/Modules/Alkami.DevOps.Inventory/tools/chocolateyUninstall.ps1
@@ -0,0 +1,25 @@
+[CmdletBinding()]
+Param()
+process {
+ $myCurrentPath = $PSScriptRoot;
+ Write-Verbose "Uninstalling the Module from $myCurrentPath";
+
+ $parentPath = (Split-Path $myCurrentPath);
+ $systemModulePath = "C:\Program Files\WindowsPowerShell\Modules\";
+ $myModulePath = (Join-Path $parentPath "module");
+
+ $metadata = ([Xml](Get-Content (Join-Path $parentPath "*.nuspec"))).package.metadata;
+
+ $id = $metadata.id;
+
+ $version = $metadata.version -replace '-pre.+','';
+
+ $targetModulePath = (Join-Path $systemModulePath $id);
+ $targetModuleVersionPath = (Join-Path $targetModulePath $version);
+
+ if (Test-Path $targetModuleVersionPath) {
+ Write-Information "Removing module at [$targetModuleVersionPath]!!"
+ Remove-Item $targetModuleVersionPath -Recurse -Force;
+ }
+}
+
diff --git a/Modules/Alkami.DevOps.Minikube/Alkami.DevOps.Minikube.nuspec b/Modules/Alkami.DevOps.Minikube/Alkami.DevOps.Minikube.nuspec
new file mode 100644
index 0000000..d0bdd5e
--- /dev/null
+++ b/Modules/Alkami.DevOps.Minikube/Alkami.DevOps.Minikube.nuspec
@@ -0,0 +1,25 @@
+
+
+
+ Alkami.DevOps.Minikube
+ $version$
+ Alkami Platform Modules - DevOps - Minikube
+ Alkami Technologies
+ Alkami Technologies
+ https://extranet.alkamitech.com/display/ORB/Alkami.DevOps.Minikube
+ https://www.alkami.com/files/alkamilogo75x75.png
+ http://alkami.com/files/orblicense.html
+ false
+ Installs the Alkami Minikube module for use with PowerShell.
+
+ PowerShell
+ Copyright (c) 2022 Alkami Technologies
+
+
+
+
+
+
+
+
+
diff --git a/Modules/Alkami.DevOps.Minikube/Alkami.DevOps.Minikube.psd1 b/Modules/Alkami.DevOps.Minikube/Alkami.DevOps.Minikube.psd1
new file mode 100644
index 0000000..c9b8b19
--- /dev/null
+++ b/Modules/Alkami.DevOps.Minikube/Alkami.DevOps.Minikube.psd1
@@ -0,0 +1,11 @@
+@{
+ RootModule = 'Alkami.DevOps.Minikube.psm1'
+ ModuleVersion = '1.0.6'
+ GUID = '65934fe9-ddda-47fe-809c-674429c85d74'
+ Author = 'twitecki'
+ CompanyName = 'Alkami Technologies, Inc.'
+ Copyright = '(c) 2022 Alkami Technologies, Inc. All rights reserved.'
+ PowerShellVersion = '5.0'
+ RequiredModules = 'Alkami.PowerShell.Common'
+ FunctionsToExport = 'Export-MinikubeDevDynamic','Install-MinikubeDependencies','Reset-MiniKubeSecrets','Reset-MinikubeServices','Restart-Wsl','Start-Minikube'
+}
diff --git a/Modules/Alkami.DevOps.Minikube/Alkami.DevOps.Minikube.pssproj b/Modules/Alkami.DevOps.Minikube/Alkami.DevOps.Minikube.pssproj
new file mode 100644
index 0000000..ee6b757
--- /dev/null
+++ b/Modules/Alkami.DevOps.Minikube/Alkami.DevOps.Minikube.pssproj
@@ -0,0 +1,40 @@
+
+
+ Debug
+ 2.0
+ {d0e8804a-489a-4a1c-858e-c71d106880df}
+ Exe
+ Alkami.DevOps.Minikube
+ Alkami.DevOps.Minikube
+ Alkami.DevOps.Minikube
+ Invoke-Pester;
+ ..\build-project.ps1 (Join-Path $(SolutionDir) "Alkami.DevOps.Minikube")
+
+
+ true
+ full
+ false
+ bin\
+ DEBUG;TRACE
+ prompt
+ 4
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Modules/Alkami.DevOps.Minikube/Private/Get-MinikubeConfigurationInformation.ps1 b/Modules/Alkami.DevOps.Minikube/Private/Get-MinikubeConfigurationInformation.ps1
new file mode 100644
index 0000000..9d5466b
--- /dev/null
+++ b/Modules/Alkami.DevOps.Minikube/Private/Get-MinikubeConfigurationInformation.ps1
@@ -0,0 +1,34 @@
+function Get-MinikubeConfigurationInformation {
+<#
+ .SYNOPSIS
+ Gets the Minikube configuration information used to configure Alkami applications in Minikube
+
+ .DESCRIPTION
+ Gets the Minikube configuration information used to configure Alkami applications in Minikube
+
+ .EXAMPLE
+ Get-MinikubeConfigurationInformation
+#>
+ [CmdletBinding()]
+ param()
+
+ $ErrorActionPreference = "Stop"
+
+ $localKubernetesConfigurationFolder = ".alkami-k8s-dev"
+ $localK8sConfigurationPath = "${HOME}/${localKubernetesConfigurationFolder}";
+ $localServiceVersionsFileName = "values.local-service-versions.yaml"
+ $localServiceVersionsFilePath = "$localK8sConfigurationPath/$localServiceVersionsFileName"
+ $localServiceCustomizationsFileName = "values.local-service-configs.yaml"
+ $localServiceCustomizationsFilePath = "$localK8sConfigurationPath/$localServiceCustomizationsFileName"
+ $helmChartName = "alkami-local-dev"
+
+ return @{
+ localKubernetesConfigurationFolder=$localKubernetesConfigurationFolder
+ localK8sConfigurationPath=$localK8sConfigurationPath
+ localServiceVersionsFileName=$localServiceVersionsFileName
+ localServiceVersionsFilePath=$localServiceVersionsFilePath
+ localServiceCustomizationsFileName=$localServiceCustomizationsFileName
+ localServiceCustomizationsFilePath=$localServiceCustomizationsFilePath
+ helmChartName=$helmChartName
+ };
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Minikube/Public/Export-MinikubeDevDynamic.ps1 b/Modules/Alkami.DevOps.Minikube/Public/Export-MinikubeDevDynamic.ps1
new file mode 100644
index 0000000..a04ba63
--- /dev/null
+++ b/Modules/Alkami.DevOps.Minikube/Public/Export-MinikubeDevDynamic.ps1
@@ -0,0 +1,43 @@
+function Export-MinikubeDevDynamic {
+<#
+ .SYNOPSIS
+ Exports the existing developer dynamic databases into their own container that is importable by someone else
+
+ .DESCRIPTION
+ Exports the existing developer dynamic databases into their own container that is importable by someone else
+
+ .EXAMPLE
+ Export-MinikubeDevDynamic
+#>
+ [CmdletBinding()]
+ param()
+
+ $ErrorActionPreference = "Stop"
+ $logLead = (Get-LogLeadName)
+ $resourcesPath = Join-Path $PSScriptRoot "Resources"
+
+ Write-Host "$logLead : Updating kubectl context to minikube"
+ minikube update-context
+
+ Write-Host "$logLead : Exporting database in minikube"
+ $minikubeJson = docker inspect minikube
+ $minikubeContainerId = (ConvertFrom-Json -InputObject "$minikubeJson").Id
+ Write-Host "$logLead : Minikube container id: $minikubeContainerId"
+ docker cp ${minikubeContainerId}:/mnt/data "$PSScriptRoot"
+
+ Write-Host "$logLead : Building docker image with exported DB"
+ $exportDockerFilePath = "$resourcesPath\DatabaseExportDockerfile"
+ $guid = [guid]::NewGuid().toString()
+ $tag = "export-$env:UserName-$guid"
+ $localDockerImageName = "alkami.db.developerdynamic.export:$tag"
+ docker build -t "$localDockerImageName" -f $exportDockerFilePath "$PSScriptRoot"
+
+ Remove-Item -Recurse -Force "$PSScriptRoot\data"
+
+ Write-Host "$logLead : Successfully built $localDockerImageName. Pushing to proget"
+ $progetImage = "packagerepo.orb.alkamitech.com/alkami/library/alkami.db.developerdynamic:$tag"
+ docker tag "alkami.db.developerdynamic.export:$tag" "$progetImage"
+ docker push "$progetImage"
+
+ Write-Host "$logLead : Exported minikube database to: $progetImage"
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Minikube/Public/Install-MinikubeDependencies.ps1 b/Modules/Alkami.DevOps.Minikube/Public/Install-MinikubeDependencies.ps1
new file mode 100644
index 0000000..af69686
--- /dev/null
+++ b/Modules/Alkami.DevOps.Minikube/Public/Install-MinikubeDependencies.ps1
@@ -0,0 +1,65 @@
+function Install-MinikubeDependencies {
+<#
+
+.SYNOPSIS
+ Installs the dependencies required to run Alkami services in Minikube
+
+.DESCRIPTION
+ Installs the dependencies required to run Alkami services in Minikube
+
+.EXAMPLE
+ Install-MinikubeDependencies
+#>
+ [CmdletBinding()]
+ param(
+ )
+
+ $ErrorActionPreference = "Stop"
+ $logLead = (Get-LogLeadName)
+
+ $chocoVersion = choco -v
+
+ Write-Host "$loglead : Installing Minikube dependencies"
+
+ if ($chocoVersion) {
+ Write-Host "$loglead : Using choco version: $chocoVersion"
+ }
+ else {
+ Write-Host "$loglead : Chocolatey not installed. Install Chocolatey before trying again."
+ }
+
+ Write-Host "$loglead : Installing Minikube..."
+ choco install minikube --version 1.25.1 -y
+
+ Write-Host "$loglead : Installing the kubectl..."
+ choco install kubernetes-cli --version 1.22.2 -y
+
+ Write-Host "$loglead : Installing helm..."
+ choco install kubernetes-helm --version 3.7.2 -y
+
+ Write-Host "$loglead : Installing skaffold..."
+ choco install skaffold --version 1.39.0 -y
+
+ Write-Host "$loglead : Installing yaml tools for powershell..."
+ choco install powershell-yaml --version 0.4.2 -y
+
+ $kerberosInstalled = (Get-ItemProperty HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\* | Where-Object { $_.DisplayName -Match 'MIT Kerberos for Windows' })
+ If (-Not $kerberosInstalled) {
+ Write-Host "$loglead : Installing mitkerberos..."
+ choco install mitkerberos --version 4.1 -y
+ }
+
+ $url = "alk-devdynamic.localhost"
+ Write-Host "$loglead : Checking if $url needs to be added to hosts..."
+ $hostsContent = Get-HostsFileContent
+ $hostsString = "127.0.0.1{0}{1}"
+ $hostsRegex = "^127\.0\.0\.1\s+$url"
+
+ if (($hostsContent | Where-Object {$_ -match $hostsRegex }).Count -gt 0) {
+ Write-Host "$loglead : Loopback Hosts File Entry for $url already exists"
+ } else {
+ Write-Host "$loglead : Adding alk-devdynamic.localhost to hosts..."
+ $newHostsEntry = ($hostsString -f "`t`t", $url)
+ Add-HostsFileContent -contentToAdd $newHostsEntry -Force
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Minikube/Public/Reset-MiniKubeSecrets.ps1 b/Modules/Alkami.DevOps.Minikube/Public/Reset-MiniKubeSecrets.ps1
new file mode 100644
index 0000000..d6b3523
--- /dev/null
+++ b/Modules/Alkami.DevOps.Minikube/Public/Reset-MiniKubeSecrets.ps1
@@ -0,0 +1,91 @@
+function Reset-MinikubeSecrets {
+<#
+ .SYNOPSIS
+ Resets secrets used in the local service development environment
+
+ .DESCRIPTION
+ Resets secrets used in the local service development environment: aws access key, ecr access key, kerberos ticket
+
+ .PARAMETER AwsProfile
+ [string] Will use the specified AWS profile when refreshing AWS credentials
+
+ .EXAMPLE
+ Refresh-Secrets -AwsProfile SRE
+#>
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory = $false)]
+ [string]$AwsProfile = "Dev"
+ )
+
+ $ErrorActionPreference = "Stop"
+ $logLead = (Get-LogLeadName)
+ $tempAwsProfile = "temp-$AwsProfile".ToLower()
+
+ Write-Host "$logLead : Verifying kube context..."
+
+ kubectl config use-context minikube
+
+ Write-Host "$logLead : Refreshing aws session with profile: $tempAwsProfile..."
+
+ Update-AWSProfile -Profile $AwsProfile
+ $AWS_ECR_LOGIN = aws ecr get-login-password --region us-east-1 --profile $tempAwsProfile
+ docker login --username AWS --password $AWS_ECR_LOGIN 327695573722.dkr.ecr.us-east-1.amazonaws.com
+ docker login --username AWS --password $AWS_ECR_LOGIN 790953160341.dkr.ecr.us-east-1.amazonaws.com
+
+ Write-Host "$logLead : Re-Mounting docker credentials for ECR access..."
+
+ $DOCKER_AWS_AUTH = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes("AWS:$AWS_ECR_LOGIN"))
+ $DOCKER_CONFIG_JSON = "
+ {
+ 'auths': {
+ '327695573722.dkr.ecr.us-east-1.amazonaws.com': {
+ 'auth': '$DOCKER_AWS_AUTH'
+ },
+ '790953160341.dkr.ecr.us-east-1.amazonaws.com': {
+ 'auth': '$DOCKER_AWS_AUTH'
+ }
+ }
+ }" -replace "'", '"'
+
+ $DOCKER_CONFIG_JSON | Out-File dockerconfig.json -Encoding Ascii
+
+ if (kubectl get secret awsecr-cred -n localhost --ignore-not-found --output=yaml) {
+ Write-Host "$logLead : Deleting existing Kubernetes ECR access secret: awssecr-cred..."
+ kubectl delete secret awsecr-cred -n localhost
+ }
+
+ Write-Host "$logLead : Creating Kubernetes ECR access secret: awssecr-cred..."
+ kubectl create secret generic awsecr-cred -n localhost --from-file=.dockerconfigjson=dockerconfig.json --type=kubernetes.io/dockerconfigjson
+ Remove-Item dockerconfig.json
+
+ Write-Host "$logLead : Re-Mounting config map for AWS resource access..."
+
+ $AWS_ACCESS_KEY_ID = aws configure get aws_access_key_id --profile $tempAwsProfile
+ $AWS_SECRET_ACCESS_KEY = aws configure get aws_secret_access_key --profile $tempAwsProfile
+ $AWS_SESSION_TOKEN = aws configure get aws_session_token --profile $tempAwsProfile
+
+ if (kubectl get configmap aws-config -n localhost --ignore-not-found --output=yaml) {
+ Write-Host "$logLead : Deleting existing Kubernetes AWS access config map: aws-config..."
+ kubectl delete configmap aws-config -n localhost
+ }
+
+ Write-Host "$logLead : Creating Kubernetes AWS access config map: aws-config..."
+ kubectl create configmap aws-config -n localhost --from-literal=AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID --from-literal=AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY --from-literal=AWS_SESSION_TOKEN=$AWS_SESSION_TOKEN
+
+ Write-Host "$logLead : Re-Mounting Kerberos ticket for database access..."
+
+ Write-Host "$logLead : Purging existing Kerberos tickets..."
+ klist purge
+
+ Write-Host "$logLead : Obtaining new Kerberos ticket..."
+ kinit
+
+ if (kubectl get configmap kerberos-config -n localhost --ignore-not-found --output=yaml) {
+ Write-Host "$logLead : Deleting existing Kerberos ticket config map: kerberos-config..."
+ kubectl delete configmap kerberos-config -n localhost
+ }
+
+ Write-Host "$logLead : Creating Kerberos ticket config map: kerberos-config..."
+ kubectl create configmap kerberos-config -n localhost --from-file=c:\ProgramData\MIT\Kerberos5\
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Minikube/Public/Reset-MinikubeServices.ps1 b/Modules/Alkami.DevOps.Minikube/Public/Reset-MinikubeServices.ps1
new file mode 100644
index 0000000..704b40c
--- /dev/null
+++ b/Modules/Alkami.DevOps.Minikube/Public/Reset-MinikubeServices.ps1
@@ -0,0 +1,47 @@
+function Reset-MinikubeServices {
+<#
+ .SYNOPSIS
+ Resets Minikube services to match those defined in the service version and configuration files
+
+ .DESCRIPTION
+ Use this function to reset your cluster to match your service(s) configuration files.
+
+ .PARAMETER Force
+ [switch] Will force a complete reinstallation and sync up between the services config file and the Minikube cluster. Use this when removing or excluding a service from the configuration.
+
+ .EXAMPLE
+ Refresh-Services -Force
+#>
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory = $false)]
+ [switch]$Force
+ )
+
+ $ErrorActionPreference = "Stop"
+ $logLead = (Get-LogLeadName)
+
+ $config = Get-MinikubeConfigurationInformation
+
+ if ($Force.IsPresent)
+ {
+ Write-Host "$loglead : Forcing refresh of services..."
+
+ Write-Host "$loglead : Deleting localhost namespace..."
+ kubectl delete ns localhost
+
+ Write-Host "$loglead : Creating localhost namespace..."
+ kubectl create ns localhost
+
+ Write-Host "$loglead : Refreshing Minikube secrets..."
+ Reset-MinikubeSecrets
+
+ Write-Host "$loglead : Installing alkami-local-dev services..."
+ helm install alkami-local-dev --repo https://packagerepo.orb.alkamitech.com/helm/helm-charts $config.helmChartName -n localhost --create-namespace --dependency-update -f $config.localServiceVersionsFilePath -f $config.localServiceCustomizationsFilePath
+ }
+ else
+ {
+ Write-Host "$loglead : Upgrading alkami-local-dev services..."
+ helm upgrade alkami-local-dev --repo https://packagerepo.orb.alkamitech.com/helm/helm-charts $config.helmChartName -n localhost --create-namespace --dependency-update -f $config.localServiceVersionsFilePath -f $config.localServiceCustomizationsFilePath
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Minikube/Public/Restart-Wsl.ps1 b/Modules/Alkami.DevOps.Minikube/Public/Restart-Wsl.ps1
new file mode 100644
index 0000000..8ceffb8
--- /dev/null
+++ b/Modules/Alkami.DevOps.Minikube/Public/Restart-Wsl.ps1
@@ -0,0 +1,46 @@
+function Restart-Wsl {
+<#
+ .SYNOPSIS
+ Restarts Wsl and waits for it to restart
+
+ .DESCRIPTION
+ Restarts Wsl and waits for it to restart
+
+ .EXAMPLE
+ Restart-Wsl
+#>
+ [CmdletBinding()]
+ param()
+
+ $ErrorActionPreference = "Stop"
+ $logLead = (Get-LogLeadName)
+
+ Write-Host "$logLead : Stopping all Docker Desktop processes"
+ Get-Process "*docker desktop*" | Stop-Process
+
+ Write-Host "$logLead : Shutting down WSL"
+
+ wsl --shutdown
+
+ Write-Host "$logLead : Waiting for Wsl to shutdown"
+ Start-Sleep -Seconds 5
+
+ Write-Host "$logLead : Starting all Docker Desktop processes"
+ Start-Process "C:\Program Files\Docker\Docker\Docker Desktop.exe"
+
+ Write-Host "$logLead : Waiting for Docker Desktop to startup"
+
+ $job = Start-Job -ScriptBlock {
+ do
+ {
+ docker version
+ }
+ while($lastexitcode -ne 0)
+ }
+
+ Wait-Job -Job $job | Out-Null
+
+ Remove-Job -Job $job
+
+ Write-Host "$logLead : Wsl successfully restarted"
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Minikube/Public/Start-Minikube.ps1 b/Modules/Alkami.DevOps.Minikube/Public/Start-Minikube.ps1
new file mode 100644
index 0000000..525198e
--- /dev/null
+++ b/Modules/Alkami.DevOps.Minikube/Public/Start-Minikube.ps1
@@ -0,0 +1,206 @@
+function Start-Minikube {
+<#
+ .SYNOPSIS
+ Starts a Minikube Kubernetes cluster ready to host Alkami applications
+
+ .DESCRIPTION
+ Starts a Minikube Kubernetes cluster ready to host Alkami applications
+
+ .PARAMETER InstallDependencies
+ [switch] Will install all dependencies for running Minikube locally. Run this the first time starting Minikube.
+
+ .PARAMETER IncludeMetricsServer
+ [switch] Will include metrics server as part of the Minikube cluster.
+
+ .PARAMETER IncludeKibana
+ [switch] Will include Kibana as part of the Minikube cluster.
+
+ .PARAMETER IncludeArgoCD
+ [switch] Will include ArgoCD and Argo Rollouts as part of the Minikube cluster
+
+ .PARAMETER ForceReset
+ [switch] Will delete and recreate the entire minikube cluster. Required when wanting to edit the CPU and Memory allocation.
+
+ .PARAMETER Cpus
+ [uint32] Will set the Minikube CPU resource allocation to the specificed number of CPUs.
+
+ .PARAMETER Memory
+ [uint32] Will set the Minikube memory resource allocation to the specificed number of MegaBytes.
+
+ .PARAMETER IngressPort
+ [uint32] Will expose ingress traffic into the Minikube cluster on the specified port. Default: 10000
+
+ .EXAMPLE
+ Start-Minikube -InstallDependencies -IncludeMetricsServer -Cpus 6 -Memory 8 -IngressPort 7000
+#>
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory = $false)]
+ [switch]$InstallDependencies,
+
+ [Parameter(Mandatory = $false)]
+ [switch]$IncludeMetricsServer,
+
+ [Parameter(Mandatory = $false)]
+ [switch]$IncludeKibana,
+
+ [Parameter(Mandatory = $false)]
+ [switch]$IncludeArgoCD,
+
+ [Parameter(Mandatory = $false)]
+ [switch]$ForceReset,
+
+ [Parameter(Mandatory = $false)]
+ [uint32]$Cpus = 5,
+
+ [Parameter(Mandatory = $false)]
+ [uint32]$Memory = 6,
+
+ [Parameter(Mandatory = $false)]
+ [uint32]$IngressPort = 10000
+ )
+
+ $ErrorActionPreference = "Stop"
+ $logLead = (Get-LogLeadName)
+ $resourcesPath = Join-Path $PSScriptRoot "Resources"
+ $config = Get-MinikubeConfigurationInformation
+
+ if ($InstallDependencies.IsPresent) {
+ Install-MinikubeDependencies
+ }
+
+ if ($ForceReset.IsPresent) {
+ minikube delete
+ }
+
+ # Configure WSL memory usage.
+ $wslConfigPath = "${Env:HOMEPATH}\.wslconfig"
+ Write-Host "$logLead : Configuring WSL resource settings at: $wslConfigPath."
+ $memoryLimit = "$($Memory)GB"
+ $wslConfig = @"
+[wsl2]
+memory=$memoryLimit
+swap=0
+"@
+
+ Set-Content -Path $wslConfigPath -Value $wslConfig -Force
+
+ Restart-Wsl
+
+ [string]$certsDirectory= "$resourcesPath\cacerts\*"
+
+ [string]$miniKubeCertsDirectory = "${Env:HOMEPATH}\.minikube\certs"
+
+ if (!(Test-Path $miniKubeCertsDirectory))
+ {
+ New-Item $miniKubeCertsDirectory -ItemType Directory
+ }
+
+ Write-Host "$logLead : Copying CA Certs to minikube cert location: ${miniKubeCertsDirectory}"
+
+ Copy-item -Force -Recurse $certsDirectory -Destination $miniKubeCertsDirectory
+
+ # The wslconfig allocates memory for wsl. Docker desktop doesn't get all of that memory so minikube will need less than 1024 bytes per gig allocated to wsl.
+ $memoryBytes = $Memory * 800
+
+ minikube config set cpus $Cpus
+ minikube config set memory $memoryBytes
+
+ minikube start --driver=docker `
+ --embed-certs `
+ --addons dashboard `
+ --addons ingress `
+ --docker-opt=dns=10.0.16.42 `
+ --docker-opt=dns=10.0.16.43 `
+ --docker-opt=dns-search=corp.alkamitech.com `
+ --docker-opt=dns-search=fh.local `
+ --ports=$($IngressPort):443 `
+ --ports=32000:32000 `
+ --extra-config=kubelet.housekeeping-interval=10s
+
+ if ($IncludeMetricsServer.IsPresent) {
+ minikube addons enable metrics-server
+ }
+
+ Write-Host "$logLead : Copying Kerberos configuration file. (krb5.ini)"
+ Copy-Item (Join-Path $resourcesPath "\krb5.ini") -Destination "C:\ProgramData\MIT\Kerberos5" -Force
+
+ Write-Host "$logLead : Configuring Kerberos ticket cache location environment variable."
+ # Set env variable for current session
+ $Env:KRB5CCNAME = "c:\ProgramData\MIT\Kerberos5\krb5cc_0"
+ # Set env variable for future sessions
+ [System.Environment]::SetEnvironmentVariable('KRB5CCNAME','c:\ProgramData\MIT\Kerberos5\krb5cc_0', [System.EnvironmentVariableTarget]::Machine)
+
+ Write-Host "$logLead : Creating localhost namespace..."
+
+ kubectl create ns localhost
+
+ Write-Host "$logLead : Initializing Kubernetes secrets..."
+
+ Reset-MinikubeSecrets
+
+ Write-Host "$logLead : Configuring tls for ingress"
+ kubectl apply -f (Join-Path $resourcesPath "ingress-tls-secret.yaml")
+
+ $ingressDeploy = kubectl get deployment/ingress-nginx-controller -n ingress-nginx -o yaml | ConvertFrom-Yaml
+
+ Write-Host "$logLead : Updating Ingress Nginx with proxy forwarding enabled..."
+ $ingressDeets = kubectl get cm ingress-nginx-controller -n ingress-nginx -o yaml | ConvertFrom-Yaml
+
+ $ingressDeets.Data.Add('use-forwarded-headers', 'true')
+ $ingressDeets | ConvertTo-Yaml | kubectl apply -f -
+
+ Write-Host "$logLead : Deleting ingress nginx deployment"
+ kubectl delete deployment/ingress-nginx-controller -n ingress-nginx
+
+ $ingressDeploy.spec.template.spec.containers[0].args += "--default-ssl-certificate=ingress-nginx/ingress-tls-secret"
+ $ingressDeploy.metadata.Remove('managedFields')
+ $ingressDeploy.Remove('status')
+ $modifiedDeploy = $ingressDeploy | ConvertTo-Yaml
+
+ Write-Host "$logLead : Recreating ingress nginx deployment with default ssl certificiate"
+ $modifiedDeploy | kubectl apply -f -
+
+ Write-Host "$logLead : Waiting for ingress nginx controller to be healthy before proceeding..."
+ kubectl rollout status deployment/ingress-nginx-controller -n ingress-nginx
+
+ kubectl apply -f (Join-Path $resourcesPath "ingress-dashboard.yaml")
+
+ Write-Host "$logLead : Checking for local kubernetes configuration file path: ${$localK8sConfigurationPath}"
+
+ if (-not (Test-Path $config.localK8sConfigurationPath)) {
+ Write-Host "$logLead : Missing local kubernetes configuration file path. Creating..."
+ New-Item -path $HOME -name $config.localKubernetesConfigurationFolder -type "directory"
+ New-Item -path $config.localK8sConfigurationPath -name $config.localServiceVersionsFileName -type "file" -value "# Use this file to configure which services to install. You can copy any existing gitops values file for any environment - for example: https://bitbucket.corp.alkami.net/projects/AUTO/repos/alkami.gitops.kubernetes/browse/alkami-services/environments/tde/values.tde.yaml"
+ New-Item -path $config.localK8sConfigurationPath -name $config.localServiceCustomizationsFileName -type "file" -value "# Use this file to customize any service definitions via env variables etc. You can also exclude a service from being installed into your local environment (exclude: true)"
+ } else {
+ Write-Host "$logLead : Found existing local kubernetes configuration file path at: $localK8sConfigurationPath"
+ }
+
+ if (!(helm repo list | select-string proget)) {
+ Write-Host "$logLead : Missing helm repo configuration. Adding proget repo..."
+ helm repo add proget "https://packagerepo.orb.alkamitech.com/helm/helm-charts"
+ }
+
+ if ($IncludeArgoCD.IsPresent) {
+ Write-Host "$logLead : Adding ArgoCD helm repo configuration..."
+ helm repo add argo https://argoproj.github.io/argo-helm
+ helm repo add argo-rollouts https://argoproj.github.io/argo-helm
+ }
+
+ Write-Host "$logLead : Updating helm repos..."
+ helm repo update
+
+ Write-Host "$logLead : Installing alkami-local-dev helm chart..."
+ helm install alkami-local-dev --repo https://packagerepo.orb.alkamitech.com/helm/helm-charts $config.helmChartName -n localhost --create-namespace --dependency-update -f $config.localServiceVersionsFilePath -f $config.localServiceCustomizationsFilePath
+
+ if ($IncludeKibana.IsPresent) {
+ helm install alkami-dev-kibana --repo https://packagerepo.orb.alkamitech.com/helm/helm-charts alkami-dev-kibana --dependency-update
+ }
+
+ if ($IncludeArgoCD.IsPresent) {
+ $currentDate = (get-date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
+ helm install argocd argo/argo-cd -n argocd --create-namespace --set server.ingress.enabled=true --set server.ingress.hosts[0]=argocd.localhost.dev.alkami.net --set server.extraArgs[0]='--insecure' --set config.secret.extraArgs[1]='--disable-auth' --set configs.secret.argocdServerAdminPassword='$2y$10$CyYdVLTiR8OO2gGwkQsAeuwAFYeSOzPH6Kf/aan7fLau57fgVaUaq' --set configs.secret.argocdServerAdminPasswordMtime="$currentDate"
+ helm install argo-rollouts argo-rollouts/argo-rollouts -n argo-rollouts --create-namespace
+ }
+}
diff --git a/Modules/Alkami.DevOps.Minikube/Resources/cacerts/alkamica.pem b/Modules/Alkami.DevOps.Minikube/Resources/cacerts/alkamica.pem
new file mode 100644
index 0000000..b73c925
--- /dev/null
+++ b/Modules/Alkami.DevOps.Minikube/Resources/cacerts/alkamica.pem
@@ -0,0 +1,29 @@
+-----BEGIN CERTIFICATE-----
+MIIE9zCCA9+gAwIBAgIJAJJDI3J7eaSmMA0GCSqGSIb3DQEBBQUAMIGtMQswCQYD
+VQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTESMBAGA1UEBxMJTG9zIEFsdG9z
+MRUwEwYDVQQKEwxuZXRTa29wZSBJbmMxGDAWBgNVBAsTD0NlcnQgTWFuYWdlbWVu
+dDEdMBsGA1UEAxMUY2FhZG1pbi5uZXRza29wZS5jb20xJTAjBgkqhkiG9w0BCQEW
+FmNlcnRhZG1pbkBuZXRza29wZS5jb20wHhcNMTMwNjE5MjMyMTE3WhcNNDMwNjEy
+MjMyMTE3WjCBrTELMAkGA1UEBhMCVVMxEzARBgNVBAgTCkNhbGlmb3JuaWExEjAQ
+BgNVBAcTCUxvcyBBbHRvczEVMBMGA1UEChMMbmV0U2tvcGUgSW5jMRgwFgYDVQQL
+Ew9DZXJ0IE1hbmFnZW1lbnQxHTAbBgNVBAMTFGNhYWRtaW4ubmV0c2tvcGUuY29t
+MSUwIwYJKoZIhvcNAQkBFhZjZXJ0YWRtaW5AbmV0c2tvcGUuY29tMIIBIjANBgkq
+hkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxzKWllimAaCY6P8qG1OjPrs4/5b/ofQX
+e7Gd/vCoAtWEClQ6TxC1YkUl7sYF9bu1CUD3NHWsQfReezCKGyBPafcu+twg+Dcd
+yc/uaCxz6V9BpsPT6lgzYkDDpVE63I9zlMGnaS+hIAdplaPjcXHUXe/mYNXtKAte
+duaUDg76R1588BLvOVWn6txFoDxKqDsfkzAoI3uoPO7AH/0EFAokAYzt9f1G1hLE
+v30U9feQ0OIgGSpdiqo/pLDaRUpAI2ZmOfm5DNu/6vEF0G6p7rNY1rIUynTVmzKY
+P+5w61fsBN19OUYCvCgF6NOpBZgl93xCocnZ1Zd3DjR9M2QFFdXrXwIDAQABo4IB
+FjCCARIwHQYDVR0OBBYEFNQ+GYzg+VGt/zUO4p1Ja1DuzKF+MIHiBgNVHSMEgdow
+gdeAFNQ+GYzg+VGt/zUO4p1Ja1DuzKF+oYGzpIGwMIGtMQswCQYDVQQGEwJVUzET
+MBEGA1UECBMKQ2FsaWZvcm5pYTESMBAGA1UEBxMJTG9zIEFsdG9zMRUwEwYDVQQK
+EwxuZXRTa29wZSBJbmMxGDAWBgNVBAsTD0NlcnQgTWFuYWdlbWVudDEdMBsGA1UE
+AxMUY2FhZG1pbi5uZXRza29wZS5jb20xJTAjBgkqhkiG9w0BCQEWFmNlcnRhZG1p
+bkBuZXRza29wZS5jb22CCQCSQyNye3mkpjAMBgNVHRMEBTADAQH/MA0GCSqGSIb3
+DQEBBQUAA4IBAQCKwspwM/SpIrbFEKSh4bfhR6p9YZ8nL6V5Q68lQooIFBTndPi4
+0lazoss4NrUchuCvDe9LHgfDQMf5LABiyy6RixMhWYa85hoUHkULDwunSYHJEKhH
+OzuxQl31m7/jQtL7RtAvTzdnykI5lrGdOjvCgxFDa6lBS5fmDUs+5DpDbHWamExC
+ksBmuDhV8+yh+7MZriSOCEzDNlxUm8qbK2TYvaQhM7n+YM4BG+2DVWtVPtyiVTFd
+ss27AwG2hyhc+Czg5PU2V1Z+JsQyEOl4G8xLHH6hdQPWC8gBlsPc/nemEBxhnqNi
+fAkUAvHXsEwI5PdVcvP2KI4hSz60FQNMQEr6
+-----END CERTIFICATE-----
diff --git a/Modules/Alkami.DevOps.Minikube/Resources/cacerts/alkamienterpriseca.pem b/Modules/Alkami.DevOps.Minikube/Resources/cacerts/alkamienterpriseca.pem
new file mode 100644
index 0000000..9444d5a
--- /dev/null
+++ b/Modules/Alkami.DevOps.Minikube/Resources/cacerts/alkamienterpriseca.pem
@@ -0,0 +1,34 @@
+-----BEGIN CERTIFICATE-----
+MIIF3TCCA8WgAwIBAgIQIcBw3ESSAKxPclLhwj+AwjANBgkqhkiG9w0BAQsFADB1
+MRMwEQYKCZImiZPyLGQBGRYDY29tMRowGAYKCZImiZPyLGQBGRYKYWxrYW1pdGVj
+aDEUMBIGCgmSJomT8ixkARkWBGNvcnAxLDAqBgNVBAMTI0Fsa2FtaSBFbnRlcnBy
+aXNlIFRydXN0ZWQgUm9vdCBDQTAxMB4XDTIwMDcyOTE2MzE1NFoXDTMyMDcyOTE2
+NDE1MFowdTETMBEGCgmSJomT8ixkARkWA2NvbTEaMBgGCgmSJomT8ixkARkWCmFs
+a2FtaXRlY2gxFDASBgoJkiaJk/IsZAEZFgRjb3JwMSwwKgYDVQQDEyNBbGthbWkg
+RW50ZXJwcmlzZSBUcnVzdGVkIFJvb3QgQ0EwMTCCAiIwDQYJKoZIhvcNAQEBBQAD
+ggIPADCCAgoCggIBALf7suNprUZJLTwZSYLKa4ZtZXb3mijr5kaCliBQ2s+zCFiK
+IQ7VeV2PGU2HhQ7TI083mdHpkReiJC/XnZG5OLgNV34PszL4vaB/pfs/D11/Yl0J
+q/I03YAjLuvN1B0ncu34jI+Lt0Skn/qS4+LN5DcB9UEx6LB+tO62cLkduh1fAxid
+NHE3Y6Vv8HaNS31wxzz9v8cwdc6HE28Mo1s7yH+0KxhHymZZEE16zD/a3SBpr0uF
+f/jiZt7JfvB7mmZW3RD7EUkyjt5Zey/XSzzUPyqaSP/9nxDDdSAN1MXJUCJmJNE/
+hg162DxBnbw9dK7wJ0GnC2CZCpp5fOdjmomJgr2KOAc/LtIqlfbeafEGN7CKN9tw
+RI8Ifh0C1A+TUmQ5pm9AoUsV++E0xS/oQw2BqxInnLXwC6HbdepQr2t3GmVrHCh7
+RdGIZBuMyFW6GO8VooSjvewhZnaEUCBQccfe1u4qLb65FKH4LCedPcZJj2I2Q+Rq
+gtqz9ixrdLcSfEUlR06E0LjSECYlwv3b1saW0U3m9MYh7rv0pC+h91FEh+mR+if4
+jxFoippDzeuAjdpB9CZWmJLrh1FlLk62LWUKWpIyFXMFnvQGEb3V09NJT/R3zoL6
+r10q7u4UDm+MXwyZHSDmOwgIpgHD0O5TR4goKJ5OE+B+5MqupVGYtU+9NuCXAgMB
+AAGjaTBnMBMGCSsGAQQBgjcUAgQGHgQAQwBBMA4GA1UdDwEB/wQEAwIBhjAPBgNV
+HRMBAf8EBTADAQH/MB0GA1UdDgQWBBQ3LgGw9Kf7bhIGQusLIAR4GSpxYDAQBgkr
+BgEEAYI3FQEEAwIBADANBgkqhkiG9w0BAQsFAAOCAgEAZn6erjl5ySbFSuPq9AYR
+gcI4BNGUf19GS8ahAzzCqH9W8a3s1AOC4PjCYUH1DZWco/WY8DlhsKqOL7lyiMoL
+hGxVdD4PYKgSOqmNBinNEl0xRQ1FKdjxygxIFU7X970ZiZsTJKV/gDqS90HfY3uL
+YGZMWDChIYONtZnmxMr3D/rWn8K0a+ziDbrDgnLVAy6X7pw4uefRBfm5TqVi66Ak
+F2Tj4IeBf5QFUGLXVQNzac/Bn6Wh0y0d9TcHfZT4uwvREB5v6RWmTcAqUqLCyDzu
+i5hq+mzegW7KZz9gLnfBdrVEG9vR4ZiDwZy3J02PHTT/zfmtAd65sw0QJwfiLWgg
+Mpc1+StwBlLaX6QZMY+PEifc108TV8TREUew4dBRJdZY3Yx4Pk9qbWqP0dN/DAs+
+uQcET1pxVqozad8zBcXEd7g80bmOKF6XYgOUeqVjR3Gha6phLz679A4NcpbqTzl6
+i+FO9quLlNgzcqwqqANSsdC2pGCYFhYMOgRU5ygQr/89Bun4cvLuOctToeZg+0by
+janN/o4r+xRIvUTRdrF88v5gpLmLHE4Giq5JLmpPAXxjHPnwhh5+Mrozp1XLvDDt
+Jchu44wugkOJuCJnBQUu1HXi99GhEk4Wr3UZ/QRUaor7KcxiTnGfvX5h1HnPTNps
+Kmw7n+AmxIMk8zaCHky7HsQ=
+-----END CERTIFICATE-----
diff --git a/Modules/Alkami.DevOps.Minikube/Resources/cacerts/bitbucket.pem b/Modules/Alkami.DevOps.Minikube/Resources/cacerts/bitbucket.pem
new file mode 100644
index 0000000..69592f5
--- /dev/null
+++ b/Modules/Alkami.DevOps.Minikube/Resources/cacerts/bitbucket.pem
@@ -0,0 +1,34 @@
+-----BEGIN CERTIFICATE-----
+MIIF6DCCBNCgAwIBAgIQBHWZSb1ru3eGnAuhZXHHITANBgkqhkiG9w0BAQsFADBG
+MQswCQYDVQQGEwJVUzEPMA0GA1UEChMGQW1hem9uMRUwEwYDVQQLEwxTZXJ2ZXIg
+Q0EgMUIxDzANBgNVBAMTBkFtYXpvbjAeFw0yMTA3MTQwMDAwMDBaFw0yMjA4MTIy
+MzU5NTlaMCQxIjAgBgNVBAMTGWJpdGJ1Y2tldC5jb3JwLmFsa2FtaS5uZXQwggEi
+MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCuhOmLtN6LXrJbRO/a4aXViJjM
+kJTTxZ4s4iE2As43XKgqtgzGIBBwox+rLH/LPzuoBN5p7D7QlhHb8P8KKFvX62/P
+ixIIABfsZ/jJOBh4PB0pLTMucysU+0sMoAIn7sAqnGHIiq385UMiWt6tv9rcx1vi
+7VB5wg0sQjJ/sPWHJLFw8kZM9ooERRTBtGfP5NbUJk9ex6YDadbH0c/Murp/9yCQ
+G2/8yA8U9UOawDn7yBeAvPTNID3uhgqW3IPEl97huNdbnFOBXZ9RhDqtfsaQc3YI
+5CJEIvyyIQxLmnQVGIRk0BkntgBXJ4s0Gh9laN/GPO/aIzN1c9SQSaNzhmhjAgMB
+AAGjggLyMIIC7jAfBgNVHSMEGDAWgBRZpGYGUqB7lZI8o5QHJ5Z0W/k90DAdBgNV
+HQ4EFgQUNoEYTY74OzP+E7SMgtWF1rrQER8wJAYDVR0RBB0wG4IZYml0YnVja2V0
+LmNvcnAuYWxrYW1pLm5ldDAOBgNVHQ8BAf8EBAMCBaAwHQYDVR0lBBYwFAYIKwYB
+BQUHAwEGCCsGAQUFBwMCMDsGA1UdHwQ0MDIwMKAuoCyGKmh0dHA6Ly9jcmwuc2Nh
+MWIuYW1hem9udHJ1c3QuY29tL3NjYTFiLmNybDATBgNVHSAEDDAKMAgGBmeBDAEC
+ATB1BggrBgEFBQcBAQRpMGcwLQYIKwYBBQUHMAGGIWh0dHA6Ly9vY3NwLnNjYTFi
+LmFtYXpvbnRydXN0LmNvbTA2BggrBgEFBQcwAoYqaHR0cDovL2NydC5zY2ExYi5h
+bWF6b250cnVzdC5jb20vc2NhMWIuY3J0MAwGA1UdEwEB/wQCMAAwggF+BgorBgEE
+AdZ5AgQCBIIBbgSCAWoBaAB1ACl5vvCeOTkh8FZzn2Old+W+V32cYAr4+U1dJlwl
+XceEAAABeqbAEhQAAAQDAEYwRAIgNBQSZ5T+VkjP2IXiHcg3DEanOYFKkSi28uvk
+nS8KyVsCICnDtAu11R68/culSg1ZwMGbB7x5+LhpdvDkAn9xN4OiAHYAQcjKsd8i
+RkoQxqE6CUKHXk4xixsD6+tLx2jwkGKWBvYAAAF6psASHQAABAMARzBFAiEA0JRT
+UThxXp2dcsKowmthUSbyCL33lUx0GUw7BKRVL+sCICP+A5Ehj9Lvo1imko46MUJS
+zWvfzBeuvP4bfPt1rW4pAHcA36Veq2iCTx9sre64X04+WurNohKkal6OOxLAIERc
+KnMAAAF6psASdAAABAMASDBGAiEAjTB5aEFFq1KhvNHPzHfG4dYCwoXZfhiHt/Wr
+wMek4k4CIQCgM6RefXs+V406RI2m/xDSREAmIo3kSVgBmJHUxprsGDANBgkqhkiG
+9w0BAQsFAAOCAQEApzPcmbB/rPpX5dwSo8HxzmFMCkDKOm3Yow+KD4Eqtmpi+yup
+agSTgp7/yGUn0zd/mDtCIqoFc3U+bVajzdzFYlcNYKKNNf9P1cWHBngq+IdswYO0
+9WhlHtn3dZNZLCciEC0qOC94LkKMyzazuMTJqunxInUU4be7rFfuoUITcXkl3nx+
+ayYlDbJEEj3pCd9VD7SSHvn/OIY6TfjcUWu6l7uG/JIygFJ6E4zbIz83XrgbA7cf
+RjQ3CMghMt/6B+0U63shD3Ji+qwwgSFsjnvHDouEHp6dYwLVjQUzjMDYwVg8CkA7
+eXFwBpSYaiylvyNimorTtXVI+nsIpcuc9I7OUQ==
+-----END CERTIFICATE-----
diff --git a/Modules/Alkami.DevOps.Minikube/Resources/cacerts/crl5d.pem b/Modules/Alkami.DevOps.Minikube/Resources/cacerts/crl5d.pem
new file mode 100644
index 0000000..202ab17
--- /dev/null
+++ b/Modules/Alkami.DevOps.Minikube/Resources/cacerts/crl5d.pem
@@ -0,0 +1,27 @@
+-----BEGIN CERTIFICATE-----
+MIIEhTCCA22gAwIBAgIRAI89BCr/SzvVCgAAAAErhhswDQYJKoZIhvcNAQELBQAw
+RjELMAkGA1UEBhMCVVMxIjAgBgNVBAoTGUdvb2dsZSBUcnVzdCBTZXJ2aWNlcyBM
+TEMxEzARBgNVBAMTCkdUUyBDQSAxQzMwHhcNMjExMjI3MDkzMTQzWhcNMjIwMzIx
+MDkzMTQyWjATMREwDwYDVQQDDAgqLmdjci5pbzBZMBMGByqGSM49AgEGCCqGSM49
+AwEHA0IABCFO2fjluCduNMWBZOF+f8oA2oo7aXMDBInGXLzp7u4rR9FsFwd52GDm
+PQ0k75e9JxqC8TBYr0QljJ4DVMnlnuujggJqMIICZjAOBgNVHQ8BAf8EBAMCB4Aw
+EwYDVR0lBAwwCgYIKwYBBQUHAwEwDAYDVR0TAQH/BAIwADAdBgNVHQ4EFgQUckS8
+gIPpksW7z9gFprTdhDJ+knUwHwYDVR0jBBgwFoAUinR/r4XN7pXNPZzQ4kYU83E1
+HScwagYIKwYBBQUHAQEEXjBcMCcGCCsGAQUFBzABhhtodHRwOi8vb2NzcC5wa2ku
+Z29vZy9ndHMxYzMwMQYIKwYBBQUHMAKGJWh0dHA6Ly9wa2kuZ29vZy9yZXBvL2Nl
+cnRzL2d0czFjMy5kZXIwGwYDVR0RBBQwEoIIKi5nY3IuaW+CBmdjci5pbzAhBgNV
+HSAEGjAYMAgGBmeBDAECATAMBgorBgEEAdZ5AgUDMDwGA1UdHwQ1MDMwMaAvoC2G
+K2h0dHA6Ly9jcmxzLnBraS5nb29nL2d0czFjMy9RT3ZKME4xc1QyQS5jcmwwggEF
+BgorBgEEAdZ5AgQCBIH2BIHzAPEAdgApeb7wnjk5IfBWc59jpXflvld9nGAK+PlN
+XSZcJV3HhAAAAX37cjC7AAAEAwBHMEUCIQCOKm34cS8aPgCbGjzzRMQPrwpJQYQw
+CEG8TyLKKY1/aQIgGIBLbr6TjLzlr+/uERVzUebwM6IEY03X+LFpu8CeuukAdwBB
+yMqx3yJGShDGoToJQodeTjGLGwPr60vHaPCQYpYG9gAAAX37cjDmAAAEAwBIMEYC
+IQCaGySDyumyE8mY4fbbJBQTmTE7jaLWhPVB3VyvuxXF+wIhAK3fOwV5Bv35Bjwb
+NvFyZzn62oRELEzUKL8pSO1+fR+IMA0GCSqGSIb3DQEBCwUAA4IBAQAZorur4aYb
+UITVxa2TfK7IxKVSdHLOelv+gMjceaRuyCC7dWm/bk2RDE5ngPxT5ipsA86QYiYc
+lIakDnNEsrMg8FDgp0gySyAznE4Ans3YGNuacxgZBrg9t7vVOmYb9+p6nG/BcNEC
+4+4dgv4f8ILyTSG59zsIL6SIW3J1pAtJOSwffutS6FN/UzVVF8d7NG83Q6++tUx/
+15yqNFm5uR1pcUAP/jdElRjAgeDVUpG/yUTbRQHjOTNdRNzS7TGf8QmwoT23LnRI
+icbPkO7yAsYbNBURah3t/MHFeWh1b8hNisZjbzeNMiBOi2DdQbP27mZ/6kSNJR3y
+r1UPRxHWONlp
+-----END CERTIFICATE-----
diff --git a/Modules/Alkami.DevOps.Minikube/Resources/ingress-dashboard.yaml b/Modules/Alkami.DevOps.Minikube/Resources/ingress-dashboard.yaml
new file mode 100644
index 0000000..f297749
--- /dev/null
+++ b/Modules/Alkami.DevOps.Minikube/Resources/ingress-dashboard.yaml
@@ -0,0 +1,23 @@
+apiVersion: networking.k8s.io/v1
+kind: Ingress
+metadata:
+ name: kubernetes-dashboard-ingress
+ namespace: kubernetes-dashboard
+ annotations:
+ "helm.sh/resource-policy": keep
+spec:
+ ingressClassName: nginx
+ tls:
+ - hosts:
+ - dashboard.localhost.dev.alkami.net
+ rules:
+ - host: dashboard.localhost.dev.alkami.net
+ http:
+ paths:
+ - path: /
+ pathType: Prefix
+ backend:
+ service:
+ name: kubernetes-dashboard
+ port:
+ number: 9090
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Minikube/Resources/ingress-tls-secret.yaml b/Modules/Alkami.DevOps.Minikube/Resources/ingress-tls-secret.yaml
new file mode 100644
index 0000000..a302eb9
--- /dev/null
+++ b/Modules/Alkami.DevOps.Minikube/Resources/ingress-tls-secret.yaml
@@ -0,0 +1,11 @@
+apiVersion: v1
+kind: Secret
+metadata:
+ name: ingress-tls-secret
+ namespace: ingress-nginx
+ annotations:
+ "helm.sh/resource-policy": keep
+type: kubernetes.io/tls
+data:
+ tls.crt: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUVKRENDQXd5Z0F3SUJBZ0lSQUtzczAxNm5KT1hOSGVBc2lzZDM5YTR3RFFZSktvWklodmNOQVFFTEJRQXcKZGpFTE1Ba0dBMVVFQmhNQ1ZWTXhIakFjQmdOVkJBb01GVUZzYTJGdGFTQlVaV05vYm05c2IyZDVJRWx1WXpFTApNQWtHQTFVRUN3d0NTVlF4RGpBTUJnTlZCQWdNQlZSbGVHRnpNUm93R0FZRFZRUUREQkZ6ZFdKallURXVZV3hyCllXMXBMbTVsZERFT01Bd0dBMVVFQnd3RlVHeGhibTh3SGhjTk1qSXdNekE0TVRZd01qQXhXaGNOTWpNd05EQTQKTVRjd01qQXhXakFsTVNNd0lRWURWUVFEREJvcUxteHZZMkZzYUc5emRDNWtaWFl1WVd4cllXMXBMbTVsZERDQwpBU0l3RFFZSktvWklodmNOQVFFQkJRQURnZ0VQQURDQ0FRb0NnZ0VCQUlmV2lwWDA5MFc1TnNFZlZYOUJQYUl3Cm9LeVBEUzFpWU9Xb1NzemdWN3NhNDU0OXFhUDVUeFM0MFRmUmpaSXB2UTZhMnI1S3RMU0RlSEhnZEtTT2N5d2IKUllYWkcrS20rRytvRFlhbUFTUHJMamJqVVJtTXVxbnV2dUJRQjVOdlZzSlJEL0tzeUxodUtqN1EwTFhIT0FQWgpkQ1lpVkxCNi9jMEdxK0JTUWorSm9UR0NiQ215dXBDLzV5Q21wRlVpYjlVZXdNc29jSzlEMU96b1luTHNxY1czCkF5bGJEYnl3bTFuWEpRNUVOeHI5cE90bGpnM0V1M3o0bFNtdFlSbmFIQ1RHemtyQUJOZnByUit6TEwzZXRycysKQklhMHJQMC9CY0RyeG1BOFh1RzlCem1OSTRpTWNtN3d2WGR1TXMydnhtYkw2dlczaVljeGFHWUxMUDZQMkFjQwpBd0VBQWFPQi9UQ0IrakFsQmdOVkhSRUVIakFjZ2hvcUxteHZZMkZzYUc5emRDNWtaWFl1WVd4cllXMXBMbTVsCmREQUpCZ05WSFJNRUFqQUFNQjhHQTFVZEl3UVlNQmFBRkV3eHlqYThRZjJreU1NaEdISjIwTkwza2E0Zk1CMEcKQTFVZERnUVdCQlJ0aHZCMUxiVzhiU2tmNVVYMlNwYTFxak1jbmpBT0JnTlZIUThCQWY4RUJBTUNCYUF3SFFZRApWUjBsQkJZd0ZBWUlLd1lCQlFVSEF3RUdDQ3NHQVFVRkJ3TUNNRmNHQTFVZEh3UlFNRTR3VEtCS29FaUdSbWgwCmRIQTZMeTlqY213dWMyVmpMbUZzYTJGdGFTNXVaWFF2WTNKc0x6WTRORFkzT1RaaUxUZGpOV1V0TkdWaFpTMWkKTldObExUZ3hOVFJsWWpFek5UbGxZeTVqY213d0RRWUpLb1pJaHZjTkFRRUxCUUFEZ2dFQkFDNm1VbEg2ZmVVTApXOU45NCszNjBzaHByUkNudVZHaU83WkxxRG1PeXlReHJWMHJwRGFaQ2svamY5b3pQUDlqQ1U1UmhxQlhBNXNvCnNNbDhNLzJXcjQxeWUva29idHVZUGlNVUo1KzFPRXhEMWdDNVRzYlpmWDdnMWZ2Q0NYUEZ3UTV1Z29mT0tsQUIKNzVPTTQ5eFFhMkhaV1FTZFcvV2tjeUJMQVhmemlyeUgyYlg0TXJTeEhGdmZGbmZqZTJ4c2hRNWZwSFlLbHVlSgplK09FUWVxMXhMUzczUHpobEhFcU43aE9QSWVRc0pyanBvQk5YbkhKU0tOVG5IdGhuUExSemVEbkhVWTc1dG5XCnhSME1WaHlYYUtVbkZreTdnYTA3b1V1THZ0WGJNangyU2ZpZVdRdkREdGdRbTBwUFM4M0VreFg1eFNNLzUvL0kKVW5uQ01RTGNzNE09Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K
+ tls.key: LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCk1JSUV2QUlCQURBTkJna3Foa2lHOXcwQkFRRUZBQVNDQktZd2dnU2lBZ0VBQW9JQkFRQ0gxb3FWOVBkRnVUYkIKSDFWL1FUMmlNS0NzancwdFltRGxxRXJNNEZlN0d1T2VQYW1qK1U4VXVORTMwWTJTS2IwT210cStTclMwZzNoeAo0SFNram5Nc0cwV0YyUnZpcHZodnFBMkdwZ0VqNnk0MjQxRVpqTHFwN3I3Z1VBZVRiMWJDVVEveXJNaTRiaW8rCjBOQzF4emdEMlhRbUlsU3dldjNOQnF2Z1VrSS9pYUV4Z213cHNycVF2K2NncHFSVkltL1ZIc0RMS0hDdlE5VHMKNkdKeTdLbkZ0d01wV3cyOHNKdFoxeVVPUkRjYS9hVHJaWTROeEx0OCtKVXByV0VaMmh3a3hzNUt3QVRYNmEwZgpzeXk5M3JhN1BnU0d0S3o5UHdYQTY4WmdQRjdodlFjNWpTT0lqSEp1OEwxM2JqTE5yOFpteStyMXQ0bUhNV2htCkN5eitqOWdIQWdNQkFBRUNnZ0VBSGpWZGU0elBRcysyT0F1T1dXNzZWR3ZwQjRjR29LMnNxOVlaMEdjaHk0Zm0KejhXWnlOQUVRTEQ3UWlVVmpVZy85WlFGaW1VVnU4RXpFMndkdEl6RFd5OHpibGxDaE15cUdqYXV6MTl5aUhqZwpQYStlMVFaQmF0SWYyOFdnY2E3RWhoRTk4VE53cmVjOStOczZWdnFYWCsvSGowVjZQUWNWRXpmbEdFMWkwenNtCnp1UEhQVnlkVnNMMzJiT04wdWJISFpJNXFNaVQzenJMeE8rRWllQ0ZBQVkrSjVReDU1bUZlbnpybW1NVUdhU20KTUFNdVVLSnY2dndKS3Jtdk5RbStlVTZTdTBHT1lVbXVvRjc5N0FJY0FEQjdYWm9Ob05iTjYwUStDdUhyUU5oegpISW92bGpTTlpRck1ZajM2YVNmSUgxWHd2bEFNcXF4dnJySW9HTnc0SVFLQmdRQzdmeit1cFdRMjRXQSt4MDZUCm1SZko3U2xHOG9OWXZ5V0dHL3N6ZnVDYnlLOUttWHNyZTB1OXFpejBVYkY4Wm9xK2thdGtYZFFkNTF0bUpkVUwKaFFSTUhLTTNtc1locEcrRHA3TEVvYmxRbmlsaVRsTWlONndMcjRLa2J4eWRySEdyTnZRWmhKZ0pkb3poQWdxSgppeVdFR1dFeCtQd21KYzNLRVRrQU1oWUQ1d0tCZ1FDNWQ1Vmp5TENEcmE2WjZGdjdYaHBEVTFxSUhod3AxTXNQCkZpVVRVUlpreldHTGVkbk1RQTAwOHRXNU02Ky9MaGNnUkp1UldaUFljaVIzVHNNWE9OUzdiNTgzK3ZicXFQNkcKSSs4TUlaYzE3WUdkUWJXYzBCYUIyamdHUDZQU3NIMit3cVhBLy8zTDl1dUdjVUZUN1ZzSDQyQlhJT3RidnBxNAo0TjUwM2drRzRRS0JnRGJvTk1YNE9UaTVGMjVLLzMvSnZXV3N4Z0c0MHk0U0MvTVNEcVl0NmFpMVJHQWNRaTJoCmxiU2RPVHp6RDM3V3FKcldIZEx1aDBlYWtQR0E4cnJFNFZWSXJhT0M5N0t5Yk5XcExuald3MllRYXg0V3dkR3IKYS82Z0R5b0lQK0VNdHR6azR1YjJKVy9mLzdHRTM0RVg5b3lRd2gzWVJEOEhveFFocHdlZm8wTFJBb0dBTmxVWQo5NDF3WUhMK0JtcHluOVgyZmFpcWlkdkFSbVRuUTdrcURWbWc1TkRoOVpreHU4czcwem9jY0UvNitWZklRSlM0CnVrRHl0ZUxpV2UxQjY4aWpVWEdteENDS096NWNxZkZXODBmWDQxMTdyaFQwM2taN2dYanJGckdJRFkzVW1KQ3YKUERZQ0pNRm1TQkZmb1BXVXlGL005bGxYZVo0Qjk0MHd2aTNabVNFQ2dZQWc4dEVzQ0tKWSswRU5td1ZjOENKaQpPd3VwajI5ckp4eVB5Sll5ZzFqQzllNzJtT3VGNnkyRzhreEJXek1qNUphczBPSFhHWjlweWZTN1JTcHRQMkVXCloySlA3RldaZCtvY1kvc2gzeEY1Njh2QnNnQ0NkS0x2VndTV3FrUk1oMm5TTk1CQUxCd2E1U3dNb0RWanZieUYKRjRJV0hjeXIzZ2hOeXpJRjd1NDJsZz09Ci0tLS0tRU5EIFBSSVZBVEUgS0VZLS0tLS0K
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Minikube/Resources/krb5.ini b/Modules/Alkami.DevOps.Minikube/Resources/krb5.ini
new file mode 100644
index 0000000..575f4ca
--- /dev/null
+++ b/Modules/Alkami.DevOps.Minikube/Resources/krb5.ini
@@ -0,0 +1,34 @@
+[logging]
+kdc = SYSDEBUG
+kdc = FILE:/var/krb5/log/krb5kdc.log
+admin_server = FILE:/var/krb5/log/kadmin.log
+default = FILE:/var/krb5/log/krb5lib.log
+
+[libdefaults]
+dns_lookup_realm = true
+dns_lookup_kdc = true
+forwardable = true
+ticket_lifetime = 7d
+renew_lifetime = 0
+rdns = false
+default_realm = CORP.ALKAMITECH.COM
+
+[realms]
+
+CORP.ALKAMITECH.COM = {
+ kdc = corp.alkamitech.com.
+ admin_server = CORP.ALKAMITECH.COM
+ default_domain = CORP.ALKAMITECH.COM
+}
+
+FH.LOCAL = {
+ kdc = fh.local.
+ admin_server = FH.LOCAL
+ default_domain = FH.LOCAL
+}
+
+[domain_realm]
+.fh.local = FH.LOCAL
+fh.local = FH.LOCAL
+.corp.alkamitech.com = CORP.ALKAMITECH.COM
+corp.alkamitech.com = CORP.ALKAMITECH.COM
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Minikube/tools/chocolateyInstall.ps1 b/Modules/Alkami.DevOps.Minikube/tools/chocolateyInstall.ps1
new file mode 100644
index 0000000..74befcf
--- /dev/null
+++ b/Modules/Alkami.DevOps.Minikube/tools/chocolateyInstall.ps1
@@ -0,0 +1,38 @@
+[CmdletBinding()]
+Param()
+process {
+ $myCurrentPath = $PSScriptRoot;
+ Write-Verbose "Installing the Module from $myCurrentPath";
+
+ $parentPath = (Split-Path $myCurrentPath);
+ $systemModulePath = "C:\Program Files\WindowsPowerShell\Modules\";
+ $myModulePath = (Join-Path $parentPath "module");
+
+ $metadata = ([Xml](Get-Content (Join-Path $parentPath "*.nuspec"))).package.metadata;
+
+ $id = $metadata.id;
+ $version = $metadata.version -replace '-pre.+','';
+
+ $targetModulePath = (Join-Path $systemModulePath $id);
+ $targetModuleVersionPath = (Join-Path $targetModulePath $version);
+
+ if (Test-Path $targetModulePath) {
+ ## If the target folder already existed, remove it, because we are re-installing this package, obviously
+ if (Test-Path $targetModuleVersionPath) {
+ Write-Warning "Found an already existing module at [$targetModuleVersionPath]!!"
+ Remove-Item $targetModuleVersionPath -Recurse -Force;
+ }
+
+ ## Clear previous children for name conflicts
+ (Get-ChildItem $targetModulePath) | ForEach-Object {
+ Write-Information "Removing module located at [$_]";
+ Remove-Item $_.FullName -Recurse -Force;
+ }
+ }
+
+ Write-Host "Copying module $id to [$targetModuleVersionPath]";
+ Copy-Item $myModulePath -Destination $targetModuleVersionPath -Recurse -Force;
+
+ ## Ensure the module was able to load
+ Import-Module $id -Global;
+}
diff --git a/Modules/Alkami.DevOps.Minikube/tools/chocolateyUninstall.ps1 b/Modules/Alkami.DevOps.Minikube/tools/chocolateyUninstall.ps1
new file mode 100644
index 0000000..29b2f77
--- /dev/null
+++ b/Modules/Alkami.DevOps.Minikube/tools/chocolateyUninstall.ps1
@@ -0,0 +1,23 @@
+[CmdletBinding()]
+Param()
+process {
+ $myCurrentPath = $PSScriptRoot;
+ Write-Verbose "Uninstalling the Module from $myCurrentPath";
+
+ $parentPath = (Split-Path $myCurrentPath);
+ $systemModulePath = "C:\Program Files\WindowsPowerShell\Modules\";
+ $myModulePath = (Join-Path $parentPath "module");
+
+ $metadata = ([Xml](Get-Content (Join-Path $parentPath "*.nuspec"))).package.metadata;
+
+ $id = $metadata.id;
+ $version = $metadata.version -replace '-pre.+','';
+
+ $targetModulePath = (Join-Path $systemModulePath $id);
+ $targetModuleVersionPath = (Join-Path $targetModulePath $version);
+
+ if (Test-Path $targetModuleVersionPath) {
+ Write-Information "Removing module at [$targetModuleVersionPath]!!"
+ Remove-Item $targetModuleVersionPath -Recurse -Force;
+ }
+}
diff --git a/Modules/Alkami.DevOps.Operations/Alkami.DevOps.Operations.nuspec b/Modules/Alkami.DevOps.Operations/Alkami.DevOps.Operations.nuspec
new file mode 100644
index 0000000..6364866
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Alkami.DevOps.Operations.nuspec
@@ -0,0 +1,37 @@
+
+
+
+ Alkami.DevOps.Operations
+ $version$
+ Alkami Platform Modules - DevOps - Operations
+ Alkami Technologies
+ Alkami Technologies
+ https://extranet.alkamitech.com/display/ORB/Alkami.DevOps.Operations
+ https://www.alkami.com/files/alkamilogo75x75.png
+ http://alkami.com/files/orblicense.html
+ false
+ Installs the DevOps Operations module for use with PowerShell.
+
+ PowerShell
+ Copyright (c) 2018 Alkami Technologies
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Modules/Alkami.DevOps.Operations/Alkami.DevOps.Operations.psd1 b/Modules/Alkami.DevOps.Operations/Alkami.DevOps.Operations.psd1
new file mode 100644
index 0000000..f1922cd
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Alkami.DevOps.Operations.psd1
@@ -0,0 +1,20 @@
+@{
+ RootModule = 'Alkami.DevOps.Operations.psm1'
+ ModuleVersion = '3.27.0'
+ GUID = '2db01ae4-953d-419a-aef3-d2edab019161'
+ Author = 'SRE,dsage,cbrand'
+ CompanyName = 'Alkami Technologies, Inc.'
+ Copyright = '(c) 2018 Alkami Technologies, Inc.. All rights reserved.'
+ Description = 'A set of cmdlets used for day-to-day operations'
+ FileList = @('Resources\PackageFiltering-min.json', 'Resources\SQL\RadiumPurgeScript.sql', 'Resources\SQL\RadiumPurgeScriptWithJobFilter.sql')
+ RequiredModules = 'Alkami.PowerShell.Common','Alkami.PowerShell.IIS','Alkami.PowerShell.Configuration','Alkami.DevOps.Common','Alkami.PowerShell.AD','Alkami.PowerShell.Services','Alkami.PowerShell.Choco'
+ FunctionsToExport = 'Add-DomainMember','Add-Route53HostedZoneVpcAssociation','Backup-WinTest','Clear-AllRecycleBins','Clear-AwsSqsQueue','Clear-CloudFlareCache','Clear-CloudFlareCacheForSites','Clear-RadiumJobs','Close-TcpSocket','Close-TcpStreamWriter','Convert-CloudFlareARecordsToCnames','Convert-GMSAAccounts','ConvertTo-NormalizedLoadBalancerState','Copy-AlkamiRelease','Copy-LaunchConfiguration','Disable-Microservices','Disable-UnnecessaryMicroservices','Enable-NecessaryMicroservices','Format-IpAddress','Format-SlackMessage','Get-AllLoadBalancerStates','Get-AlwaysEnabledProviderPackageIds','Get-ASInstanceHealth','Get-ASInstanceState','Get-AwsRegionByHostname','Get-BadLogConfiguration','Get-BounceJobBuildId','Get-CloudFlareAuthenticationHeaders','Get-CloudFlareZoneId','Get-DataForSmokeTest','Get-EC2InstancesByHostname','Get-EntrustUserCredentialsFromSecretServer','Get-EnvironmentShortName','Get-LatestOrbAmi','Get-LoadBalancerState','Get-LoadBalancerStateAndHealth','Get-NginxAuthHeaders','Get-NginxHostStates','Get-NginxIpAddresses','Get-NginxUpstreams','Get-OrbAutoScalingGroup','Get-PercentageServerCount','Get-ProviderMappingFilePath','Get-ProviderMappingFileUnion','Get-Route53HostedZoneIdList','Get-Route53HostedZoneTagList','Get-SupportedAwsRegions','Get-TenantTimeZone','Get-VpcIdList','Install-FailedChocoPackages','Install-WinTest','Invoke-RollingScriptBlock','Move-Route53HostedZone','New-Route53HostedZone','New-TcpSocket','New-TcpStreamWriter','Open-ChocolateyLibDir','Open-HostsFile','Open-MachineConfig','Open-NagConfig','Open-PowerShellModulesDir','Ping-EntrustServices','Ping-Host','Push-TcpStreamWriterBuffer','Read-ProviderConfigurationFromTenants','Read-ProviderMappingFile','Read-RadiumPurgeScript','Remove-ADUserProfiles','Repair-LoadBalancerState','Restart-Tier','Select-RunningEC2InstancesByHostname','Send-Counter','Send-DeploymentPackageCounter','Send-DeploymentPackageSizeOrbGauge','Send-DeploymentTypeCounter','Send-DeploymentTypeTimer','Send-Gauge','Send-IncidentSnsMessage','Send-Metric','Send-NewIncidentSnsMessage','Send-ResolveAllIncidentsSnsMessage','Send-Timer','Send-UpdateIncidentSnsMessage','Set-ASInstanceState','Set-EC2InstanceTag','Set-LoadBalancerState','Set-MicroserviceConfigurationBasedState','Set-NagAlerts','Set-NginxHostState','Set-NginxUpstreamHost','Set-Route53HostedZoneVpcAssocations','Set-TracedMessagesEnabled','Show-NetTCPConnections','Test-IsCurrentTimeInsideTenantTimeRange','Test-OpenPorts','Test-SmtpServer','Test-SymConnectNonLoopbackExists','Test-SymitarPorts','Test-TcpConnection','Test-VpcExists','Update-AlkamiDatabase','Update-Database','Update-S3BucketTags','Update-StagedConfigurationValues','Update-TrustedSites','Wait-Route53ChangeStatus','Write-ChocoCommandsToFile','Write-TcpSocketStreamWriter'
+ PrivateData = @{
+ PSData = @{
+ Tags = @('powershell', 'module', 'operations')
+ ProjectUri = 'https://extranet.alkamitech.com/display/SRE/Alkami.DevOps.Operations+Module'
+ IconUri = 'https://www.alkami.com/files/alkamilogo75x75.png'
+ }
+ }
+ HelpInfoURI = 'https://extranet.alkamitech.com/display/SRE/Alkami.DevOps.Operations+Module'
+}
diff --git a/Modules/Alkami.DevOps.Operations/Alkami.DevOps.Operations.pssproj b/Modules/Alkami.DevOps.Operations/Alkami.DevOps.Operations.pssproj
new file mode 100644
index 0000000..3141b5c
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Alkami.DevOps.Operations.pssproj
@@ -0,0 +1,131 @@
+
+
+
+ Debug
+ 2.0
+ {6CAFC0C6-A428-4d30-A9F9-700E829FEA51}
+ Exe
+ MyApplication
+ MyApplication
+ Alkami.DevOps.Operations
+ SRE
+ Alkami Technology
+ (c) 2017 Alkami Technology. All rights reserved.
+ A set of cmdlets used for day-to-day operations
+ 2db01ae4-953d-419a-aef3-d2edab019161
+ 0.0.0.1
+
+
+
+
+ Alkami.DevOps.Common
+ Invoke-Pester;
+ ..\build-project.ps1 (Join-Path $(SolutionDir) "Alkami.DevOps.Operations")
+
+
+ true
+ full
+ false
+ bin\Debug\
+ DEBUG;TRACE
+ prompt
+ 4
+
+
+ pdbonly
+ true
+ bin\Release\
+ TRACE
+ prompt
+ 4
+
+
+
+ Alkami.DevOps.Common
+ {1bd8fc22-5882-4d5c-8128-81f1d61f8d77}
+ True
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/AlkamiManifest.xml b/Modules/Alkami.DevOps.Operations/AlkamiManifest.xml
new file mode 100644
index 0000000..d977b10
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/AlkamiManifest.xml
@@ -0,0 +1,12 @@
+
+
+ 1.0
+
+ Alkami
+ Alkami.DevOps.Operations
+ SREModule
+
+
+ Production
+
+
diff --git a/Modules/Alkami.DevOps.Operations/Private/Get-ADUserProfileList.ps1 b/Modules/Alkami.DevOps.Operations/Private/Get-ADUserProfileList.ps1
new file mode 100644
index 0000000..be1c216
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Private/Get-ADUserProfileList.ps1
@@ -0,0 +1,57 @@
+function Get-ADUserProfileList {
+ <#
+.SYNOPSIS
+ Retrieves an array of CIM instance user profiles on the target server that are in either the CORP or FH domains.
+
+.SYNOPSIS
+ Retrieves an array of CIM instance user profiles on the target server that are in either the CORP or FH domains.
+
+.PARAMETER ComputerName
+ The name of the server where the operation should be performed. If omitted, defaults to the current host.
+
+.PARAMETER Domains
+ Array of domains to process. If omitted, defaults to both CORP and FH.
+
+.OUTPUTS
+ An array of CIM instance user profile objects.
+#>
+ [CmdLetBinding()]
+ [OutputType([object[]])]
+ param(
+ [Parameter(Mandatory = $false)]
+ [string] $ComputerName = $null,
+
+ [Parameter(Mandatory = $false)]
+ [ValidateSet('CORP', 'FH')]
+ [string[]] $Domains = @('CORP', 'FH')
+ )
+
+ $logLead = (Get-LogLeadName)
+ $result = @()
+
+ # Test if we can get CIM Instances; abort if not.
+ if ($null -eq (Get-Command Get-CimInstance -ErrorAction SilentlyContinue)) {
+ Write-Warning "$logLead : Can't CimInstance on this host; aborting."
+ return $result
+ }
+
+ # Determine if we are executing remotely.
+ $splatParams = @{}
+ if ( -not (Test-StringIsNullOrWhitespace -Value $ComputerName) -and -not (Compare-StringToLocalMachineIdentifiers -stringToCheck $ComputerName )) {
+
+ $splatParams['ComputerName'] = "$ComputerName"
+ }
+
+ $workingList = Get-CimInstance -ClassName Win32_UserProfile @splatParams
+ foreach ( $domain in $Domains ) {
+
+ $interim = $workingList | Where-Object { $_.SID.StartsWith($_DomainSidPrefix[$domain]) }
+ if ( -not (Test-IsCollectionNullOrEmpty -Collection $interim )) {
+
+ Write-Verbose "$logLead : Found $($interim.Length) entries for $domain domain."
+ $result += $interim
+ }
+ }
+
+ return $result
+}
diff --git a/Modules/Alkami.DevOps.Operations/Private/Get-ADUserProfileList.tests.ps1 b/Modules/Alkami.DevOps.Operations/Private/Get-ADUserProfileList.tests.ps1
new file mode 100644
index 0000000..9dcaf4c
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Private/Get-ADUserProfileList.tests.ps1
@@ -0,0 +1,135 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$global:functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+
+InModuleScope -ModuleName Alkami.DevOps.Operations -ScriptBlock {
+ Write-Host "InModuleScope - Overriding SUT: $($global:functionPath)"
+ Import-Module $global:functionPath -Force
+ $moduleForMock = ''
+ $inScopeModuleForAssert = 'Alkami.DevOps.Operations'
+
+ Describe 'Get-ADUserProfileList' {
+
+ Mock -CommandName Get-LogLeadName -ModuleName $moduleForMock -MockWith { return 'Get-ADUserProfileList.tests' }
+ Mock -CommandName Get-Command -ModuleName $moduleForMock -MockWith { return $true }
+ Mock -CommandName Compare-StringToLocalMachineIdentifiers -ModuleName $moduleForMock -MockWith { return $true }
+ Mock -CommandName Test-IsCollectionNullOrEmpty -ModuleName $moduleForMock -MockWith { return $false }
+ Mock -CommandName Write-Warning -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Write-Verbose -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Test-StringIsNullOrWhitespace -ModuleName $moduleForMock -MockWith { return $true }
+
+ Mock -CommandName Get-CimInstance -ModuleName $moduleForMock -MockWith {
+ $result = @()
+ $result += New-Object PSObject @{ SID = "$($_DomainSidPrefix['CORP'])-12345" }
+ $result += New-Object PSObject @{ SID = "$($_DomainSidPrefix['CORP'])-54321" }
+ $result += New-Object PSObject @{ SID = "$($_DomainSidPrefix['FH'])-12345" }
+ $result += New-Object PSObject @{ SID = '1-2-3-4-5-12345' }
+
+ return $result
+ }
+
+ Context 'Input Validation' {
+
+ It 'Throws if Domain list contains invalid value' {
+
+ { Get-ADUserProfileList -Domains @('Test') } | Should -Throw
+ }
+ }
+
+ Context 'Logic' {
+
+ It 'Writes warning if Get-CimInstance is unavailable' {
+
+ Mock -CommandName Get-Command -ModuleName $moduleForMock -MockWith { return $null }
+
+ Get-ADUserProfileList | Out-Null
+
+ Assert-MockCalled -ModuleName $inScopeModuleForAssert -CommandName Write-Warning -Times 1 -Exactly -Scope It `
+ -ParameterFilter { $Message -match "Can't CimInstance on this host; aborting." }
+
+ Mock -CommandName Get-Command -ModuleName $moduleForMock -MockWith { return $true }
+ }
+
+ It 'Returns empty list and aborts if Get-CimInstance is unavailable' {
+
+ Mock -CommandName Get-Command -ModuleName $moduleForMock -MockWith { return $null }
+
+ $result = Get-ADUserProfileList
+ $result | Should -HaveCount 0
+
+ Assert-MockCalled -ModuleName $inScopeModuleForAssert -CommandName Get-CimInstance -Times 0 -Exactly -Scope It
+
+ Mock -CommandName Get-Command -ModuleName $moduleForMock -MockWith { return $true }
+ }
+
+ It 'Performs command on localhost by default' {
+
+ Get-ADUserProfileList | Out-Null
+
+ Assert-MockCalled -ModuleName $inScopeModuleForAssert -CommandName Test-StringIsNullOrWhitespace -Times 1 -Exactly -Scope It
+ Assert-MockCalled -ModuleName $inScopeModuleForAssert -CommandName Compare-StringToLocalMachineIdentifiers -Times 0 -Exactly -Scope It
+ Assert-MockCalled -ModuleName $inScopeModuleForAssert -CommandName Write-Warning -Times 0 -Exactly -Scope It
+ Assert-MockCalled -ModuleName $inScopeModuleForAssert -CommandName Get-CimInstance -Times 1 -Exactly -Scope It `
+ -ParameterFilter { ( -not $PSBoundParameters.ContainsKey('ComputerName')) }
+ }
+
+ It 'Performs command on localhost if ComputerName matches current server' {
+
+ Mock -CommandName Test-StringIsNullOrWhitespace -ModuleName $moduleForMock -MockWith { return $false }
+
+ $test = 'server1.test.local'
+
+ Get-ADUserProfileList -ComputerName $test | Out-Null
+
+ Assert-MockCalled -ModuleName $inScopeModuleForAssert -CommandName Test-StringIsNullOrWhitespace -Times 1 -Exactly -Scope It `
+ -ParameterFilter { $Value -eq $test }
+ Assert-MockCalled -ModuleName $inScopeModuleForAssert -CommandName Compare-StringToLocalMachineIdentifiers -Times 1 -Exactly -Scope It `
+ -ParameterFilter { $stringToCheck -eq $test }
+ Assert-MockCalled -ModuleName $inScopeModuleForAssert -CommandName Get-CimInstance -Times 1 -Exactly -Scope It `
+ -ParameterFilter { ( -not $PSBoundParameters.ContainsKey('ComputerName')) }
+
+ Mock -CommandName Test-StringIsNullOrWhitespace -ModuleName $moduleForMock -MockWith { return $true }
+ }
+
+ It 'Performs command on remote server if ComputerName does not match current server' {
+
+ $test = 'server2.test.local'
+
+ Mock -CommandName Test-StringIsNullOrWhitespace -ModuleName $moduleForMock -MockWith { return $false }
+ Mock -CommandName Compare-StringToLocalMachineIdentifiers -ModuleName $moduleForMock -MockWith { return $false }
+
+ Get-ADUserProfileList -ComputerName $test | Out-Null
+
+ Assert-MockCalled -ModuleName $inScopeModuleForAssert -CommandName Test-StringIsNullOrWhitespace -Times 1 -Exactly -Scope It `
+ -ParameterFilter { $Value -eq $test }
+ Assert-MockCalled -ModuleName $inScopeModuleForAssert -CommandName Compare-StringToLocalMachineIdentifiers -Times 1 -Exactly -Scope It `
+ -ParameterFilter { $stringToCheck -eq $test }
+ Assert-MockCalled -ModuleName $inScopeModuleForAssert -CommandName Get-CimInstance -Times 1 -Exactly -Scope It `
+ -ParameterFilter { $ComputerName -eq $test }
+
+ Mock -CommandName Test-StringIsNullOrWhitespace -ModuleName $moduleForMock -MockWith { return $true }
+ Mock -CommandName Compare-StringToLocalMachineIdentifiers -ModuleName $moduleForMock -MockWith { return $true }
+ }
+
+ It 'Filters out non-Active Directory profiles' {
+
+ $results = Get-ADUserProfileList
+
+ foreach ( $result in $results ) {
+ ($result.SID.StartsWith($_DomainSidPrefix['CORP']) -or $result.SID.StartsWith($_DomainSidPrefix['FH'])) | Should -BeTrue
+ }
+ }
+
+ It 'Applies Domain filter if specified' {
+
+ $results = Get-ADUserProfileList -Domains @('CORP')
+
+ foreach ( $result in $results ) {
+ $result.SID.StartsWith($_DomainSidPrefix['CORP']) | Should -BeTrue
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Private/Get-ADUserProfileListToRemove.ps1 b/Modules/Alkami.DevOps.Operations/Private/Get-ADUserProfileListToRemove.ps1
new file mode 100644
index 0000000..75d77bf
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Private/Get-ADUserProfileListToRemove.ps1
@@ -0,0 +1,58 @@
+function Get-ADUserProfileListToRemove {
+ <#
+.SYNOPSIS
+ Retrieves an array of CIM instance user profiles that can be removed.
+
+.DESCRIPTION
+ Retrieves an array of CIM instance user profiles that can be removed. Excludes user profiles
+ where the name of the folder contains the name of a user who has a running process. Will skip
+ any profiles whose path contains '$' in an attempt to exclude gMSA accounts.
+
+.PARAMETER ComputerName
+ The name of the server where the operation should be performed. If omitted, defaults to the current host.
+
+.PARAMETER Domains
+ Array of domains to process. If omitted, defaults to both CORP and FH.
+
+.OUTPUTS
+ An array of CIM instance user profile objects.
+#>
+ [CmdLetBinding()]
+ [OutputType([object[]])]
+ param(
+ [Parameter(Mandatory = $false)]
+ [string] $ComputerName = $null,
+
+ [Parameter(Mandatory = $false)]
+ [ValidateSet('CORP', 'FH')]
+ [string[]] $Domains = @('CORP', 'FH')
+ )
+
+ # Define the hardcoded list of usernames to skip per SYSENG-4133.
+ $exclusionList = @(
+ 'appviewx-svc',
+ 'fh-netwrixmsa',
+ 'jumpbox.jenkins'
+ )
+
+ # Get the AD user profiles.
+ $result = Get-ADUserProfileList -ComputerName $ComputerName -Domains $Domains
+
+ # Filter out users with active processes.
+ $activeUsernames = Get-UsernamesWithProcesses -ComputerName $ComputerName
+ $result = $result | Where-Object {
+ $username = $_.LocalPath.Split('\')[-1]
+ return ($activeUsernames -notcontains $username)
+ }
+
+ # Filter out users in our exclusion list.
+ $result = $result | Where-Object {
+ $username = $_.LocalPath.Split('\')[-1]
+ return ($exclusionList -notcontains $username)
+ }
+
+ # Filter out users with a '$' in their local path (probably gMSA).
+ $result = $result | Where-Object { -not $_.LocalPath.Contains('$') }
+
+ return $result
+}
diff --git a/Modules/Alkami.DevOps.Operations/Private/Get-ADUserProfileListToRemove.tests.ps1 b/Modules/Alkami.DevOps.Operations/Private/Get-ADUserProfileListToRemove.tests.ps1
new file mode 100644
index 0000000..133f4b6
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Private/Get-ADUserProfileListToRemove.tests.ps1
@@ -0,0 +1,88 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$global:functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+
+InModuleScope -ModuleName Alkami.DevOps.Operations -ScriptBlock {
+ Write-Host "InModuleScope - Overriding SUT: $($global:functionPath)"
+ Import-Module $global:functionPath -Force
+ $moduleForMock = ''
+ $inScopeModuleForAssert = 'Alkami.DevOps.Operations'
+
+ Describe 'Get-ADUserProfileListToRemove' {
+
+ Mock -CommandName Get-ADUserProfileList -ModuleName $moduleForMock -MockWith {
+ $result = @()
+ $result += New-Object PSObject @{ LocalPath = 'TestDrive:\Test\test1$' }
+ $result += New-Object PSObject @{ LocalPath = 'TestDrive:\Test\test2' }
+ $result += New-Object PSObject @{ LocalPath = 'TestDrive:\Test\test3' }
+
+ return $result
+ }
+
+ Mock -CommandName Get-UsernamesWithProcesses -ModuleName $moduleForMock -MockWith {
+ return @('test2', 'test4', 'test5')
+ }
+
+ Context 'Input Validation' {
+
+ It 'Throws if Domain list contains invalid value' {
+
+ { Get-ADUserProfileListToRemove -Domains @('Test') } | Should -Throw
+ }
+ }
+
+ Context 'Logic' {
+
+ It 'Performs command on localhost by default' {
+
+ Get-ADUserProfileListToRemove | Out-Null
+
+ Assert-MockCalled -ModuleName $inScopeModuleForAssert -CommandName Get-ADUserProfileList -Times 1 -Exactly -Scope It `
+ -ParameterFilter { [string]::IsNullOrWhiteSpace($ComputerName) }
+ Assert-MockCalled -ModuleName $inScopeModuleForAssert -CommandName Get-UsernamesWithProcesses -Times 1 -Exactly -Scope It `
+ -ParameterFilter { [string]::IsNullOrWhiteSpace($ComputerName) }
+ }
+
+ It 'Uses ComputerName parameter if provided' {
+
+ $test = 'server1.test.local'
+
+ Get-ADUserProfileListToRemove -ComputerName $test | Out-Null
+
+ Assert-MockCalled -ModuleName $inScopeModuleForAssert -CommandName Get-ADUserProfileList -Times 1 -Exactly -Scope It `
+ -ParameterFilter { $ComputerName -eq $test }
+ Assert-MockCalled -ModuleName $inScopeModuleForAssert -CommandName Get-UsernamesWithProcesses -Times 1 -Exactly -Scope It `
+ -ParameterFilter { $ComputerName -eq $test }
+ }
+
+ It 'Uses Domain parameter if provided' {
+
+ $test = @('CORP')
+ Get-ADUserProfileListToRemove -Domains $test | Out-Null
+
+ Assert-MockCalled -ModuleName $inScopeModuleForAssert -CommandName Get-ADUserProfileList -Times 1 -Exactly -Scope It `
+ -ParameterFilter { $null -eq (Compare-Object $Domains $test ) }
+ }
+
+ It 'Filters out users with running processes' {
+
+ $results = Get-ADUserProfileListToRemove
+
+ foreach ( $result in $results ) {
+ $result.LocalPath | Should -Not -Match 'test2'
+ }
+ }
+
+ It 'Filters out user profiles with a local path containing $' {
+
+ $results = Get-ADUserProfileListToRemove
+
+ foreach ( $result in $results ) {
+ $result.LocalPath.Contains('$') | Should -BeFalse
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Private/Get-DecoratedNetTCPConnections.ps1 b/Modules/Alkami.DevOps.Operations/Private/Get-DecoratedNetTCPConnections.ps1
new file mode 100644
index 0000000..dc9ea2c
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Private/Get-DecoratedNetTCPConnections.ps1
@@ -0,0 +1,52 @@
+function Get-DecoratedNetTCPConnections {
+ <#
+.SYNOPSIS
+ Gets Net TCP Connections with Process and Username information
+
+.PARAMETER UngroupConnections
+ Do not group by connection's OwningProcess
+#>
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory = $false)]
+ [Alias("ShowUngrouped")]
+ [switch]$UngroupConnections
+ )
+
+ $logLead = Get-LogLeadName
+
+ [System.Collections.ArrayList]$connections = Get-NetTCPConnection | Sort-Object -Property OwningProcess
+ $uniqueProcessIds = $connections | Select-Object -ExpandProperty OwningProcess -Unique
+ [System.Collections.ArrayList]$matchingProcesses = Get-Process -IncludeUserName -Id $uniqueProcessIds -ErrorAction SilentlyContinue | Sort-Object -Property Id -ErrorAction SilentlyContinue
+
+ if (!($UngroupConnections.IsPresent)) {
+
+ $groupedConnections = $connections | Group-Object -Property OwningProcess
+
+ foreach ($process in $matchingProcesses) {
+
+ $groupedConnections | Where-Object { $_.Name -eq $process.Id } | Add-Member -NotePropertyMembers @{ProcessName = $($process.Name); UserName = $($process.UserName) }
+ }
+
+ $groupedConnections | Where-Object { $null -eq $_.ProcessName } | ForEach-Object {
+
+ Write-Verbose -Message ("$logLead : Adding Unknown Process and User to Orphaned Process with ID {0}" -f $_.Name)
+ $_ | Add-Member -NotePropertyMembers @{ProcessName = "Unknown"; UserName = "Unknown" }
+ }
+
+ $sortedConnections = $groupedConnections | Select-Object -Property Count, ProcessName, UserName, Name, Group | Sort-Object -Property Count -Descending
+ } else {
+
+ Write-Warning -Message "$logLead : Preparing ungrouped connections. This might take a bit..."
+
+ $ungroupedConnections = $connections
+ foreach ($process in $matchingProcesses) {
+
+ $ungroupedConnections | Where-Object { $_.OwningProcess -eq $process.Id } | Add-Member -NotePropertyMembers @{ProcessName=$($process.Name);UserName=$($process.UserName)}
+ }
+
+ $sortedConnections = $ungroupedConnections | Select-Object -Property LocalPort, LocalAddress, RemotePort, RemoteAddress, State, ProcessName, UserName | Sort-Object -Property LocalPort -Descending
+ }
+
+ return $sortedConnections
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Private/Get-ELBHealthcheckEndpoints.ps1 b/Modules/Alkami.DevOps.Operations/Private/Get-ELBHealthcheckEndpoints.ps1
new file mode 100644
index 0000000..f322ac9
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Private/Get-ELBHealthcheckEndpoints.ps1
@@ -0,0 +1,68 @@
+function Get-ELBHealthcheckEndpoints {
+
+<#
+.SYNOPSIS
+ Gets the ELB healthcheck URLs for the server from which it's run.
+
+.DESCRIPTION
+ Gets the ELB healthcheck URLs for the server from which it's run. First obtains the name from Get-CurrentInstanceTags,
+ uses the tag to obtain the ARN, then uses the ARN to obtain the endpoint.
+
+.EXAMPLE
+ Get-ELBHealthcheckEndpoints
+
+ https://localhost:8443/IdentityGuardAuthService/services/AuthenticationServiceV11
+ https://localhost:8444/IdentityGuardAdminService/services/AdminServiceV11
+
+#>
+
+ [CmdletBinding()]
+ param()
+
+ $logLead = (Get-LogLeadName)
+
+ Import-AWSModule # ELB2
+
+ if (!(Test-IsAws)) {
+ Write-Warning "$logLead : This function can only be executed on an AWS server"
+ return
+ }
+
+ # Get the name of the current machine and clean it to match ELB naming convention
+ $name = (Get-CurrentInstanceTags | Where-Object { $_.Key -eq 'Name' } | Select-Object -First 1).Value
+ $name = $name -replace '\.','-'
+ $name = $name -replace '_','-'
+ $name += '-alb'
+ Write-Host "$logLead : Name computed as $name"
+
+ # Get the ARN using the name generated above and verify there is only 1
+ $endpointARN = Get-ELB2LoadBalancer -Name $name
+ if (Test-IsCollectionNullOrEmpty -collection $endpointARN) {
+
+ Write-Error "Result returned a NULL or empty value for ARN"
+ return $null
+
+ } elseif($endpointARN.Count -gt 1) {
+
+ Write-Error "Result returned more than 1 ARN"
+ return $null
+ }
+
+ # Get the Endpoint data using the ARN
+ $elbEndpoints = Get-ELB2TargetGroup -loadbalancerarn $endpointARN.loadbalancerarn
+ if (Test-IsCollectionNullOrEmpty -collection $elbEndpoints) {
+
+ Write-Error "Result returned NULL or empty value for endpoints"
+ return $null
+ }
+
+ # Put together the URLs with the Endpoint data
+ $results = @()
+ foreach ($endpoint in $elbEndpoints) {
+
+ $url = '{0}://localhost:{1}{2}' -f $endpoint.Protocol.ToString().ToLower(), $endpoint.Port, $endpoint.healthcheckpath
+ $results += $url
+ }
+
+ return $results
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Private/Get-UsernamesWithProcesses.ps1 b/Modules/Alkami.DevOps.Operations/Private/Get-UsernamesWithProcesses.ps1
new file mode 100644
index 0000000..b634f97
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Private/Get-UsernamesWithProcesses.ps1
@@ -0,0 +1,56 @@
+function Get-UsernamesWithProcesses {
+ <#
+.SYNOPSIS
+ Retrieves the unique set of usernames associated with all running processes.
+
+.SYNOPSIS
+ Retrieves the unique set of usernames associated with all running processes. This call requires elevated privileges to execute.
+
+.PARAMETER ComputerName
+ The name of the server where the operation should be performed. If omitted, defaults to the current host.
+
+.OUTPUTS
+ An array of strings containing the usernames of all users with running processes.
+
+.EXAMPLE
+ Get-UsernamesWithProcesses
+
+SYSTEM
+njones
+LOCAL SERVICE
+DWM-2
+UMFD-0
+UMFD-2
+NETWORK SERVICE
+#>
+ [CmdLetBinding()]
+ [OutputType([string[]])]
+ param(
+ [Parameter(Mandatory = $false)]
+ [string] $ComputerName = $null
+ )
+
+ $scriptBlock = {
+
+ if ( -not (Test-IsAdmin)) {
+ throw 'This call requires elevated permissions to run.'
+ }
+
+ $result = (Get-Process -IncludeUserName).UserName
+ $result = $result | Where-Object { -not (Test-StringIsNullOrWhitespace -Value $_) }
+ $result = $result | ForEach-Object { $_.Split('\')[-1] }
+ $result = $result | Select-Object -Unique
+
+ return $result
+ }
+
+ # Determine if we are executing remotely.
+ $splatParams = @{}
+ if ( -not (Test-StringIsNullOrWhitespace -Value $ComputerName) -and -not (Compare-StringToLocalMachineIdentifiers -stringToCheck $ComputerName )) {
+
+ $splatParams['ComputerName'] = "$ComputerName"
+ }
+
+ $result = Invoke-Command -ScriptBlock $scriptBlock @splatParams
+ return $result
+}
diff --git a/Modules/Alkami.DevOps.Operations/Private/Get-UsernamesWithProcesses.tests.ps1 b/Modules/Alkami.DevOps.Operations/Private/Get-UsernamesWithProcesses.tests.ps1
new file mode 100644
index 0000000..56536f6
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Private/Get-UsernamesWithProcesses.tests.ps1
@@ -0,0 +1,86 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ''
+
+Describe 'Get-UsernamesWithProcesses' {
+
+ $testServer = 'server.test.local'
+
+ Mock -CommandName Test-IsAdmin -ModuleName $moduleForMock -MockWith { return $true }
+ Mock -CommandName Compare-StringToLocalMachineIdentifiers -ModuleName $moduleForMock -MockWith { return $true }
+ Mock -CommandName Test-StringIsNullOrWhitespace -ModuleName $moduleForMock -MockWith { return [string]::IsNullOrWhiteSpace($Value) }
+ Mock -CommandName Invoke-Command -ModuleName $moduleForMock -MockWith { return $ScriptBlock.Invoke() }
+
+ Mock -CommandName Get-Process -ModuleName $moduleForMock -MockWith {
+ return @(
+ @{ Username = 'Test\Username' },
+ @{ Username = 'Test\Username' },
+ @{ Username = $null },
+ @{ Username = '' },
+ @{ Username = 'Test2\Username' },
+ @{ Username = 'Test2\Username2' }
+ )
+ }
+
+ Context 'Logic' {
+
+ It 'Performs command on localhost by default' {
+
+ Mock -CommandName Test-StringIsNullOrWhitespace -ModuleName $moduleForMock -MockWith { return $true }
+
+ Get-UsernamesWithProcesses | Out-Null
+
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Test-StringIsNullOrWhitespace -Times 1 -Scope It
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Compare-StringToLocalMachineIdentifiers -Times 0 -Exactly -Scope It
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Get-Process -Times 1 -Exactly -Scope It
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Invoke-Command -Times 1 -Exactly -Scope It `
+ -ParameterFilter { ( -not $PSBoundParameters.ContainsKey('ComputerName')) }
+
+ Mock -CommandName Test-StringIsNullOrWhitespace -ModuleName $moduleForMock -MockWith { return [string]::IsNullOrWhiteSpace($Value) }
+ }
+
+ It 'Performs command on localhost if ComputerName matches current server' {
+
+ Get-UsernamesWithProcesses -ComputerName $testServer | Out-Null
+
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Test-StringIsNullOrWhitespace -Times 1 -Scope It
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Get-Process -Times 1 -Exactly -Scope It
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Compare-StringToLocalMachineIdentifiers -Times 1 -Exactly -Scope It `
+ -ParameterFilter { $stringToCheck -eq $testServer }
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Invoke-Command -Times 1 -Exactly -Scope It `
+ -ParameterFilter { ( -not $PSBoundParameters.ContainsKey('ComputerName')) }
+ }
+
+ It 'Performs command on remote server if ComputerName does not match current server' {
+
+ Mock -CommandName Compare-StringToLocalMachineIdentifiers -ModuleName $moduleForMock -MockWith { return $false }
+
+ $testServer2 = 'server2.test.local'
+
+ Get-UsernamesWithProcesses -ComputerName $testServer2 | Out-Null
+
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Test-StringIsNullOrWhitespace -Times 1 -Scope It
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Get-Process -Times 1 -Exactly -Scope It
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Compare-StringToLocalMachineIdentifiers -Times 1 -Exactly -Scope It `
+ -ParameterFilter { $stringToCheck -eq $testServer2 }
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Invoke-Command -Times 1 -Exactly -Scope It `
+ -ParameterFilter { $ComputerName -eq $testServer2 }
+
+ Mock -CommandName Compare-StringToLocalMachineIdentifiers -ModuleName $moduleForMock -MockWith { return $true }
+ }
+
+ It 'Returns unique usernames only' {
+
+ $result = Get-UsernamesWithProcesses
+ $result | Should -HaveCount 2
+ $result | Should -Contain 'Username'
+ $result | Should -Contain 'Username2'
+
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Get-Process -Times 1 -Exactly -Scope It
+ }
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Private/VariableDeclarations.ps1 b/Modules/Alkami.DevOps.Operations/Private/VariableDeclarations.ps1
new file mode 100644
index 0000000..5f09ec9
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Private/VariableDeclarations.ps1
@@ -0,0 +1,4 @@
+$_DomainSidPrefix = @{
+ CORP = 'S-1-5-21-1114677349-1136464307-1343122729'
+ FH = 'S-1-5-21-977684154-3037035785-3226355436'
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Public/Add-DomainMember.ps1 b/Modules/Alkami.DevOps.Operations/Public/Add-DomainMember.ps1
new file mode 100644
index 0000000..a9eca3a
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Add-DomainMember.ps1
@@ -0,0 +1,177 @@
+function Add-DomainMember {
+ <#
+.SYNOPSIS
+ Adds the current machine to the specified domain, and changes the computer name to the user supplied value or value of the alk:hostname tag
+
+.DESCRIPTION
+ Adds the current machine to the specified domain, and changes the computer name to the user supplied value or value of the alk:hostname tag
+
+.PARAMETER Cred
+ [PSCredential] Pass a credential object to capture the credentials before running the command.
+ The function will automatically prompt you for the domain credentials when you run it.
+
+.PARAMETER OUPath
+ [string] The path to the OU that the computer needs to be added to.
+
+.PARAMETER Domain
+ [string] The name of the domain that you want to join the machine to.
+
+.PARAMETER NewHostName
+ [string] The new name of the target machine, this will also be the name added to Active Directory.
+ This parameter is not mandatory. If this parameter is not provided, the function will look for the alk:hostname AWS tag.
+ If it cannot find either the parameter or the AWS tag the function will fail.
+
+.PARAMETER SecurityGroupNames
+ [string[]] The names of the AD security groups to assign to the machine.
+ This parameter is not mandatory. If this parameter is not provided, the machine will not be added to any Security Groups when joined to the domain.
+
+.PARAMETER NoRestart
+ [switch] Flag indicating that the computer should not restart.
+ Note: On EC2 instances with EC2 Launch V2, this flag should be set if calling this function from the userdata script.
+ Ref: https://docs.aws.amazon.com/AWSEC2/latest/WindowsGuide/ec2launch-v2-settings.html#ec2launch-v2-exit-codes-reboots
+
+.Example
+ Add-DomainMember -NewHostName "app1637199" -Domain "fh.local" -SecurityGroupNames @("WSUS-Servers-9pm") -OUPath "OU=POD19,OU=FH Computers,DC=fh,DC=local"
+#>
+
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory = $true)]
+ [PSCredential] $Cred,
+
+ [Parameter(Mandatory = $true)]
+ [ValidateNotNullOrEmpty()]
+ [string]$OUPath,
+
+ [Parameter(Mandatory = $true)]
+ [ValidateNotNullOrEmpty()]
+ [string]$Domain,
+
+ [Parameter(Mandatory = $false)]
+ [ValidateNotNullOrEmpty()]
+ [string[]]$SecurityGroupNames = $null,
+
+ [Parameter(Mandatory = $false)]
+ [ValidateNotNullOrEmpty()]
+ [string]$NewHostName = $null,
+
+ [Parameter(Mandatory = $false)]
+ [switch]$NoRestart
+ )
+
+ $logLead = (Get-LogLeadName)
+ Import-AWSModule
+
+ if ($true -eq (Get-CimInstance Win32_ComputerSystem).PartOfDomain) {
+
+ Write-Warning "$logLead : This computer is already domain joined."
+ return
+ }
+
+ # Find the PDC in the target domain.
+ [string] $pdcServer = (Get-ADDomain -Credential $Cred -Server $Domain).PDCEmulator
+ if ( [string]::IsNullOrWhitespace( $pdcServer )) {
+
+ Write-Error "$logLead : Unable to find the primary domain controller in the target domain."
+ return
+ }
+
+ $secGroupIdentities = @()
+ if ( $PSBoundParameters.ContainsKey( 'SecurityGroupNames' )) {
+
+ foreach ( $securityGroup in $SecurityGroupNames ) {
+
+ Write-Verbose "$logLead : Determining if security group '$securityGroup' exists on domain '$Domain'"
+
+ $curSecGroup = Get-ADGroup -Filter { GroupCategory -eq 'Security' -and Name -eq $securityGroup } -Server $pdcServer -Credential $Cred
+ if ( $null -eq $curSecGroup ) {
+
+ Write-Error "$logLead : Security Group $securityGroup not found; aborting."
+ return
+
+ } else {
+
+ $secGroupIdentities += $curSecGroup
+ }
+ }
+ }
+
+ if ($false -eq $PSBoundParameters.ContainsKey( 'NewHostName' )) {
+
+ try {
+
+ $hostnameTag = Get-InstanceHostname (Get-CurrentInstance)
+
+ } catch {
+
+ Write-Warning "$logLead : Get-InstanceHostname threw exception : $($_.Exception.Message)"
+ $hostnameTag = $null
+ }
+
+ if ( [string]::IsNullOrEmpty( $hostnameTag )) {
+
+ Write-Error "$logLead : Hostname was not provided and could not be read from tags; aborting."
+ return
+
+ } else {
+
+ Write-Verbose "$logLead : Using computer name from tags: $hostnameTag"
+ $alkName = $hostnameTag
+ }
+
+ } else {
+
+ Write-Verbose "$logLead : Using computer name from user input: $NewHostName"
+ $alkName = $NewHostName
+ }
+
+ $currentHostname = (Get-FullyQualifiedServerName).Split('.')[0]
+ if ( $currentHostname -ne $alkName ) {
+
+ Write-Host "$logLead : Renaming computer to $alkName"
+ Rename-Computer -ComputerName $env:COMPUTERNAME -NewName $alkName -Force
+ Start-Sleep -s 10
+
+ } else {
+
+ Write-Host "$logLead : Skipping computer rename; computer is already named $alkName"
+ }
+
+ Add-Computer -DomainName $Domain -OUPath "$OUPath" -Server $pdcServer -Credential $Cred -Options JoinWithNewName, AccountCreate
+
+ # Determine if there are security groups to add to the computer.
+ if ( $false -eq ( Test-IsCollectionNullOrEmpty $secGroupIdentities )) {
+
+ # Test to see if the computer object exists. We cannot attach security groups until it does.
+ $adComputerTestBlock = {
+
+ param([string] $hostname, [string] $dc, [PSCredential] $cred)
+ return (Get-ADComputer $hostname -Server $dc -Credential $cred)
+ }
+
+ # We have to use the NETBIOS name which clips at 15 characters.
+ $netbiosName = $alkName.Substring(0, [System.Math]::Min(15, $alkName.Length))
+ $testResult = Invoke-CommandWithRetry -ScriptBlock $adComputerTestBlock -Arguments @($netbiosName, $pdcServer, $Cred) -Seconds 5 -MaxRetries 12 -ErrorAction SilentlyContinue
+ if ( $null -eq $testResult) {
+
+ Write-Warning "$logLead : Skipping security groups because the domain controller did not report existence of the computer object."
+
+ } else {
+
+ foreach ( $secGroupIdentity in $secGroupIdentities ) {
+
+ Write-Host "$logLead : Adding Machine to the $($secGroupIdentity.Name) Security Group"
+ Add-ADGroupMember -Identity $secGroupIdentity -Members $testResult -Server $pdcServer -Credential $Cred
+ }
+ }
+ }
+
+ if ( $NoRestart ) {
+
+ Write-Warning "$logLead : Skipping restart of instance at user request. A restart is required after joining the domain."
+
+ } else {
+
+ Restart-Computer -Force
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Public/Add-DomainMember.tests.ps1 b/Modules/Alkami.DevOps.Operations/Public/Add-DomainMember.tests.ps1
new file mode 100644
index 0000000..6c2044f
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Add-DomainMember.tests.ps1
@@ -0,0 +1,272 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ''
+
+# Define fake PSCredential user.
+$user = 'AlkamiFakeUser'
+$pass = Get-SecureString 'abc123'
+$testCredential = New-Object System.Management.Automation.PSCredential($user, $pass)
+
+Import-AWSModule
+
+Describe 'Add-DomainMember' {
+
+ Mock -CommandName Get-LogLeadName -ModuleName $moduleForMock -MockWith { return 'Add-DomainMember.tests' }
+ Mock -CommandName Get-FullyQualifiedServerName -ModuleName $moduleForMock -MockWith { return 'Test.domain.test' }
+ Mock -CommandName Import-AWSModule -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Write-Verbose -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Write-Warning -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Write-Error -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Get-CurrentInstance -ModuleName $moduleForMock -MockWith { return New-Object Amazon.EC2.Model.Instance }
+ Mock -CommandName Get-ADDomain -ModuleName $moduleForMock -MockWith { return @{PDCEmulator = '1.2.3.4' } }
+ Mock -CommandName Get-ADGroup -ModuleName $moduleForMock -MockWith { return New-Object Microsoft.ActiveDirectory.Management.ADGroup }
+ Mock -CommandName Invoke-CommandWithRetry -ModuleName $moduleForMock -MockWith { return New-Object Microsoft.ActiveDirectory.Management.ADComputer }
+ Mock -CommandName Test-IsCollectionNullOrEmpty -ModuleName $moduleForMock -MockWith { return $false }
+ Mock -CommandName Get-InstanceHostname -ModuleName $moduleForMock -MockWith { return 'AlkHostnameTag' }
+
+ Context 'Input Validation' {
+
+ It 'OUPath Should Not Be Null' {
+ { Add-DomainMember -Cred $testCredential -OUPath $null } | Should -Throw
+ }
+
+ It 'OUPath Should Not Be Empty' {
+ { Add-DomainMember -Cred $testCredential -OUPath '' } | Should -Throw
+ }
+
+ It 'Domain Should Not Be Null' {
+ { Add-DomainMember -Cred $testCredential -Domain $null -OUPath $null } | Should -Throw
+ }
+
+ It 'Domain Should Not Be Empty' {
+ { Add-DomainMember -Cred $testCredential -Domain '' -OUPath '' } | Should -Throw
+ }
+ }
+
+ Context 'Result Validation' {
+
+ Mock -CommandName Get-CimInstance -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Rename-Computer -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Start-Sleep -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Add-Computer -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Add-ADGroupMember -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Restart-Computer -ModuleName $moduleForMock -MockWith {}
+
+ It 'Should Return With Warning If Domain Joined' {
+
+ Mock -CommandName Get-CimInstance -ModuleName $moduleForMock -MockWith { return New-Object psobject -Property @{PartOfDomain = $true } }
+
+ Add-DomainMember -Cred $testCredential -Domain 'Test' -OUPath 'ou=bar,ou=foo,dc=test,dc=local'
+
+ Assert-MockCalled -CommandName Get-CimInstance -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Rename-Computer -Times 0 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Start-Sleep -Times 0 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Add-Computer -Times 0 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Add-ADGroupMember -Times 0 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Restart-Computer -Times 0 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Write-Error -Times 0 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Write-Warning -Times 1 -Exactly -Scope It -ModuleName $moduleForMock `
+ -ParameterFilter { $Message -match 'This computer is already domain joined' }
+
+ Mock -CommandName Get-CimInstance -ModuleName $moduleForMock -MockWith {}
+ }
+
+ It 'Should Return With Error If No Domain Controller IP Address Is Found' {
+
+ Mock -CommandName Get-ADDomain -ModuleName $moduleForMock -MockWith { return $null }
+
+ Add-DomainMember -Cred $testCredential -Domain 'Test' -OUPath 'ou=bar,ou=foo,dc=test,dc=local' -SecurityGroupNames @( 'Bad' )
+
+ Assert-MockCalled -CommandName Get-ADDomain -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Get-ADGroup -Times 0 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Rename-Computer -Times 0 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Start-Sleep -Times 0 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Add-Computer -Times 0 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Add-ADGroupMember -Times 0 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Restart-Computer -Times 0 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Write-Warning -Times 0 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Write-Error -Times 1 -Exactly -Scope It -ModuleName $moduleForMock `
+ -ParameterFilter { $Message -match 'Unable to find the primary domain controller in the target domain' }
+
+ Mock -CommandName Get-ADDomain -ModuleName $moduleForMock -MockWith { return @{PDCEmulator = '1.2.3.4' } }
+ }
+
+ It 'Should Return With Error If Invalid Security Group Name Is Provided' {
+
+ Mock -CommandName Get-ADGroup -ModuleName $moduleForMock -MockWith { return $null }
+
+ Add-DomainMember -Cred $testCredential -Domain 'Test' -OUPath 'ou=bar,ou=foo,dc=test,dc=local' -SecurityGroupNames @( 'Bad' )
+
+ Assert-MockCalled -CommandName Get-ADGroup -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Rename-Computer -Times 0 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Start-Sleep -Times 0 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Add-Computer -Times 0 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Add-ADGroupMember -Times 0 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Restart-Computer -Times 0 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Write-Warning -Times 0 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Write-Error -Times 1 -Exactly -Scope It -ModuleName $moduleForMock `
+ -ParameterFilter { $Message -match 'Security Group Bad not found; aborting' }
+
+ Mock -CommandName Get-ADGroup -ModuleName $moduleForMock -MockWith { return New-Object Microsoft.ActiveDirectory.Management.ADGroup }
+ }
+
+ It 'Should Return With Error If Hostname tag is Empty and Value not provided as parameter' {
+
+ Mock -CommandName Get-InstanceHostname -ModuleName $moduleForMock -MockWith { throw 'Hostname Tag is Empty' }
+
+ Add-DomainMember -Cred $testCredential -Domain 'Test' -OUPath 'ou=bar,ou=foo,dc=test,dc=local'
+
+ Assert-MockCalled -CommandName Get-ADGroup -Times 0 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Get-InstanceHostname -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Get-CurrentInstance -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Rename-Computer -Times 0 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Start-Sleep -Times 0 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Add-Computer -Times 0 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Add-ADGroupMember -Times 0 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Restart-Computer -Times 0 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Write-Warning -Times 1 -Exactly -Scope It -ModuleName $moduleForMock `
+ -ParameterFilter { $Message -match 'Get-InstanceHostname threw exception : Hostname Tag is Empty' }
+ Assert-MockCalled -CommandName Write-Error -Times 1 -Exactly -Scope It -ModuleName $moduleForMock `
+ -ParameterFilter { $Message -match 'Hostname was not provided and could not be read from tags; aborting' }
+
+ Mock -CommandName Get-InstanceHostname -ModuleName $moduleForMock -MockWith { return 'AlkHostnameTag' }
+ }
+
+ It 'Should use alk:hostname tag if parameter is not provided' {
+
+ Add-DomainMember -Cred $testCredential -Domain 'Test' -OUPath 'ou=bar,ou=foo,dc=test,dc=local'
+
+ Assert-MockCalled -CommandName Get-ADGroup -Times 0 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Get-InstanceHostname -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Get-CurrentInstance -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Rename-Computer -Times 1 -Exactly -Scope It -ModuleName $moduleForMock `
+ -ParameterFilter { $NewName -match 'AlkHostnameTag' }
+ Assert-MockCalled -CommandName Start-Sleep -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Add-Computer -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Add-ADGroupMember -Times 0 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Restart-Computer -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Write-Warning -Times 0 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Write-Error -Times 0 -Exactly -Scope It -ModuleName $moduleForMock
+ }
+
+ It 'Should use hostname parameter if provided' {
+
+ Add-DomainMember -Cred $testCredential -Domain 'Test' -OUPath 'ou=bar,ou=foo,dc=test,dc=local' -NewHostName 'HostnameParameter'
+
+ Assert-MockCalled -CommandName Get-ADGroup -Times 0 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Get-InstanceHostname -Times 0 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Get-CurrentInstance -Times 0 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Rename-Computer -Times 1 -Exactly -Scope It -ModuleName $moduleForMock `
+ -ParameterFilter { $NewName -match 'HostnameParameter' }
+ Assert-MockCalled -CommandName Start-Sleep -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Add-Computer -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Add-ADGroupMember -Times 0 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Restart-Computer -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Write-Warning -Times 0 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Write-Error -Times 0 -Exactly -Scope It -ModuleName $moduleForMock
+ }
+
+ It 'Should Skip Host Rename If Computer Name Matches Target Value' {
+
+ Add-DomainMember -Cred $testCredential -Domain 'Test' -OUPath 'ou=bar,ou=foo,dc=test,dc=local' -NewHostName 'Test'
+
+ Assert-MockCalled -CommandName Get-ADGroup -Times 0 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Get-InstanceHostname -Times 0 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Get-CurrentInstance -Times 0 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Rename-Computer -Times 0 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Start-Sleep -Times 0 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Add-Computer -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Add-ADGroupMember -Times 0 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Restart-Computer -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Write-Warning -Times 0 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Write-Error -Times 0 -Exactly -Scope It -ModuleName $moduleForMock
+ }
+
+ It 'Should Add Computer to All Specified Security Groups' {
+ Add-DomainMember -Cred $testCredential -NewHostName 'Test' -Domain 'Test' -OUPath 'ou=bar,ou=foo,dc=test,dc=local' -SecurityGroupNames @( 'Test1', 'Test2', 'Test3' )
+
+ Assert-MockCalled -CommandName Get-ADGroup -Times 3 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Rename-Computer -Times 0 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Start-Sleep -Times 0 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Add-Computer -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Test-IsCollectionNullOrEmpty -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Invoke-CommandWithRetry -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Add-ADGroupMember -Times 3 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Restart-Computer -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Write-Warning -Times 0 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Write-Error -Times 0 -Exactly -Scope It -ModuleName $moduleForMock
+ }
+
+ It 'Should Skip Security Group processing if No Security Groups Were Provided' {
+ Mock -CommandName Test-IsCollectionNullOrEmpty -ModuleName $moduleForMock -MockWith { return $true }
+
+ Add-DomainMember -Cred $testCredential -NewHostName 'Test' -Domain 'Test' -OUPath 'ou=bar,ou=foo,dc=test,dc=local'
+
+ Assert-MockCalled -CommandName Get-ADGroup -Times 0 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Test-IsCollectionNullOrEmpty -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Invoke-CommandWithRetry -Times 0 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Add-ADGroupMember -Times 0 -Exactly -Scope It -ModuleName $moduleForMock
+
+ Mock -CommandName Test-IsCollectionNullOrEmpty -ModuleName $moduleForMock -MockWith { return $false }
+ }
+
+ It 'Should Skip Security Group processing if ADComputer object is Not Detected' {
+
+ Mock -CommandName Invoke-CommandWithRetry -ModuleName $moduleForMock -MockWith { return $null }
+
+ Add-DomainMember -Cred $testCredential -NewHostName 'Test' -Domain 'Test' -OUPath 'ou=bar,ou=foo,dc=test,dc=local' -SecurityGroupNames @( 'Test1', 'Test2', 'Test3' )
+
+ Assert-MockCalled -CommandName Test-IsCollectionNullOrEmpty -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Invoke-CommandWithRetry -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Add-ADGroupMember -Times 0 -Exactly -Scope It -ModuleName $moduleForMock
+
+ Mock -CommandName Invoke-CommandWithRetry -ModuleName $moduleForMock -MockWith { return New-Object Microsoft.ActiveDirectory.Management.ADComputer }
+ }
+
+ It 'Should Write a Warning if ADComputer object is Not Detected' {
+
+ Mock -CommandName Invoke-CommandWithRetry -ModuleName $moduleForMock -MockWith { return $null }
+
+ Add-DomainMember -Cred $testCredential -NewHostName 'Test' -Domain 'Test' -OUPath 'ou=bar,ou=foo,dc=test,dc=local' -SecurityGroupNames @( 'Test1', 'Test2', 'Test3' )
+
+ Assert-MockCalled -CommandName Test-IsCollectionNullOrEmpty -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Invoke-CommandWithRetry -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Write-Warning -Times 1 -Exactly -Scope It -ModuleName $moduleForMock `
+ -ParameterFilter { $Message -match 'Skipping security groups because the domain controller did not report existence of the computer object' }
+
+ Mock -CommandName Invoke-CommandWithRetry -ModuleName $moduleForMock -MockWith { return New-Object Microsoft.ActiveDirectory.Management.ADComputer }
+ }
+
+ It 'Should Target the Same Domain Controller for All Active Directory Operations' {
+ Add-DomainMember -Cred $testCredential -NewHostName 'Test' -Domain 'Test' -OUPath 'ou=bar,ou=foo,dc=test,dc=local' -SecurityGroupNames @( 'Test1', 'Test2', 'Test3' )
+
+ Assert-MockCalled -CommandName Get-ADGroup -Times 3 -Exactly -Scope It -ModuleName $moduleForMock `
+ -ParameterFilter { $Server -match '1.2.3.4' }
+ Assert-MockCalled -CommandName Add-Computer -Times 1 -Exactly -Scope It -ModuleName $moduleForMock `
+ -ParameterFilter { $Server -match '1.2.3.4' }
+ Assert-MockCalled -CommandName Add-ADGroupMember -Times 3 -Exactly -Scope It -ModuleName $moduleForMock `
+ -ParameterFilter { $Server -match '1.2.3.4' }
+ # Because Get-ADComputer is wrapped by Invoke-CommandWithRetry, we can't test that one . . .
+ }
+
+ It 'Should Skip Restart If NoRestart Flag Is Provided' {
+
+ Add-DomainMember -Cred $testCredential `
+ -NewHostName 'Test' `
+ -Domain 'Test' `
+ -OUPath 'ou=bar,ou=foo,dc=test,dc=local' `
+ -SecurityGroupNames @( 'Test1', 'Test2', 'Test3' ) `
+ -NoRestart
+
+ Assert-MockCalled -CommandName Restart-Computer -Times 0 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Write-Warning -Times 1 -Exactly -Scope It -ModuleName $moduleForMock `
+ -ParameterFilter { $Message -match 'Skipping restart of instance at user request' }
+ }
+ }
+}
+
+Remove-Module -Name AWSPowerShell
diff --git a/Modules/Alkami.DevOps.Operations/Public/Add-Route53HostedZoneVpcAssociation.ps1 b/Modules/Alkami.DevOps.Operations/Public/Add-Route53HostedZoneVpcAssociation.ps1
new file mode 100644
index 0000000..261d63a
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Add-Route53HostedZoneVpcAssociation.ps1
@@ -0,0 +1,96 @@
+function Add-Route53HostedZoneVpcAssociation {
+
+<#
+.SYNOPSIS
+ Adds VPC associations to an existing Route53 Hosted Zone.
+
+.DESCRIPTION
+ Adds VPC associations to an existing Route53 Hosted Zone. Refer to https://aws.amazon.com/premiumsupport/knowledge-center/private-hosted-zone-different-account/.
+
+.PARAMETER ZoneId
+ [string] The ID of the Route53 Hosted Zone to update.
+
+.PARAMETER ZoneProfileName
+ [string] The AWS profile where the Route53 Hosted Zone exists (e.g. 'temp-prod').
+
+.PARAMETER VpcId
+ [string] The ID of the VPC to associate with the Route53 Hosted Zone.
+
+.PARAMETER VpcRegion
+ [string] The AWS region of the VPC to associate with the Route53 Hosted Zone.
+
+.PARAMETER VpcProfileName
+ [string] The AWS profile where the VPC rcists (e.g. 'temp-prod').
+
+.EXAMPLE
+ Add-Route53HostedZoneVpcAssociation -ZoneId 'ZONE9999' -ZoneProfileName 'temp-prod' -VpcId 'vpc-1234' -VpcRegion 'us-east-1' -VpcProfileName 'temp-prod'
+
+[Add-Route53HostedZoneVpcAssociation]: Creating VPC 'vpc-1234' association authorization for Route53 Hosted Zone 'ZONE9999'
+[Add-Route53HostedZoneVpcAssociation]: Creating VPC 'vpc-1234' association for Route53 Hosted Zone 'ZONE9999'
+[Add-Route53HostedZoneVpcAssociation]: Removing VPC 'vpc-1234' association authorization for Route53 Hosted Zone 'ZONE9999'
+#>
+
+ [OutputType([Boolean])]
+ [CmdletBinding()]
+ param (
+ [Parameter(Mandatory = $true)]
+ [ValidateNotNullOrEmpty()]
+ [string] $ZoneId,
+
+ [Parameter(Mandatory = $true)]
+ [ValidateNotNullOrEmpty()]
+ [string] $ZoneProfileName,
+
+ [Parameter(Mandatory = $true)]
+ [ValidateNotNullOrEmpty()]
+ [string] $VpcId,
+
+ [Parameter(Mandatory = $true)]
+ [ValidateScript({$_ -in (Get-AWSRegion).region})]
+ [string] $VpcRegion,
+
+ [Parameter(Mandatory = $true)]
+ [ValidateNotNullOrEmpty()]
+ [string] $VpcProfileName
+ )
+
+ $logLead = (Get-LogLeadName)
+
+ Import-AWSModule
+
+ # Step 1: Validate the Zone ID exists using the provided information.
+ $targetZone = ( Get-R53HostedZone -Id $ZoneId -ProfileName $ZoneProfileName )
+ if ( $null -eq $targetZone ) {
+
+ Write-Error "$logLead : Route53 Hosted Zone ID '$ZoneId' not found using profile '$ZoneProfileName'."
+ return $false
+ }
+
+ # Step 2: Validate the VPC exists using the provided information.
+ if ( $false -eq (Test-VpcExists -Id $VpcId -ProfileName $VpcProfileName -Region $VpcRegion)) {
+
+ Write-Error "$logLead : VPC ID '$VpcId' in region '$VpcRegion' not found using profile '$ZoneProfileName'."
+ return $false
+ }
+
+ # Step 3: Validate that the VPC is not already associated with the Route53 Hosted Zone (aka: "No-Op").
+ $vpcExists = $targetZone.VPCs | Where-Object { $_.VPCId -eq $VpcId }
+ if ( $null -ne $vpcExists ) {
+ Write-Host "$logLead : VPC ID '$VpcId' already associated with Route53 Hosted Zone ID '$ZoneId'."
+ return $true
+ }
+
+ # Step 4: Authorize the VPC association (using the Hosted Zone profile).
+ Write-Host ( "{0}: Creating VPC '{1}' association authorization for Route53 Hosted Zone '{2}'" -f $logLead, $VpcId, $ZoneId )
+ New-R53VPCAssociationAuthorization -HostedZoneId $ZoneId -VPC_VPCId $VpcId -VPC_VPCRegion $VpcRegion -ProfileName $ZoneProfileName | Out-Null
+
+ # Step 5: Create the VPC association (using the VPC profile).
+ Write-Host ( "{0}: Creating VPC '{1}' association for Route53 Hosted Zone '{2}'" -f $logLead, $VpcId, $ZoneId )
+ $changeOutput = Register-R53VPCWithHostedZone -HostedZoneId $ZoneId -VPC_VPCId $VpcId -VPC_VPCRegion $VpcRegion -ProfileName $VpcProfileName
+ Wait-Route53ChangeStatus -ChangeId $changeOutput.Id -AwsProfileName $ZoneProfileName
+
+ # Step 6: Remove the VPC association authorization (using the Hosted Zone profile).
+ Write-Host ( "{0}: Removing VPC '{1}' association authorization for Route53 Hosted Zone '{2}'" -f $logLead, $VpcId, $ZoneId )
+ Remove-R53VPCAssociationAuthorization -HostedZoneId $ZoneId -VPC_VPCId $VpcId -VPC_VPCRegion $VpcRegion -ProfileName $ZoneProfileName -Force | Out-Null
+ return $true
+}
diff --git a/Modules/Alkami.DevOps.Operations/Public/Add-Route53HostedZoneVpcAssociation.tests.ps1 b/Modules/Alkami.DevOps.Operations/Public/Add-Route53HostedZoneVpcAssociation.tests.ps1
new file mode 100644
index 0000000..8b2dccb
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Add-Route53HostedZoneVpcAssociation.tests.ps1
@@ -0,0 +1,153 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ""
+
+Describe "Add-Route53HostedZoneVpcAssociation" {
+
+ Mock -CommandName Get-AWSRegion -ModuleName $moduleForMock -MockWith {
+ return @(
+ @{ 'Region' = 'us-east-1' },
+ @{ 'Region' = 'us-west-2' }
+ )
+ }
+
+ Mock -CommandName Import-AWSModule -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Get-LogLeadName -ModuleName $moduleForMock -MockWith { return 'Add-Route53HostedZoneVpcAssociation.tests' }
+
+ Context "Input Validation" {
+
+ It "Zone ID Should Not Be Null" {
+
+ { Add-Route53HostedZoneVpcAssociation -ZoneId $null } | Should -Throw
+ }
+
+ It "Zone ID Should Not Be Empty" {
+
+ { Add-Route53HostedZoneVpcAssociation -ZoneId '' } | Should -Throw
+ }
+
+ It "Zone Profile Name Should Not Be Null" {
+
+ { Add-Route53HostedZoneVpcAssociation -ZoneId 'Test' -ZoneProfileName $null } | Should -Throw
+ }
+
+ It "Zone Profile Name Should Not Be Empty" {
+
+ { Add-Route53HostedZoneVpcAssociation -ZoneId 'Test' -ZoneProfileName '' } | Should -Throw
+ }
+
+ It "VPC ID Should Not Be Null" {
+
+ { Add-Route53HostedZoneVpcAssociation -ZoneId 'Test' -ZoneProfileName 'Test' -VpcId $null } | Should -Throw
+ }
+
+ It "VPC ID Should Not Be Empty" {
+
+ { Add-Route53HostedZoneVpcAssociation -ZoneId 'Test' -ZoneProfileName 'Test' -VpcId '' } | Should -Throw
+ }
+
+ It "VPC Region Should Not Be Null" {
+
+ { Add-Route53HostedZoneVpcAssociation -ZoneId 'Test' -ZoneProfileName 'Test' -VpcId 'Test' -VpcRegion $null } | Should -Throw
+ }
+
+ It "VPC Region Should Not Be Empty" {
+
+ { Add-Route53HostedZoneVpcAssociation -ZoneId 'Test' -ZoneProfileName 'Test' -VpcId 'Test' -VpcRegion '' } | Should -Throw
+ }
+
+ It "VPC Region Should Be In Supported Regions List" {
+
+ { Add-Route53HostedZoneVpcAssociation -ZoneId 'Test' -ZoneProfileName 'Test' -VpcId 'Test' -VpcRegion 'Test' } | Should -Throw
+ }
+
+ It "VPC Profile Name Should Not Be Null" {
+
+ { Add-Route53HostedZoneVpcAssociation -ZoneId 'Test' -ZoneProfileName 'Test' -VpcId 'Test' -VpcRegion 'us-east-1' -VpcProfileName $null } | Should -Throw
+ }
+
+ It "VPC Profile Name Should Not Be Empty" {
+
+ { Add-Route53HostedZoneVpcAssociation -ZoneId 'Test' -ZoneProfileName 'Test' -VpcId 'Test' -VpcRegion 'us-east-1' -VpcProfileName '' } | Should -Throw
+ }
+ }
+
+ Context "Result Validation" {
+
+ It "Should Return With Error If Hosted Zone Does Not Exist" {
+
+ Mock -CommandName Get-R53HostedZone -ModuleName $moduleForMock -MockWith { return $null }
+ Mock -CommandName Write-Error -ModuleName $moduleForMock -MockWith {}
+
+ $result = Add-Route53HostedZoneVpcAssociation -ZoneId 'Test' -ZoneProfileName 'Test' -VpcId 'Test' -VpcRegion 'us-east-1' -VpcProfileName 'Test'
+ $result | Should -BeFalse
+
+ Assert-MockCalled -CommandName Get-R53HostedZone -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Write-Error -Times 1 -Exactly -Scope It -ModuleName $moduleForMock `
+ -ParameterFilter { $Message -match "Route53 Hosted Zone ID .* not found" }
+ }
+
+ It "Should Return With Error If VPC Does Not Exist" {
+
+ Mock -CommandName Get-R53HostedZone -ModuleName $moduleForMock -MockWith { return $true }
+ Mock -CommandName Test-VpcExists -ModuleName $moduleForMock -MockWith { return $false }
+ Mock -CommandName Write-Error -ModuleName $moduleForMock -MockWith {}
+
+ $result = Add-Route53HostedZoneVpcAssociation -ZoneId 'Test' -ZoneProfileName 'Test' -VpcId 'Test' -VpcRegion 'us-east-1' -VpcProfileName 'Test'
+ $result | Should -BeFalse
+
+ Assert-MockCalled -CommandName Get-R53HostedZone -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Test-VpcExists -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Write-Error -Times 1 -Exactly -Scope It -ModuleName $moduleForMock `
+ -ParameterFilter { $Message -match "VPC ID .* in region .* not found" }
+ }
+
+ It "Should Return Success With No Actions Taken When VPC Association Already Exists On Route53 Hosted Zone" {
+
+ Mock -CommandName Get-R53HostedZone -ModuleName $moduleForMock -MockWith { @{ "VPCs" = @( @{ 'VPCId' = 'Test'})} }
+ Mock -CommandName Test-VpcExists -ModuleName $moduleForMock -MockWith { return $true }
+ Mock -CommandName Write-Error -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName New-R53VPCAssociationAuthorization -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Register-R53VPCWithHostedZone -ModuleName $moduleForMock -MockWith { @{ 'ID' = 'Test'} }
+ Mock -CommandName Wait-Route53ChangeStatus -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Remove-R53VPCAssociationAuthorization -ModuleName $moduleForMock -MockWith {}
+
+ $result = Add-Route53HostedZoneVpcAssociation -ZoneId 'Test' -ZoneProfileName 'Test' -VpcId 'Test' -VpcRegion 'us-east-1' -VpcProfileName 'Test'
+ $result | Should -BeTrue
+
+ Assert-MockCalled -CommandName Get-R53HostedZone -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Test-VpcExists -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Write-Error -Times 0 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName New-R53VPCAssociationAuthorization -Times 0 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Register-R53VPCWithHostedZone -Times 0 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Wait-Route53ChangeStatus -Times 0 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Remove-R53VPCAssociationAuthorization -Times 0 -Exactly -Scope It -ModuleName $moduleForMock
+ }
+
+ It "Should Return Success When VPC Association Created On Route53 Hosted Zone" {
+
+ Mock -CommandName Get-R53HostedZone -ModuleName $moduleForMock -MockWith { @{ "VPCs" = @( @{ 'VPCId' = 'Test'})} }
+ Mock -CommandName Test-VpcExists -ModuleName $moduleForMock -MockWith { return $true }
+ Mock -CommandName Write-Error -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName New-R53VPCAssociationAuthorization -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Register-R53VPCWithHostedZone -ModuleName $moduleForMock -MockWith { @{ 'ID' = 'Test'} }
+ Mock -CommandName Wait-Route53ChangeStatus -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Remove-R53VPCAssociationAuthorization -ModuleName $moduleForMock -MockWith {}
+
+ $result = Add-Route53HostedZoneVpcAssociation -ZoneId 'Test' -ZoneProfileName 'Test' -VpcId 'FullTest' -VpcRegion 'us-east-1' -VpcProfileName 'Test'
+ $result | Should -BeTrue
+
+ Assert-MockCalled -CommandName Get-R53HostedZone -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Test-VpcExists -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName New-R53VPCAssociationAuthorization -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Register-R53VPCWithHostedZone -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Wait-Route53ChangeStatus -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Remove-R53VPCAssociationAuthorization -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Write-Error -Times 0 -Exactly -Scope It -ModuleName $moduleForMock
+ }
+ }
+}
diff --git a/Modules/Alkami.DevOps.Operations/Public/Backup-WinTest.ps1 b/Modules/Alkami.DevOps.Operations/Public/Backup-WinTest.ps1
new file mode 100644
index 0000000..c3831c6
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Backup-WinTest.ps1
@@ -0,0 +1,35 @@
+function Backup-WinTest {
+<#
+.SYNOPSIS
+ Copy C:\Tools\WinTest\Current\* to C:\Tools\WinTest\{DATETIMESTAMP} folder
+ Returns full path to newly backed up folder
+#>
+
+ [CmdletBinding()]
+ Param (
+ [Parameter(Mandatory=$false)]
+ [string]$sourcePath = "c:\Tools\WinTest\Current",
+ [Parameter(Mandatory=$false)]
+ [string]$backupPath = "c:\Tools\WinTest\"
+ )
+
+ $logLead = (Get-LogLeadName);
+ $datetime = Get-Date -Format "yyyyMMddHHmm"
+ $backupFolder = Join-Path -Path $backupPath -ChildPath $datetime
+
+ if (Test-Path -Path $sourcePath) {
+ if (Test-Path -Path $backupFolder) {
+ Write-Warning ("$logLead : Backup folder already exists at {0} `nNo backup performed" -f $backupFolder)
+ return $null
+ }
+ Write-Verbose ("$logLead : Renaming {0} to {1}" -f $sourcePath, $backupFolder)
+ Rename-Item -Path $sourcePath -NewName $datetime
+ Write-Verbose ("$logLead : Previous WinTest backed up to {0}" -f $backupFolder)
+ return $backupFolder
+ } else {
+ Write-Verbose "$logLead : WinTest Current folder does not exist, no backup performed"
+ return $null
+ }
+
+}
+
diff --git a/Modules/Alkami.DevOps.Operations/Public/Clear-AllRecycleBins.ps1 b/Modules/Alkami.DevOps.Operations/Public/Clear-AllRecycleBins.ps1
new file mode 100644
index 0000000..4155bdd
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Clear-AllRecycleBins.ps1
@@ -0,0 +1,95 @@
+function Clear-AllRecycleBins {
+<#
+.SYNOPSIS
+ Clears the recycle bin for all users on the current server
+
+.PARAMETER CreateScheduledTask
+ [switch] If passed to the command, will create and register
+ a scheduled task to run every Saturday weekly to clear all recycle bins
+ if one doesn't already exist, otherwise, skip
+#>
+ [CmdletBinding()]
+ param (
+ [Parameter(Mandatory = $false)]
+ [switch]$CreateScheduledTask
+ )
+
+ $logLead = Get-LogLeadName
+
+ if (Test-Path "C:\`$Recycle.Bin") {
+
+ Write-Verbose "$logLead : Looking for symlinks in recycle bins"
+ $symLinks = Get-ChildItem "C:\`$Recycle.bin" -Recurse -Force -ErrorAction "SilentlyContinue" | Where-Object {$_.PSIsContainer -and $_.Attributes.ToString() -match "ReparsePoint"}
+
+ if ($null -ne $symLinks) {
+
+ Write-Host "$logLead : Symlinks found. Deleting them."
+ $symLinks | ForEach-Object {
+ Write-Host "$logLead : Removing Symlink $($_.FullName)"
+ (Get-Item $_.FullName).Delete()
+ }
+ }
+
+ $itemsToDelete = Get-ChildItem "C:\`$Recycle.bin" -Force -Directory
+ $totalCleared = 0
+
+ foreach ($binToDelete in $itemsToDelete) {
+
+ $userSID = $binToDelete.Name
+
+ try {
+
+ $userName = Get-UsernameFromSid -SecurityIdentifier $userSID -ErrorAction SilentlyContinue
+
+ } catch {
+
+ $userName = "UNKNOWN"
+ }
+
+ $binLength = 0
+ Get-ChildItem $binToDelete.FullName -Force -Recurse | ForEach-Object { $binLength += $_.Length}
+ $totalCleared += $binLength
+
+ $roundedData = $([math]::Round(($binLength/1024/1024),2))
+
+ if ($roundedData -eq 0) {
+
+ Write-Host "$logLead : User [$userName] has negligable data in the recycling bin which will be removed."
+
+ } else {
+
+ Write-Host "$logLead : User [$userName] has $($roundedData)mb of data in the recycling bin which will be removed."
+ }
+
+ Remove-Item $binToDelete.FullName -Force -Recurse
+ }
+
+ Write-Host "$logLead : Deletes finished. Number of items deleted: $($itemsToDelete.Count) totalling $([math]::Round(($totalCleared/1024/1024),2))mb"
+ } else {
+
+ Write-Host "$logLead : Recycle Bin does not exist. Skipping Recycle Bin clear."
+ }
+
+ if ($createScheduledTask.IsPresent) {
+
+ $taskName = "Clear All Recycle Bins"
+ $taskArray = ScheduledTasks\Get-ScheduledTask | Select-Object -Property TaskName
+
+ $recycleBinTask = $taskArray | Where-Object { $_.TaskName -eq $taskName }
+ $taskExists = $null -ne $recycleBinTask
+
+ if (!($taskExists)) {
+
+ $actionArgument = '-WindowStyle Hidden -command "&{Clear-AllRecycleBins}"'
+ $registerDescription = "Clears the recycle bin of all users on the current server weekly at 9AM."
+
+ $action = New-ScheduledTaskAction -Execute 'Powershell.exe' -Argument $actionArgument
+ $trigger = New-ScheduledTaskTrigger -Weekly -At 9am -DaysOfWeek "Saturday"
+
+ Register-ScheduledTask -Action $action -Trigger $trigger -TaskName $taskName -Description $registerDescription -User "system" -RunLevel "Highest"
+ } else {
+
+ Write-Host "$logLead : The recycle bin task already exists. Skipping creation of a new one."
+ }
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Public/Clear-AwsSqsQueue.ps1 b/Modules/Alkami.DevOps.Operations/Public/Clear-AwsSqsQueue.ps1
new file mode 100644
index 0000000..35aa650
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Clear-AwsSqsQueue.ps1
@@ -0,0 +1,236 @@
+function Clear-AwsSqsQueue {
+
+ <#
+ .SYNOPSIS
+ Clears all messages from AWS Queues specified by the user.
+
+ .DESCRIPTION
+ Clears all messages from AWS Queues specified by the user. Supports exact match, queue prefix, and dynamically matched queues
+ using machine.config settings
+
+ .PARAMETER QueueName
+ A specific SQS queue name. When supplied, only this queue will be cleared, and only if there is an exact match on Queue Name
+
+ .PARAMETER QueuePrefix
+ A prefix for queues. When supplied, all queues with this name prefix will be cleared
+
+ .PARAMETER AllMatchedQueues
+ Dynamically determines the appropriate queue name format using local machine data. Will only work on an application server
+
+ .PARAMETER IncludeDeadLetterQueues
+ By default, dead letter queues are excluded. When this flag is supplied, dead letter queues will also be cleared
+
+ .PARAMETER Force
+ When supplied, bypasses all confirmations. Typically not recommended to do so but it's best practice to include
+
+ .LINK
+ Runbook: https://confluence.alkami.com/x/B71iDg
+
+ .EXAMPLE
+ Clear-AwsSqsQueue -QueueName "Qa-trin-AlkamiMSBACHOptInAlertsHost" -Region us-east-1 -Profile temp-qa
+[Clear-AwsSqsQueue] : Searching for SQS Queues Using Queue Prefix: [Qa-trin-AlkamiMSBACHOptInAlertsHost]
+[Clear-AwsSqsQueue] : Found 1 queue(s) to purge based on user input.
+[Clear-AwsSqsQueue] : Purging Messages from Queue: [https://sqs.us-east-1.amazonaws.com/668894625708/Qa-trin-AlkamiMSBACHOptInAlertsHost]
+
+ .EXAMPLE
+ Clear-AwsSqsQueue -QueuePrefix "Qa-trin-" -Region us-east-1 -Profile temp-qa
+[Clear-AwsSqsQueue] : Searching for SQS Queues Using Queue Prefix: [Qa-trin-]
+[Clear-AwsSqsQueue] : Found 7 queue(s) to purge based on user input.
+
+Large Queue Count Purge
+7 SQS queues will be purged. Are you sure you want to continue?
+[Y] Yes [A] Yes to All [N] No [L] No to All [S] Suspend [?] Help (default is "Y"): y
+[Clear-AwsSqsQueue] : Purging Messages from Queue: [https://sqs.us-east-1.amazonaws.com/668894625708/Qa-trin-AlkamiAppRadiumWindowsService-Accounts]
+[Clear-AwsSqsQueue] : Purging Messages from Queue: [https://sqs.us-east-1.amazonaws.com/668894625708/Qa-trin-AlkamiAppRadiumWindowsService-Transactions]
+[Clear-AwsSqsQueue] : Purging Messages from Queue: [https://sqs.us-east-1.amazonaws.com/668894625708/Qa-trin-AlkamiAppRadiumWindowsService.fifo]
+[Clear-AwsSqsQueue] : Purging Messages from Queue: [https://sqs.us-east-1.amazonaws.com/668894625708/Qa-trin-AlkamiMSAccountRefreshSubscriptionFilterHost]
+[Clear-AwsSqsQueue] : Purging Messages from Queue: [https://sqs.us-east-1.amazonaws.com/668894625708/Qa-trin-AlkamiMSAlertSubscriptionChangedConsumerCOCCHost]
+[Clear-AwsSqsQueue] : Purging Messages from Queue: [https://sqs.us-east-1.amazonaws.com/668894625708/Qa-trin-AlkamiMSAuthenticationWorkflowHost-UsernameChanged]
+[Clear-AwsSqsQueue] : Purging Messages from Queue: [https://sqs.us-east-1.amazonaws.com/668894625708/Qa-trin-AlkamiMSAutomaticDepositAlertHost]
+ #>
+
+ [CmdletBinding(SupportsShouldProcess = $true)]
+ param(
+ [Parameter(Mandatory = $true, ParameterSetName = "QueueName")]
+ [string]$QueueName,
+
+ [Parameter(Mandatory = $true, ParameterSetName = "QueuePrefix")]
+ [string]$QueuePrefix,
+
+ [Parameter(Mandatory = $true, ParameterSetName = "AllMatchedQueues")]
+ [switch]$AllMatchedQueues,
+
+ [Parameter(Mandatory = $false, ParameterSetName = "__AllParameterSets")]
+ [switch]$IncludeDeadLetterQueues,
+
+ [Parameter(Mandatory = $false, ParameterSetName = "__AllParameterSets")]
+ [switch]$Force
+ )
+ DynamicParam {
+
+ # Adds Dynamic Parameters for AWS Region and ProfileName
+ Get-AwsStandardDynamicParameters
+ }
+
+ begin {
+
+ Import-AWSModule
+ $logLead = Get-LogLeadName
+
+ $parameterSet = $PSCmdlet.ParameterSetName
+ $isRunningInAws = Test-IsAws
+
+ $awsSplatParams = @{}
+ $nextToken = ""
+ $queueSearchPrefix = ""
+
+ $exactMatchOnly = ("QueueName" -eq $parameterSet)
+
+ $Region = $PsBoundParameters["Region"]
+ $ProfileName = $PsBoundParameters["ProfileName"]
+ }
+
+ process {
+
+ # Identify the Appropriate Target Region
+ if (Test-StringIsNullOrEmpty -Value $Region) {
+
+ if ($isRunningInAws) {
+
+ # Use the Current Instances Region
+ $awsSplatParams["Region"] = Get-CurrentInstanceRegion
+
+ } else {
+
+ # Cannot Continue
+ Write-Warning "$logLead : No region was supplied and this command is not running in AWS. Execution cannot continue"
+ return
+ }
+ } else {
+
+ # Use the User Supplied Region
+ $awsSplatParams["Region"] = "$Region"
+ }
+
+ # Identify How to Authenticate Our Calls
+ if (Test-StringIsNullOrEmpty $ProfileName) {
+
+ if ($isRunningInAws) {
+
+ # Use the IAM Instance Profile
+ Write-Verbose "$logLead : No profile was supplied to the function. The IAM instance profile will be used in lieu."
+
+ } else {
+
+ # Cannot Continue
+ Write-Warning "$logLead : No profile was supplied and this command is not running in AWS. Execution cannot continue."
+ return
+ }
+ } else {
+
+ # Use the User Supplied Profile Name
+ $awsSplatParams["ProfileName"] = "$ProfileName"
+ }
+
+ # Deal with calculated the queue search prefix if needed
+ if ("AllMatchedQueues" -eq $parameterSet) {
+
+ if (-NOT $isRunningInAws) {
+
+ Write-Warning "$logLead : No search parameters were supplied, and calculated queue names are only available in AWS. Execution cannot continue"
+ return
+ }
+
+ # Dynamically figure out what queues to purge based on values from the machine.config
+ $environmentType = Get-EnvironmentType
+ $designation = (Get-EnvironmentNameSafeDesignation).ToLowerInvariant()
+
+ if (Test-StringIsNullOrEmpty -Value $environmentType) {
+
+ Write-Warning "$logLead : Could not read the environment type from the current machine.config. Execution cannot continue."
+ return
+ }
+
+ if (Test-StringIsNullOrEmpty -Value $designation) {
+
+ Write-Warning "$logLead : Could not read the name safe desgination from the current machine.config. Execution cannot continue."
+ return
+ }
+
+ $queueSearchPrefix = $environmentType + "-" + $designation + "-"
+ Write-Verbose "$logLead : No queue name or prefix supplied. Calculated search prefix: $queueSearchPrefix"
+
+ } else {
+
+ # Otherwise we'll just search for what they asked for
+ $queueSearchPrefix = Get-CoalescedStringValue -ValueA $QueueName -ValueB $QueuePrefix
+ }
+
+ Write-Host "$logLead : Searching for SQS Queues Using Queue Prefix: [$queueSearchPrefix]"
+ $queueSearchResult = Get-SQSQueue -MaxResult 1000 -Select "*" -QueueNamePrefix $queueSearchPrefix @awsSplatParams
+ [array]$targetQueues = $queueSearchResult.QueueUrls
+
+ if (Test-IsCollectionNullOrEmpty -Collection $targetQueues) {
+
+ Write-Warning "$logLead : No matching queues found."
+ return
+ }
+
+ $nextToken = $queueSearchResult.NextToken
+
+ # In their infinite wisdom, AWS limits the number of queues that may be returned to no more than 1000 at a time
+ # This handles that
+ # Though really, should anyone be trying to purge more than 1000 queues at a time?
+ # Dear reader, not my problem. We will prompt for confirmation if more than 5 queues are selected for purging later on.
+ # ToDo: Move this in to a Get-AllSQSQueues type function
+ if (-NOT (Test-StringIsNullOrEmpty $nextToken)) {
+
+ do {
+ Write-Verbose "$logLead : Searching for queues using next token due to batching limits"
+ $queueSearchResult = Get-SQSQueue -MaxResult 1000 -Select "*" -QueueNamePrefix $queueSearchPrefix -NextToken $nextToken @awsSplatParams
+
+ $targetQueues += $queueSearchResult.QueueUrls
+ $nextToken = $queueSearchResult.NextToken
+
+ } while (-NOT (Test-StringIsNullOrEmpty -Value $nextToken))
+ }
+
+ # Remove all Dead Letter Queues by Default
+ if (-NOT ($IncludeDeadLetterQueues.IsPresent)) {
+
+ Write-Verbose "$logLead : Removing dead letter queues from targets"
+ $targetQueues = $targetQueues | Where-Object {$_ -notmatch "-Dead$"}
+ }
+
+ # Only use Exact Matches if a Queue Name Was Provided
+ if ($exactMatchOnly) {
+
+ $targetQueues = $targetQueues | Where-Object {$_ -match "/$QueueName$"}
+ }
+
+ $queuesCount = $targetQueues.Count
+ Write-Host "$logLead : Found $queuesCount queue(s) to purge based on user input."
+
+ if ($queuesCount -gt 5 -and (-NOT ($Force.IsPresent))) {
+
+ $continuePrompt = "$queuesCount SQS queues will be purged. Are you sure you want to continue?"
+ [bool]$yesToAll = $false
+ [bool]$noToAll = $false
+ if (-NOT (Test-ShouldContinue -Query $continuePrompt -Caption "Large Queue Count Purge" -YesToAll ([ref]$yesToAll) -NoToAll ([ref]$noToAll)) -or $noToAll) {
+
+ Write-Warning "$logLead : User aborted process. Purge will not be performed."
+ return
+ }
+ }
+
+ foreach ($queue in $targetQueues) {
+
+ $purgeMessage = "Purging Messages from Queue: [$queue]"
+ if ($yesToAll -or $Force.IsPresent -or (Test-ShouldProcess -Target $queue -Action "Purging SQS Queue")) {
+
+ Write-Host "$logLead : $purgeMessage"
+ Clear-SQSQueue -QueueUrl $queue @awsSplatParams
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Public/Clear-AwsSqsQueue.tests.ps1 b/Modules/Alkami.DevOps.Operations/Public/Clear-AwsSqsQueue.tests.ps1
new file mode 100644
index 0000000..ff2f82b
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Clear-AwsSqsQueue.tests.ps1
@@ -0,0 +1,301 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ""
+
+Describe "Clear-AwsSqsQueue" {
+
+ Mock -CommandName Clear-SQSQueue -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Get-SQSQueue -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Get-AwsStandardDynamicParameters -ModuleName $moduleForMock -MockWith {
+
+ # Assign fake/safe values here
+ $dynamicParams = @(
+ @{ Name = "Region"; Values = @( "canada-east-99","antartica-south-1" ); },
+ @{ Name = "ProfileName"; Values = @( "bippity-boppity","boo" ) }
+ )
+
+ $runtimeParameterDictionary = New-Object System.Management.Automation.RuntimeDefinedParameterDictionary
+
+ foreach ($param in $dynamicParams) {
+
+ $attributeCollection = New-Object System.Collections.ObjectModel.Collection[System.Attribute]
+ $parameterAttribute = New-Object -Type System.Management.Automation.ParameterAttribute
+ $parameterAttribute.Mandatory = $false
+ $parameterAttribute.ParameterSetName = "__AllParameterSets"
+ $attributeCollection.Add($parameterAttribute)
+ $validateSetAttribute = New-Object System.Management.Automation.ValidateSetAttribute($param.Values)
+ $attributeCollection.Add($validateSetAttribute)
+
+ $runtimeParameter = New-Object System.Management.Automation.RuntimeDefinedParameter($param.Name, [string], $attributeCollection)
+ $runtimeParameterDictionary.Add($param.Name, $RuntimeParameter)
+ }
+
+ return $runtimeParameterDictionary
+ }
+
+ Context "Error Handling" {
+
+ It "Writes a Warning and Returns Early if Region is Not Supplied and Execution is Not in AWS" {
+
+ Mock -CommandName Write-Warning -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Test-IsAws -ModuleName $moduleForMock -MockWith { return $false }
+
+
+ Clear-AwsSqsQueue -ProfileName "bippity-boppity" -QueueName "AintNoWayThisIsALegitPrefix"
+
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Write-Warning -Times 1 -Exactly -Scope It `
+ -ParameterFilter { $Message -Match "No region was supplied and this command is not running in AWS" }
+
+ Assert-MockCalled -CommandName Clear-SQSQueue -Times 0 -Exactly -Scope It
+ }
+
+ It "Writes a Warning and Returns Early if Profile is Not Supplied and Execution is Not in AWS" {
+
+ Mock -CommandName Write-Warning -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Test-IsAws -ModuleName $moduleForMock -MockWith { return $false }
+
+ Clear-AwsSqsQueue -Region "antartica-south-1" -QueueName "AintNoWayThisIsALegitPrefix"
+
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Write-Warning -Times 1 -Exactly -Scope It `
+ -ParameterFilter { $Message -Match "No profile was supplied and this command is not running in AWS" }
+
+ Assert-MockCalled -CommandName Clear-SQSQueue -Times 0 -Exactly -Scope It
+ }
+
+ It "Writes a Warning and Returns Early if AllMatchedQueues Is Supplied and Execution is Not in AWS" {
+
+ Mock -CommandName Write-Warning -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Test-IsAws -ModuleName $moduleForMock -MockWith { return $false }
+
+ Clear-AwsSqsQueue -Region "antartica-south-1" -ProfileName "bippity-boppity" -AllMatchedQueues
+
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Write-Warning -Times 1 -Exactly -Scope It `
+ -ParameterFilter { $Message -Match "calculated queue names are only available in AWS" }
+
+ Assert-MockCalled -CommandName Clear-SQSQueue -Times 0 -Exactly -Scope It
+ }
+
+ It "Writes a Warning and Returns Early if No Queues are Found with the Search Value" {
+
+ Mock -CommandName Write-Warning -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Test-IsAws -ModuleName $moduleForMock -MockWith { return $false }
+ Mock -CommandName Get-SQSQueue -ModuleName $moduleForMock -MockWith { return $null }
+
+ Clear-AwsSqsQueue -Region "antartica-south-1" -ProfileName "bippity-boppity" -QueueName "FooToDaBar"
+
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Write-Warning -Times 1 -Exactly -Scope It `
+ -ParameterFilter { $Message -Match "No matching queues found" }
+
+ Assert-MockCalled -CommandName Clear-SQSQueue -Times 0 -Exactly -Scope It
+ }
+ }
+
+ Context "Logic" {
+
+ It "Iterates over All Queues When There are Paged Results Available" {
+
+ Mock -CommandName Get-SQSQueue -ModuleName $moduleForMock -MockWith {
+
+ return New-Object PSObject -Property @{
+
+ # Next Token is returned by AWS when there is another page of results
+ NextToken = "1";
+ QueueUrls = @("FirstGetCallQueueA","FirstGetCallQueueB");
+ }
+ } -ParameterFilter { ([string]$NextToken).Length -eq 0 }
+
+ Mock -CommandName Get-SQSQueue -ModuleName $moduleForMock -MockWith {
+
+ return New-Object PSObject -Property @{
+
+ # Next Token is returned by AWS when there is another page of results
+ NextToken = "";
+ QueueUrls = @("SecondGetCallQueueA","SecondGetCallQueueB");
+ }
+ } -ParameterFilter { ([string]$NextToken).Length -gt 0 }
+
+ Clear-AwsSqsQueue -Region "antartica-south-1" -ProfileName "boo" -QueuePrefix "TestData"
+
+ Assert-MockCalled -CommandName Get-SQSQueue -Times 2 -Exactly -Scope It
+ Assert-MockCalled -CommandName Clear-SQSQueue -Times 4 -Exactly -Scope It
+ }
+
+ It "Prompts the User to Confirm if More than 5 Queues are Targeted and Force is Not Supplied" {
+
+ Mock -CommandName Test-ShouldContinue -ModuleName $moduleForMock -MockWith { return $true }
+
+ Mock -CommandName Get-SQSQueue -ModuleName $moduleForMock -MockWith {
+
+ return New-Object PSObject -Property @{
+
+ NextToken = "";
+ QueueUrls = @("1","2","3","4","5","NumberBlocks");
+ }
+ } -ParameterFilter { ([string]$NextToken).Length -eq 0 }
+
+ Clear-AwsSqsQueue -Region "antartica-south-1" -ProfileName "boo" -QueuePrefix "AintNoWay" -Force:$false
+ Assert-MockCalled -CommandName Test-ShouldContinue -Times 1 -Exactly -Scope It
+ Assert-MockCalled -CommandName Clear-SQSQueue -Times 6 -Exactly -Scope It
+ }
+
+ It "Does Not Prompt the User to Confirm if Less than 6 Queues are Targeted" {
+
+ Mock -CommandName Test-ShouldContinue -ModuleName $moduleForMock -MockWith { return $true }
+
+ Mock -CommandName Get-SQSQueue -ModuleName $moduleForMock -MockWith {
+
+ return New-Object PSObject -Property @{
+
+ NextToken = "";
+ QueueUrls = @("6","7","8","9","10");
+ }
+ } -ParameterFilter { ([string]$NextToken).Length -eq 0 }
+
+ Clear-AwsSqsQueue -Region "antartica-south-1" -ProfileName "boo" -QueuePrefix "TheyGonnaStopMyFlow" -Force:$false
+ Assert-MockCalled -CommandName Test-ShouldContinue -Times 0 -Exactly -Scope It
+ Assert-MockCalled -CommandName Clear-SQSQueue -Times 5 -Exactly -Scope It
+ }
+
+ It "Writes a Warning and Exits if the User Selects No to All at the Test-ShouldContinue Prompt" {
+
+ Mock -CommandName Test-ShouldContinue -ModuleName $moduleForMock -MockWith { return $false }
+ Mock -CommandName Write-Warning -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Get-SQSQueue -ModuleName $moduleForMock -MockWith {
+
+ return New-Object PSObject -Property @{
+
+ NextToken = "";
+ QueueUrls = @("6","7","8","9","10", "NumberBlocks");
+ }
+ } -ParameterFilter { ([string]$NextToken).Length -eq 0 }
+
+ Clear-AwsSqsQueue -Region "antartica-south-1" -ProfileName "boo" -QueuePrefix "DontListenToTheBuzz" -Force:$false
+ Assert-MockCalled -CommandName Test-ShouldContinue -Times 1 -Exactly -Scope It
+ Assert-MockCalled -CommandName Clear-SQSQueue -Times 0 -Exactly -Scope It
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Write-Warning -Times 1 -Exactly -Scope It `
+ -ParameterFilter { $Message -Match "User aborted process" }
+ }
+
+ It "Does Not Prompt the User if they Supply the Force Flag" {
+
+ Mock -CommandName Test-ShouldContinue -ModuleName $moduleForMock -MockWith { $false }
+ Mock -CommandName Test-ShouldProcess -ModuleName $moduleForMock -MockWith { $false }
+
+ Mock -CommandName Get-SQSQueue -ModuleName $moduleForMock -MockWith {
+
+ return New-Object PSObject -Property @{
+
+ NextToken = "";
+ QueueUrls = @("6","7","8","9","10", "NumberBlocks");
+ }
+ } -ParameterFilter { ([string]$NextToken).Length -eq 0 }
+
+ Clear-AwsSqsQueue -Region "antartica-south-1" -ProfileName "boo" -QueuePrefix "DontGetCaughtUpInTheHype" -Force
+
+ Assert-MockCalled -CommandName Test-ShouldContinue -Times 0 -Exactly -Scope It
+ Assert-MockCalled -CommandName Test-ShouldProcess -Times 0 -Exactly -Scope It
+
+ Assert-MockCalled -CommandName Clear-SQSQueue -Times 6 -Exactly -Scope It
+ }
+
+ It "Removes Dead Letter Queue Matches if Not Specified to Be Purged" {
+
+ Mock -CommandName Test-ShouldContinue -ModuleName $moduleForMock -MockWith { $true }
+ Mock -CommandName Test-ShouldProcess -ModuleName $moduleForMock -MockWith { $true }
+
+ Mock -CommandName Get-SQSQueue -ModuleName $moduleForMock -MockWith {
+
+ return New-Object PSObject -Property @{
+
+ NextToken = "";
+ QueueUrls = @("Foo","Foo-Dead");
+ }
+ } -ParameterFilter { ([string]$NextToken).Length -eq 0 }
+
+ Clear-AwsSqsQueue -Region "antartica-south-1" -ProfileName "boo" -QueuePrefix "YoullLoseEverythingThatYouWorkedFor" -IncludeDeadLetterQueues:$false
+ Assert-MockCalled -CommandName Clear-SQSQueue -Times 1 -Exactly -Scope It
+ Assert-MockCalled -CommandName Clear-SQSQueue -Times 0 -Exactly -Scope It `
+ -ParameterFilter { $QueueUrl -like "*Dead"}
+ }
+
+ It "Includes Dead Letter Queue Matches if Specified to Be Purged" {
+
+ Mock -CommandName Test-ShouldContinue -ModuleName $moduleForMock -MockWith { $true }
+ Mock -CommandName Test-ShouldProcess -ModuleName $moduleForMock -MockWith { $true }
+
+ Mock -CommandName Get-SQSQueue -ModuleName $moduleForMock -MockWith {
+
+ return New-Object PSObject -Property @{
+
+ NextToken = "";
+ QueueUrls = @("Foo","Foo-Dead");
+ }
+ } -ParameterFilter { ([string]$NextToken).Length -eq 0 }
+
+ Clear-AwsSqsQueue -Region "antartica-south-1" -ProfileName "boo" -QueuePrefix "IfItAintBrokeDontFixIt" -IncludeDeadLetterQueues
+ Assert-MockCalled -CommandName Clear-SQSQueue -Times 2 -Exactly -Scope It
+ Assert-MockCalled -CommandName Clear-SQSQueue -Times 1 -Exactly -Scope It `
+ -ParameterFilter { $QueueUrl -eq "Foo-Dead"}
+ }
+ }
+
+ Context "AllMatchedQueues Parameter Set" {
+
+ It "Constructs the Queue Prefix from Machine Config Values in the AllMatchedQueues Path" {
+
+ Mock -CommandName Test-IsAws -ModuleName $moduleForMock -MockWith { $true }
+ Mock -CommandName Get-EnvironmentType -ModuleName $moduleForMock -MockWith { return "FightClub" }
+ Mock -CommandName Get-EnvironmentNameSafeDesignation -ModuleName $moduleForMock -MockWith { return "HisNameIsRobertPaulson" }
+
+ Clear-AwsSqsQueue -Region "antartica-south-1" -ProfileName "boo" -AllMatchedQueues
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Get-SQSQueue -Times 1 -Exactly -Scope It `
+ -ParameterFilter { $QueueNamePrefix -Match "FightClub-HisNameIsRobertPaulson" }
+ }
+ }
+
+ Context "QueueName Parameter Set" {
+
+ It "Does Not Purge a Queue if a Dead Letter Queue Name is Specified without the DeadLetterQueue flag in the QueueName Path" {
+
+ Mock -CommandName Test-IsAws -ModuleName $moduleForMock -MockWith { $true }
+ Mock -CommandName Write-Warning -ModuleName $moduleForMock -MockWith {}
+
+ Mock -CommandName Get-SQSQueue -ModuleName $moduleForMock -MockWith {
+
+ return New-Object PSObject -Property @{
+
+ NextToken = "";
+ QueueUrls = @("TestQueue-Dead");
+ }
+ }
+
+ Clear-AwsSqsQueue -Region "antartica-south-1" -ProfileName "boo" -QueueName "TestQueue-DEAD"
+
+ Assert-MockCalled -CommandName Clear-SQSQueue -Scope It -Times 0 -Exactly
+ }
+
+ It "Clears Only Exact Matches on Queue Name in the QueueName Path" {
+
+ Mock -CommandName Test-IsAws -ModuleName $moduleForMock -MockWith { $true }
+ Mock -CommandName Write-Warning -ModuleName $moduleForMock -MockWith {}
+
+ Mock -CommandName Get-SQSQueue -ModuleName $moduleForMock -MockWith {
+
+ return New-Object PSObject -Property @{
+
+ NextToken = "";
+ QueueUrls = @("https://MatchingQueue", "https://MatchingQueue2", "https://MatchingQueue3", "https://NonMatchingQueue4");
+ }
+ }
+
+ Clear-AwsSqsQueue -Region "antartica-south-1" -ProfileName "boo" -QueueName "MatchingQueue"
+
+ Assert-MockCalled -CommandName Clear-SQSQueue -Scope It -Times 1 -Exactly -ParameterFilter { $QueueUrl -eq "https://MatchingQueue" }
+ Assert-MockCalled -CommandName Clear-SQSQueue -Scope It -Times 0 -Exactly -ParameterFilter { $QueueUrl -match "MatchingQueue\d" }
+ }
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Public/Clear-CloudFlareCache.ps1 b/Modules/Alkami.DevOps.Operations/Public/Clear-CloudFlareCache.ps1
new file mode 100644
index 0000000..c155e5f
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Clear-CloudFlareCache.ps1
@@ -0,0 +1,116 @@
+function Clear-CloudFlareCache {
+ <#
+.SYNOPSIS
+ Clears CloudFlare Cache for a Given Zone and Hosts
+
+.DESCRIPTION
+ Makes an API call to clear CloudFlare cache for a particular zone, and limits the request by Host. This has to be
+ limited due to our OLBEDGE.NET zone which houses multiple FIs that also leverage CloudFlare. Host counts over 30 will
+ be split in to multiple API calls due to API filter limits
+
+.PARAMETER AuthenticationDictionary
+ Authentication headers required to execute requests. This object is the result from
+ calls to Get-CloudFlareAuthenticationHeaders
+
+.PARAMETER ZoneId
+ [string] The parent zone ID for the host
+
+.PARAMETER HostNames
+ [string[]] An array of hostnames used to filter the cache bust
+
+.PARAMETER BaseUri
+ [string] The base CloudFlare API URL. Defaults to https://api.cloudflare.com/client/v4
+
+.PARAMETER MaxHosts
+ [int] The maximum number of hosts the API supports. Defaults to 30. When the hosts provided exceeds this count, multiple API calls will be made
+
+.LINK
+
+https://api.cloudflare.com/#zone-purge-files-by-cache-tags-or-host
+#>
+
+ [CmdletBinding()]
+ param(
+
+ [Parameter(Mandatory = $true)]
+ [Alias("CloudFlareAuthenticationHeaders")]
+ [System.Collections.Generic.Dictionary[[String],[String]]]$AuthenticationDictionary,
+
+ [Parameter(Mandatory = $true)]
+ [Alias("CloudFlareZoneId")]
+ [string[]]$ZoneId,
+
+ [Parameter(Mandatory = $true)]
+ [Alias("Host")]
+ [string[]]$HostNames,
+
+ [Parameter(Mandatory = $false)]
+ [Alias("CloudFlareBaseUri")]
+ [string]$BaseUri = "https://api.cloudflare.com/client/v4",
+
+ [Parameter(Mandatory = $false)]
+ [Alias("MaxHostsCount")]
+ [int]$MaxHosts = 30,
+
+ [Parameter(Mandatory = $false)]
+ [bool]$WriteCacheClearErrorsAsErrors = $true
+ )
+ $logLead = Get-LogLeadName
+ $cacheClearURL = "$BaseUri/zones/$ZoneId/purge_cache"
+ if (!(Test-IsCollectionNullOrEmpty -Collection $HostNames) -and $HostNames.Count -gt 1) {
+ $hostDescriptor = "Hosts"
+ } else {
+ $hostDescriptor = "Host"
+ }
+
+ # Group Hosts In to Sets Based on $MaxHosts
+ $hostCounter = New-Object PSObject -Property @{ HostCount = 0; }
+ $hostGroups = $HostNames | Group-Object -Property { [Math]::Floor($hostCounter.HostCount++ / $MaxHosts) }
+
+ # Because of the way groups work, the Count property is the count of groups if more than 1, or the count of group
+ # objects if exactly 1. It is less than ideal
+ if ($null -ne $hostGroups[1]) {
+ $hostGroupsCount = $hostGroups.Count
+ Write-Warning "$logLead : Found $hostGroupsCount Host Groups Based on API Limit. Multiple API Calls Will Be Made"
+
+ # if someone can explain the point of this loop to me, that would be great
+ # trowton - 2022-02-14
+ $i = 1
+ foreach ($hostGroup in $hostGroups) {
+ Write-Verbose "$logLead : [Group $i of $hostGroupsCount] : $($hostGroup.Group)"
+ $i++
+ }
+ }
+
+ # Iterate Over Each Group
+ foreach ($hostGroup in $hostGroups) {
+
+ $hostGroupTargets = $hostGroup.Group
+ $data = (@{ hosts = @( $hostGroupTargets ) } | ConvertTo-Json)
+
+ Write-Host "$logLead : Calling $cacheClearURL to Clear Cache for $hostDescriptor [$hostGroupTargets]"
+ Write-Verbose "$logLead : Request Body: $data"
+
+ $cacheClearResult = Invoke-RestMethod -Method Post -ContentType "application/json" -Header $AuthenticationDictionary `
+ -Uri "$cacheClearURL" -Body $data
+
+ Write-Verbose ("$logLead : CloudFlare Response: {0}" -f ($cacheClearResult | ConvertTo-Json -Depth 3) )
+
+ if (!($cacheClearResult.Success)) {
+ Write-Warning "$logLead : An Error Occurred Making the API Call:"
+ if ($WriteCacheClearErrorsAsErrors -eq $true) {
+ Write-Warning "Detected this is a production job run and will print errors as errors!"
+ if ($null -ne $cacheClearResult.errors) {
+ $cacheClearResult.errors | Select-Object -ExpandProperty Message | ForEach-Object { Write-Host "##teamcity[buildProblem description='$_']" }
+ }
+ } else {
+ Write-Host "Detected this is a non-production job run and will print errors as warnings!"
+ if ($null -ne $cacheClearResult.errors) {
+ $cacheClearResult.errors | Select-Object -ExpandProperty Message | ForEach-Object { Write-Host "##teamcity[message text='$_' status='WARNING']" }
+ }
+ }
+ } else {
+ Write-Host "$logLead : Successfully Cleared Cache for $hostDescriptor [$hostGroupTargets]"
+ }
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Public/Clear-CloudFlareCache.tests.ps1 b/Modules/Alkami.DevOps.Operations/Public/Clear-CloudFlareCache.tests.ps1
new file mode 100644
index 0000000..e897ff3
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Clear-CloudFlareCache.tests.ps1
@@ -0,0 +1,148 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ""
+
+Describe "Clear-CloudFlareCache" {
+ Mock -CommandName Write-Host -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Write-Warning -ModuleName $moduleForMock -MockWith {}
+
+ $fakeAuthCredentials = Get-CloudFlareAuthenticationHeaders "fake.mcfake@email.org" "123456"
+ $fakeZoneID = "123456"
+ $fakeHostName1 = "fakehost"
+ $fakeHostName2 = "fakehost2"
+ $fake35HostArray = @( "firsttechqastg.orb.alkamitech.com", "admin-firsttechqastg.orb.alkamitech.com", "firsttechqastg-ip.orb.alkamitech.com",
+ "fsbarcadiaqastg.orb.alkamitech.com", "admin-fsbarcadiaqastg.orb.alkamitech.com", "fsbarcadiaqastg-ip.orb.alkamitech.com",
+ "charlotteqastg.orb.alkamitech.com", "admin-charlotteqastg.orb.alkamitech.com", "charlotteqastg-ip.orb.alkamitech.com",
+ "educatorsqastg.orb.alkamitech.com", "admin-educatorsqastg.orb.alkamitech.com", "educatorsqastg-ip.orb.alkamitech.com",
+ "empowerqastg.orb.alkamitech.com", "admin-empowerqastg.orb.alkamitech.com", "empowerqastg-ip.orb.alkamitech.com",
+ "iccuqastg.orb.alkamitech.com", "admin-iccuqastg.orb.alkamitech.com", "iccuqastg-ip.orb.alkamitech.com", "inspirusqastg.orb.alkamitech.com",
+ "admin-inspirusqastg.orb.alkamitech.com", "inspirusqastg-ip.orb.alkamitech.com", "kernschoolsqastg.orb.alkamitech.com",
+ "admin-kernschoolsqastg.orb.alkamitech.com", "kernschoolsqastg-ip.orb.alkamitech.com", "lgeqastg.orb.alkamitech.com",
+ "admin-lgeqastg.orb.alkamitech.com", "lgeqastg-ip.orb.alkamitech.com", "missionqastg.orb.alkamitech.com", "admin-missionqastg.orb.alkamitech.com",
+ "missionqastg-ip.orb.alkamitech.com", "patelcoqastg.orb.alkamitech.com", "admin-patelcoqastg.orb.alkamitech.com", "patelcoqastg-ip.orb.alkamitech.com",
+ "wauchulaqastg.orb.alkamitech.com", "admin-wauchulaqastg.orb.alkamitech.com" )
+
+ $fakeSingleHostBody = (@{ hosts = @( $fakeHostName1 ) } | ConvertTo-Json)
+ $fakeMultiHostBody = (@{ hosts = @( $fakeHostName1, $fakeHostName2 ) } | ConvertTo-Json)
+ $fake35HostBody = (@{ hosts = @( $fake35HostArray ) } | ConvertTo-Json)
+ $fake35HostBodyGroup1 = (@{ hosts = @( ($fake35HostArray | Select-Object -First 30) ) } | ConvertTo-Json)
+ $fake35HostBodyGroup2 = (@{ hosts = @( $fake35HostArray | Select-Object -Skip 30 ) } | ConvertTo-Json)
+
+ Context "Parameter Validation" {
+ Mock -CommandName Invoke-RestMethod -MockWith {
+
+ [PSCustomObject]@{
+ Uri = $uri;
+ Method = $method;
+ ContentType = $contentType;
+ Header = $header;
+ Body = $body;
+ Success = $true;
+ }
+
+ } -ModuleName $moduleForMock
+
+ $fakeZoneDefaultUrl = "https://api.cloudflare.com/client/v4/zones/$fakeZoneID/purge_cache"
+
+ It "Defaults to CloudFlare API v4" {
+
+ Clear-CloudFlareCache $fakeAuthCredentials $fakeZoneID $fakeHostName1
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Invoke-RestMethod -Scope It -Exactly 1 `
+ -ParameterFilter { $uri -like "*$fakeZoneDefaultUrl*" }
+ }
+
+ It "Uses the Parameterized URL in the Rest Call" {
+
+ $fakeZoneFakeUrl = "https://fake.api.com/client/v9000/zones/$fakeZoneID/purge_cache"
+ Clear-CloudFlareCache $fakeAuthCredentials $fakeZoneID $fakeHostName1 $fakeZoneFakeUrl
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Invoke-RestMethod -Scope It -Exactly 1 `
+ -ParameterFilter { $uri -like "*$fakeZoneFakeUrl*" }
+ }
+
+ It "Uses a Single HostName in the Body if Specified" {
+
+ Clear-CloudFlareCache $fakeAuthCredentials $fakeZoneID $fakeHostName1
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Invoke-RestMethod -Scope It -Exactly 1 `
+ -ParameterFilter { $body -eq $fakeSingleHostBody }
+ }
+
+ It "Uses an Array of Hosts in the Body if Specified" {
+
+ Clear-CloudFlareCache $fakeAuthCredentials $fakeZoneID @($fakeHostName1, $fakeHostName2)
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Invoke-RestMethod -Scope It -Exactly 1 `
+ -ParameterFilter { $body -eq $fakeMultiHostBody }
+ }
+
+ It "Splits Calls When More than 30 Hosts Provided By Default" {
+
+ Clear-CloudFlareCache $fakeAuthCredentials $fakeZoneID $fake35HostArray
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Invoke-RestMethod -Scope It -Exactly 1 `
+ -ParameterFilter { $body -eq $fake35HostBodyGroup1 }
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Invoke-RestMethod -Scope It -Exactly 1 `
+ -ParameterFilter { $body -eq $fake35HostBodyGroup2 }
+ }
+
+ It "Uses the MaxHosts Parameter to Determine Host Split Count" {
+
+ Clear-CloudFlareCache $fakeAuthCredentials $fakeZoneID $fake35HostArray -MaxHosts 35
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Invoke-RestMethod -Scope It -Exactly 1 `
+ -ParameterFilter { $body -eq $fake35HostBody }
+ }
+ }
+
+ Context "Response Evaluation" {
+
+ It "Continues Without Error on Success" {
+
+ Mock -CommandName Invoke-RestMethod -MockWith {
+
+ [PSCustomObject]@{
+ Uri = $uri;
+ Method = $method;
+ ContentType = $contentType;
+ Header = $header;
+ Body = $body;
+ Success = $true;
+ }
+
+ } -ModuleName $moduleForMock
+
+ { Clear-CloudFlareCache $fakeAuthCredentials $fakeZoneID @($fakeHostName1, $fakeHostName2) } `
+ | Should -Not -Throw
+ }
+
+ It "Continues Without Throwing on CF Error with Warning" {
+
+ Mock -CommandName Write-Warning -MockWith {
+
+ [PSCustomObject]@{
+ Message = $Message
+ }
+
+ } -ModuleName $moduleForMock
+
+ Mock -CommandName Invoke-RestMethod -MockWith {
+
+ [PSCustomObject]@{
+ Uri = $uri;
+ Method = $method;
+ ContentType = $contentType;
+ Header = $header;
+ Body = $body;
+ Success = $false;
+ }
+
+ } -ModuleName $moduleForMock
+
+ { Clear-CloudFlareCache $fakeAuthCredentials $fakeZoneID @($fakeHostName1, $fakeHostName2) } | `
+ Should -Not -Throw
+
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Write-Warning -Scope It -Exactly 1 `
+ -ParameterFilter { $message -like "*An Error Occurred Making the API Call*" }
+ }
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Public/Clear-CloudFlareCacheForSites.ps1 b/Modules/Alkami.DevOps.Operations/Public/Clear-CloudFlareCacheForSites.ps1
new file mode 100644
index 0000000..3c0371e
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Clear-CloudFlareCacheForSites.ps1
@@ -0,0 +1,173 @@
+function Clear-CloudFlareCacheForSites {
+
+ <#
+
+.SYNOPSIS
+ Clears CloudFlare Cache for All Configured Sites in IIS
+
+.DESCRIPTION
+ Describes all HTTPS bindings for admin, client, and IP-STS sites in IIS, and calls Clear-CloudFlareCache
+ for each. If the zone cannot be located, attempts to clear the cache for the host in the Fallback Zone
+
+.PARAMETER authenticationDictionary
+ [dictionary] Authentication headers required to execute requests. This object is the result from
+ calls to Get-CloudFlareAuthenticationHeaders
+
+.PARAMETER zoneId
+ [string] The parent zone ID for the host
+
+.PARAMETER hostFilterArray
+ [string[]] An optional array of hostnames used to filter the cache bust
+
+.PARAMETER baseUri
+ [string] The base CloudFlare API URL. Defaults to https://api.cloudflare.com/client/v4
+
+.PARAMETER fallBackZoneName
+ [string] The zone to query if the zone cant be located by client URL. Defaults to olbedge.net
+#>
+
+ [CmdletBinding()]
+ param(
+
+ [Parameter(Mandatory = $true)]
+ [Alias("CloudFlareAuthenticationHeaders")]
+ [System.Collections.Generic.Dictionary[[String], [String]]]$authenticationDictionary,
+
+ [Parameter(Mandatory = $false)]
+ [Alias("HostFilter")]
+ [string[]]$hostFilterArray,
+
+ [Parameter(Mandatory = $false)]
+ [Alias("CloudFlareBaseUri")]
+ [string]$baseUri = "https://api.cloudflare.com/client/v4",
+
+ [Parameter(Mandatory = $false)]
+ [Alias("FallBackZone")]
+ [string]$fallBackZoneName = "olbedge.net",
+
+ [Parameter(Mandatory = $false)]
+ [Alias("ReturnActiveOnly")]
+ [bool]$ActiveOnly,
+
+ [Parameter(Mandatory = $false)]
+ [bool]$WriteCacheClearErrorsAsErrors = $true
+
+ )
+
+ $logLead = Get-LogLeadName
+
+ Write-Verbose "$logLead : Pulling IIS Sites"
+ $sites = Get-IISSiteList -IncludeIPSTS
+
+ $httpsBindings = $sites.Bindings | Where-Object { $_.Protocol -like "https" }
+
+ [array]$hostsToClear = @()
+
+ if (Test-IsCollectionNullOrEmpty $hostFilterArray) {
+
+ # No filter passed in, we're gonna clear 'em all
+ $hostsToClear = $httpsBindings | Select-Object -ExpandProperty Host
+
+ } else {
+
+ foreach ($hostFilter in $hostFilterArray) {
+
+ # Find Matching Sites to Clear
+ $matchingSites = $httpsBindings | Where-Object { $_.Host -eq $hostFilter }
+
+ if ($null -eq $matchingSites) {
+
+ # Couldn't find a match from IIS vs. the filter
+ Write-Warning "$logLead : Unable to find matching site for supplied filter value $hostFilter"
+
+ } else {
+
+ $hostsToClear += ($matchingSites | Select-Object -ExpandProperty Host)
+ }
+ }
+ }
+
+ $knownZones = @()
+ $targetsToClear = @()
+
+ foreach ($targetHost in $hostsToClear) {
+
+ # Split on '.' and take the last two values to calculate the zone
+ $zoneName = [String]::Join(".", ($targetHost -split "\." | Select-Object -Last 2))
+ $knownZone = $knownZones | Where-Object { $_.ZoneName -eq $zoneName } | Select-Object -First 1
+
+ # We haven't already looked up this zone, so we need to get the Zone ID
+ if ($null -eq $knownZone) {
+
+ $zoneId = Get-CloudFlareZoneId -AuthenticationHeaders $authenticationDictionary -ZoneName $zoneName -CloudFlareBaseUri $baseUri -ActiveOnly $ActiveOnly
+
+ if ($null -eq $zoneId) {
+
+ # No zone ID, no cache clearing.
+ Write-Warning "$logLead : Could Not Pull Zone ID for Host $targetHost."
+
+ if ($null -eq $fallBackZoneName) {
+
+ # Moving on
+ Write-Warning "$logLead : No Fallback Zone Name was Supplied -- Cache Will Not Be Cleared"
+ continue;
+ }
+
+ Write-Host "$logLead : Will attempt to clear site cache using fallback zone $fallBackZoneName"
+
+ $fallBackZoneId = ($knownZones | Where-Object { $_.ZoneName -eq $fallBackZoneName } | Select-Object -First 1).ZoneId
+ if ($null -eq $fallBackZoneId) {
+
+ Write-Host "$logLead : Looking up zone ID for fallback zone $fallBackZoneName"
+ $zoneId = Get-CloudFlareZoneId -AuthenticationHeaders $authenticationDictionary -ZoneName $fallBackZoneName -CloudFlareBaseUri $baseUri -ActiveOnly $ActiveOnly
+ $knownZones += @{ZoneName = $fallBackZoneName; ZoneId = $zoneId }
+ }
+
+ if ($null -eq $zoneId) {
+
+ if ($null -eq $fallBackZoneId) {
+
+ # We can't find the primary or fallback zone ID. Moving on
+ Write-Warning "$logLead : Could not find Zone or Fallback Zone ID. Execution for host $targetHost cannot continue"
+ continue;
+
+ } else {
+
+ # Bust with Fallback Zone ID -- they're either in olbedge or they don't exist
+ Write-Host "$logLead : Using Previously Retrieved Fallback Zone ID $fallBackZoneID for host $targetHost"
+ $zoneId = $fallBackZoneId
+ }
+ }
+ }
+
+ Write-Verbose "$logLead : Adding Zone $zoneName to known zones with ID $zoneId"
+ $knownZones += @{ZoneName = $zoneName; ZoneId = $zoneId }
+
+ Write-Verbose "$logLead : Adding Cache Clear Target $targetHost with Zone ID $zoneId"
+ $targetsToClear += ( New-Object PSObject -Property @{ HostName = $targetHost; ZoneId = $zoneId; } )
+
+ } else {
+
+ Write-Verbose "$logLead : Using Known Zone ID $($knownZone.ZoneId)"
+
+ Write-Verbose "$logLead : Adding Cache Clear Target $targetHost with Zone ID $($knownZone.ZoneId)"
+ $targetsToClear += ( New-Object PSObject -Property @{ HostName = $targetHost; ZoneId = $knownZone.ZoneId; } )
+ }
+ }
+
+ if (Test-IsCollectionNullOrEmpty $targetsToClear) {
+
+ Write-Warning "$logLead : No valid targets to clear. See the prior log output for details"
+ return;
+ }
+
+ $groupedTargets = ($targetsToClear | Group-Object -Property ZoneId -AsString -AsHashTable)
+
+ foreach ($target in $groupedTargets.GetEnumerator()) {
+
+ $hostNamesForZone = ($target.Value | Select-Object -ExpandProperty Hostname)
+ $zoneIdToBust = $target.Name
+ Write-Verbose ("$logLead : Clearing Cache for Zone ID $zoneIdToBust. Target sites: [{0}]" -f ($hostNamesForZone -join "," ))
+ Clear-CloudFlareCache -CloudFlareAuthenticationHeaders $authenticationDictionary -CloudFlareZoneId $zoneIdToBust -Host $hostNamesForZone -CloudFlareBaseUri $baseUri -WriteCacheClearErrorsAsErrors $WriteCacheClearErrorsAsErrors
+ }
+}
diff --git a/Modules/Alkami.DevOps.Operations/Public/Clear-CloudFlareCacheForSites.tests.ps1 b/Modules/Alkami.DevOps.Operations/Public/Clear-CloudFlareCacheForSites.tests.ps1
new file mode 100644
index 0000000..b64601a
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Clear-CloudFlareCacheForSites.tests.ps1
@@ -0,0 +1,266 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+#Write-Host "Overriding SUT: $functionPath"
+#Import-Module $functionPath -Force
+$moduleForMock = ""
+$moduleForMock = "Alkami.DevOps.Operations"
+
+Describe "Clear-CloudFlareCacheForSites" {
+
+ $fakeAuthCredentials = Get-CloudFlareAuthenticationHeaders "fake.mcfake@email.org" "123456"
+ $fakeZoneID = "123456"
+
+ Mock -CommandName Invoke-RestMethod -MockWith {
+
+ [PSCustomObject]@{
+ Uri = $uri;
+ Method = $method;
+ ContentType = $contentType;
+ Header = $header;
+ Body = $body;
+ Success = $true;
+ }
+
+ } -ModuleName $moduleForMock
+
+ Mock -CommandName Get-IISSiteList -MockWith {
+
+ $adminBindings = @(
+
+ (New-Object PSObject -Property @{
+
+ host = "admin.fakesite.com"
+ protocol = "http"
+ }),
+
+ (New-Object PSObject -Property @{
+
+ host = "admin.fakesite.com"
+ protocol = "https"
+ })
+ )
+
+ $clientBindings = @(
+
+ (New-Object PSObject -Property @{
+
+ host = "client.fakesite.com"
+ protocol = "http"
+ }),
+
+ (New-Object PSObject -Property @{
+
+ host = "client.fakesite.com"
+ protocol = "https"
+ })
+ )
+
+ $ipstsBindings = @(
+
+ (New-Object PSObject -Property @{
+
+ host = "ip.fakesite.com"
+ protocol = "http"
+ }),
+
+ (New-Object PSObject -Property @{
+
+ host = "ip.fakesite.com"
+ protocol = "https"
+ })
+ )
+
+ $sites = @(
+ [PsCustomObject]@{
+ Bindings = $adminBindings
+ State = "Started"
+ },
+ [PsCustomObject]@{
+ Bindings = $clientBindings
+ State = "Started"
+ },
+ [PsCustomObject]@{
+ Bindings = $ipstsBindings
+ State = "Started"
+ }
+ )
+
+ return $sites
+
+ } -ModuleName $moduleForMock
+
+ Mock -CommandName Write-Host -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Write-Warning -ModuleName $moduleForMock -MockWith {}
+
+ Context "Parameter Validation" {
+
+ Mock -CommandName Get-CloudFlareZoneId -MockWith { "123456" } -ModuleName $moduleForMock
+
+ It "Defaults to CloudFlare API v4" {
+
+ Clear-CloudFlareCacheForSites $fakeAuthCredentials
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Invoke-RestMethod -Scope It -Exactly 1 `
+ -ParameterFilter { $uri -like "*$fakeZoneDefaultUrl*" }
+ }
+
+ It "Uses the Parameterized URL in the Rest Call" {
+
+ $fakeZoneFakeUrl = "https://fake.api.com/client/v9000/zones/$fakeZoneID/purge_cache"
+ Clear-CloudFlareCacheForSites $fakeAuthCredentials -baseUri $fakeZoneFakeUrl
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Invoke-RestMethod -Scope It -Exactly 1 `
+ -ParameterFilter { $uri -like "*$fakeZoneFakeUrl*" }
+ }
+
+ It "Defaults to Fallback Zone OLBEdge.Net" {
+
+ Mock -CommandName Get-CloudFlareZoneId -MockWith { $null } -ParameterFilter { $ZoneName -ne "olbedge.net" } -ModuleName $moduleForMock
+ Mock -CommandName Get-CloudFlareZoneId -MockWith { "123" } -ParameterFilter { $ZoneName -eq "olbedge.net" } -ModuleName $moduleForMock
+
+ Clear-CloudFlareCacheForSites $fakeAuthCredentials
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Get-CloudFlareZoneId -Scope It -Exactly 1 `
+ -ParameterFilter { $ZoneName -like "*olbedge.net*" }
+ }
+
+ It "Uses the Parameterized FallBackZoneName in the Rest Call" {
+
+ Mock -CommandName Get-CloudFlareZoneId -MockWith { $null } -ParameterFilter { $ZoneName -ne "fakefallback.net" } -ModuleName $moduleForMock
+ Mock -CommandName Get-CloudFlareZoneId -MockWith { "678" } -ParameterFilter { $ZoneName -eq "fakefallback.net" } -ModuleName $moduleForMock
+
+ Clear-CloudFlareCacheForSites $fakeAuthCredentials -FallBackZone "fakefallback.net"
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Get-CloudFlareZoneId -Scope It -Exactly 1 `
+ -ParameterFilter { $ZoneName -like "*fakefallback.net*" }
+ }
+ }
+
+ Context "Zone Lookup" {
+
+ It "Does Not Lookup the Fallback Zone if the Primary Is Found" {
+
+ Mock -CommandName Get-CloudFlareZoneId -MockWith { "999" } -ParameterFilter { $ZoneName -ne "olbedge.net" } -ModuleName $moduleForMock
+ Mock -CommandName Get-CloudFlareZoneId -MockWith { $null } -ParameterFilter { $ZoneName -eq "olbedge.net" } -ModuleName $moduleForMock
+
+ Clear-CloudFlareCacheForSites $fakeAuthCredentials
+
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Get-CloudFlareZoneId -Scope It -Exactly 1 `
+ -ParameterFilter { $ZoneName -notlike "*olbedge.net*" }
+
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Get-CloudFlareZoneId -Scope It -Exactly 0 `
+ -ParameterFilter { $ZoneName -like "*olbedge.net*" }
+ }
+
+ It "Uses the Fallback Zone if the Primary Is not Found" {
+
+ Mock -CommandName Get-CloudFlareZoneId -MockWith { $null } -ParameterFilter { $ZoneName -ne "olbedge.net" } -ModuleName $moduleForMock
+ Mock -CommandName Get-CloudFlareZoneId -MockWith { "999" } -ParameterFilter { $ZoneName -eq "olbedge.net" } -ModuleName $moduleForMock
+
+ Clear-CloudFlareCacheForSites $fakeAuthCredentials | Out-Null
+
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Get-CloudFlareZoneId -Scope It -Exactly 1 `
+ -ParameterFilter { $ZoneName -notlike "*olbedge.net*" }
+
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Get-CloudFlareZoneId -Scope It -Exactly 1 `
+ -ParameterFilter { $ZoneName -like "*olbedge.net*" }
+ }
+
+ It "Only Looks Up Unique Zones Once" {
+
+ Mock -CommandName Get-IISSiteList -MockWith {
+
+ $bindings = @(
+
+ (New-Object PSObject -Property @{
+
+ host = "admin.fakesite.com"
+ protocol = "https"
+ }),
+
+ (New-Object PSObject -Property @{
+
+ host = "admin2.fakesite.com"
+ protocol = "https"
+ })
+
+ (New-Object PSObject -Property @{
+
+ host = "admin.fakesite2.com"
+ protocol = "https"
+ })
+ )
+
+ $sites = @(
+ [PsCustomObject]@{
+ Bindings = $bindings
+ State = "Started"
+ }
+ )
+
+ return $sites
+
+ } -ModuleName $moduleForMock
+
+ Mock -CommandName Get-CloudFlareZoneId -MockWith { return "123456" } -ParameterFilter { $ZoneName -like "*fakesite.com*" } -ModuleName $moduleForMock
+ Mock -CommandName Get-CloudFlareZoneId -MockWith { return "789101" } -ParameterFilter { $ZoneName -like "*fakesite2.com*" } -ModuleName $moduleForMock
+
+ Clear-CloudFlareCacheForSites $fakeAuthCredentials
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Get-CloudFlareZoneId -Scope It -Exactly 1 `
+ -ParameterFilter { $ZoneName -like "*fakesite.com*" }
+
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Get-CloudFlareZoneId -Scope It -Exactly 1 `
+ -ParameterFilter { $ZoneName -like "*fakesite2.com*" }
+ }
+ }
+
+ Context "Zone Grouping" {
+
+ It "Groups Items By Zone ID" {
+
+ Mock -CommandName Get-IISSiteList -MockWith {
+
+ $bindings = @(
+
+ (New-Object PSObject -Property @{
+
+ host = "admin.fakesite.com"
+ protocol = "https"
+ }),
+
+ (New-Object PSObject -Property @{
+
+ host = "admin.fakesite2.com"
+ protocol = "https"
+ }),
+
+ (New-Object PSObject -Property @{
+
+ host = "admin2.fakesite.com"
+ protocol = "https"
+ })
+ )
+
+ $sites = @(
+ [PsCustomObject]@{
+ Bindings = $bindings
+ State = "Started"
+ }
+ )
+
+ return $sites
+
+ } -ModuleName $moduleForMock
+
+ Mock -CommandName Get-CloudFlareZoneId -MockWith { return "123456" } -ParameterFilter { $ZoneName -like "*fakesite.com*" } -ModuleName $moduleForMock
+ Mock -CommandName Get-CloudFlareZoneId -MockWith { return "789101" } -ParameterFilter { $ZoneName -like "*fakesite2.com*" } -ModuleName $moduleForMock
+
+ $fakeMultiHostBody = (@{ hosts = @( "admin.fakesite.com", "admin2.fakesite.com" ) } | ConvertTo-Json)
+
+ Clear-CloudFlareCacheForSites $fakeAuthCredentials
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Invoke-RestMethod -Scope It -Exactly 1 `
+ -ParameterFilter { $Body -eq $fakeMultiHostBody }
+
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Invoke-RestMethod -Scope It -Exactly 1 `
+ -ParameterFilter { $Body -ne $fakeMultiHostBody }
+ }
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Public/Clear-RadiumJobs.ps1 b/Modules/Alkami.DevOps.Operations/Public/Clear-RadiumJobs.ps1
new file mode 100644
index 0000000..9bd593f
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Clear-RadiumJobs.ps1
@@ -0,0 +1,135 @@
+function Clear-RadiumJobs {
+ <#
+ .SYNOPSIS
+ Executes a T-SQL script to purge the Radium queue
+
+ .DESCRIPTION
+ Executes to the master database and executes an operational script which sets running jobs
+ to a cancelled state using a variety of criteria.
+
+ .PARAMETER MasterConnectionString
+ The master database connection string. If not supplied is attempted to be read from the machine.config
+
+ .PARAMETER SecondsBetweenExecution
+ The number of seconds to pause between executions of the purge script. Defaults to 15 and must be no less than 5
+
+ .PARAMETER QueueCountFloor
+ The minimum number of matching jobs which are in running or waiting status before a purge will execute. Defaults to 500
+
+ .PARAMETER MinimumJobAgeSeconds
+ The minimum age in seconds of a job before it will be considered for purging. Defaults to 15
+
+ .PARAMETER MaximumIterations
+ The maximum number of iterations to execute the purge job. Defaults to 7,200, or 2 hours at the default frequency, with the expectation that someone will cancel execution once the incident is resolved.
+
+ .PARAMETER JobFilter
+ When supplied, will filter eligible jobs for purging by the job type. Valid values are 'SyncAccountAndTransactionsJob', 'SyncBillPayJob', 'SyncUserJob', or 'SyncTransactionsJob'
+
+ .LINK
+ Runbook: https://confluence.alkami.com/x/rbBiDg
+
+ .EXAMPLE
+ Clear-RadiumJobs -SecondsBetweenExecution 5 -QueueCountFloor 50
+
+ [Clear-RadiumJobs] : Beginning Radium purge. This command will run until cancelled. To cancel, press CONTROL+C or close the shell.
+ 12-21-2022 11:29:55 PM : Queue size (0) under threshold for purge (50). No action taken.
+
+ .EXAMPLE
+ Clear-RadiumJobs -SecondsBetweenExecution 5 -QueueCountFloor 50 -JobFilter SyncBillPayJob
+
+ [Clear-RadiumJobs] : Beginning Radium purge. This command will run until cancelled. To cancel, press CONTROL+C or close the shell.
+ 12-21-2022 11:29:55 PM : Queue size (0) for SyncBillPayJob jobs under threshold for purge (50). No action taken.
+ #>
+
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory = $false)]
+ [string]$MasterConnectionString = $null,
+
+ [Parameter(Mandatory = $false)]
+ [int]$SecondsBetweenExecution = 15,
+
+ [Parameter(Mandatory = $false)]
+ [int]$QueueCountFloor = 500,
+
+ [Parameter(Mandatory = $false)]
+ [int]$MinimumJobAgeSeconds = 15,
+
+ [Parameter(Mandatory = $false)]
+ [int]$MaximumIterations = 7200,
+
+ [Parameter(Mandatory = $false)]
+ [ValidateSet("SyncAccountAndTransactionsJob", "SyncBillPayJob", "SyncUserJob", "SyncTransactionsJob")]
+ [string]$JobFilter = $null
+ )
+
+ $logLead = Get-LogLeadName
+ $useJobFilterScriptVariant = !(Test-StringIsNullOrWhiteSpace -Value $JobFilter)
+
+ Write-Host ("$logLead : Beginning Radium purge. This command will run until cancelled or until $MaximumIterations runs have executed. " + `
+ "To cancel, press CONTROL+C or close the shell.") -ForegroundColor Yellow
+
+ $targetMasterConnectionString = Get-CoalescedStringValue -ValueA $MasterConnectionString -ValueB (Get-MasterConnectionString)
+ if (Test-StringIsNullOrWhitespace -Value $targetMasterConnectionString) {
+
+ $emptyConnectionStringWarning = "$logLead : No master connection string was supplied to the function, and the connection string was not found in the machine.config. " + `
+ "Re-Execute on a properly configured server or supply an explicit connection string to the function."
+ Write-Warning $emptyConnectionStringWarning
+
+ return
+ }
+
+ if ($SecondsBetweenExecution -lt 5) {
+
+ Write-Warning "$logLead : To avoid negative impact on the database server, SecondsBetweenExecution must be no less than 5. Correct the parameter and rerun."
+ return
+ }
+
+ if ($MinimumJobAgeSeconds -eq 0) {
+
+ Write-Warning "$logLead : MinimumJobAgeSeconds must not be equal to 0. Correct the parameter and rerun."
+ return
+
+ } elseif ($MinimumJobAgeSeconds -gt 0) {
+
+ Write-Verbose "$logLead : Converting MinimumJobAgeSeconds value [$MinimumJobAgeSeconds] to a negative value"
+ $MinimumJobAgeSeconds = $MinimumJobAgeSeconds * -1
+ }
+
+ $radiumPurgeScript = Read-RadiumPurgeScript -UseJobFilterScriptVariant $useJobFilterScriptVariant
+ if (Test-StringIsNullOrWhitespace -Value $radiumPurgeScript) {
+
+ Write-Warning "$logLead : Unable to read the Radium purge script. Execution cannot continue. Notify SRE for investigation"
+ return
+ }
+
+ $parameters = @(
+
+ @{ Name = "@QueueCountThreshold"; Value = $QueueCountFloor; },
+ @{ Name = "@AgeThresholdSeconds"; Value = $MinimumJobAgeSeconds; }
+ )
+
+ if ($useJobFilterScriptVariant) {
+
+ $parameters += @{ Name = "@JobFilter"; Value = $JobFilter }
+ }
+
+ $iteration = 1
+ do {
+
+ Invoke-NonQueryByConnectionString -ConnectionString $targetMasterConnectionString -QueryString $radiumPurgeScript -SqlInputParameters $parameters -CommandTimeout 5
+
+ Write-Verbose "[Iteration: $iteration / $MaximumIterations]: Sleeping $SecondsBetweenExecution seconds between executions"
+
+ if ($iteration -lt $MaximumIterations) {
+
+ # No Need to Sleep if We're on the Last Execution
+ Start-Sleep -Seconds $SecondsBetweenExecution
+ }
+
+ $iteration++
+
+ } while ($iteration -le $MaximumIterations)
+
+ Write-Host "$logLead : Completed $($iteration - 1) Iterations of the Purge Script. Loop stopped."
+}
diff --git a/Modules/Alkami.DevOps.Operations/Public/Clear-RadiumJobs.tests.ps1 b/Modules/Alkami.DevOps.Operations/Public/Clear-RadiumJobs.tests.ps1
new file mode 100644
index 0000000..49471e8
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Clear-RadiumJobs.tests.ps1
@@ -0,0 +1,136 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ""
+
+Describe "Clear-RadiumJobs" {
+
+ Mock -CommandName Invoke-NonQueryByConnectionString -ModuleName $moduleForMock -MockWith {}
+
+ Context "Logic" {
+
+ It "Uses the Value for MasterConnectionString if Supplied" {
+
+ $fakeConnectionString = "data source=ZeroWing.local;Integrated Security=SSPI;Database=AllYourBaseAreBelongtoUs"
+ Clear-RadiumJobs -MasterConnectionString $fakeConnectionString -MaximumIterations 1
+
+ Assert-MockCalled -CommandName Invoke-NonQueryByConnectionString -Times 1 -Exactly -Scope It `
+ -ModuleName $moduleForMock -ParameterFilter { $ConnectionString -eq $fakeConnectionString }
+ }
+
+ It "Uses the machine.config for the MasterConnectionString if Not Supplied" {
+
+ Mock -CommandName Get-MasterConnectionString -ModuleName $moduleForMock -MockWith { return "data source=i.can.haz;Integrated Security=SSPI;Database=Cheezburgur"}
+ Clear-RadiumJobs -MaximumIterations 1
+
+ Assert-MockCalled -CommandName Invoke-NonQueryByConnectionString -Times 1 -Exactly -Scope It `
+ -ModuleName $moduleForMock -ParameterFilter { $ConnectionString -eq "data source=i.can.haz;Integrated Security=SSPI;Database=Cheezburgur" }
+ }
+
+ It "Uses the Value for SecondsBetweenExecution if Supplied" {
+
+ Mock -CommandName Start-Sleep -ModuleName $moduleForMock -MockWith {}
+ Clear-RadiumJobs -MasterConnectionString "fake" -MaximumIterations 2 -SecondsBetweenExecution 5
+
+ Assert-MockCalled -CommandName Start-Sleep -Times 1 -Exactly -Scope It `
+ -ModuleName $moduleForMock -ParameterFilter { $Seconds -eq 5 }
+ }
+
+ It "Uses the Value for QueueCountFloor if Supplied" {
+
+ Clear-RadiumJobs -MasterConnectionString "fake" -MaximumIterations 1 -QueueCountFloor 1024
+
+ Assert-MockCalled -CommandName Invoke-NonQueryByConnectionString -Times 1 -Exactly -Scope It `
+ -ModuleName $moduleForMock -ParameterFilter {
+
+ ($SqlInputParameters | Where-Object {$_.Name -eq "@QueueCountThreshold"}).Value -eq 1024
+ }
+ }
+
+ It "Uses the Value for MinimumJobAgeSeconds if Supplied" {
+
+ Clear-RadiumJobs -MasterConnectionString "fake" -MaximumIterations 1 -MinimumJobAgeSeconds -2048
+
+ Assert-MockCalled -CommandName Invoke-NonQueryByConnectionString -Times 1 -Exactly -Scope It `
+ -ModuleName $moduleForMock -ParameterFilter {
+
+ ($SqlInputParameters | Where-Object {$_.Name -eq "@AgeThresholdSeconds"}).Value -eq -2048
+ }
+ }
+
+ It "Converts Positive Integers for MinimumJobAgeSeconds to Negative Values" {
+
+ Clear-RadiumJobs -MasterConnectionString "fake" -MaximumIterations 1 -MinimumJobAgeSeconds 4096
+
+ Assert-MockCalled -CommandName Invoke-NonQueryByConnectionString -Times 1 -Exactly -Scope It `
+ -ModuleName $moduleForMock -ParameterFilter {
+
+ ($SqlInputParameters | Where-Object {$_.Name -eq "@AgeThresholdSeconds"}).Value -eq -4096
+ }
+ }
+
+ It "Runs Only for the Maximum Number of Iterations Supplied" {
+
+ Mock -CommandName Start-Sleep -ModuleName $moduleForMock -MockWith {}
+
+ Clear-RadiumJobs -MasterConnectionString "fake" -MaximumIterations 3
+ Assert-MockCalled -CommandName Invoke-NonQueryByConnectionString -Times 3 -Exactly -Scope It
+ }
+ }
+
+ Context "Error Handling" {
+
+ It "Writes a Warning and Exits Early if MinimumJobAgeSeconds is equal to 0" {
+
+ Mock -CommandName Write-Warning -ModuleName $moduleForMock -MockWith {}
+
+ Clear-RadiumJobs -MasterConnectionString "fake" -MaximumIterations 3 -MinimumJobAgeSeconds 0
+
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Write-Warning -Times 1 -Exactly -Scope It `
+ -ParameterFilter { $Message -Match "MinimumJobAgeSeconds must not be equal to 0." }
+
+ Assert-MockCalled -CommandName Invoke-NonQueryByConnectionString -Times 0 -Exactly -Scope It
+ }
+
+ It "Writes a Warning and Exits Early if the Connection String Cannot be Determined" {
+
+ Mock -CommandName Write-Warning -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Get-MasterConnectionString -ModuleName $moduleForMock -MockWith { return $null }
+
+ Clear-RadiumJobs -MaximumIterations 3
+
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Write-Warning -Times 1 -Exactly -Scope It `
+ -ParameterFilter { $Message -Match "No master connection string was supplied to the function, and the connection string was not found in the machine.config." }
+
+ Assert-MockCalled -CommandName Invoke-NonQueryByConnectionString -Times 0 -Exactly -Scope It
+ }
+
+ It "Writes a Warning and Exits Early if the SecondsBetweenExecution is Less than 5" {
+
+ Mock -CommandName Write-Warning -ModuleName $moduleForMock -MockWith {}
+
+ Clear-RadiumJobs -MasterConnectionString "fake" -SecondsBetweenExecution 3
+
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Write-Warning -Times 1 -Exactly -Scope It `
+ -ParameterFilter { $Message -Match "To avoid negative impact on the database server, SecondsBetweenExecution must be no less than 5" }
+
+ Assert-MockCalled -CommandName Invoke-NonQueryByConnectionString -Times 0 -Exactly -Scope It
+ }
+
+ It "Writes a Warning and Exits Early if the Script File Cannot be Located" {
+
+ Mock -CommandName Write-Warning -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Read-RadiumPurgeScript -ModuleName $moduleForMock -MockWith { return $null }
+
+ Clear-RadiumJobs -MasterConnectionString "fake" -MaximumIterations 3
+
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Write-Warning -Times 1 -Exactly -Scope It `
+ -ParameterFilter { $Message -Match "Unable to read the Radium purge script." }
+
+ Assert-MockCalled -CommandName Invoke-NonQueryByConnectionString -Times 0 -Exactly -Scope It
+ }
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Public/Close-TcpSocket.ps1 b/Modules/Alkami.DevOps.Operations/Public/Close-TcpSocket.ps1
new file mode 100644
index 0000000..7dfe520
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Close-TcpSocket.ps1
@@ -0,0 +1,16 @@
+function Close-TcpSocket {
+<#
+.SYNOPSIS
+ When given an object of type System.Net.Sockets.TCPClient, executes Close().
+.EXAMPLE
+ Close-TcpSocket -Socket $Socket
+.INPUTS
+ Socket
+#>
+
+ [cmdletbinding()]
+ param(
+ $Socket
+ )
+ $Socket.Close()
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Public/Close-TcpStreamWriter.ps1 b/Modules/Alkami.DevOps.Operations/Public/Close-TcpStreamWriter.ps1
new file mode 100644
index 0000000..a55cd3e
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Close-TcpStreamWriter.ps1
@@ -0,0 +1,15 @@
+function Close-TcpStreamWriter {
+<#
+.SYNOPSIS
+ When given an object of type System.IO.StreamWriter, executes Close().
+.EXAMPLE
+ Close-TcpStreamWriter -Writer $Writer
+.INPUTS
+ Writer
+#>
+ [cmdletbinding()]
+ param(
+ $Writer
+ )
+ $Writer.Close()
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Public/Convert-CloudFlareARecordsToCnames.ps1 b/Modules/Alkami.DevOps.Operations/Public/Convert-CloudFlareARecordsToCnames.ps1
new file mode 100644
index 0000000..c64b66b
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Convert-CloudFlareARecordsToCnames.ps1
@@ -0,0 +1,137 @@
+function Convert-CloudFlareARecordsToCnames {
+<#
+.SYNOPSIS
+ Finds existing A records for Orb (client, admin, ipsts), deletes them, and creates new CNAME records
+
+.DESCRIPTION
+ Finds existing A records for Orb (client, admin, ipsts), deletes them, and creates new CNAME records.
+
+.PARAMETER baseUri
+ [string] The base CloudFlare API URL. Defaults to https://api.cloudflare.com/client/v4
+
+.PARAMETER cloudflareAuthKey
+ [string] API key; necessary to authenticate with CloudFlare
+
+.PARAMETER cloudflareEmail
+ [string] Email account used to login to CloudFlare
+
+.PARAMETER targetAddress
+ [string] Target URI for the new CNAME records. Defaults to prod-paloaltopublic-443b9021358ce9f5.elb.us-east-1.amazonaws.com
+
+.PARAMETER zoneId
+ [string] The parent zone id for the host.
+
+.PARAMETER zoneName
+ [string] The parent zone name (ex: myfiname.com) for the host. Used to pull the zoneId. Either this or the Zone Id is required.
+
+#>
+ [CmdletBinding()]
+ [OutputType([System.Object[]])]
+ param(
+
+ [Parameter(Mandatory = $false)]
+ [Alias("CloudFlareBaseUri")]
+ [string]$baseUri = "https://api.cloudflare.com/client/v4",
+
+ [Parameter(Mandatory = $true)]
+ [Alias("AuthKey")]
+ [string[]]$cloudflareAuthKey,
+
+ [Parameter(Mandatory = $true)]
+ [Alias("Email")]
+ [string]$cloudflareEmail,
+
+ [Parameter(Mandatory = $false)]
+ [string]$targetAddress = "prod-paloaltopublic-443b9021358ce9f5.elb.us-east-1.amazonaws.com",
+
+ [Parameter(Mandatory = $true,
+ ParameterSetName = "ZoneId")]
+ [string]$zoneId,
+
+ [Parameter(Mandatory = $true,
+ ParameterSetName = "ZoneName")]
+ [string]$zoneName
+ )
+
+ $logLead = Get-LogLeadName
+
+ $headers = New-Object "System.Collections.Generic.Dictionary[[String],[String]]"
+ $headers.Add("X-Auth-Email", $cloudflareEmail)
+ $headers.Add("X-Auth-Key", $cloudflareAuthKey)
+
+ if (!$zoneId) {
+ # Get zoneId using zone name
+ Write-Verbose("$logLead : Getting ZoneId.")
+ try {
+ $getZoneIdResult = Invoke-RestMethod -Method Get -ContentType "application/json" -Header $headers -Uri "$baseUri/zones?name=$zoneName"
+ } catch {
+ $err = $_.Exception;
+ Write-Warning ("$logLead : Exception getting zone Id." )
+ return $err
+ }
+ $zoneId = $getZoneIdResult.result.id
+ Write-Verbose ("$logLead : $zoneId")
+ }
+
+ # Get all dns records for provided zoneId
+
+ Write-Verbose("$logLead : Getting A records.")
+ try {
+ $dnsRecords = Invoke-RestMethod -Method Get -ContentType "application/json" -Header $headers -Uri "$baseUri/zones/$zoneId/dns_records"
+ } catch {
+ Write-Warning ("$logLead : Exception occurred pulling A records. ")
+ return;
+ }
+
+ # Get all A records from dns record list
+ $aRecords = $dnsRecords.result.Where{ $_.type -eq 'A' }
+
+ $createResults = @();
+
+ if ($null -eq $aRecords -or $aRecords.count -eq 0 ) {
+ Write-Warning ("$logLead : No A records found. ")
+ } else {
+ foreach ($record in $aRecords) {
+ # Find each prefix (admin, ipsts, etc.) from the name field. These will be the name values for each of the CNAMEs
+ $recordPrefix = $record.name.Replace($record.zone_Name, "");
+ $recordPrefix = $recordPrefix.Substring(0, $recordPrefix.length - 1);
+ Write-Verbose ("$logLead : Processing $recordPrefix")
+
+ # Delete the A record
+ Write-Verbose("$logLead : Deleting A record $($record.name).")
+ try {
+ $deleteResult = Invoke-RestMethod -Method Delete -ContentType "application/json" -Header $headers "$baseUri/zones/$zoneId/dns_records/$($record.id)"
+ Write-Verbose ("$logLead : Delete Result: $($deleteResult.success)")
+ } catch {
+ $err = $_.Exception;
+ Write-Warning ("$logLead : Exception Deleting A record $($recordPrefix)")
+ break;
+ }
+
+ # Create the CNAME record; AWS palo address is the default value.
+ Write-Verbose("$logLead : Creating CNAME record $($recordPrefix).")
+ $data = @{type = "CNAME"; name = $recordPrefix; content = "$targetAddress"; proxied = $true} | ConvertTo-JSON
+
+ try {
+ $createResult = Invoke-RestMethod -Method Post -ContentType "application/json" -Header $headers "$baseUri/zones/$zoneId/dns_records/" -Body $data
+ Write-Verbose ("$logLead : $($createResult.result.id) was created.")
+ } catch {
+ $err = $_.Exception;
+ Write-Warning ("$logLead : Exception Creating CNAME for $($recordPrefix) : $($createResult.errors) `r`n`r`n`r`n ********** This CNAME record now needs to be created manually. **********")
+ break;
+ }
+
+ # Confirm that a CNAME was created for each A record
+ try {
+ $createdSiteResult = Invoke-RestMethod -Method Get -ContentType "application/json" -Headers $headers -Uri "$baseUri/zones/$zoneId/dns_records/$($createResult.result.id)"
+ $createResults += $createdSiteResult.result;
+ } catch {
+ $err = $_.Exception;
+ Write-Warning ("$logLead : Create call succeded for site $($recordPrefix), but couldn't find site : $($createResult.errors)")
+ break;
+ }
+ }
+
+ return $createResults
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Public/Convert-CloudFlareARecordsToCnames.tests.ps1 b/Modules/Alkami.DevOps.Operations/Public/Convert-CloudFlareARecordsToCnames.tests.ps1
new file mode 100644
index 0000000..0f59734
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Convert-CloudFlareARecordsToCnames.tests.ps1
@@ -0,0 +1,940 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ""
+
+Describe "Convert-CloudFlareARecordsToCnames" {
+
+ Context "Non-Api call Tests" {
+ Mock -ModuleName $moduleForMock -CommandName Get-LogLeadName -MockWith { return "mock log lead" }
+
+ It "Requires either ZoneId or ZoneName to be supplied" {
+ {Convert-CloudFlareARecordsToCnames -cloudflareEmail "fake@alkamitech.com" -cloudflareAuthKey "FakeAuthKey" } `
+ | should -Throw
+ }
+ }
+
+ Context "Successful Tests" {
+ # Get Zone Id from Name
+ Mock -ModuleName $moduleForMock -CommandName Invoke-RestMethod -MockWith {
+ # Sample zone : https://api.cloudflare.com/zones?name=samplezone.com
+ '{
+ "result":[
+ {
+ "id":"deada5dc1124",
+ "name":"samplezone.com",
+ "status":"active",
+ "paused":false,
+ "type":"partial",
+ "development_mode":0,
+ "verification_key":"136684221-79922656",
+ "original_name_servers":[
+ "ns49.domaincontrol.com",
+ "ns50.domaincontrol.com"
+ ],
+ "original_registrar":"godaddy.com, llc",
+ "original_dnshost":"godaddy",
+ "modified_on":"2019-02-27T02:45:51.557296Z",
+ "created_on":"2019-01-29T13:41:03.568147Z",
+ "activated_on":"2019-02-20T16:56:23.002562Z",
+ "meta":{
+ "step":3,
+ "wildcard_proxiable":true,
+ "custom_certificate_quota":1,
+ "page_rule_quota":100,
+ "phishing_detected":false,
+ "multiple_railguns_allowed":false
+ },
+ "owner":{
+ "id":"deada5dc1124add404f6a4fb0e60324f",
+ "type":"organization",
+ "name":"Alkami"
+ },
+ "account":{
+ "id":"deada5dc1124add404f6a4fb0e60324f",
+ "name":"Alkami"
+ },
+ "permissions":[
+ "#analytics:read",
+ "#cache_purge:edit",
+ "#dns_records:edit",
+ "#dns_records:read",
+ "#legal:read",
+ "#organization:read",
+ "#subscription:read",
+ "#worker:edit",
+ "#worker:read",
+ "#zone:read",
+ "#zone_settings:read"
+ ],
+ "plan":{
+ "id":"94f3b7b768b0458b56d2cac4fe5ec0f9",
+ "name":"Enterprise Website",
+ "price":0,
+ "currency":"USD",
+ "frequency":"monthly",
+ "is_subscribed":true,
+ "can_subscribe":true,
+ "legacy_id":"enterprise",
+ "legacy_discount":false,
+ "externally_managed":true
+ }
+ }
+ ],
+ "result_info":{
+ "page":1,
+ "per_page":20,
+ "total_pages":1,
+ "count":1,
+ "total_count":1
+ },
+ "success":true,
+ "errors":[
+
+ ],
+ "messages":[
+
+ ]
+ }' | ConvertFrom-Json
+ } -ParameterFilter { ($Uri -like "*zones?name=*") -and ($Method -eq "Get") }
+
+ # Get A Records for Zone
+ Mock -ModuleName $moduleForMock -CommandName Invoke-RestMethod -MockWith {
+ # Sample dns records: https://api.cloudflare.com/zones/deada5dc1124/dns_records
+ # ^ fake zone id
+ '{
+ "result":[
+ {
+ "id":"a32f0a74fe",
+ "type":"A",
+ "name":"my.multi.sub.zone.samplezone.com",
+ "content":"199.79.51.50",
+ "proxiable":true,
+ "proxied":true,
+ "ttl":1,
+ "locked":false,
+ "zone_id":"deada5dc1124",
+ "zone_name":"samplezone.com",
+ "modified_on":"2019-02-27T02:44:00.109525Z",
+ "created_on":"2019-02-27T02:44:00.109525Z",
+ "meta":{
+ "auto_added":false,
+ "managed_by_apps":false,
+ "managed_by_argo_tunnel":false
+ }
+ }
+ ],
+ "result_info":{
+ "page":1,
+ "per_page":20,
+ "total_pages":1,
+ "count":3,
+ "total_count":3
+ },
+ "success":true,
+ "errors":[
+
+ ],
+ "messages":[
+
+ ]
+ }' | ConvertFrom-Json
+ } -ParameterFilter { $Uri -like "*zones/*/dns_records" -and ($Method -eq "Get") }
+
+ # Delete A Records
+ Mock -ModuleName $moduleForMock -CommandName Invoke-RestMethod -MockWith {
+ '{
+ "result": {
+ "id": "372e67954025e0ba6aaa6d586b9e0b59"
+ },
+ "success" : "true"
+ }' | ConvertFrom-Json
+ } -ParameterFilter { $Method -eq "Delete" }
+
+ # Create CNAME Records
+ Mock -ModuleName $moduleForMock -CommandName Invoke-RestMethod -MockWith {
+ '{
+ "success": true,
+ "errors": [],
+ "messages": [],
+ "result": {
+ "id":"51345ac1b00f",
+ "type":"CNAME",
+ "recs_added": 5,
+ "total_records_parsed": 5
+ },
+ "timing": {
+ "start_time": "2014-03-01T12:20:00Z",
+ "end_time": "2014-03-01T12:20:01Z",
+ "process_time": 1
+ }
+ }' | ConvertFrom-Json
+ } -ParameterFilter { $Method -eq "Post" }
+
+ # Get created CNAME Record
+ Mock -ModuleName $moduleForMock -CommandName Invoke-RestMethod -MockWith {
+ '{
+ "result":
+ {
+ "id":"51345ac1b00f",
+ "type":"CNAME",
+ "name":"my.multi.sub.zone",
+ "content":"prod-paloaltopublic-443b9021358ce9f5.elb.us-east-1.amazonaws.com",
+ "proxiable":true,
+ "proxied":true,
+ "ttl":1,
+ "locked":false,
+ "zone_id":"deada5dc1124",
+ "zone_name":"samplezone.com",
+ "modified_on":"2018-11-13T18:03:21.771437Z",
+ "created_on":"2018-11-13T18:03:21.771437Z",
+ "meta":{
+ "auto_added":false,
+ "managed_by_apps":false,
+ "managed_by_argo_tunnel":false
+ }
+ },
+ "success":true,
+ "errors":[
+
+ ],
+ "messages":[
+
+ ]
+ }' | ConvertFrom-Json
+ } -ParameterFilter { $Uri -like "*zones/*/dns_records/*" -and ($Method -eq "Get") }
+
+ Mock -ModuleName $moduleForMock -CommandName Get-LogLeadName -MockWith { return "mock log lead" }
+
+ It "Should Validate that A records are deleted" {
+ Convert-CloudFlareARecordsToCnames -cloudflareEmail "fake@alkamitech.com" -cloudflareAuthKey "FakeAuthKey" -ZoneName "FakeZone.com"
+
+ Assert-MockCalled -ModuleName $moduleForMock Invoke-RestMethod -ParameterFilter { $Method -eq "Delete" }
+ }
+
+ It "Should Validate that CNAME records are created" {
+ $createdDomainResults = Convert-CloudFlareARecordsToCnames -cloudflareEmail "fake@alkamitech.com" -cloudflareAuthKey "FakeAuthKey" -ZoneName "FakeZone.com";
+
+ Assert-MockCalled -ModuleName $moduleForMock Invoke-RestMethod -ParameterFilter { $Method -eq "Post" }
+
+ $createdDomainResults.type | should -Not -BeNullOrEmpty
+ }
+
+ It "Should Validate that CNAME records exist after having created them" {
+ $createdDomainResults = Convert-CloudFlareARecordsToCnames -cloudflareEmail "fake@alkamitech.com" -cloudflareAuthKey "FakeAuthKey" -ZoneName "FakeZone.com";
+ $createdDomainResults.type | should -Be "CNAME"
+ }
+
+ It "Should handle subdomains correctly" {
+ $createdDomainResults = Convert-CloudFlareARecordsToCnames -cloudflareEmail "fake@alkamitech.com" -cloudflareAuthKey "FakeAuthKey" -ZoneName "FakeZone.com";
+ $createdDomainResults.name | should -Be "my.multi.sub.zone" # matches the name in the mock. Specifically, the name from the A records, without the zone on the end.
+ }
+ }
+
+ Context "Error Tests" {
+ Mock -ModuleName $moduleForMock -CommandName Get-LogLeadName -MockWith { return "mock log lead" }
+
+ It "Should Warn when no A records are found." {
+
+ # Get zone id
+ Mock -ModuleName $moduleForMock -CommandName Invoke-RestMethod -MockWith {
+ # Sample zone : https://api.cloudflare.com/zones?name=samplezone.com
+ '{
+ "result":[
+ {
+ "id":"deada5dc1124",
+ "name":"samplezone.com",
+ "status":"active",
+ "paused":false,
+ "type":"partial",
+ "development_mode":0,
+ "verification_key":"136684221-79922656",
+ "original_name_servers":[
+ "ns49.domaincontrol.com",
+ "ns50.domaincontrol.com"
+ ],
+ "original_registrar":"godaddy.com, llc",
+ "original_dnshost":"godaddy",
+ "modified_on":"2019-02-27T02:45:51.557296Z",
+ "created_on":"2019-01-29T13:41:03.568147Z",
+ "activated_on":"2019-02-20T16:56:23.002562Z",
+ "meta":{
+ "step":3,
+ "wildcard_proxiable":true,
+ "custom_certificate_quota":1,
+ "page_rule_quota":100,
+ "phishing_detected":false,
+ "multiple_railguns_allowed":false
+ },
+ "owner":{
+ "id":"deada5dc1124add404f6a4fb0e60324f",
+ "type":"organization",
+ "name":"Alkami"
+ },
+ "account":{
+ "id":"deada5dc1124add404f6a4fb0e60324f",
+ "name":"Alkami"
+ },
+ "permissions":[
+ "#analytics:read",
+ "#cache_purge:edit",
+ "#dns_records:edit",
+ "#dns_records:read",
+ "#legal:read",
+ "#organization:read",
+ "#subscription:read",
+ "#worker:edit",
+ "#worker:read",
+ "#zone:read",
+ "#zone_settings:read"
+ ],
+ "plan":{
+ "id":"94f3b7b768b0458b56d2cac4fe5ec0f9",
+ "name":"Enterprise Website",
+ "price":0,
+ "currency":"USD",
+ "frequency":"monthly",
+ "is_subscribed":true,
+ "can_subscribe":true,
+ "legacy_id":"enterprise",
+ "legacy_discount":false,
+ "externally_managed":true
+ }
+ }
+ ],
+ "result_info":{
+ "page":1,
+ "per_page":20,
+ "total_pages":1,
+ "count":1,
+ "total_count":1
+ },
+ "success":true,
+ "errors":[
+
+ ],
+ "messages":[
+
+ ]
+ }' | ConvertFrom-Json
+ } -ParameterFilter { ($Uri -like "*zones?name=*") -and ($Method -eq "Get") }
+
+ # Get A Records for Zone
+ Mock -ModuleName $moduleForMock -CommandName Invoke-RestMethod -MockWith {
+ # Sample dns records: https://api.cloudflare.com/zones/deada5dc1124/dns_records
+ # ^ fake zone id
+ '{
+ "result":[
+ ],
+ "result_info":{
+ },
+ "success":true,
+ "errors":[
+ ],
+ "messages":[
+
+ ]
+ }' | ConvertFrom-Json
+ } -ParameterFilter { $Uri -like "*zones/*/dns_records" -and ($Method -eq "Get") }
+
+ Convert-CloudFlareARecordsToCnames -cloudflareEmail "fake@alkamitech.com" -cloudflareAuthKey "FakeAuthKey" -ZoneName "FakeZone.com" 3>&1 `
+ | should -BeLike "* No A records found*"
+ }
+
+ It "Should Warn when call to get A records returns non-200." {
+
+ # Get zone id
+ Mock -ModuleName $moduleForMock -CommandName Invoke-RestMethod -MockWith {
+ # Sample zone : https://api.cloudflare.com/zones?name=samplezone.com
+ '{
+ "result":[
+ {
+ "id":"deada5dc1124",
+ "name":"samplezone.com",
+ "status":"active",
+ "paused":false,
+ "type":"partial",
+ "development_mode":0,
+ "verification_key":"136684221-79922656",
+ "original_name_servers":[
+ "ns49.domaincontrol.com",
+ "ns50.domaincontrol.com"
+ ],
+ "original_registrar":"godaddy.com, llc",
+ "original_dnshost":"godaddy",
+ "modified_on":"2019-02-27T02:45:51.557296Z",
+ "created_on":"2019-01-29T13:41:03.568147Z",
+ "activated_on":"2019-02-20T16:56:23.002562Z",
+ "meta":{
+ "step":3,
+ "wildcard_proxiable":true,
+ "custom_certificate_quota":1,
+ "page_rule_quota":100,
+ "phishing_detected":false,
+ "multiple_railguns_allowed":false
+ },
+ "owner":{
+ "id":"deada5dc1124add404f6a4fb0e60324f",
+ "type":"organization",
+ "name":"Alkami"
+ },
+ "account":{
+ "id":"deada5dc1124add404f6a4fb0e60324f",
+ "name":"Alkami"
+ },
+ "permissions":[
+ "#analytics:read",
+ "#cache_purge:edit",
+ "#dns_records:edit",
+ "#dns_records:read",
+ "#legal:read",
+ "#organization:read",
+ "#subscription:read",
+ "#worker:edit",
+ "#worker:read",
+ "#zone:read",
+ "#zone_settings:read"
+ ],
+ "plan":{
+ "id":"94f3b7b768b0458b56d2cac4fe5ec0f9",
+ "name":"Enterprise Website",
+ "price":0,
+ "currency":"USD",
+ "frequency":"monthly",
+ "is_subscribed":true,
+ "can_subscribe":true,
+ "legacy_id":"enterprise",
+ "legacy_discount":false,
+ "externally_managed":true
+ }
+ }
+ ],
+ "result_info":{
+ "page":1,
+ "per_page":20,
+ "total_pages":1,
+ "count":1,
+ "total_count":1
+ },
+ "success":true,
+ "errors":[
+
+ ],
+ "messages":[
+
+ ]
+ }' | ConvertFrom-Json
+ } -ParameterFilter { ($Uri -like "*zones?name=*") -and ($Method -eq "Get") }
+
+ # Get A Records for Zone
+ Mock -ModuleName $moduleForMock -CommandName Invoke-RestMethod -MockWith {
+ # Sample dns records: https://api.cloudflare.com/zones/deada5dc1124/dns_records
+ # ^ fake zone id
+ throw(
+ '{
+ "result":[
+ ],
+ "result_info":{
+ },
+ "success":false,
+ "errors":[
+ {"code":1002,"message":"Invalid dns record identifier"}
+ ],
+ "messages":[
+
+ ]
+ }') | ConvertFrom-Json
+ } -ParameterFilter { $Uri -like "*zones/*/dns_records" -and ($Method -eq "Get") }
+
+ Convert-CloudFlareARecordsToCnames -cloudflareEmail "fake@alkamitech.com" -cloudflareAuthKey "FakeAuthKey" -ZoneName "FakeZone.com" 3>&1 `
+ | should -BeLike "*Exception occurred pulling A records*"
+ }
+
+ It "Should Warn when a delete fails" {
+ # Get zone id
+ Mock -ModuleName $moduleForMock -CommandName Invoke-RestMethod -MockWith {
+ # Sample zone : https://api.cloudflare.com/zones?name=samplezone.com
+ '{
+ "result":[
+ {
+ "id":"deada5dc1124",
+ "name":"samplezone.com",
+ "status":"active",
+ "paused":false,
+ "type":"partial",
+ "development_mode":0,
+ "verification_key":"136684221-79922656",
+ "original_name_servers":[
+ "ns49.domaincontrol.com",
+ "ns50.domaincontrol.com"
+ ],
+ "original_registrar":"godaddy.com, llc",
+ "original_dnshost":"godaddy",
+ "modified_on":"2019-02-27T02:45:51.557296Z",
+ "created_on":"2019-01-29T13:41:03.568147Z",
+ "activated_on":"2019-02-20T16:56:23.002562Z",
+ "meta":{
+ "step":3,
+ "wildcard_proxiable":true,
+ "custom_certificate_quota":1,
+ "page_rule_quota":100,
+ "phishing_detected":false,
+ "multiple_railguns_allowed":false
+ },
+ "owner":{
+ "id":"deada5dc1124add404f6a4fb0e60324f",
+ "type":"organization",
+ "name":"Alkami"
+ },
+ "account":{
+ "id":"deada5dc1124add404f6a4fb0e60324f",
+ "name":"Alkami"
+ },
+ "permissions":[
+ "#analytics:read",
+ "#cache_purge:edit",
+ "#dns_records:edit",
+ "#dns_records:read",
+ "#legal:read",
+ "#organization:read",
+ "#subscription:read",
+ "#worker:edit",
+ "#worker:read",
+ "#zone:read",
+ "#zone_settings:read"
+ ],
+ "plan":{
+ "id":"94f3b7b768b0458b56d2cac4fe5ec0f9",
+ "name":"Enterprise Website",
+ "price":0,
+ "currency":"USD",
+ "frequency":"monthly",
+ "is_subscribed":true,
+ "can_subscribe":true,
+ "legacy_id":"enterprise",
+ "legacy_discount":false,
+ "externally_managed":true
+ }
+ }
+ ],
+ "result_info":{
+ "page":1,
+ "per_page":20,
+ "total_pages":1,
+ "count":1,
+ "total_count":1
+ },
+ "success":true,
+ "errors":[
+
+ ],
+ "messages":[
+
+ ]
+ }' | ConvertFrom-Json
+ } -ParameterFilter { ($Uri -like "*zones?name=*") -and ($Method -eq "Get") }
+
+ # Get A Records for Zone
+ Mock -ModuleName $moduleForMock -CommandName Invoke-RestMethod -MockWith {
+ # Sample dns records: https://api.cloudflare.com/zones/deada5dc1124/dns_records
+ # ^ fake zone id
+ '{
+ "result":[
+ {
+ "id":"a32f0a74fe",
+ "type":"A",
+ "name":"my.multi.sub.zone.samplezone.com",
+ "content":"199.79.51.50",
+ "proxiable":true,
+ "proxied":true,
+ "ttl":1,
+ "locked":false,
+ "zone_id":"deada5dc1124",
+ "zone_name":"samplezone.com",
+ "modified_on":"2019-02-27T02:44:00.109525Z",
+ "created_on":"2019-02-27T02:44:00.109525Z",
+ "meta":{
+ "auto_added":false,
+ "managed_by_apps":false,
+ "managed_by_argo_tunnel":false
+ }
+ }
+ ],
+ "result_info":{
+ "page":1,
+ "per_page":20,
+ "total_pages":1,
+ "count":3,
+ "total_count":3
+ },
+ "success":true,
+ "errors":[
+
+ ],
+ "messages":[
+
+ ]
+ }' | ConvertFrom-Json
+ } -ParameterFilter { $Uri -like "*zones/*/dns_records" -and ($Method -eq "Get") }
+
+ # Delete A Records - fails
+ Mock -ModuleName $moduleForMock -CommandName Invoke-RestMethod -MockWith {
+ throw('{
+ "result": {
+ "id": "372e67954025e0ba6aaa6d586b9e0b59"
+ },
+ "success" : false
+ }') | ConvertFrom-Json
+ } -ParameterFilter { $Method -eq "Delete" }
+
+ Convert-CloudFlareARecordsToCnames -cloudflareEmail "fake@alkamitech.com" -cloudflareAuthKey "FakeAuthKey" -ZoneName "FakeZone.com" 3>&1 `
+ | should -BeLike "*Exception Deleting A record*"
+ }
+
+ It "Should Warn when a create fails" {
+ # Get zone id
+ Mock -ModuleName $moduleForMock -CommandName Invoke-RestMethod -MockWith {
+ # Sample zone : https://api.cloudflare.com/zones?name=samplezone.com
+ '{
+ "result":[
+ {
+ "id":"deada5dc1124",
+ "name":"samplezone.com",
+ "status":"active",
+ "paused":false,
+ "type":"partial",
+ "development_mode":0,
+ "verification_key":"136684221-79922656",
+ "original_name_servers":[
+ "ns49.domaincontrol.com",
+ "ns50.domaincontrol.com"
+ ],
+ "original_registrar":"godaddy.com, llc",
+ "original_dnshost":"godaddy",
+ "modified_on":"2019-02-27T02:45:51.557296Z",
+ "created_on":"2019-01-29T13:41:03.568147Z",
+ "activated_on":"2019-02-20T16:56:23.002562Z",
+ "meta":{
+ "step":3,
+ "wildcard_proxiable":true,
+ "custom_certificate_quota":1,
+ "page_rule_quota":100,
+ "phishing_detected":false,
+ "multiple_railguns_allowed":false
+ },
+ "owner":{
+ "id":"deada5dc1124add404f6a4fb0e60324f",
+ "type":"organization",
+ "name":"Alkami"
+ },
+ "account":{
+ "id":"deada5dc1124add404f6a4fb0e60324f",
+ "name":"Alkami"
+ },
+ "permissions":[
+ "#analytics:read",
+ "#cache_purge:edit",
+ "#dns_records:edit",
+ "#dns_records:read",
+ "#legal:read",
+ "#organization:read",
+ "#subscription:read",
+ "#worker:edit",
+ "#worker:read",
+ "#zone:read",
+ "#zone_settings:read"
+ ],
+ "plan":{
+ "id":"94f3b7b768b0458b56d2cac4fe5ec0f9",
+ "name":"Enterprise Website",
+ "price":0,
+ "currency":"USD",
+ "frequency":"monthly",
+ "is_subscribed":true,
+ "can_subscribe":true,
+ "legacy_id":"enterprise",
+ "legacy_discount":false,
+ "externally_managed":true
+ }
+ }
+ ],
+ "result_info":{
+ "page":1,
+ "per_page":20,
+ "total_pages":1,
+ "count":1,
+ "total_count":1
+ },
+ "success":true,
+ "errors":[
+
+ ],
+ "messages":[
+
+ ]
+ }' | ConvertFrom-Json
+ } -ParameterFilter { ($Uri -like "*zones?name=*") -and ($Method -eq "Get") }
+
+ # Get A Records for Zone
+ Mock -ModuleName $moduleForMock -CommandName Invoke-RestMethod -MockWith {
+ # Sample dns records: https://api.cloudflare.com/zones/deada5dc1124/dns_records
+ # ^ fake zone id
+ '{
+ "result":[
+ {
+ "id":"a32f0a74fe",
+ "type":"A",
+ "name":"my.multi.sub.zone.samplezone.com",
+ "content":"199.79.51.50",
+ "proxiable":true,
+ "proxied":true,
+ "ttl":1,
+ "locked":false,
+ "zone_id":"deada5dc1124",
+ "zone_name":"samplezone.com",
+ "modified_on":"2019-02-27T02:44:00.109525Z",
+ "created_on":"2019-02-27T02:44:00.109525Z",
+ "meta":{
+ "auto_added":false,
+ "managed_by_apps":false,
+ "managed_by_argo_tunnel":false
+ }
+ }
+ ],
+ "result_info":{
+ "page":1,
+ "per_page":20,
+ "total_pages":1,
+ "count":3,
+ "total_count":3
+ },
+ "success":true,
+ "errors":[
+
+ ],
+ "messages":[
+
+ ]
+ }' | ConvertFrom-Json
+ } -ParameterFilter { $Uri -like "*zones/*/dns_records" -and ($Method -eq "Get") }
+
+ # Delete A Records
+ Mock -ModuleName $moduleForMock -CommandName Invoke-RestMethod -MockWith {
+ '{
+ "result": {
+ "id": "372e67954025e0ba6aaa6d586b9e0b59"
+ },
+ "success" : "true"
+ }' | ConvertFrom-Json
+ } -ParameterFilter { $Method -eq "Delete" }
+
+ # Create CNAME Records - fails
+ Mock -ModuleName $moduleForMock -CommandName Invoke-RestMethod -MockWith {
+ throw('{
+ "success": false,
+ "errors": [{
+ "code":1004,
+ "message":"DNS Validation Error",
+ "error_chain":[
+ {
+ "code":9003,
+ "message":"Invalid ''proxied'' value, must be a boolean"
+ }
+ ]
+ }],
+ "messages": [],
+ "result": {
+ },
+ "timing": {
+ "start_time": "2014-03-01T12:20:00Z",
+ "end_time": "2014-03-01T12:20:01Z",
+ "process_time": 1
+ }
+ }') | ConvertFrom-Json
+ } -ParameterFilter { $Method -eq "Post" }
+
+ Convert-CloudFlareARecordsToCnames -cloudflareEmail "fake@alkamitech.com" -cloudflareAuthKey "FakeAuthKey" -ZoneName "FakeZone.com" 3>&1 `
+ | should -BeLike "*Exception Creating CNAME for*"
+ }
+
+ It "Should Warn when a create succeeds, but we can't get info about it" {
+ # Get Zone Id from Name
+ Mock -ModuleName $moduleForMock -CommandName Invoke-RestMethod -MockWith {
+ # Sample zone : https://api.cloudflare.com/zones?name=samplezone.com
+ '{
+ "result":[
+ {
+ "id":"deada5dc1124",
+ "name":"samplezone.com",
+ "status":"active",
+ "paused":false,
+ "type":"partial",
+ "development_mode":0,
+ "verification_key":"136684221-79922656",
+ "original_name_servers":[
+ "ns49.domaincontrol.com",
+ "ns50.domaincontrol.com"
+ ],
+ "original_registrar":"godaddy.com, llc",
+ "original_dnshost":"godaddy",
+ "modified_on":"2019-02-27T02:45:51.557296Z",
+ "created_on":"2019-01-29T13:41:03.568147Z",
+ "activated_on":"2019-02-20T16:56:23.002562Z",
+ "meta":{
+ "step":3,
+ "wildcard_proxiable":true,
+ "custom_certificate_quota":1,
+ "page_rule_quota":100,
+ "phishing_detected":false,
+ "multiple_railguns_allowed":false
+ },
+ "owner":{
+ "id":"deada5dc1124add404f6a4fb0e60324f",
+ "type":"organization",
+ "name":"Alkami"
+ },
+ "account":{
+ "id":"deada5dc1124add404f6a4fb0e60324f",
+ "name":"Alkami"
+ },
+ "permissions":[
+ "#analytics:read",
+ "#cache_purge:edit",
+ "#dns_records:edit",
+ "#dns_records:read",
+ "#legal:read",
+ "#organization:read",
+ "#subscription:read",
+ "#worker:edit",
+ "#worker:read",
+ "#zone:read",
+ "#zone_settings:read"
+ ],
+ "plan":{
+ "id":"94f3b7b768b0458b56d2cac4fe5ec0f9",
+ "name":"Enterprise Website",
+ "price":0,
+ "currency":"USD",
+ "frequency":"monthly",
+ "is_subscribed":true,
+ "can_subscribe":true,
+ "legacy_id":"enterprise",
+ "legacy_discount":false,
+ "externally_managed":true
+ }
+ }
+ ],
+ "result_info":{
+ "page":1,
+ "per_page":20,
+ "total_pages":1,
+ "count":1,
+ "total_count":1
+ },
+ "success":true,
+ "errors":[
+
+ ],
+ "messages":[
+
+ ]
+ }' | ConvertFrom-Json
+ } -ParameterFilter { ($Uri -like "*zones?name=*") -and ($Method -eq "Get") }
+
+ # Get A Records for Zone
+ Mock -ModuleName $moduleForMock -CommandName Invoke-RestMethod -MockWith {
+ # Sample dns records: https://api.cloudflare.com/zones/deada5dc1124/dns_records
+ # ^ fake zone id
+ '{
+ "result":[
+ {
+ "id":"a32f0a74fe",
+ "type":"A",
+ "name":"my.multi.sub.zone.samplezone.com",
+ "content":"199.79.51.50",
+ "proxiable":true,
+ "proxied":true,
+ "ttl":1,
+ "locked":false,
+ "zone_id":"deada5dc1124",
+ "zone_name":"samplezone.com",
+ "modified_on":"2019-02-27T02:44:00.109525Z",
+ "created_on":"2019-02-27T02:44:00.109525Z",
+ "meta":{
+ "auto_added":false,
+ "managed_by_apps":false,
+ "managed_by_argo_tunnel":false
+ }
+ }
+ ],
+ "result_info":{
+ "page":1,
+ "per_page":20,
+ "total_pages":1,
+ "count":3,
+ "total_count":3
+ },
+ "success":true,
+ "errors":[
+
+ ],
+ "messages":[
+
+ ]
+ }' | ConvertFrom-Json
+ } -ParameterFilter { $Uri -like "*zones/*/dns_records" -and ($Method -eq "Get") }
+
+ # Delete A Records
+ Mock -ModuleName $moduleForMock -CommandName Invoke-RestMethod -MockWith {
+ '{
+ "result": {
+ "id": "372e67954025e0ba6aaa6d586b9e0b59"
+ },
+ "success" : "true"
+ }' | ConvertFrom-Json
+ } -ParameterFilter { $Method -eq "Delete" }
+
+ # Create CNAME Records
+ Mock -ModuleName $moduleForMock -CommandName Invoke-RestMethod -MockWith {
+ '{
+ "success": true,
+ "errors": [],
+ "messages": [],
+ "result": {
+ "id":"51345ac1b00f",
+ "type":"CNAME",
+ "recs_added": 5,
+ "total_records_parsed": 5
+ },
+ "timing": {
+ "start_time": "2014-03-01T12:20:00Z",
+ "end_time": "2014-03-01T12:20:01Z",
+ "process_time": 1
+ }
+ }' | ConvertFrom-Json
+ } -ParameterFilter { $Method -eq "Post" }
+
+ # Get created CNAME Record - fails; error doesn't matter, just need a failure.
+ Mock -ModuleName $moduleForMock -CommandName Invoke-RestMethod -MockWith {
+ throw('{
+ "result":
+ {
+ },
+ "success":false,
+ "errors":[
+ {"code":1002,"message":"Invalid dns record identifier"}
+ ],
+ "messages":[
+
+ ]
+ }') | ConvertFrom-Json
+ } -ParameterFilter { $Uri -like "*zones/*/dns_records/*" -and ($Method -eq "Get") }
+
+ Convert-CloudFlareARecordsToCnames -cloudflareEmail "fake@alkamitech.com" -cloudflareAuthKey "FakeAuthKey" -ZoneName "FakeZone.com" 3>&1 `
+ | should -BeLike "*Create call succeded for site*but couldn't find site*"
+ }
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Public/Convert-GMSAAccounts.ps1 b/Modules/Alkami.DevOps.Operations/Public/Convert-GMSAAccounts.ps1
new file mode 100644
index 0000000..c8d7cf2
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Convert-GMSAAccounts.ps1
@@ -0,0 +1,44 @@
+Function Convert-GMSAaccounts {
+ <#
+.SYNOPSIS
+ Update service GMSA Service User from one that starts with the old prefix to one that starts with the new prefix
+
+.PARAMETER GmsaPrefixOld
+ Old or previous GMSA Username prefix
+
+.PARAMETER GmsaPrefixNew
+ New or current GMSA Username prefix
+#>
+
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory = $true)]
+ [string]$GmsaPrefixOld,
+
+ [Parameter(Mandatory = $true)]
+ [string]$GmsaPrefixNew
+ )
+
+ $logLead = Get-LogLeadName
+ $services = Get-ChocolateyServices
+
+ Foreach ($service in $services) {
+ $serviceName = $service.Name
+ $userName = Get-WindowsServiceUser -ServiceName $serviceName
+ $command = 'sc.exe'
+
+ if ($username -like "fh\$gmsaPrefixOld.micro`$") {
+ $username = "fh\$gmsaPrefixNew.micro`$"
+
+ } elseif ($username -like "fh\$gmsaPrefixOld.dbms`$") {
+ $username = "fh\$gmsaPrefixNew.dbms`$"
+
+ } else {
+ Write-Warning -Message "$logLead : $serviceName GMSA not changed from $userName"
+
+ }
+ $params = 'config', $serviceName, 'obj=', $userName, 'password= ', ''
+
+ & $command $params
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Public/ConvertTo-NormalizedLoadBalancerState.ps1 b/Modules/Alkami.DevOps.Operations/Public/ConvertTo-NormalizedLoadBalancerState.ps1
new file mode 100644
index 0000000..0b1fb1d
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/ConvertTo-NormalizedLoadBalancerState.ps1
@@ -0,0 +1,67 @@
+function ConvertTo-NormalizedLoadBalancerState {
+ <#
+ .SYNOPSIS
+ Takes strings from NGINX Upstream and AWS LB states and returns normalized strings for the states
+ .DESCRIPTION
+ Takes the string 'Down' from NGINX and returns 'Standby' to normalize to the AWS LB string for Standby state
+ .PARAMETER LBTYPE
+ Which LoadBalancer Type is the the InputState from?
+ Accepts "NGINX" or "AWS"
+ .PARAMETER InputState
+ Accepts the raw state string for NGINX or AWS LBs
+ .PARAMETER TreatNGINXUnhealthyAsUnhealthy
+ Should the NGINX 'UNhealthy' state be treated as being 'Up' which is normalized to 'Active' or returned as 'Unhealthy'?
+ .EXAMPLE
+ ConvertTo-NormalizedLoadBalancerState -LBType "AWS" -InputState "Pending"
+ 'ActivePending'
+ ConvertTo-NormalizedLoadBalancerState -LBType "NGINX" -InputState "Up"
+ 'Active'
+ ConvertTo-NormalizedLoadBalancerState -LBType "NGINX" -InputState "Unhealthy"
+ 'Active'
+ ConvertTo-NormalizedLoadBalancerState -LBType "NGINX" -InputState "Unhealthy" -TreatNGINXUnhealthyAsUnhealthy
+ 'Unhealthy'
+ #>
+
+ [CmdletBinding()]
+ [OutputType([System.String])]
+ param(
+ [Parameter(Mandatory = $true)]
+ [ValidateSet("NGINX", "AWS")]
+ [string]$LBType,
+ [Parameter(Mandatory = $false)]
+ [string]$InputState,
+ [Parameter(Mandatory = $false)]
+ [switch]$TreatNGINXUnhealthyAsUnhealthy = $false
+ )
+
+ $logLead = Get-LogLeadName
+
+ if ($LBType -eq "NGINX") {
+ # Handle nginx load balancers.
+ Write-Host "$logLead Nginx state is: [$InputState]; translating for programatic use."
+ if ($InputState -eq 'Up') {
+ return "Active"
+ } elseif ($InputState -eq 'Unhealthy' -and $TreatNGINXUnhealthyAsUnhealthy -eq $true) {
+ return "Unhealthy"
+ } elseif ($InputState -eq 'Unhealthy' -and $TreatNGINXUnhealthyAsUnhealthy -eq $false) {
+ return "Active"
+ } elseif ($InputState -eq 'Down') {
+ return "Standby"
+ } else {
+ Throw "$loglead : Unknown Nginx state: [$inputState]"
+ }
+ } elseif ($LBType -eq "AWS") {
+ # Handle AWS load balancers.
+ Write-Host "$logLead AWS State is: [$inputState]; Transforming string."
+ $fixedState = switch ( $inputState ) {
+ Pending { 'ActivePending' }
+ InService { 'Active' }
+ EnteringStandby { 'StandbyPending' }
+ Standby { 'Standby' }
+ default { Throw "$loglead : Unknown AWS LB state: [$inputState]" }
+ }
+ return $fixedState
+ } else {
+ Throw "$loglead : Unable to determine LbType: [$LBType]"
+ }
+}
diff --git a/Modules/Alkami.DevOps.Operations/Public/ConvertTo-NormalizedLoadBalancerState.tests.ps1 b/Modules/Alkami.DevOps.Operations/Public/ConvertTo-NormalizedLoadBalancerState.tests.ps1
new file mode 100644
index 0000000..1f2b1c7
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/ConvertTo-NormalizedLoadBalancerState.tests.ps1
@@ -0,0 +1,76 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ""
+
+Describe "ConvertTo-NormalizedLoadBalancerState" {
+ Mock -ModuleName $moduleForMock -CommandName Write-Host -MockWith { }
+ Context "NGINX" {
+ It "up returns Active" {
+ $result = ConvertTo-NormalizedLoadBalancerState -LBType "NGINX" -InputState "Up"
+ $result | Should -Be "Active"
+ }
+ It "UnHealthy returns Active" {
+ $result = ConvertTo-NormalizedLoadBalancerState -LBType "NGINX" -InputState "Unhealthy"
+ $result | Should -Be "Active"
+ }
+ It "Unhealthy returns Standby" {
+ $result = ConvertTo-NormalizedLoadBalancerState -LBType "NGINX" -InputState "Unhealthy" -TreatNGINXUnhealthyAsUnhealthy
+ $result | Should -Be "Unhealthy"
+ }
+ It "Down returns Standby" {
+ $result = ConvertTo-NormalizedLoadBalancerState -LBType "NGINX" -InputState "Down"
+ $result | Should -Be "Standby"
+ }
+ It "garbage in garbage out" {
+ try {
+ ConvertTo-NormalizedLoadBalancerState -LBType "NGINX" -InputState "gbarbage" -ErrorAction Stop
+ } catch {
+ # I did this because i was getting runtimeExceptions that were not being caught by "Should -Throw" -George
+ $errorThrown = $true
+ }
+ $errorThrown | Should Be $true
+ }
+ }
+ Context "AWS" {
+ It "Pending returns ActivePending" {
+ $result = ConvertTo-NormalizedLoadBalancerState -LBType "AWS" -InputState "Pending"
+ $result | Should -Be "ActivePending"
+ }
+ It "InService returns Active" {
+ $result = ConvertTo-NormalizedLoadBalancerState -LBType "AWS" -InputState "InService"
+ $result | Should -Be "Active"
+ }
+ It "EnteringStandby returns StandbyPending" {
+ $result = ConvertTo-NormalizedLoadBalancerState -LBType "AWS" -InputState "EnteringStandby"
+ $result | Should -Be "StandbyPending"
+ }
+ It "Standby returns Standby" {
+ $result = ConvertTo-NormalizedLoadBalancerState -LBType "AWS" -InputState "Standby"
+ $result | Should -Be "Standby"
+ }
+ It "garbage in garbage out" {
+ try {
+ ConvertTo-NormalizedLoadBalancerState -LBType "AWS" -InputState "gbarbage" -ErrorAction Stop
+ } catch {
+ # I did this because i was getting runtimeExceptions that were not being caught by "Should -Throw" -George
+ $errorThrown = $true
+ }
+ $errorThrown | Should Be $true
+ }
+ }
+ Context "bad lb type input" {
+ It "Garbage in, throw out"{
+ try {
+ ConvertTo-NormalizedLoadBalancerState -LBType "blarg" -InputState "gbarbage" -ErrorAction Stop
+ } catch {
+ # I did this because i was getting runtimeExceptions that were not being caught by "Should -Throw" -George
+ $errorThrown = $true
+ }
+ $errorThrown | Should Be $true
+ }
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Public/Copy-AlkamiRelease.Tests.ps1 b/Modules/Alkami.DevOps.Operations/Public/Copy-AlkamiRelease.Tests.ps1
new file mode 100644
index 0000000..205c1b8
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Copy-AlkamiRelease.Tests.ps1
@@ -0,0 +1,122 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ""
+
+<#
+Notes:
+* Did not test "Radium 2" existence
+#>
+Describe "Copy-AlkamiRelease" {
+ ## https://pester.dev/docs/usage/testdrive/
+ $existingOrbPath = (Join-Path $TestDrive orb)
+ $tempOrbDeployPath = (Join-Path (Join-Path (Join-Path $TestDrive temp) deploy) orb)
+
+ Context "Neither folder already exists workflow test" {
+ It "this should throw an error" {
+ { Copy-AlkamiRelease -orbDirectory $existingOrbPath -tempDirectory $tempOrbDeployPath } | Should -Throw "not found"
+ }
+ }
+
+ $garbageFileToBeGone = (Join-Path $existingOrbPath "letmebegone.txt")
+ $log4netFileToBeRemain = (Join-Path $existingOrbPath "log4net.config")
+ $garbageFileToBeFound = (Join-Path $existingOrbPath "showme.txt")
+ $garbageFileToBeCopied = (Join-Path $tempOrbDeployPath "showme.txt")
+
+ Mock -CommandName Set-AppTierFolderAndFilePermissions -MockWith { Write-Warning "I set the values for logging changes" } -ModuleName $moduleForMock
+
+ Mock -CommandName Get-OrbPath -MockWith { return (Join-Path $TestDrive orb) } -ModuleName $moduleForMock
+ Mock -CommandName Get-TempOrbDeployPath -MockWith { return (Join-Path (Join-Path (Join-Path $TestDrive temp) deploy) orb) } -ModuleName $moduleForMock
+
+ Context "Basic workflow test" {
+ ##ensure our test files exist as expected.
+
+ New-Item -Path $garbageFileToBeGone -ItemType File -Value "I should be gone after this test" -Force
+ New-Item -Path $garbageFileToBeCopied -ItemType File -Value "I should be in the target folder after deploy" -Force
+
+ Copy-AlkamiRelease
+
+ It "The file should not be there, the folder should have been deleted" {
+ (Test-Path $garbageFileToBeGone) | Should -Be $false
+ }
+ It "The file should be there, we should have copied it" {
+ (Test-Path $garbageFileToBeFound) | Should -Be $true
+ }
+ }
+
+ Context "Leaves log4net.config workflow test" {
+ ##ensure our test files exist as expected.
+
+ New-Item -Path $garbageFileToBeGone -ItemType File -Value "I should be gone after this test" -Force
+ New-Item -Path $log4netFileToBeRemain -ItemType File -Value "I should stick around" -Force
+ New-Item -Path $garbageFileToBeCopied -ItemType File -Value "I should be in the target folder after deploy" -Force
+
+ Copy-AlkamiRelease
+
+ It "The log4net file should not be deleted" {
+ (Test-Path $log4netFileToBeRemain) | Should -Be $true
+ Write-Debug (Get-Content $log4netFileToBeRemain)
+ }
+
+ It "The file should not be there, the folder should have been deleted" {
+ (Test-Path $garbageFileToBeGone) | Should -Be $false
+ }
+ It "The file should be there, we should have copied it" {
+ (Test-Path $garbageFileToBeFound) | Should -Be $true
+ }
+ }
+
+ Context "When -verbose flag passed in" {
+
+ # Stub files to make the function work
+ New-Item -Path $garbageFileToBeGone -ItemType File -Value "I should be gone after this test" -Force
+ New-Item -Path $log4netFileToBeRemain -ItemType File -Value "I should stick around" -Force
+ New-Item -Path $garbageFileToBeCopied -ItemType File -Value "I should be in the target folder after deploy" -Force
+
+ # Mocks for stuff we care about
+ Mock -CommandName Remove-FileSystemItem -MockWith { Write-Verbose "Called write-verbose" } -ModuleName $moduleForMock
+ Mock -CommandName Write-Verbose -ModuleName $moduleForMock
+
+ Copy-AlkamiRelease -verbose
+
+ It "Should call Write-Verbose inside of Remove-FileSystemItem" {
+ Assert-MockCalled Write-Verbose -ModuleName $moduleForMock -ParameterFilter {$Message -eq "Called write-verbose"}
+ }
+ }
+
+ Mock -CommandName Get-OrbPath -MockWith { throw "this should not get called" } -ModuleName $moduleForMock
+ Mock -CommandName Get-TempOrbDeployPath -MockWith { throw "this should not get called" } -ModuleName $moduleForMock
+
+ Context "Specified folders already exist workflow test" {
+ ##ensure our test files exist as expected.
+
+ New-Item -Path $garbageFileToBeGone -ItemType File -Value "I should be gone after this test" -Force
+ New-Item -Path $garbageFileToBeCopied -ItemType File -Value "I should be in the target folder after deploy" -Force
+
+ Copy-AlkamiRelease -orbDirectory $existingOrbPath -tempDirectory $tempOrbDeployPath
+
+ It "The file should not be there, the folder should have been deleted" {
+ (Test-Path $garbageFileToBeGone) | Should -Be $false
+ }
+ It "The file should be there, we should have copied it" {
+ (Test-Path $garbageFileToBeFound) | Should -Be $true
+ }
+ }
+
+ Mock -CommandName Set-AppTierFolderAndFilePermissions -MockWith { Write-Information "I set the values for logging changes" } -ModuleName $moduleForMock
+
+ Context "Specified orb folder does not already exist workflow test" {
+ ##ensure our test files exist as expected.
+
+ New-Item -Path $garbageFileToBeCopied -ItemType File -Value "I should be in the target folder after deploy" -Force
+
+ Copy-AlkamiRelease -orbDirectory $existingOrbPath -tempDirectory $tempOrbDeployPath
+
+ It "The file should be there, we should have copied it" {
+ (Test-Path $garbageFileToBeFound) | Should -Be $true
+ }
+ }
+}
diff --git a/Modules/Alkami.DevOps.Operations/Public/Copy-AlkamiRelease.ps1 b/Modules/Alkami.DevOps.Operations/Public/Copy-AlkamiRelease.ps1
new file mode 100644
index 0000000..0d2c878
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Copy-AlkamiRelease.ps1
@@ -0,0 +1,71 @@
+function Copy-AlkamiRelease {
+<#
+.SYNOPSIS
+ Copy Orb from specified folder to destination folder. Defaults to value of Get-OrbPath
+
+.PARAMETER OrbDirectory
+ [string] This is the location where ORB is installed to. Can be left blank, will default to Get-OrbPath
+
+.PARAMETER TempDirectory
+ [string] This is where the ORB files are found. If the folder does not exist, this will throw. Defaults to Get-TempOrbDeployPath
+#>
+ [CmdletBinding()]
+ Param (
+ [Parameter(Mandatory=$false)]
+ [Alias("OrbPath")]
+ [string]$OrbDirectory,
+
+ [Parameter(Mandatory=$false)]
+ [Alias("TempOrbPath")]
+ [string]$TempDirectory
+ )
+
+ $logLead = (Get-LogLeadName)
+
+ if([string]::IsNullOrEmpty($OrbDirectory)) {
+ $OrbDirectory = (Get-OrbPath)
+ }
+ if([string]::IsNullOrEmpty($TempDirectory)) {
+ $TempDirectory = (Get-TempOrbDeployPath)
+ }
+
+ if(!(Test-Path $TempDirectory)) {
+ throw "$logLead : $TempDirectory folder not found - nothing to copy from"
+ }
+
+ $radium2Path = (Join-Path ($OrbDirectory) "Radium 2")
+ $radium2PathExists = (Test-Path $radium2Path)
+
+ # The purpose of this is that when installing ORB, the only ones creating symlinks are SRE
+ # If we have created a symlink, that is intended to speed up the application deployment/startup process
+ # We should only remove symlinks if we aren't following the symlink strategy (reverting to a prior version/style of ORB typically)
+ # If we are using the symlink strategy, this reduces the working number of filesystem copies being invoked, which is a net-win for speed
+ # This also can obviate the need to delete the temporary asp.net files, possibly.
+ $isSymlinkStrategy = (Test-InstallerUseSymlinkStrategy)
+ Write-Host "$loglead : Is Symlink Strategy? [$isSymlinkStrategy]"
+
+ if (!(Test-Path $OrbDirectory)) {
+ (New-Item $OrbDirectory -ItemType Directory) | Out-Null
+ Set-AppTierFolderAndFilePermissions
+ }
+ if (!(Test-Path $OrbDirectory)) {
+ throw "$logLead : $OrbDirectory folder not found - should have been created in this function"
+ }
+
+ Write-Host "$logLead : Cleaning $OrbDirectory excluding log4net.config"
+ Remove-FileSystemItem $OrbDirectory -Recurse -Exclude "log4net.config" -Force -SkipSymlinks:$isSymlinkStrategy -Verbose:$VerbosePreference
+
+ Write-Host "$logLead : Copy files from $TempDirectory to $OrbDirectory"
+ (Copy-Item $TempDirectory (Split-Path $OrbDirectory -Parent) -Recurse -Force) | Out-Null
+
+ if($radium2PathExists){
+ Write-Host "$logLead : Copy Radium to Radium 2..."
+ Write-Verbose "$logLead : Removing Radium2 folder"
+ (Remove-FileSystemItem $radium2Path -Recurse -Force -SkipSymlinks:$isSymlinkStrategy -Verbose:$VerbosePreference) | Out-Null
+ Write-Verbose "$logLead : Radium2 folder removed"
+ (New-Item $radium2Path -ItemType Directory -Force) | Out-Null
+ (Copy-Item (Join-Path ($OrbDirectory) "Radium\*") $radium2Path -Recurse -Force) | Out-Null
+ } else {
+ Write-Host ("$logLead : 'Radium 2' folder not found, skipping")
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Public/Copy-LaunchConfiguration.ps1 b/Modules/Alkami.DevOps.Operations/Public/Copy-LaunchConfiguration.ps1
new file mode 100644
index 0000000..1c70cfd
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Copy-LaunchConfiguration.ps1
@@ -0,0 +1,90 @@
+
+function Copy-LaunchConfiguration {
+<#
+.SYNOPSIS
+ Creates a new Launch Configuration in AWS based on an existing Launch Configuration.
+
+.DESCRIPTION
+ Will create a new Launch Configuration from an existing one. New LC will require the AMI ID that's passed in.
+ This exists because there is no way to edit or copy an existing Launch Configuration to update it
+ to a newer AMI.
+
+Returns one Amazon.AutoScaling.Model.LaunchConfiguration object.
+
+.PARAMETER launchConfig
+ [Amazon.AutoScaling.Model.LaunchConfiguration] Existing Launch Configuration object to copy.
+
+.PARAMETER imageId
+ [string] AMI ImageID as a string for adding to the new Launch Configuration.
+
+.PARAMETER newLaunchConfigName
+ [string] Name of the new Launch Configuration. Must be unique.
+
+.PARAMETER profileName
+ [string] AWS Profile. ("Sandbox", "prod")
+
+.PARAMETER awsRegion
+ [string] AWS Region. ("us-east-1")
+
+.EXAMPLE
+ Copy-LaunchConfiguration -launchConfig [Amazon.AutoScaling.Model.LaunchConfiguration]object -imageId "ami-06922c371ca793273" -newLaunchConfigName "0.0_Sandbox_web-2019082120433000000001"
+#>
+ [CmdletBinding()]
+ Param (
+ [Parameter(Mandatory=$true)]
+ [Amazon.AutoScaling.Model.LaunchConfiguration]$launchConfig,
+
+ [Parameter(Mandatory=$true)]
+ [string]$imageId,
+
+ [Parameter(Mandatory=$true)]
+ [string]$newLaunchConfigName,
+
+ [Parameter(Mandatory=$true)]
+ [string]$profileName,
+
+ [Alias("Region")]
+ [Parameter(Mandatory=$true)]
+ [string]$awsRegion
+ )
+ $logLead = (Get-LogLeadName)
+
+ Import-AWSModule # AS
+
+ # Create a new Launch Configuration object.
+ Write-Host "$logLead : Creating a new ASLaunchConfiguration $newLaunchConfigName with AMI $imageId from LaunchConfig $($launchConfig.LaunchConfigurationName)"
+
+ Invoke-CommandWithRetry -Arguments ($profileName, $awsRegion, $newLaunchConfigName, $imageId, $launchConfig) -MaxRetries 3 -Exponential -ScriptBlock {
+ param($sbProfileName, $sbAwsRegion, $sbNewLaunchConfigName, $sbImageId, $sbLaunchConfig)
+
+ New-ASLaunchConfiguration -ProfileName $sbProfileName -Region $sbAwsRegion `
+ -LaunchConfigurationName $sbNewLaunchConfigName `
+ -ImageId $sbImageId `
+ -KeyName $sbLaunchConfig.KeyName `
+ -SecurityGroup $sbLaunchConfig.SecurityGroups `
+ -AssociatePublicIpAddress $sbLaunchConfig.AssociatePublicIpAddress `
+ -BlockDeviceMapping $sbLaunchConfig.BlockDeviceMappings `
+ -ClassicLinkVPCId $sbLaunchConfig.ClassicLinkVPCId `
+ -ClassicLinkVPCSecurityGroup $sbLaunchConfig.ClassicLinkVPCSecurityGroups `
+ -EbsOptimized $sbLaunchConfig.EbsOptimized `
+ -InstanceMonitoring_Enabled $true `
+ -IamInstanceProfile $sbLaunchConfig.IamInstanceProfile `
+ -InstanceType $sbLaunchConfig.InstanceType `
+ -KernelId $sbLaunchConfig.KernelId `
+ -PlacementTenancy $sbLaunchConfig.PlacementTenancy `
+ -RamdiskId $sbLaunchConfig.RamdiskId `
+ -SpotPrice $sbLaunchConfig.SpotPrice `
+ -UserData $sbLaunchConfig.UserData `
+ }
+
+ # Get the new launch configuration
+ $newLaunchConfig = (
+ Invoke-CommandWithRetry -Arguments ($profileName, $awsRegion, $newLaunchConfigName) -MaxRetries 3 -Exponential -ScriptBlock{
+ param($sbProfileName, $sbAwsRegion, $sbNewLaunchConfigName)
+
+ return Get-ASLaunchConfiguration -ProfileName $sbProfileName -Region $sbAwsRegion -LaunchConfigurationName $sbNewLaunchConfigName
+ }
+ )
+ Write-Host "$logLead : New Launch Configuration Name:$($newLaunchConfig.LaunchConfigurationName)"
+ return $newLaunchConfig
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Public/Disable-Microservices.ps1 b/Modules/Alkami.DevOps.Operations/Public/Disable-Microservices.ps1
new file mode 100644
index 0000000..cb82b9c
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Disable-Microservices.ps1
@@ -0,0 +1,53 @@
+function Disable-Microservices {
+<#
+.SYNOPSIS
+ Disables all alkami/SDK microservices, except for the untouchable broker, beacon, and subscription service.
+#>
+ [CmdletBinding()]
+ Param(
+ [Parameter(Mandatory=$false)]
+ [string[]]$microservicesToIgnore
+ )
+
+ $logLead = (Get-LogLeadName);
+
+ Write-Host "$logLead : Now disabling microservice services."
+
+ # We never want to disable these microservices automatically.
+ $ignoreList = @("Alkami.Services.Subscriptions.Host", "Alkami.MicroServices.Features.Beacon.Host", "Alkami.MicroServices.Broker.Host");
+ $ignoreList += $microservicesToIgnore;
+ Write-Verbose "$logLead : Not Disabling Microservices: $ignoreList";
+
+ # Get list of chocolatey microservices.
+ $packages = Get-CategorizedChocoPackages
+ $microservicePackages = @()
+ $microservicePackages += $packages.WithMigrations
+ $microservicePackages += $packages.WithoutMigrations
+
+ # Disable them all.
+ foreach($package in $microservicePackages)
+ {
+ # Skip packages in the ignore list.
+ if($ignoreList | Where-Object { $_ -eq $package.Name })
+ {
+ Write-Verbose "$logLead : Skipping $($package.Name)"
+ continue;
+ }
+
+ # Note: This returns $null if the service is already disabled, without the -includeDisabled flag.
+ $service = Get-ServiceByChocoName $package.Name
+
+ if($service)
+ {
+ # Stop the service if it's running, and then disable it.
+ Stop-AlkamiService $service.Name
+
+ Write-Verbose "$logLead : Disabling service for chocolatey package `"$($package.Name)`""
+ Set-Service $service.Name -StartupType Disabled
+ }
+ else
+ {
+ Write-Verbose "$logLead : `"$($package.Name)`" is already disabled, or a service could not be found to disable."
+ }
+ }
+}
diff --git a/Modules/Alkami.DevOps.Operations/Public/Disable-UnnecessaryMicroservices.ps1 b/Modules/Alkami.DevOps.Operations/Public/Disable-UnnecessaryMicroservices.ps1
new file mode 100644
index 0000000..a2db03f
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Disable-UnnecessaryMicroservices.ps1
@@ -0,0 +1,44 @@
+function Disable-UnnecessaryMicroServices {
+
+ <#
+.SYNOPSIS
+ Stops and disables unnecessary microservices based on configured providers in a pod
+
+.DESCRIPTION
+ Stops and disables unnecessary microservices based on configured providers in a pod. Compares a Vanguard maintained package to provider list to all tenant providers,
+ by filtering out invalid providers (Deleted or Invalid Provider Type.) Stops identified superfluous services and sets their start mode to disabled.
+
+.PARAMETER ExcludePackageId
+ Array of PackageIds to exclude from being disabled. This will be combined with return value from Get-AlwaysEnabledProviderPackageIds
+
+.PARAMETER PrintDetailedResults
+ When specified, prints a detailed accounting of services acted upon
+
+.NOTES
+ SupportsShouldProcess enabled function. Supports both -WhatIf and -Confirm
+
+.LINK
+ Get-AlwaysEnabledProviderPackageIds
+#>
+
+ [CmdletBinding(SupportsShouldProcess)]
+ [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSShouldProcess', '', Scope='Function', Justification="We want -WhatIf support passed down in to the underlying functions")]
+ param(
+ [Parameter(Mandatory = $false)]
+ [string[]]$ExcludePackageId = @(),
+ [Parameter(Mandatory = $false)]
+ [switch]$PrintDetailedResults
+ )
+
+ $logLead = Get-LogLeadName
+
+ [array]$unmatchedProviders = Get-ProviderMappingFileUnion -OverrideEnabledProviderPackageIds $ExcludePackageId -ReturnUnmatched
+
+ if (Test-IsCollectionNullOrEmpty $unmatchedProviders) {
+
+ Write-Warning "$logLead : Found no unmatched providers. While possible, it is highly unlikely. Review the mapping file contents for issues"
+ return
+ }
+
+ Set-MicroserviceConfigurationBasedState -ProviderData $unmatchedProviders -Disable -PrintDetailedResults:$PrintDetailedResults
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Public/Disable-UnnecessaryMicroservices.tests.ps1 b/Modules/Alkami.DevOps.Operations/Public/Disable-UnnecessaryMicroservices.tests.ps1
new file mode 100644
index 0000000..c504924
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Disable-UnnecessaryMicroservices.tests.ps1
@@ -0,0 +1,53 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ""
+
+Describe "Disable-UnnecessaryMicroservices" {
+
+ Mock -CommandName Get-LogLeadName -ModuleName $moduleForMock -MockWith { "[$sut (Pester)]" }
+
+ Context "Tests" {
+
+ It "Writes a Warning and Exits Early if a Null is Returned" {
+
+ Mock -CommandName Get-AlwaysEnabledProviderPackageIds -ModuleName $moduleForMock -MockWith { return @() }
+ Mock -CommandName Get-ProviderMappingFileUnion -ModuleName $moduleForMock -MockWith { return $null }
+ Mock -CommandName Write-Warning -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Set-MicroserviceConfigurationBasedState -ModuleName $moduleForMock -MockWith {}
+
+ Disable-UnnecessaryMicroServices
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Write-Warning -Times 1 -Exactly -Scope It `
+ -ParameterFilter { $Message -Match "Found no unmatched providers." }
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Set-MicroserviceConfigurationBasedState -Times 0 -Exactly -Scope It
+ }
+
+ It "Writes a Warning and Exits Early if an Empty Array is Returned" {
+
+ Mock -CommandName Get-AlwaysEnabledProviderPackageIds -ModuleName $moduleForMock -MockWith { return @() }
+ Mock -CommandName Get-ProviderMappingFileUnion -ModuleName $moduleForMock -MockWith { return @() }
+ Mock -CommandName Write-Warning -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Set-MicroserviceConfigurationBasedState -ModuleName $moduleForMock -MockWith {}
+
+ Disable-UnnecessaryMicroServices
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Write-Warning -Times 1 -Exactly -Scope It `
+ -ParameterFilter { $Message -Match "Found no unmatched providers." }
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Set-MicroserviceConfigurationBasedState -Times 0 -Exactly -Scope It
+ }
+
+ It "Calls the Set State Function with the Disable Flag" {
+
+ Mock -CommandName Get-AlwaysEnabledProviderPackageIds -ModuleName $moduleForMock -MockWith { return @() }
+ Mock -CommandName Get-ProviderMappingFileUnion -ModuleName $moduleForMock -MockWith { return @( "how", "DARE", "you" ) }
+ Mock -CommandName Set-MicroserviceConfigurationBasedState -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Test-IsCollectionNullOrEmpty -ModuleName $moduleForMock -MockWith { return $false }
+
+ Disable-UnnecessaryMicroServices
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Set-MicroserviceConfigurationBasedState -Times 1 -Exactly -Scope It `
+ -ParameterFilter { $Disable.IsPresent }
+ }
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Public/Enable-NecessaryMicroservices.ps1 b/Modules/Alkami.DevOps.Operations/Public/Enable-NecessaryMicroservices.ps1
new file mode 100644
index 0000000..bdaf64d
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Enable-NecessaryMicroservices.ps1
@@ -0,0 +1,49 @@
+function Enable-NecessaryMicroservices {
+
+ <#
+.SYNOPSIS
+ Enables necessary microservices based on configured providers in a pod
+
+.DESCRIPTION
+ Starts and enables necessary microservices based on configured providers in a pod. Compares a Vanguard maintained package to provider list to all tenant providers,
+ by filtering out invalid providers (Deleted or Invalid Provider Type.) Starts identified services and sets their start mode to enabled.
+
+.PARAMETER IncludePackageId
+ Array of PackageIds to include in being enabled. This will be combined with return value from Get-AlwaysEnabledProviderPackageIds
+
+.PARAMETER PrintDetailedResults
+ When specified, prints a detailed accounting of services acted upon
+
+.NOTES
+ SupportsShouldProcess enabled function. Supports both -WhatIf and -Confirm
+
+.LINK
+ Get-AlwaysEnabledProviderPackageIds
+#>
+
+ [CmdletBinding(SupportsShouldProcess)]
+ [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSShouldProcess', '', Scope='Function', Justification="We want -WhatIf support passed down in to the underlying functions")]
+ param(
+ [Parameter(Mandatory = $false)]
+ [string[]]$IncludePackageId = @(),
+ [Parameter(Mandatory = $false)]
+ [switch]$PrintDetailedResults
+ )
+
+ $logLead = Get-LogLeadName
+
+ #$alwaysEnabledPackageIds = Get-AlwaysEnabledProviderPackageIds
+ #[array]$packagesToForceEnabling = $IncludePackageId + $alwaysEnabledPackageIds
+
+ [array]$matchedProviders = Get-ProviderMappingFileUnion -OverrideEnabledProviderPackageIds $IncludePackageId -ReturnMatched
+
+ $unmatchedProviders = $unmatchedProviders.Where({ $_.PackageId -notin $packagesToSkipDisabling })
+
+ if (Test-IsCollectionNullOrEmpty $matchedProviders) {
+
+ Write-Warning "$logLead : Found no provider microservices that need to be enabled based on tenant configuration. Exiting."
+ return
+ }
+
+ Set-MicroserviceConfigurationBasedState -ProviderData $matchedProviders -Enable -PrintDetailedResults:$PrintDetailedResults
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Public/Enable-NecessaryMicroservices.tests.ps1 b/Modules/Alkami.DevOps.Operations/Public/Enable-NecessaryMicroservices.tests.ps1
new file mode 100644
index 0000000..92ecbe3
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Enable-NecessaryMicroservices.tests.ps1
@@ -0,0 +1,53 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ""
+
+Describe "Enable-NecessaryMicroservices" {
+
+ Mock -CommandName Get-LogLeadName -ModuleName $moduleForMock -MockWith { "[$sut (Pester)]" }
+
+ Context "Tests" {
+
+ It "Writes a Warning and Exits Early if a Null is Returned" {
+
+ Mock -CommandName Get-AlwaysEnabledProviderPackageIds -ModuleName $moduleForMock -MockWith { return @() }
+ Mock -CommandName Get-ProviderMappingFileUnion -ModuleName $moduleForMock -MockWith { return $null }
+ Mock -CommandName Write-Warning -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Set-MicroserviceConfigurationBasedState -ModuleName $moduleForMock -MockWith {}
+
+ Enable-NecessaryMicroservices
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Write-Warning -Times 1 -Exactly -Scope It `
+ -ParameterFilter { $Message -Match "Found no provider microservices that need to be enabled" }
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Set-MicroserviceConfigurationBasedState -Times 0 -Exactly -Scope It
+ }
+
+ It "Writes a Warning and Exits Early if an Empty Array is Returned" {
+
+ Mock -CommandName Get-AlwaysEnabledProviderPackageIds -ModuleName $moduleForMock -MockWith { return @() }
+ Mock -CommandName Get-ProviderMappingFileUnion -ModuleName $moduleForMock -MockWith { return @() }
+ Mock -CommandName Write-Warning -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Set-MicroserviceConfigurationBasedState -ModuleName $moduleForMock -MockWith {}
+
+ Enable-NecessaryMicroservices
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Write-Warning -Times 1 -Exactly -Scope It `
+ -ParameterFilter { $Message -Match "Found no provider microservices that need to be enabled" }
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Set-MicroserviceConfigurationBasedState -Times 0 -Exactly -Scope It
+ }
+
+ It "Calls the Set State Function with the Disable Flag" {
+
+ Mock -CommandName Get-AlwaysEnabledProviderPackageIds -ModuleName $moduleForMock -MockWith { return @() }
+ Mock -CommandName Get-ProviderMappingFileUnion -ModuleName $moduleForMock -MockWith { return @( "how", "DARE", "you" ) }
+ Mock -CommandName Set-MicroserviceConfigurationBasedState -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Test-IsCollectionNullOrEmpty -ModuleName $moduleForMock -MockWith { return $false }
+
+ Enable-NecessaryMicroservices
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Set-MicroserviceConfigurationBasedState -Times 1 -Exactly -Scope It `
+ -ParameterFilter { $Enable.IsPresent }
+ }
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Public/Format-IpAddress.ps1 b/Modules/Alkami.DevOps.Operations/Public/Format-IpAddress.ps1
new file mode 100644
index 0000000..9e1881a
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Format-IpAddress.ps1
@@ -0,0 +1,17 @@
+Function Format-IpAddress {
+ <#
+.SYNOPSIS
+ When given a ip address string, returns type System.Net.IPAddress.
+.EXAMPLE
+ Close-TcpSocket -Socket $Socket
+.INPUTS
+ Socket
+.NOTES
+ This does not validate that the input IP is a valid dotted-quad address. see https://docs.microsoft.com/en-us/dotnet/api/system.net.ipaddress.parse?view=netcore-3.1#System_Net_IPAddress_Parse_System_String_
+ Example: Format-IpAddress -Ip "127.1" translates to 127.0.0.1
+#>
+ [CmdletBinding()]
+ [OutputType([System.Net.IPAddress])]
+ param($Ip)
+ return [System.Net.IPAddress]::Parse($Ip)
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Public/Format-SlackMessage.ps1 b/Modules/Alkami.DevOps.Operations/Public/Format-SlackMessage.ps1
new file mode 100644
index 0000000..eeffbd8
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Format-SlackMessage.ps1
@@ -0,0 +1,40 @@
+
+function Format-SlackMessage {
+ <#
+ .SYNOPSIS
+ Format a Slack message.
+ .DESCRIPTION
+ Used to format the slack message body for use by Publish-MessageToSlack
+ .EXAMPLE
+ $messageText = "$environmentName [$Server] - Removed from load balancer"
+ $slackMessage = Format-SlackMessage -MessageText $messageText -IconEmoji ":teamcity:" -UserName "Rolling Patching"
+ Publish-MessageToSlack -MessageBody $slackMessage -slackHookUrl $sbSlackHookUrl -channels $sbSlackChannels
+ .INPUTS
+ [Parameter(Mandatory = $true)]
+ [string] $MessageText
+ [string] $IconEmoji
+ [string] $UserName
+ .OUTPUTS
+ [hashtable] Message Body
+ .NOTES
+ used a lot in Invoke-RollingScriptBlock
+ #>
+ [CmdletBinding()]
+ [OutputType([System.Collections.Hashtable])]
+ Param (
+ [Parameter(Mandatory = $true)]
+ [string] $MessageText,
+ [string] $IconEmoji,
+ [string] $UserName
+ )
+
+ #Build a message for Slack.
+ $messageBody = @{
+
+ "username" = "$UserName";
+ "text" = "$MessageText";
+ "icon_emoji" = "$IconEmoji";
+ }
+
+ return $messageBody
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Public/Format-SlackMessage.tests.ps1 b/Modules/Alkami.DevOps.Operations/Public/Format-SlackMessage.tests.ps1
new file mode 100644
index 0000000..a3d947c
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Format-SlackMessage.tests.ps1
@@ -0,0 +1,39 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ""
+
+Describe "Format-SlackMessage" {
+ Context "Returns expected value" {
+ It "username provided as string" {
+ $expectedValue = "expectedValue"
+ $param = "username"
+ (Format-SlackMessage -MessageText $expectedValue -UserName $expectedValue)[$param] | Should -Be $expectedValue
+ }
+ It "MessageText provided as string" {
+ $expectedValue = "expectedValue"
+ $param = "text"
+ (Format-SlackMessage -MessageText $expectedValue)[$param] | Should -Be $expectedValue
+ }
+ It "icon_emoji provided as string" {
+ $expectedValue = "expectedValue"
+ $param = "icon_emoji"
+ (Format-SlackMessage -MessageText $expectedValue -IconEmoji $expectedValue)[$param] | Should -Be $expectedValue
+ }
+ }
+ Context "ensurses mandatory value is supplied " {
+ It "MessageText is supplied" {
+ $expectedValue = "expectedValue"
+ $param = "text"
+ (Format-SlackMessage -MessageText $expectedValue)[$param] | Should -Be $expectedValue
+ }
+ It "MessageText is not supplied" {
+ $expectedValue = $null
+ $param = "text"
+ {(Format-SlackMessage -MessageText $expectedValue)[$param]} | Should -Throw
+ }
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Public/Get-ASInstanceHealth.ps1 b/Modules/Alkami.DevOps.Operations/Public/Get-ASInstanceHealth.ps1
new file mode 100644
index 0000000..7671e5c
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Get-ASInstanceHealth.ps1
@@ -0,0 +1,117 @@
+function Get-ASInstanceHealth {
+<#
+.SYNOPSIS
+ Gets an EC2 Instance's health state from the ASG. This function can only be run against an AWS server.
+
+.DESCRIPTION
+ Gets an EC2 Instance's health from the ASG. May run against a single remote machine. If no machine name parameter is specified, executes against localhost.
+ This function can only be run against an AWS server.
+
+.PARAMETER ComputerName
+ [string] Optional FQDN of the machine to retrieve ASG status. Defaults to the local machine hostname.
+
+.PARAMETER InstanceId
+ [string] Optional known instance id.
+
+.PARAMETER ProfileName
+ [string] Optional profile name. Preferred that it be provided.
+
+.PARAMETER Region
+ [string] Optional region name. Preferred that it be provided.
+
+.EXAMPLE
+ Get-ASInstanceHealth
+
+ [Get-ASInstanceHealth] : Attempting to Get Computer WEB1636121 Instance Health status.
+ [Get-ASInstanceHealth] : Computer WEB1636121 Instance Health status: HEALTHY
+
+.EXAMPLE
+ Get-ASInstanceHealth -ComputerName 'APP1611693.fh.local' -ProfileName Prod
+
+ [Get-ASInstanceHealth] : Computer APP1611693.fh.local [InstanceId: i-0fa327b1d33fc2aab] Health status: UNHEALTHY.
+ UNHEALTHY
+#>
+
+ [CmdletBinding(DefaultParameterSetName="ComputerName")]
+ [OutputType([string])]
+ param(
+ [Alias("MachineName", "ServerName")]
+ [Parameter(Mandatory = $false, ParameterSetName = "ComputerName")]
+ [string]$ComputerName = $null,
+
+ [Parameter(Mandatory = $true, ParameterSetName = "InstanceId")]
+ [string]$InstanceId = $null,
+
+ [Parameter(Mandatory = $false)]
+ [string]$ProfileName,
+
+ [Parameter(Mandatory = $false)]
+ [string]$Region
+ )
+
+ $logLead = Get-LogLeadName
+
+ $errorReturn = 'Unknown'
+
+ Import-AWSModule # AS
+
+ if ([string]::IsNullOrWhiteSpace($ComputerName) -and [string]::IsNullOrWhiteSpace($InstanceId)) {
+ $ComputerName = (Get-FullyQualifiedServerName)
+ }
+
+ # If no hostname, but we do have an instance id, we can get the hostname from the instance tags
+ if ([string]::IsNullOrWhiteSpace($ComputerName) -and ![string]::IsNullOrWhiteSpace($InstanceId)) {
+ $regions = Get-SupportedAwsRegions
+ foreach ($region in ($regions)) {
+ try {
+ $instance = (Get-EC2Instance -InstanceId $InstanceId -Region $region -ProfileName $ProfileName -ErrorAction SilentlyContinue).Instances
+ if ($null -ne $instance) {
+ $ComputerName = $instance.Tags.Where( { $_.Key -eq 'alk:hostname' }).Value
+ Write-Host "$logLead : Resolved hostname by tag to [$ComputerName]"
+ break
+ }
+ } catch {
+ <#I literally don't care, this is a loop. There's an error check for empty below. #>
+ Write-Verbose "$logLead : Could not find instance in Region [$region] - This just means it could be in any of [$($regions -join ',')]"
+ }
+ }
+ }
+
+ # If no instanceid, we can get the instanceid from the hostname
+ if ([string]::IsNullOrWhiteSpace($InstanceId)) {
+ if ([string]::IsNullOrWhiteSpace($ComputerName)) {
+ throw "$logLead : Neither -ComputerName [$ComputerName] nor -InstanceId [$InstanceId] have useful parameters to determine state. Please provide appropriate parameters."
+ }
+ $InstanceId = @(Get-EC2InstancesByHostname -Servers $ComputerName -ProfileName $ProfileName)[0].InstanceId
+ Write-Host "$logLead : Resolved [$ComputerName] to InstanceId [$InstanceId]"
+ }
+
+ if ([string]::IsNullOrWhiteSpace($ComputerName)) {
+ throw "$logLead : Unable to determine ComputerName for InstanceId [$InstanceId]"
+ }
+
+ if ([string]::IsNullOrWhiteSpace($InstanceId)) {
+ throw "$logLead : Could not retrieve the instance id for computer: [$ComputerName]. Returning `$null"
+ }
+
+ $instanceRegion = $Region
+
+ if ([string]::IsNullOrWhiteSpace($instanceRegion)) {
+ $instanceRegion = (Get-AwsRegionByHostname -ComputerName $ComputerName)
+ }
+
+ Write-Verbose "$logLead : Getting Current AutoScalingInstance."
+ $autoScalingInstance = (Get-ASAutoScalingInstance -InstanceID $InstanceId -Region $instanceRegion -ProfileName $ProfileName)
+
+ if ($null -eq $autoScalingInstance) {
+ Write-Warning "$logLead : Could not retrieve the parent ASG Instance with ID $InstanceId."
+ return $errorReturn
+ }
+
+ $asgName = $autoScalingInstance.AutoScalingGroupName
+ $health = $autoScalingInstance.HealthStatus
+ Write-Verbose "$logLead : ASG $asgName Reports Instance $currentInstanceId is $health."
+
+ Write-Host "$logLead : Computer $ComputerName [InstanceId: $InstanceId] Health status: $health."
+ return $health
+}
diff --git a/Modules/Alkami.DevOps.Operations/Public/Get-ASInstanceHealth.tests.ps1 b/Modules/Alkami.DevOps.Operations/Public/Get-ASInstanceHealth.tests.ps1
new file mode 100644
index 0000000..554cc1f
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Get-ASInstanceHealth.tests.ps1
@@ -0,0 +1,87 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ""
+
+Describe "Get-ASInstanceHealth" {
+ Mock -ModuleName $moduleForMock -CommandName Get-EC2InstancesByHostname -MockWith { return @(@{InstanceId = "dummyid"; Tags = @(@{ Key = "alk:hostname"; Value = "hostname"; }) }) }
+ Mock -ModuleName $moduleForMock -CommandName Get-EC2Instance -MockWith {
+ return @{
+ Instances = @(
+ @{
+ InstanceId = "dummyid";
+ Tags = @(
+ @{ Key = "alk:hostname"; Value = "hostname"; }
+ )
+ }
+ )
+ }
+ }
+ Mock -ModuleName $moduleForMock -CommandName Get-FullyQualifiedServerName -MockWith { return "hostname" }
+ Mock -ModuleName $moduleForMock -CommandName Get-ASAutoScalingInstance -MockWith { return @{ LifecycleState = "garbage"; AutoScalingGroupName = "AutoScalingGroupName"; } }
+ Mock -ModuleName $moduleForMock -CommandName Get-LogLeadName -MockWith { return "[UUT]" }
+ Mock -ModuleName $moduleForMock -CommandName Import-AWSModule -MockWith { <# NOOP #> }
+ Mock -ModuleName $moduleForMock -CommandName Get-AwsRegionByHostname -MockWith { return "al-kami-1" }
+ Mock -ModuleName $moduleForMock -CommandName Write-Verbose -MockWith { <# NOOP #> }
+ Mock -ModuleName $moduleForMock -CommandName Write-Warning -MockWith { <# NOOP #> }
+ Mock -ModuleName $moduleForMock -CommandName Write-Error -MockWith { <# NOOP #> }
+ Mock -ModuleName $moduleForMock -CommandName Write-Host -MockWith { <# NOOP #> }
+
+ Context "Error Handling" {
+
+ $expectedValue = 'Unknown'
+
+ It "Writes a warning and returns Unknown if instance ID could not be retrieved" {
+ Mock -CommandName Get-ASAutoScalingInstance -ModuleName $moduleForMock -MockWith { return $null }
+
+ $result = Get-ASInstanceHealth
+
+ Assert-MockCalled -CommandName Get-EC2InstancesByHostname -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+
+ $result | Should -Be $expectedValue
+ }
+
+ It "Writes a warning and returns Unknown if ASG could not be retrieved" {
+
+ Mock -CommandName Get-ASAutoScalingInstance -ModuleName $moduleForMock -MockWith { return $null }
+
+ $result = Get-ASInstanceHealth
+
+ Assert-MockCalled -CommandName Get-ASAutoScalingInstance -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Write-Warning -Times 1 -Exactly -Scope It `
+ -ModuleName $moduleForMock -ParameterFilter { $Message -match "Could not retrieve the parent ASG Instance with ID" }
+
+ $result | Should -Be $expectedValue
+ }
+ }
+
+ Context "Status Mappings" {
+
+ It "UNHEALTHY status maps to UNHEALTHY" {
+
+ Mock -CommandName Get-ASAutoScalingInstance -ModuleName $moduleForMock -MockWith { return @{ HealthStatus = 'UNHEALTHY'; AutoScalingGroupName = 'Test' } }
+
+ $expectedValue = 'UNHEALTHY'
+ $result = Get-ASInstanceHealth
+
+ Assert-MockCalled -CommandName Get-ASAutoScalingInstance -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+
+ $result | Should -Be $expectedValue
+ }
+
+ It "HEALTHY status maps to HEALTHY" {
+
+ Mock -CommandName Get-ASAutoScalingInstance -ModuleName $moduleForMock -MockWith { return @{ HealthStatus = 'HEALTHY'; AutoScalingGroupName = 'Test' } }
+
+ $expectedValue = 'HEALTHY'
+ $result = Get-ASInstanceHealth
+
+ Assert-MockCalled -CommandName Get-ASAutoScalingInstance -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+
+ $result | Should -Be $expectedValue
+ }
+ }
+}
diff --git a/Modules/Alkami.DevOps.Operations/Public/Get-ASInstanceState.ps1 b/Modules/Alkami.DevOps.Operations/Public/Get-ASInstanceState.ps1
new file mode 100644
index 0000000..c832c3b
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Get-ASInstanceState.ps1
@@ -0,0 +1,120 @@
+function Get-ASInstanceState {
+<#
+.SYNOPSIS
+ Gets an EC2 Instance state from the ASG. This function can only be run against an AWS server.
+
+.DESCRIPTION
+ Gets an EC2 Instance state from the ASG. May run against a single remote machine. If no machine name parameter is specified, executes against localhost.
+ This function can only be run against an AWS server.
+
+.PARAMETER ComputerName
+ [string] Optional FQDN of the machine to retrieve ASG status. Defaults to the local machine hostname.
+
+.PARAMETER InstanceId
+ [string] Optional known instance id.
+
+.PARAMETER ProfileName
+ [string] Optional profile name. Preferred that it be provided.
+
+.PARAMETER Region
+ [string] Optional region name. Preferred that it be provided.
+
+.EXAMPLE
+ Get-ASInstanceState
+
+ [Get-ASInstanceState] : Attempting to Get Computer WEB1636121 ASG status.
+ [Get-ASInstanceState] : Computer WEB1636121 ASG status: Active
+
+.EXAMPLE
+ Get-ASInstanceState -ComputerName 'APP1611693.fh.local' -ProfileName Prod
+
+ [Get-ASInstanceState] : Computer APP1611693.fh.local [InstanceId: i-0fa327b1d33fc2aab] ASG status: Active.
+ Active
+#>
+
+ [CmdletBinding(DefaultParameterSetName="ComputerName")]
+ [OutputType([string])]
+ param(
+ [Alias("MachineName", "ServerName")]
+ [Parameter(Mandatory = $false, ParameterSetName = "ComputerName")]
+ [string]$ComputerName = $null,
+
+ [Parameter(Mandatory = $true, ParameterSetName = "InstanceId")]
+ [string]$InstanceId = $null,
+
+ [Parameter(Mandatory = $false)]
+ [string]$ProfileName,
+
+ [Parameter(Mandatory = $false)]
+ [string]$Region
+
+ )
+
+ $logLead = Get-LogLeadName
+
+ $errorReturn = 'Unknown'
+
+ Import-AWSModule # AS
+
+ if ([string]::IsNullOrWhiteSpace($ComputerName) -and [string]::IsNullOrWhiteSpace($InstanceId)) {
+ $ComputerName = (Get-FullyQualifiedServerName)
+ }
+
+ # If no hostname, but we do have an instance id, we can get the hostname from the instance tags
+ if ([string]::IsNullOrWhiteSpace($ComputerName) -and ![string]::IsNullOrWhiteSpace($InstanceId)) {
+ $regions = Get-SupportedAwsRegions
+ foreach ($region in ($regions)) {
+ try {
+ $instance = (Get-EC2Instance -InstanceId $InstanceId -Region $region -ProfileName $ProfileName -ErrorAction SilentlyContinue).Instances
+ if ($null -ne $instance) {
+ $ComputerName = $instance.Tags.Where( { $_.Key -eq 'alk:hostname' }).Value
+ Write-Host "$logLead : Resolved hostname by tag to [$ComputerName]"
+ break
+ }
+ } catch {
+ <#I literally don't care, this is a loop. There's an error check for empty below. #>
+ Write-Verbose "$logLead : Could not find instance in Region [$region] - This just means it could be in any of [$($regions -join ',')]"
+ }
+ }
+ }
+
+ # If no instanceid, we can get the instanceid from the hostname
+ if ([string]::IsNullOrWhiteSpace($InstanceId)) {
+ if ([string]::IsNullOrWhiteSpace($ComputerName)) {
+ throw "$logLead : Neither -ComputerName [$ComputerName] nor -InstanceId [$InstanceId] have useful parameters to determine state. Please provide appropriate parameters."
+ }
+ $InstanceId = @(Get-EC2InstancesByHostname -Servers $ComputerName -ProfileName $ProfileName)[0].InstanceId
+ Write-Host "$logLead : Resolved [$ComputerName] to InstanceId [$InstanceId]"
+ }
+
+ if ([string]::IsNullOrWhiteSpace($ComputerName)) {
+ throw "$logLead : Unable to determine ComputerName for InstanceId [$InstanceId]"
+ }
+
+ if ([string]::IsNullOrWhiteSpace($InstanceId)) {
+ throw "$logLead : Could not retrieve the instance id for computer: [$ComputerName]. Returning `$null"
+ }
+
+ $instanceRegion = $Region
+
+ if ([string]::IsNullOrWhiteSpace($instanceRegion)) {
+ $instanceRegion = (Get-AwsRegionByHostname -ComputerName $ComputerName)
+ }
+
+ Write-Verbose "$logLead : Getting Current AutoScalingInstance."
+ $autoScalingInstance = (Get-ASAutoScalingInstance -InstanceID $InstanceId -Region $instanceRegion -ProfileName $ProfileName)
+
+ if ($null -eq $autoScalingInstance) {
+ Write-Warning "$logLead : Could not retrieve the parent ASG Instance with ID $InstanceId."
+ return $errorReturn
+ }
+
+ $lifecycleStatus = $autoScalingInstance.LifecycleState
+ $asgName = $autoScalingInstance.AutoScalingGroupName
+ Write-Verbose "$logLead : ASG $asgName Reports Instance $currentInstanceId is $lifecycleStatus."
+
+ $result = ConvertTo-NormalizedLoadBalancerState "AWS" -InputState $lifecycleStatus
+
+ Write-Host "$logLead : Computer $ComputerName [InstanceId: $InstanceId] ASG status: $result."
+ return $result
+}
diff --git a/Modules/Alkami.DevOps.Operations/Public/Get-ASInstanceState.tests.ps1 b/Modules/Alkami.DevOps.Operations/Public/Get-ASInstanceState.tests.ps1
new file mode 100644
index 0000000..be17332
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Get-ASInstanceState.tests.ps1
@@ -0,0 +1,123 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ""
+
+Describe "Get-ASInstanceState" {
+ Mock -ModuleName $moduleForMock -CommandName Get-EC2InstancesByHostname -MockWith { return @(@{InstanceId = "dummyid"; Tags = @(@{ Key = "alk:hostname"; Value = "hostname"; }) }) }
+ Mock -ModuleName $moduleForMock -CommandName Get-EC2Instance -MockWith {
+ return @{
+ Instances = @(
+ @{
+ InstanceId = "dummyid";
+ Tags = @(
+ @{ Key = "alk:hostname"; Value = "hostname"; }
+ )
+ }
+ )
+ }
+ }
+ Mock -ModuleName $moduleForMock -CommandName Get-FullyQualifiedServerName -MockWith { return "hostname" }
+ Mock -ModuleName $moduleForMock -CommandName Get-ASAutoScalingInstance -MockWith { return @{ LifecycleState = "garbage"; AutoScalingGroupName = "AutoScalingGroupName"; } }
+ Mock -ModuleName $moduleForMock -CommandName Get-LogLeadName -MockWith { return "[UUT]" }
+ Mock -ModuleName $moduleForMock -CommandName Import-AWSModule -MockWith { <# NOOP #> }
+ Mock -ModuleName $moduleForMock -CommandName Get-AwsRegionByHostname -MockWith { return "al-kami-1" }
+ Mock -ModuleName $moduleForMock -CommandName Write-Verbose -MockWith { <# NOOP #> }
+ Mock -ModuleName $moduleForMock -CommandName Write-Warning -MockWith { <# NOOP #> }
+ Mock -ModuleName $moduleForMock -CommandName Write-Error -MockWith { <# NOOP #> }
+ Mock -ModuleName $moduleForMock -CommandName Write-Host -MockWith { <# NOOP #> }
+
+ Context "Error Handling" {
+
+ $expectedValue = 'Unknown'
+
+ It "Writes a warning and returns Unknown if instance ID could not be retrieved" {
+ Mock -CommandName Get-ASAutoScalingInstance -ModuleName $moduleForMock -MockWith { return $null }
+
+ $result = Get-ASInstanceState
+
+ Assert-MockCalled -CommandName Get-EC2InstancesByHostname -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+
+ $result | Should -Be $expectedValue
+ }
+
+ It "Writes a warning and returns Unknown if ASG could not be retrieved" {
+
+ Mock -CommandName Get-ASAutoScalingInstance -ModuleName $moduleForMock -MockWith { return $null }
+
+ $result = Get-ASInstanceState
+
+ Assert-MockCalled -CommandName Get-ASAutoScalingInstance -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Write-Warning -Times 1 -Exactly -Scope It `
+ -ModuleName $moduleForMock -ParameterFilter { $Message -match "Could not retrieve the parent ASG Instance with ID" }
+
+ $result | Should -Be $expectedValue
+ }
+ }
+
+ Context "Status Mappings" {
+
+ It "Pending status maps to ActivePending" {
+
+ Mock -CommandName Get-ASAutoScalingInstance -ModuleName $moduleForMock -MockWith { return @{ LifecycleState = 'Pending'; AutoScalingGroupName = 'Test' } }
+
+ $expectedValue = 'ActivePending'
+ $result = Get-ASInstanceState
+
+ Assert-MockCalled -CommandName Get-ASAutoScalingInstance -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+
+ $result | Should -Be $expectedValue
+ }
+
+ It "InService status maps to Active" {
+
+ Mock -CommandName Get-ASAutoScalingInstance -ModuleName $moduleForMock -MockWith { return @{ LifecycleState = 'InService'; AutoScalingGroupName = 'Test' } }
+
+ $expectedValue = 'Active'
+ $result = Get-ASInstanceState
+
+ Assert-MockCalled -CommandName Get-ASAutoScalingInstance -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+
+ $result | Should -Be $expectedValue
+ }
+
+ It "EnteringStandby status maps to StandbyPending" {
+
+ Mock -CommandName Get-ASAutoScalingInstance -ModuleName $moduleForMock -MockWith { return @{ LifecycleState = 'EnteringStandby'; AutoScalingGroupName = 'Test' } }
+
+ $expectedValue = 'StandbyPending'
+ $result = Get-ASInstanceState
+
+ Assert-MockCalled -CommandName Get-ASAutoScalingInstance -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+
+ $result | Should -Be $expectedValue
+ }
+
+ It "Standby status maps to Standby" {
+
+ Mock -CommandName Get-ASAutoScalingInstance -ModuleName $moduleForMock -MockWith { return @{ LifecycleState = 'Standby'; AutoScalingGroupName = 'Test' } }
+
+ $expectedValue = 'Standby'
+ $result = Get-ASInstanceState
+
+ Assert-MockCalled -CommandName Get-ASAutoScalingInstance -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+
+ $result | Should -Be $expectedValue
+ }
+
+ It "Unmapped status maps to Unknown" {
+
+ Mock -CommandName Get-ASAutoScalingInstance -ModuleName $moduleForMock -MockWith { return $null }
+
+ $expectedValue = 'Unknown'
+ $result = Get-ASInstanceState
+
+ Assert-MockCalled -CommandName Get-ASAutoScalingInstance -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+
+ $result | Should -Be $expectedValue
+ }
+ }
+}
diff --git a/Modules/Alkami.DevOps.Operations/Public/Get-AllLoadBalancerStates.ps1 b/Modules/Alkami.DevOps.Operations/Public/Get-AllLoadBalancerStates.ps1
new file mode 100644
index 0000000..35de558
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Get-AllLoadBalancerStates.ps1
@@ -0,0 +1,99 @@
+function Get-AllLoadBalancerStates {
+<#
+.SYNOPSIS
+ Given a list of servers, fetch all the load balancer states and display them in a human readable format
+
+.PARAMETER ServerList
+ [string[]] This is a list of servernames to connect to for resetting
+
+.PARAMETER ProfileName
+ [string] AWS Profile name
+
+.PARAMETER Region
+ [string] AWS Region
+#>
+ [CmdletBinding()]
+ [OutputType([void])]
+ param (
+ [Parameter(Mandatory = $true)]
+ [string[]]$ServerList,
+ [string]$ProfileName,
+ [string]$Region
+ )
+
+ # if there are no servers, skip it!
+ if(Test-IsCollectionNullOrEmpty $ServerList)
+ {
+ Write-Host "No servers to act on. Skipping step."
+ return
+ }
+
+ #Loop over the servers and get Load Balancer to reflect current on status of machine.
+ $sb = {
+ param ($hostname, $arguments)
+
+ $isServerOn = (Test-ComputerIsAvailable -ComputerName $hostname)
+ $state = 'down'
+ if ($isServerOn) {
+ $state = 'up'
+ }
+ $lbState = (Get-LoadBalancerState -server $hostname -AwsProfileName $arguments.ProfileName -AwsRegion $arguments.Region)
+
+ return @{ Server = $hostname; OnOffState = $state; LoadBalancerState = $lbState; }
+ }
+
+ $results = (Invoke-Parallel2 -Objects $ServerList -Arguments @{ProfileName = $ProfileName; Region = $Region;} -Script $sb).Result
+
+ $allUps = @($results.Where({$_.OnOffState -eq 'up' -and $_.LoadBalancerState -eq 'Active'}))
+ $allUpButStandby = @($results.Where({$_.OnOffState -eq 'up' -and $_.LoadBalancerState -ne 'Active'}))
+
+ $allDowns = @($results.Where({$_.OnOffState -eq 'down' -and $_.LoadBalancerState -eq 'Standby'}))
+ $allDownButNotStandby = @($results.Where({$_.OnOffState -eq 'down' -and $_.LoadBalancerState -ne 'Standby'}))
+
+ if ($allUps.Count -gt 0) {
+ Write-Host "=================================================================="
+ Write-Host "These servers all appear fine (online, active)"
+ foreach ($result in $allUps) {
+ $hostname = $result.Server
+ $state = $result.OnOffState
+ $lbState = $result.LoadBalancerState
+ Write-Host "Server: [$hostname] is power-status [$state] and lb-status [$lbState]"
+ }
+ }
+
+ if ($allDowns.Count -gt 0) {
+ Write-Host "=================================================================="
+ Write-Host "These servers are all offline but not active, so probably fine"
+ foreach ($result in $allDowns) {
+ $hostname = $result.Server
+ $state = $result.OnOffState
+ $lbState = $result.LoadBalancerState
+ Write-Host "Server: [$hostname] is power-status [$state] and lb-status [$lbState]"
+ }
+ }
+
+ if ($allUpButStandby.Count -gt 0) {
+ Write-Host "=================================================================="
+ Write-Host "These servers appear problematic (online, NOT active)"
+ foreach ($result in $allUpButStandby) {
+ $hostname = $result.Server
+ $state = $result.OnOffState
+ $lbState = $result.LoadBalancerState
+ Write-Host "Server: [$hostname] is power-status [$state] and lb-status [$lbState]"
+ }
+ Write-Host "`$servers = @(`"$($allUpButStandby.Server -join '","')`")"
+ }
+
+ if ($allDownButNotStandby.Count -gt 0) {
+ Write-Host "=================================================================="
+ Write-Host "These servers appear problematic (NOT online, active)"
+ foreach ($result in $allDownButNotStandby) {
+ $hostname = $result.Server
+ $state = $result.OnOffState
+ $lbState = $result.LoadBalancerState
+ Write-Host "Server: [$hostname] is power-status [$state] and lb-status [$lbState]"
+ }
+ Write-Host "`$servers = @(`"$($allDownButNotStandby.Server -join '","')`")"
+ }
+
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Public/Get-AlwaysEnabledProviderPackageIds.ps1 b/Modules/Alkami.DevOps.Operations/Public/Get-AlwaysEnabledProviderPackageIds.ps1
new file mode 100644
index 0000000..f768f13
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Get-AlwaysEnabledProviderPackageIds.ps1
@@ -0,0 +1,21 @@
+function Get-AlwaysEnabledProviderPackageIds {
+ <#
+ .SYNOPSIS
+ Returns array of Provider PackageIds (names) that should always be Enabled
+
+ #>
+ [CmdletBinding()]
+ [OutputType([string[]])]
+ param (
+ )
+ $loglead = Get-LogLeadName
+
+ [array]$alwaysEnabledProviderPackageIds = @(
+ "Alkami.MicroServices.Features.Beacon.Host",
+ "Alkami.MicroServices.ApplicationSettings.Service.Host"
+ )
+
+ Write-Host "$loglead - Returning Providers to always keep Enabled - $($alwaysEnabledProviderPackageIds -join ",")"
+
+ return $alwaysEnabledProviderPackageIds
+}
diff --git a/Modules/Alkami.DevOps.Operations/Public/Get-AwsRegionByHostname.ps1 b/Modules/Alkami.DevOps.Operations/Public/Get-AwsRegionByHostname.ps1
new file mode 100644
index 0000000..170764c
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Get-AwsRegionByHostname.ps1
@@ -0,0 +1,69 @@
+function Get-AwsRegionByHostname {
+<#
+.SYNOPSIS
+ Returns the AWS region a hostname is in by the subnet it is in. This is a best-guess!
+ This function is not guaranteed to give you the correct result, particularly if this function goes un-maintained when new regions are added or subnets are changed.
+
+.PARAMETER ComputerName
+ The server hostname to guess what AWS region it is in.
+#>
+ [CmdletBinding()]
+ [OutputType([bool])]
+ param(
+ [Parameter(Mandatory=$true)]
+ [string]$ComputerName
+ )
+
+ # TODO: Opportunity for improvement, if we pass in $ProfileName we could use Get-SupportedAwsRegions and iterate them with Get-EC2Instance to find the hostname
+
+ $usEast = "us-east-1"
+ $usWest = "us-west-2"
+
+ # This array needs maintenance when new subnets are added, if this function
+ # is to be expected to work for instances in said subnets
+ $usWestRanges = @(
+ "32", # DR West
+ "35", # Prod West
+ "36", # Dev West
+ "46" # Mgmt West
+ )
+
+ $logLead = Get-LogLeadName
+
+ if (Compare-StringToLocalMachineIdentifiers -stringToCheck $ComputerName) {
+ Write-Verbose "$logLead : Parameter $ComputerName matches local identifiers. Returning current instance region."
+ return (Get-CurrentInstanceRegion)
+ }
+
+ # Get the IP of the ComputerName
+ $dns = (Resolve-DnsName $ComputerName -Type A)
+ if($null -eq $dns) {
+
+ Write-Warning "$logLead : Could not resolve DNS for host $ComputerName"
+ return $null
+ }
+
+ if ($dns.Count -gt 1) {
+
+ Write-Warning "$logLead : More than one IPv4 address resolved. Results may be incorrect -- we will take the first IP matching ^10."
+ }
+
+ $ipAddress = ($dns.IPAddress | Where-Object { $_ -match "^10" } | Select-Object -First 1)
+
+ if ($null -eq $ipAddress) {
+
+ Write-Warning "$logLead : No IPv4 addresses found matching ^10."
+ return $null
+ }
+
+ # Match the first octet of the IP address to a region subnet.
+ $region = $null
+ $secondOctet = $ipAddress.Split(".")[1]
+ if ($usWestRanges -contains $secondOctet) {
+ $region = $usWest
+ } else {
+ $region = $usEast
+ }
+
+ return $region
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Public/Get-AwsRegionByHostname.tests.ps1 b/Modules/Alkami.DevOps.Operations/Public/Get-AwsRegionByHostname.tests.ps1
new file mode 100644
index 0000000..37bc343
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Get-AwsRegionByHostname.tests.ps1
@@ -0,0 +1,169 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ""
+
+Describe "Get-AwsRegionByHostname" {
+
+ Context "Returns Expected Values" {
+
+ Mock Resolve-DnsName {
+ param($Name)
+
+ $ips = @{
+ # US-East-1
+ MIC1 = "10.16.0.6"
+ MIC2 = "10.27.0.7"
+
+ # US-West-2
+ MIC3 = "10.32.0.6"
+ MIC4 = "10.35.0.7"
+ MIC5 = "10.36.0.8"
+ MIC6 = "10.46.0.9"
+
+ # Wildcard US-East-1
+ MIC7 = "10.42.0.42"
+ }
+ $ip = $ips[$Name]
+ return @{ IPAddress = $ip }
+ } -ModuleName $moduleForMock
+
+ It "Returns US-East-1 on 16 subnets." {
+ Get-AwsRegionByHostname -COMPUTERNAME "MIC1" | Should -BeExactly "us-east-1"
+ }
+
+ It "Returns us-west-2 on 27 subnets." {
+ Get-AwsRegionByHostname -COMPUTERNAME "MIC2" | Should -BeExactly "us-east-1"
+ }
+
+ It "Returns us-west-2 on 32 subnets." {
+ Get-AwsRegionByHostname -COMPUTERNAME "MIC3" | Should -BeExactly "us-west-2"
+ }
+
+ It "Returns us-west-2 on 35 subnets." {
+ Get-AwsRegionByHostname -COMPUTERNAME "MIC4" | Should -BeExactly "us-west-2"
+ }
+
+ It "Returns us-west-2 on 36 subnets." {
+ Get-AwsRegionByHostname -COMPUTERNAME "MIC5" | Should -BeExactly "us-west-2"
+ }
+
+ It "Returns us-west-2 on 46 subnets." {
+ Get-AwsRegionByHostname -COMPUTERNAME "MIC6" | Should -BeExactly "us-west-2"
+ }
+
+ It "Defaults to us-east-1 for every other IP" {
+ Get-AwsRegionByHostname -COMPUTERNAME "MIC7" | Should -BeExactly "us-east-1"
+ }
+ }
+
+ Context "Edge Cases" {
+
+ It "Only uses IPv4 Addresses that Start with 10" {
+
+ Mock Resolve-DnsName {
+
+ $record1 = New-Object PSObject -Property @{
+ Name = "Foo.Bar.Com";
+ IPAddress = "8.8.8.8";
+ }
+
+ $record2 = New-Object PSObject -Property @{
+ Name = "Foo.Bar.Com";
+ IPAddress = "10.32.0.1";
+ }
+
+ return @($record1, $record2)
+
+ } -ModuleName $moduleForMock
+
+ Mock -CommandName Write-Warning -ModuleName $moduleForMock -MockWith { }
+
+ Get-AwsRegionByHostname "Foo.Bar.Com" | Should -BeExactly "us-west-2"
+ }
+
+ It "Writes a Warning if More than One IPv4 Address Found" {
+
+ Mock Resolve-DnsName {
+
+ $record1 = New-Object PSObject -Property @{
+ Name = "Foo.Bar.Com";
+ IPAddress = "10.16.0.6";
+ }
+
+ $record2 = New-Object PSObject -Property @{
+ Name = "Foo.Bar.Com";
+ IPAddress = "8.8.8.8";
+ }
+
+ return @($record1, $record2)
+
+ } -ModuleName $moduleForMock
+
+ Mock -CommandName Write-Warning -ModuleName $moduleForMock -MockWith { }
+
+ Get-AwsRegionByHostname "Foo.Bar.Com"
+ Assert-MockCalled -CommandName Write-Warning -ModuleName $moduleForMock -Scope It -Times 1 -Exactly `
+ -ParameterFilter { $Message -match "More than one IPv4 address resolved" }
+ }
+
+ It "Writes a Warning and Returns Null if No IPv4 Addresses Match ^10." {
+
+ Mock Resolve-DnsName {
+
+ $record1 = New-Object PSObject -Property @{
+ Name = "Foo.Bar.Com";
+ IPAddress = "8.8.4.4";
+ }
+
+ $record2 = New-Object PSObject -Property @{
+ Name = "Foo.Bar.Com";
+ IPAddress = "8.8.8.8";
+ }
+
+ return @($record1, $record2)
+
+ } -ModuleName $moduleForMock
+
+ Mock -CommandName Write-Warning -ModuleName $moduleForMock -MockWith { }
+
+ Get-AwsRegionByHostname "Foo.Bar.Com" | Should -BeNullOrEmpty
+ Assert-MockCalled -CommandName Write-Warning -ModuleName $moduleForMock -Scope It -Times 1 -Exactly `
+ -ParameterFilter { $Message -match "No IPv4 addresses found" }
+ }
+
+ It "Writes a Warning and Returns Null if the Hostname Cannot be Resolved" {
+
+ Mock Resolve-DnsName {
+
+ return $null
+
+ } -ModuleName $moduleForMock
+
+ Mock -CommandName Write-Warning -ModuleName $moduleForMock -MockWith { }
+
+ Get-AwsRegionByHostname "Foo.Bar.Com" | Should -BeNullOrEmpty
+ Assert-MockCalled -CommandName Write-Warning -ModuleName $moduleForMock -Scope It -Times 1 -Exactly `
+ -ParameterFilter { $Message -match "Could not resolve DNS for host" }
+ }
+ }
+
+ Context "Local Execution" {
+
+ Mock Get-CurrentInstanceRegion {
+ return "eu-northwestish-99"
+ } -ModuleName $moduleForMock
+
+ Mock Resolve-DnsName {} -ModuleName $moduleForMock
+
+ It "Calls Get-CurrentInstanceRegion When Hostname Matches Local Identifiers" {
+
+ Get-AwsRegionByHostname -COMPUTERNAME $ENV:COMPUTERNAME | Should -BeExactly "eu-northwestish-99"
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Get-CurrentInstanceRegion -Times 1 -Exactly -Scope It
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Resolve-DnsName -Times 0 -Exactly -Scope It
+ }
+ }
+}
diff --git a/Modules/Alkami.DevOps.Operations/Public/Get-BadLogConfiguration.ps1 b/Modules/Alkami.DevOps.Operations/Public/Get-BadLogConfiguration.ps1
new file mode 100644
index 0000000..2aeb658
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Get-BadLogConfiguration.ps1
@@ -0,0 +1,158 @@
+function Get-BadLogConfiguration {
+
+ <#
+.SYNOPSIS
+ Scans Chocolatey and ORB log4net Configuration for Known Issues
+
+.DESCRIPTION
+ Scans Chocolatey and ORB log4net Configuration for Known Issues, including ROOT at TRACE/DEBUG/INFO/WARN,
+ or namespaces at too verbose a level. Most parameters are configurable.
+
+.PARAMETER IncludeTracedMessageDetail
+ Namespaces defined in parameter CollapsedNameSpaces are summarized by default, due to the large number. Pass this switch to return detail on those namespaces.
+
+.PARAMETER AllowedRootLogLevels
+ An array of allowable log levels for the ROOT namespace
+
+.PARAMETER UnwantedLogLevels
+ Log levels for non-ROOT namespaces which are considered too verbose.
+
+.PARAMETER CollapsedNameSpaces
+ Namespaces to collapse and which will only return a total count of log files with undesired verbosity.
+
+.Example
+ Get-BadLogConfiguration
+
+.Example
+ Get-BadLogConfiguration -IncludeTracedMessageDetail
+
+.Example
+ Get-BadLogConfiguration -AllowedRootLogLevels @("FATAL","ERROR","WARN")
+
+.Example
+ Get-BadLogConfiguration -UnwantedLogLevels @("ALL","TRACE","DEBUG")
+
+.Example
+ Get-BadLogConfiguration -CollapsedNameSpaces @("Alkami.MicroServices.Blabbermouth","Alkami.DeepState")
+#>
+
+
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory = $false)]
+ [Switch]$IncludeTracedMessageDetail,
+
+ [Parameter(Mandatory = $false)]
+ [string[]]$AllowedRootLogLevels = @("ERROR", "FATAL"),
+
+ [Parameter(Mandatory = $false)]
+ [string[]]$UnwantedLogLevels = @("ALL", "TRACE"),
+
+ [Parameter(Mandatory = $false)]
+ [string[]]$CollapsedNameSpaces = @("Alkami.MicroServices.TracedMessages", "Alkami.ExternalServices.TracedMessages", "Alkami.TracedSql")
+ )
+
+ $logLead = Get-LogLeadName
+ [array]$log4nets = @()
+
+ $chocoPath = Join-Path -Path (Get-ChocolateyInstallPath) -ChildPath "lib"
+ if (Test-Path $chocoPath) {
+
+ $log4nets += Get-ChildItem -Path $chocoPath -Recurse -File -Filter log4net.config -Depth 2
+ }
+
+ $orbPath = Get-OrbPath
+ if (Test-Path ($orbPath)) {
+
+ $log4nets += Get-ChildItem -Path $orbPath -Recurse -File -Filter log4net.config -Depth 2
+ }
+
+ if (Test-IsCollectionNullOrEmpty $log4nets) {
+
+ Write-Warning "$logLead : Unable to find any log4net config files. Is this running on a non-ORB server?"
+ return
+ }
+
+ # These may number in the hundreds, and may be benign, so lets just get the counts unless the user asks for them.
+ # We'll make an object and attach a property to each
+ $collapsedNameSpaceObjects = @()
+ foreach ($namespaceToCollapse in $CollapsedNameSpaces) {
+
+ $collapsedNameSpaceObjects += New-Object PSObject -Property @{
+ NameSpace = $namespaceToCollapse;
+ Count = 0;
+ }
+ }
+
+ foreach ($config in $log4nets) {
+
+ if ($config.FullName -like "$chocoPath*") {
+
+ $applicationName = (Get-Item -Path $config.FullName).Directory.Parent.Name
+
+ if (@("tools", "app", "lib") -contains $applicationName) {
+
+ $applicationName = (Get-Item -Path $config.FullName).Directory.Parent.Parent.Name
+ }
+ } elseif ($config.FullName -like "$orbPath*") {
+
+ $applicationName = (Get-Item -Path $config.FullName).Directory.Name
+
+ } else {
+
+ Write-Warning "$logLead : Unknown application for log configuration at $($config.FullName)"
+ }
+
+ [xml]$configXML = Read-XmlFile -xmlPath $config.FullName
+ $log4netConfig = $configXML.configuration.log4net
+
+ foreach ($logger in $log4netConfig.logger) {
+
+ $logLevel = $logger.Level
+ $namespace = $logger.Name
+
+ $logValue = $null
+ if ($null -ne $logLevel -and $null -ne $logLevel.value) {
+
+ $logValue = $logLevel.value
+ }
+
+ if ($UnwantedLogLevels -contains $logValue) {
+
+ if ([string[]]($collapsedNameSpaceObjects.NameSpace) -contains $namespace) {
+
+ ($collapsedNameSpaceObjects | Where-Object { $_.NameSpace -eq $namespace }).Count++
+ if ($IncludeTracedMessageDetail.IsPresent) {
+
+ Write-Host "[$applicationName]: $namespace - $logValue"
+ }
+
+ } else {
+
+ Write-Host "[$applicationName]: $namespace - $logValue"
+ }
+ } elseif (("ROOT" -eq $namespace) -and ($AllowedRootLogLevels -notcontains $logLevel)) {
+
+ # Handles Inline Root Namespaces
+ $badRootLogLevelCount++
+ Write-Host "[$applicationName]: $namespace - $logValue"
+ }
+ }
+
+ if ($null -ne $configXML.configuration.log4net.root) {
+
+ # Handles Root as a Discrete Child Node of log4net
+ $rootNode = $configXML.configuration.log4net.root
+ if (($null -ne $rootNode.level) -and ($AllowedRootLogLevels -notcontains $rootNode.level.value)) {
+
+ $badRootLogLevelCount++
+ Write-Host "[$applicationName]: ROOT - $($rootNode.level.value)"
+ }
+ }
+ }
+
+ foreach ($summaryNameSpace in $collapsedNameSpaceObjects) {
+
+ Write-Warning "$logLead : $($summaryNameSpace.Count) Log Configs have $($summaryNameSpace.NameSpace) at an unsupported log level"
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Public/Get-BounceJobBuildId.ps1 b/Modules/Alkami.DevOps.Operations/Public/Get-BounceJobBuildId.ps1
new file mode 100644
index 0000000..17e28f7
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Get-BounceJobBuildId.ps1
@@ -0,0 +1,48 @@
+function Get-BounceJobBuildId {
+ <#
+ .SYNOPSIS
+ Returns the TeamCity BuildId of a "Bounce Services" job, based on naming convention.
+ The convention is "DeployV3___BounceServices"
+ Returns `$null if it cannot determine which template to use for generating the build id.
+
+ .PARAMETER EnvironmentType
+ The environment type to target - "Dev", "Qa", "Staging", "Production", etc
+ CASE SENSITIVE - Must match the BuildId in TeamCity.
+
+ .PARAMETER EnvironmentName
+ The environment name to target - "Red7", "Morph", "Ci1"
+ #>
+ [CmdletBinding()]
+ param (
+ [Parameter(Mandatory=$true)]
+ [ValidateSet("Dev","Sandbox","Qa","Staging","Production","DisasterRecovery","Pilot","LoadTest")]
+ [string]$EnvironmentType,
+ [Parameter(Mandatory=$true)]
+ [string]$EnvironmentName
+ )
+ $loglead = (Get-LogLeadName)
+ $environmentTypesWithSharedBounceJob = @("Staging","Production","DisasterRecovery","Pilot","LoadTest")
+ $environmentTypesWithSeparateBounceJob = @("Dev","Sandbox","Qa")
+
+ $bounceJobBuildId = $null
+ $bounceJobBuildIdTemplate = "REPLACEME"
+ $bounceJobBuildIdPrefix = "DeployV3"
+ $bounceJobBuildIdSuffix = "BounceServices"
+ $buildEnvironmentType = $EnvironmentType
+ $buildEnvironmentName = $EnvironmentName
+
+ if ($EnvironmentType -in $environmentTypesWithSeparateBounceJob) {
+ $bounceJobBuildIdTemplate = "{0}_{1}_{2}_{3}"
+ Write-Host "Using designation-specific Bounce Job buildId Template for EnvironmentType : $EnvironmentType"
+ $bounceJobBuildId = $bounceJobBuildIdTemplate -f $bounceJobBuildIdPrefix, $buildEnvironmentType, $buildEnvironmentName, $bounceJobBuildIdSuffix
+ } elseif ($EnvironmentType -in $environmentTypesWithSharedBounceJob) {
+ $bounceJobBuildIdTemplate = "{0}_{1}_{2}"
+ Write-Host "Using SHARED Bounce Job buildId Template for EnvironmentType : $EnvironmentType"
+ $bounceJobBuildId = $bounceJobBuildIdTemplate -f $bounceJobBuildIdPrefix, $buildEnvironmentType, $bounceJobBuildIdSuffix
+
+ } else {
+ Write-Warning -Message "$logLead : Could not find buildId template for EnvironmentType : $EnvironmentType"
+ }
+
+ return $bounceJobBuildId
+}
diff --git a/Modules/Alkami.DevOps.Operations/Public/Get-CloudFlareAuthenticationHeaders.ps1 b/Modules/Alkami.DevOps.Operations/Public/Get-CloudFlareAuthenticationHeaders.ps1
new file mode 100644
index 0000000..0a9d480
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Get-CloudFlareAuthenticationHeaders.ps1
@@ -0,0 +1,34 @@
+function Get-CloudFlareAuthenticationHeaders {
+
+<#
+.SYNOPSIS
+ Returns a Dictionary with the Headers Needed for CloudFlare API calls
+
+.DESCRIPTION
+ Returns a Dictionary containing the headers necessary to invoke a REST method in the CloudFlare API
+ User email is saved as X-Auth-Email and the user API key is saved as X-Auth-Key
+
+.PARAMETER cloudFlareEmail
+ [string] The email address of the user authorized to make the API call
+
+.PARAMETER cloudFlareAuthKey
+ [string] The API key of the user authorized to make the API call
+#>
+
+ [CmdletBinding()]
+ param (
+ [Parameter(Mandatory=$true)]
+ [Alias("X-Auth-Email")]
+ [string]$cloudFlareEmail,
+
+ [Parameter(Mandatory=$true)]
+ [Alias("X-Auth-Key")]
+ [string]$cloudFlareAuthKey
+ )
+
+ $headers = New-Object "System.Collections.Generic.Dictionary[[String],[String]]"
+ $headers.Add("X-Auth-Email", $cloudflareEmail)
+ $headers.Add("X-Auth-Key", $cloudflareAuthKey)
+
+ return $headers
+}
diff --git a/Modules/Alkami.DevOps.Operations/Public/Get-CloudFlareZoneId.ps1 b/Modules/Alkami.DevOps.Operations/Public/Get-CloudFlareZoneId.ps1
new file mode 100644
index 0000000..d41f458
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Get-CloudFlareZoneId.ps1
@@ -0,0 +1,78 @@
+function Get-CloudFlareZoneId {
+<#
+
+.SYNOPSIS
+ Retrieves the Zone ID for a Given Zone Name
+
+.DESCRIPTION
+ Makes an API call to CloudFlare to retrieve the zone ID, which is used in subsequent API calls
+
+.PARAMETER AuthenticationDictionary
+ Authentication headers required to execute requests. This object is the result from
+ calls to Get-CloudFlareAuthenticationHeaders
+
+.PARAMETER ZoneName
+ The name of the zone to search for
+
+.PARAMETER BaseUri
+ The base CloudFlare API URL. Defaults to https://api.cloudflare.com/client/v4
+
+.LINK
+
+https://api.cloudflare.com/#zone-list-zones
+#>
+
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory = $true)]
+ [Alias("AuthenticationHeaders")]
+ [System.Collections.Generic.Dictionary[[String], [String]]]$AuthenticationDictionary,
+
+ [Parameter(Mandatory = $true)]
+ [Alias("HostZone")]
+ [string]$ZoneName,
+
+ [Parameter(Mandatory = $false)]
+ [Alias("CloudFlareBaseUri")]
+ [string]$BaseUri = "https://api.cloudflare.com/client/v4",
+
+ [Parameter(Mandatory = $false)]
+ [Alias("ReturnActiveOnly")]
+ [bool]$ActiveOnly = $false
+ )
+
+ $logLead = Get-LogLeadName
+
+ if ($ActiveOnly) {
+ $zoneLookupURL = "$BaseUri/zones?name=$ZoneName&status=active"
+ } else {
+ $zoneLookupURL = "$BaseUri/zones?name=$ZoneName"
+ }
+
+ Write-Host "$logLead : Getting Zone Id from $zoneLookupURL"
+ $getZoneIdResult = Invoke-RestMethod -Method Get -ContentType "application/json" -Header $AuthenticationDictionary `
+ -Uri "$zoneLookupURL" -ErrorAction SilentlyContinue
+
+ if (!($getZoneIdResult.Success)) {
+
+ $errorsToPrint = Test-IsNull $getZoneIdResult.Errors "Unknown Error" -Strict
+ Write-Warning "$logLead : An Error Occurred Making the API Call: $errorsToPrint"
+ return $null
+ }
+
+ $resultDetails = $getZoneIdResult.result_info
+ $totalCount = $resultDetails.total_count
+
+ if ($totalCount -eq 0) {
+ Write-Warning "$logLead : Found 0 Results for Zone $ZoneName"
+ return $null
+ } elseif ($totalCount -gt 1) {
+ Write-Warning "$logLead : Found $totalCount Results for Zone $ZoneName. Something is really wrong here."
+ return $null
+ }
+
+ $zoneId = $getZoneIdResult.Result.Id
+ Write-Host "$logLead : Zone ID for Host $ZoneName is $zoneId"
+
+ return $zoneId
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Public/Get-DataForSmokeTest.Tests.ps1 b/Modules/Alkami.DevOps.Operations/Public/Get-DataForSmokeTest.Tests.ps1
new file mode 100644
index 0000000..cf8e3fb
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Get-DataForSmokeTest.Tests.ps1
@@ -0,0 +1,45 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ""
+
+Describe "Get-DataForSmokeTest" {
+
+ Context "Master Connection String Parameter is Not Supplied" {
+
+ It "Runs to Completion if the Server is an App Server" {
+
+ Mock -ModuleName $moduleForMock Test-IsAppServer { return $true }
+ Mock -ModuleName $moduleForMock Invoke-QueryOnClientDatabase { }
+ Mock -ModuleName $moduleForMock Get-CatalogsFromMaster { }
+
+ Get-DataForSmokeTest
+ Assert-MockCalled -ModuleName $moduleForMock Get-CatalogsFromMaster -Times 1 -Exactly
+ }
+
+ It "Writes a Warning if the Server is Not an App Server" {
+
+ Mock -ModuleName $moduleForMock Test-IsAppServer { return $false }
+ Mock -ModuleName $moduleForMock Get-CatalogsFromMaster { }
+ Mock -ModuleName $moduleForMock Invoke-QueryOnClientDatabase { }
+
+ { (Get-DataForSmokeTest 3>&1) -match "This function can only be run on an app server or a connection string must be supplied" } | Should Be $true
+ }
+ }
+
+ Context "Master Connection String Parameter is Supplied" {
+
+ It "Uses the Supplied Master Connection String" {
+
+ Mock -ModuleName $moduleForMock Test-IsAppServer { return $false }
+ Mock -ModuleName $moduleForMock Invoke-QueryOnClientDatabase { }
+ Mock -ModuleName $moduleForMock Get-CatalogsFromMaster {} -ParameterFilter { $connectionString -and $connectionString -eq "foo" }
+
+ Get-DataForSmokeTest "foo"
+ Assert-MockCalled -ModuleName $moduleForMock Get-CatalogsFromMaster -Times 1 -Exactly
+ }
+ }
+}
diff --git a/Modules/Alkami.DevOps.Operations/Public/Get-DataForSmokeTest.ps1 b/Modules/Alkami.DevOps.Operations/Public/Get-DataForSmokeTest.ps1
new file mode 100644
index 0000000..de55a45
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Get-DataForSmokeTest.ps1
@@ -0,0 +1,53 @@
+function Get-DataForSmokeTest {
+<#
+.SYNOPSIS
+ Gets the Data Necessary to Execute a Smoke Test
+.NOTES
+ Must be run on an App Server or Have a Master Connection String Supplied
+#>
+
+ [CmdletBinding()]
+ Param(
+ [Parameter(Mandatory = $false)]
+ [Alias("MasterConnectionString")]
+ [string]$connectionString
+ )
+
+ $logLead = (Get-LogLeadName);
+
+ if (!(Test-IsAppServer) -and [String]::IsNullOrEmpty($connectionString)) {
+ Write-Warning ("$logLead : This function can only be run on an app server or a connection string must be supplied.")
+ return;
+ }
+
+ $activeUserQuery = @"
+ DECLARE @bankEntityID bigint = (SELECT TOP 1 ID from core.Entity WHERE MasterUserID IS NULL AND TaxIdentificationNumber IS NULL)
+
+ select top 1 u.UserIdentifier
+ FROM core.Users u
+ JOIN core.STSProviderUser spu on spu.UserID = u.ID
+ JOIN core.UserAccount ua on ua.UserID = u.ID
+ JOIN core.Account a on a.ID = ua.AccountID
+ JOIN core.UserWidget uw on uw.UserID = u.ID
+ WHERE (u.EntityID IS NULL OR u.EntityID <> @bankEntityID)
+ AND u.IsActive = 1 and ISNULL(u.IsLockedOut, 0) = 0 AND u.HasLoggedInBefore = 1
+ AND u.LastLoginDate > DATEADD(month, -1, GETUTCDATE())
+ AND ua.HideFromEndUser = 0 and ua.HiddenByEndUser = 0 AND ua.HasMasterRights = 1 AND ua.Deleted = 0
+ AND a.Status = 0
+ AND uw.DeactivationDate IS NULL
+ GROUP BY spu.STSID, u.UserIdentifier
+"@
+
+ $masterCatalogs = Get-CatalogsFromMaster $connectionString
+
+ [HashTable[]]$testValues = @()
+ foreach ($tenant in $masterCatalogs) {
+ Write-Host ("$logLead : Pullling Smoke Test Data for Tenant {0}" -f $tenant.Name)
+ $userIdentifier = Invoke-QueryOnClientDatabase $tenant $activeUserQuery
+
+ $testValues += @{ Name = $tenant.Name; AdminSignature = $tenant.AdminSignature; ClientSignature = $tenant.Signature; UserIdentifier = $userIdentifier; }
+ }
+
+ return $testValues
+}
+
diff --git a/Modules/Alkami.DevOps.Operations/Public/Get-EC2InstancesByHostname.ps1 b/Modules/Alkami.DevOps.Operations/Public/Get-EC2InstancesByHostname.ps1
new file mode 100644
index 0000000..0117661
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Get-EC2InstancesByHostname.ps1
@@ -0,0 +1,75 @@
+function Get-EC2InstancesByHostname {
+ <#
+ .SYNOPSIS
+ Returns EC2 instance objects by hostname.
+
+ .PARAMETER Servers
+ The server list of hostnames to search for.
+
+ .PARAMETER ProfileName
+ The AWS profile name to use when querying for EC2 instances.
+ #>
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory=$true)]
+ $Servers,
+ [Parameter(Mandatory=$true)]
+ $ProfileName
+ )
+
+ # Get a list of the currently supported AWS regions.
+ $supportedAwsRegions = (Get-SupportedAwsRegions)
+
+ $results = @()
+ foreach($server in $Servers) {
+ $firstPeriodIndex = $server.IndexOf('.')
+ if ($firstPeriodIndex -eq -1) {
+ $firstPeriodIndex = $server.Length
+ }
+ # Strip the domain off of the hostname. That's how the hostname tag is stored.
+ $serverShort = $server.Substring(0, $firstPeriodIndex)
+ $serverShort = $serverShort.ToLower()
+
+ # Figure out what region the server is in by subnet.
+ # Note: This is not a 100% guarantee to be correct, but should be correct most of the time.
+ $regionGuess = Get-AwsRegionByHostname -COMPUTERNAME $server
+ [array]$otherRegions = ($supportedAwsRegions | Where-Object { $_ -notlike $regionGuess } )
+ [array]$regionsToSearch = @($regionGuess) + $otherRegions
+
+ # Loop through each of the regions to search, and look for the instances we want.
+ # The Get-AwsRegionByHostname guess should be right most of the time, otherwise it will fall back to looping through other expected regions.
+ $instance = $null
+ foreach($region in $regionsToSearch) {
+
+ # Query for the instances.
+ $filter = @{
+ "alk:hostname" = $serverShort
+ }
+ [array]$instances = Get-InstancesByTag -Tags $filter -ProfileName $ProfileName -Region $region -IncludeOffline
+
+ # Figure out if we found the instance.
+ $foundInstance = !(Test-IsCollectionNullOrEmpty $instances)
+
+ # If we found an instance querying the EC2 API.
+ if($foundInstance) {
+
+ # Sanity check. Make sure that we didn't find multiple servers with the same hostname.
+ if($instances.Count -gt 1) {
+ throw "Found multiple servers for hostname $server. Question your searching assumptions!"
+ }
+
+ # Record the instance in the results and break the region loop.
+ $instance = $instances[0]
+ $results += $instance
+ break
+ }
+ }
+
+ # Write an error if we could not find the instance in any of the expected regions.
+ if($null -eq $instance) {
+ Write-Error "Could not find EC2 instance for hostname $server"
+ continue
+ }
+ }
+ return $results
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Public/Get-EntrustUserCredentialsFromSecretServer.ps1 b/Modules/Alkami.DevOps.Operations/Public/Get-EntrustUserCredentialsFromSecretServer.ps1
new file mode 100644
index 0000000..e8be1d6
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Get-EntrustUserCredentialsFromSecretServer.ps1
@@ -0,0 +1,43 @@
+function Get-EntrustUserCredentialsFromSecretServer () {
+<#
+.SYNOPSIS
+ Gets the username and password of an Entrust user secret from Secret Server.
+
+.PARAMETER secretCredential
+ Credentials of the user to authenticate with on Secret Server.
+
+.PARAMETER environmentID
+ The environment ID of the Entrust user to retrieve (e.g. "12-2", "14")
+
+.PARAMETER entrustUserName
+ Entrust username on Secret Server you are searching for.
+
+.OUTPUTS
+ Either a credential object containing the username and password of the Entrust user or null.
+
+.EXAMPLE
+ Get-EntrustUserCredentialsFromSecretServer -secretCredential $credential -environmentID "17-0" -entrustUserName "Master1"
+
+Password Username
+-------- --------
+SecretStuffs Master1
+#>
+ [CmdletBinding()]
+ Param(
+ [Parameter(Mandatory=$true)]
+ [PSCredential]$secretCredential,
+ [Parameter(Mandatory=$true)]
+ [String]$environmentID,
+ [Parameter(Mandatory=$true)]
+ [ValidateSet("Master1", "Master2", "Master3", "Web", "DB")]
+ [String]$entrustUserName
+ )
+
+ $loglead = (Get-LogLeadName)
+
+ $folderName = "Entrust $($environmentID)"
+ $secretName = "$($folderName) $($entrustUserName) User"
+
+ Write-Verbose "$loglead : Searching for secret '$secretName' in folder '$folderName'"
+ return ( Get-UserCredentialsFromSecretServer $secretCredential $folderName $secretName )
+}
diff --git a/Modules/Alkami.DevOps.Operations/Public/Get-EnvironmentShortName.ps1 b/Modules/Alkami.DevOps.Operations/Public/Get-EnvironmentShortName.ps1
new file mode 100644
index 0000000..229bb48
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Get-EnvironmentShortName.ps1
@@ -0,0 +1,22 @@
+<#
+.SYNOPSIS
+ Returns the shortened environment label given a full environment name.
+
+.PARAMETER EnvironmentName
+ Full environment name as generated by the Server List Parameter Builder Jobs:
+ https://ci.corp.alkamitech.com/project/Sre_ParameterBuilders_ServerLists
+#>
+
+function Get-EnvironmentShortName {
+ [CmdletBinding()]
+ Param(
+ [string]$EnvironmentName
+ )
+
+ [Regex]$shortLabelRegex = "^(\b(Firehost|LT)\b)|(\b(Production|Staging|Dev|QA)\b)"
+
+ $shortLabel = $shortLabelRegex.Replace($EnvironmentName, "").Trim().Replace(" ", " ")
+ Write-Host ("Short Environment Label: $shortLabel")
+
+ return $shortLabel
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Public/Get-LatestOrbAmi.ps1 b/Modules/Alkami.DevOps.Operations/Public/Get-LatestOrbAmi.ps1
new file mode 100644
index 0000000..1546140
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Get-LatestOrbAmi.ps1
@@ -0,0 +1,85 @@
+function Get-LatestOrbAmi() {
+<#
+.SYNOPSIS
+ Function to get the latest ORB Alkami AMI from AWS.
+
+.DESCRIPTION
+ Uses Name filters to get the AMI list and finds the latest one based on CreationDate property of the AMI.
+ Returns one Amazon.EC2.Model.Image object
+
+.PARAMETER profileName
+ [string] AWS Profile. ("Sandbox", "prod")
+
+.PARAMETER awsRegion
+ [string] AWS Region. ("us-east-1")
+
+.PARAMETER amiNameFilter
+ Filter string used to search for AMIs. Use this to override which AMI to search on. Accepts wildcard *. Not compatible with
+ $windowsServerVersion param unless the string has "__VERSION__" in it to accept the selection of $windowsServerVersion.
+ Current name filter is in the format "windows-__VERSION__-orb-base*" where "__VERSION__" is replaced by $windowsServerVersion
+ parameter, which is defaulted to "2016".
+
+.PARAMETER windowsServerVersion
+ String of the windows version to target. Currently, ValidationSet has "2016", "2019", and "2022" as options.
+ This will be used in the amiNameFilter param to get the AMI with the selected Windows version.
+
+.EXAMPLE
+ Get-LatestOrbAMI -ProfileName temp-dev -Region us-west-2
+ Get-LatestOrbAMI -ProfileName temp-dev -Region us-west-2 -AmiNameFilter "windows-2016-orb-base*"
+ Get-LatestOrbAMI -ProfileName temp-dev -Region us-west-2 -AmiNameFilter "windows-__VERSION__-orb-base*" -WindowsServerVersion "2019"
+ Get-LatestOrbAMI -ProfileName temp-dev -Region us-west-2 -WindowsServerVersion "2016"
+ Get-LatestOrbAMI -ProfileName temp-dev -Region us-west-2 -WindowsServerVersion "2019"
+ Get-LatestOrbAMI -ProfileName temp-dev -Region us-west-2 -WindowsServerVersion "2022"
+#>
+ [CmdletBinding()]
+ Param (
+ [Parameter(Mandatory=$true)]
+ [string]$profileName,
+
+ [Alias("Region")]
+ [Parameter(Mandatory=$true)]
+ [string]$awsRegion,
+
+ [Parameter(Mandatory=$false)]
+ [string]$amiNameFilter = "windows-__VERSION__-orb-base*",
+
+ [Parameter(Mandatory=$false)]
+ [ValidateSet("2016", "2019", "2022")]
+ [string]$windowsServerVersion = "2016"
+ )
+ $logLead = (Get-LogLeadName)
+
+ Import-AWSModule # EC2
+
+ # If $windowsServerVersion is set and $amiNameFilter has a placeholder, use it in the filter.
+ $amiNameFilter = $amiNameFilter.Replace("__VERSION__", $windowsServerVersion)
+
+ # Filter searching
+ $filters = @()
+ $nameValues = New-Object 'collections.generic.list[string]'
+ $nameValues.add($amiNameFilter)
+ # Filter by AMI Name property.
+ $filterNames = New-Object Amazon.EC2.Model.Filter -Property @{Name = "name"; Values = $nameValues}
+ # This gets private images shared with the account in $profileName.
+ $filterVisibility = New-Object Amazon.EC2.Model.Filter -Property @{Name = "is-public"; Values = "false"}
+ # This gets only available AMIs, not pending, not failed.
+ $filterState = New-Object Amazon.EC2.Model.Filter -Property @{Name = "state"; Values = "available"}
+
+ $filters += $filterNames
+ $filters += $filterVisibility
+ $filters += $filterState
+
+ # Get all of the AMI Images by tags defined above. Returns a collection of Amazon.EC2.Model.Image objects.
+ $EC2ImageList = (
+ Invoke-CommandWithRetry -Arguments ($profileName, $awsRegion, $filters) -MaxRetries 3 -Exponential -ScriptBlock {
+ param($sbProfileName, $sbAwsRegion, $sbFilters)
+ return Get-EC2Image -ProfileName $sbProfileName -Region $sbAwsRegion -Filter $sbFilters
+ }
+ )
+
+ # Find the latest AMI by CreateDate
+ $latestImage = $EC2ImageList | Sort-Object CreationDate | Select-Object -Last 1
+ Write-Host "$logLead : Latest ORB AMI $($latestImage.ImageId) CreationDate: $($latestImage.CreationDate)"
+
+ return $latestImage
+}
diff --git a/Modules/Alkami.DevOps.Operations/Public/Get-LoadBalancerState.ps1 b/Modules/Alkami.DevOps.Operations/Public/Get-LoadBalancerState.ps1
new file mode 100644
index 0000000..c8447fb
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Get-LoadBalancerState.ps1
@@ -0,0 +1,73 @@
+function Get-LoadBalancerState {
+ <#
+ .SYNOPSIS
+ Gets the current state of a server with regards to a load balancer.
+ It will check both Nginx and ALB/NLB/ELBs
+ Supports Web/App servers. Mic/Fab servers are no-ops.
+
+ .PARAMETER Server
+ The string to get the server type string from. Expects the fully qualified hostname (blah.fh.local)
+
+ .PARAMETER AwsProfileName
+ The AWS Profile the Server is in.
+
+ .PARAMETER AwsRegion
+ The AWS Region the Server is in.
+
+ .OUTPUTS
+ The state of the host with regards to the loadbalancer. Returns Active/Standby to match the output of Get-ASInstanceState.
+ This doesn't imply that it's healthy, just that it has traffic directed to it.
+
+ .NOTES
+ This function will not succeed if run directly on Production and Staging web servers. They do not have the necessary IAM permissions to
+ get the NGINX state. Run this remotely (ie: from an agent) and target those servers instead.
+
+ #>
+ [CmdletBinding()]
+ [OutputType([System.String])]
+ param(
+ [Parameter(Mandatory = $true)]
+ [string]$Server,
+ [Parameter(Mandatory = $false)]
+ [string]$AwsProfileName,
+ [Parameter(Mandatory = $false)]
+ [string]$AwsRegion
+ )
+
+ $logLead = Get-LogLeadName
+
+ # Get the string type of the server.
+ $serverType = Get-ServerTypeByHostname -ComputerName $Server
+
+ # Early out if it's a mic or fab server. These don't use load balancers.
+ if (($serverType -eq "Mic") -or ($serverType -eq "Fab")) {
+ Write-Host "$logLead : [$serverType] servers do not use a load balancer. Exiting."
+ return
+ }
+
+ # Throw if this is an unsupported/unknown server type. If it's not a Web or App.
+ if (!(($serverType -eq "Web") -or ($serverType -eq "App"))) {
+ throw "$logLead : getting a load balancer on server type [$serverType] for host [$Server] is unsupported."
+ }
+
+ # Determine what type of load balancer the environment uses.
+ # Only Staging/Production web servers use nginx, and everything else is AWS load balancers.
+ $ec2Instance = @(Get-EC2InstancesByHostname -Servers $Server -ProfileName $AwsProfileName)[0]
+
+ # Should only get one instance for a given Instance Id. Get the environment value from the tag.
+ $environmentType = $ec2Instance.Tag.Where({ $_.Key -eq "alk:env" }).Value.ToLower()
+
+ $useNginx = $serverType -eq "Web" -and (($environmentType -eq "staging") -or ($environmentType -eq "prod"))
+ $loadBalancerName = if ($useNginx) { "NGINX" } else { "AWS" }
+ Write-Host "$logLead : Determined that server uses an [$loadBalancerName] load balancer."
+
+ if ($useNginx) {
+ # Handle nginx load balancers.
+ $state = Get-NginxHostStates -Hostname $Server -TargetEnvironment $environmentType -AwsProfileName $AwsProfileName -AwsRegion $AwsRegion
+ } else {
+ # Handle AWS load balancers.
+ $state = Get-ASInstanceState -ComputerName $Server -ProfileName $AwsProfileName -Region $AwsRegion
+ }
+ Write-Host "$logLead : Returning State of [$state]"
+ return $state
+}
diff --git a/Modules/Alkami.DevOps.Operations/Public/Get-LoadBalancerState.tests.ps1 b/Modules/Alkami.DevOps.Operations/Public/Get-LoadBalancerState.tests.ps1
new file mode 100644
index 0000000..8c03401
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Get-LoadBalancerState.tests.ps1
@@ -0,0 +1,101 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ""
+
+Describe "Get-LoadBalancerState" {
+ Mock -CommandName Write-Host -ModuleName $moduleForMock -MockWith { }
+ Mock -CommandName Get-LogLeadName -ModuleName $moduleForMock -MockWith { }
+ Context "When called for a AWS staging app server type" {
+ Mock -CommandName Get-EC2InstancesByHostname -ModuleName $moduleForMock -MockWith {
+ $testTags = New-Object Collections.Generic.List[pscustomobject]
+ $testTags.Add([pscustomobject]@{Key = 'alk:env'; Value = 'staging' })
+ $ec2Instance = [pscustomobject]@{Tag = $testTags }
+ return $ec2Instance
+ }
+ It "If active, returns active" {
+ Mock -CommandName Get-ASInstanceState -ModuleName $moduleForMock -MockWith { return "active" }
+ $result = Get-LoadBalancerState -Server "APP111111" -AwsProfileName "temp-fake" -AwsRegion "not-us-east-1"
+ $result | Should -Be "Active"
+ }
+ }
+ Context "When called for a AWS prod app server type" {
+ Mock -CommandName Get-EC2InstancesByHostname -ModuleName $moduleForMock -MockWith {
+ $testTags = New-Object Collections.Generic.List[pscustomobject]
+ $testTags.Add([pscustomobject]@{Key = 'alk:env'; Value = 'prod' })
+ $ec2Instance = [pscustomobject]@{Tag = $testTags }
+ return $ec2Instance
+ }
+ It "If active, returns active" {
+ Mock -CommandName Get-ASInstanceState -ModuleName $moduleForMock -MockWith { return "active" }
+ $result = Get-LoadBalancerState -Server "APP111111" -AwsProfileName "temp-fake" -AwsRegion "not-us-east-1"
+ $result | Should -Be "Active"
+ }
+ }
+ Context "When called for a Nginx Staging Web server type" {
+ Mock -CommandName Get-EC2InstancesByHostname -ModuleName $moduleForMock -MockWith {
+ $testTags = New-Object Collections.Generic.List[pscustomobject]
+ $testTags.Add([pscustomobject]@{Key = 'alk:env'; Value = 'staging' })
+ $ec2Instance = [pscustomobject]@{Tag = $testTags }
+ return $ec2Instance
+ }
+ It "If active, returns active" {
+ Mock -CommandName Get-NginxHostStates -ModuleName $moduleForMock -MockWith { return "Active" }
+ $result = Get-LoadBalancerState -Server "WEB111111" -AwsProfileName "temp-fake" -AwsRegion "not-us-east-1"
+ $result | Should -Be "Active"
+ }
+ }
+ Context "When called for a Nginx prod Web server type" {
+ Mock -CommandName Get-EC2InstancesByHostname -ModuleName $moduleForMock -MockWith {
+ $testTags = New-Object Collections.Generic.List[pscustomobject]
+ $testTags.Add([pscustomobject]@{Key = 'alk:env'; Value = 'prod' })
+ $ec2Instance = [pscustomobject]@{Tag = $testTags }
+ return $ec2Instance
+ }
+ It "If active, returns active" {
+ Mock -CommandName Get-NginxHostStates -ModuleName $moduleForMock -MockWith { return "Active" }
+ $result = Get-LoadBalancerState -Server "WEB111111" -AwsProfileName "temp-fake" -AwsRegion "not-us-east-1"
+ $result | Should -Be "Active"
+ }
+ }
+ Context "When called with an incorrect server type" {
+ Mock -CommandName Get-NginxUpstreams -ModuleName $moduleForMock -MockWith { }
+ Mock -CommandName Get-ASInstanceState -ModuleName $moduleForMock -MockWith { }
+
+ It "returns null for wrong type" {
+ $result = Get-LoadBalancerState -Server "MIC111111" -AwsProfileName "temp-fake" -AwsRegion "not-us-east-1"
+ $result | Should -Be $null
+ }
+ }
+ Context "When called with an aws dev server type" {
+ Mock -CommandName Get-NginxUpstreams -ModuleName $moduleForMock -MockWith { }
+ Mock -CommandName Get-ASInstanceState -ModuleName $moduleForMock -MockWith { }
+ Mock -CommandName Get-EC2InstancesByHostname -ModuleName $moduleForMock -MockWith {
+ $testTags = New-Object Collections.Generic.List[pscustomobject]
+ $testTags.Add([pscustomobject]@{Key = 'alk:env'; Value = 'dev' })
+ $ec2Instance = [pscustomobject]@{Tag = $testTags }
+ return $ec2Instance
+ }
+ It "returns null for wrong env" {
+ $result = Get-LoadBalancerState -Server "web111111" -AwsProfileName "temp-fake" -AwsRegion "not-us-east-1"
+ $result | Should -Be $null
+ }
+ }
+ Context "When called with an aws qa server type" {
+ Mock -CommandName Get-NginxUpstreams -ModuleName $moduleForMock -MockWith { }
+ Mock -CommandName Get-ASInstanceState -ModuleName $moduleForMock -MockWith { }
+ Mock -CommandName Get-EC2InstancesByHostname -ModuleName $moduleForMock -MockWith {
+ $testTags = New-Object Collections.Generic.List[pscustomobject]
+ $testTags.Add([pscustomobject]@{Key = 'alk:env'; Value = 'qa' })
+ $ec2Instance = [pscustomobject]@{Tag = $testTags }
+ return $ec2Instance
+ }
+ It "returns null for wrong env" {
+ $result = Get-LoadBalancerState -Server "web111111" -AwsProfileName "temp-fake" -AwsRegion "not-us-east-1"
+ $result | Should -Be $null
+ }
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Public/Get-LoadBalancerStateAndHealth.ps1 b/Modules/Alkami.DevOps.Operations/Public/Get-LoadBalancerStateAndHealth.ps1
new file mode 100644
index 0000000..4206af2
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Get-LoadBalancerStateAndHealth.ps1
@@ -0,0 +1,89 @@
+function Get-LoadBalancerStateAndHealth {
+ <#
+ .SYNOPSIS
+ Gets the current state of a server with regards to a load balancer, and will return the health of the server
+ It will check both Nginx and ALB/NLB/ELBs
+ Supports Web/App servers. Mic/Fab servers are no-ops.
+
+ .PARAMETER Server
+ The string to get the server type string from. Expects the fully qualified hostname (blah.fh.local)
+
+ .PARAMETER AwsProfileName
+ The AWS Profile the Server is in.
+
+ .PARAMETER AwsRegion
+ The AWS Region the Server is in.
+
+ .OUTPUTS
+ The state of the host with regards to the loadbalancer and the health status. Normalizes the strings to match the output of Get-ASInstanceState.
+
+ .NOTES
+ This function will not succeed if run directly on Production and Staging web servers. They do not have the necessary IAM permissions to
+ get the NGINX state. Run this remotely (ie: from an agent) and target those servers instead.
+
+ #>
+ [CmdletBinding()]
+ [OutputType([System.String])]
+ param(
+ [Parameter(Mandatory = $true)]
+ [string]$Server,
+ [Parameter(Mandatory = $false)]
+ [string]$AwsProfileName,
+ [Parameter(Mandatory = $false)]
+ [string]$AwsRegion
+ )
+
+ $logLead = Get-LogLeadName
+
+ # Get the string type of the server.
+ $serverType = Get-ServerTypeByHostname -ComputerName $Server
+
+ # Early out if it's a mic or fab server. These don't use load balancers.
+ if (($serverType -eq "Mic") -or ($serverType -eq "Fab")) {
+ Write-Host "$logLead : [$serverType] servers do not use a load balancer. Exiting."
+ return
+ }
+
+ # Throw if this is an unsupported/unknown server type. If it's not a Web or App.
+ if (!(($serverType -eq "Web") -or ($serverType -eq "App"))) {
+ throw "$logLead : getting a load balancer on server type [$serverType] for host [$Server] is unsupported."
+ }
+
+ # Determine what type of load balancer the environment uses.
+ # Only Staging/Production web servers use nginx, and everything else is AWS load balancers.
+ $ec2Instance = @(Get-EC2InstancesByHostname -Servers $Server -ProfileName $AwsProfileName)[0]
+
+ # Should only get one instance for a given Instance Id. Get the environment value from the tag.
+ $environmentType = $ec2Instance.Tag.Where({ $_.Key -eq "alk:env" }).Value.ToLower()
+
+ $useNginx = $serverType -eq "Web" -and (($environmentType -eq "staging") -or ($environmentType -eq "prod"))
+ $loadBalancerName = if ($useNginx) { "NGINX" } else { "AWS" }
+ Write-Host "$logLead : Determined that server uses an [$loadBalancerName] load balancer."
+
+ $health = $null
+ $state = $null
+ if ($useNginx) {
+ # Handle nginx load balancers.
+ $state = Get-NginxHostStates -Hostname $Server -TargetEnvironment $environmentType -AwsProfileName $AwsProfileName -AwsRegion $AwsRegion -TreatNGINXUnhealthyAsUnhealthy
+ if ($state -eq "Unhealthy") {
+ # In nginx land, just because a server is temporarly unhealthy, does not mean it is OUT of the LB
+ $health = $state.ToUpper()
+ $state = ConvertTo-NormalizedLoadBalancerState NGINX -InputState $state -TreatNGINXUnhealthyAsUnhealthy:$false
+ } else {
+ # In nginx land, if a server is not unhealthy, it is healthy
+ $health = "HEALTHY"
+ }
+ } else {
+ # Handle AWS load balancers.
+ $state = Get-ASInstanceState -ComputerName $Server -ProfileName $AwsProfileName -Region $AwsRegion
+ $health = Get-ASInstanceHealth -ComputerName $Server -ProfileName $AwsProfileName -Region $AwsRegion
+ }
+ # Return combined state and health
+ $returnObject = @{
+ Health = $health
+ State = $state
+ }
+
+ Write-Host "$logLead : Returning State: [$($returnObject.State)], Health [$($returnObject.Health)]"
+ return $returnObject
+}
diff --git a/Modules/Alkami.DevOps.Operations/Public/Get-LoadBalancerStateAndHealth.tests.ps1 b/Modules/Alkami.DevOps.Operations/Public/Get-LoadBalancerStateAndHealth.tests.ps1
new file mode 100644
index 0000000..c0ccc59
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Get-LoadBalancerStateAndHealth.tests.ps1
@@ -0,0 +1,152 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ""
+
+Describe "Get-LoadBalancerStateAndHealth" {
+ Mock -CommandName Write-Host -ModuleName $moduleForMock -MockWith { }
+ Mock -CommandName Get-LogLeadName -ModuleName $moduleForMock -MockWith { }
+ Context "When called for a AWS staging app server type" {
+ Mock -CommandName Get-ASInstanceState -ModuleName $moduleForMock -MockWith { return "active" }
+ Mock -CommandName Get-ASInstanceHealth -ModuleName $moduleForMock -MockWith { return "HEALTHY" }
+ Mock -CommandName Get-EC2InstancesByHostname -ModuleName $moduleForMock -MockWith {
+ $testTags = New-Object Collections.Generic.List[pscustomobject]
+ $testTags.Add([pscustomobject]@{Key = 'alk:env'; Value = 'staging' })
+ $ec2Instance = [pscustomobject]@{Tag = $testTags }
+ return $ec2Instance
+ }
+ It "If active, state returns active" {
+ $result = Get-LoadBalancerStateAndHealth -Server "APP111111" -AwsProfileName "temp-fake" -AwsRegion "not-us-east-1"
+ $result.State | Should -Be "Active"
+ }
+ It "If Healthy, health returns healthy" {
+ $result = Get-LoadBalancerStateAndHealth -Server "APP111111" -AwsProfileName "temp-fake" -AwsRegion "not-us-east-1"
+ $result.Health | Should -Be "HEALTHY"
+ }
+ }
+ Context "When called for a AWS prod app server type" {
+ Mock -CommandName Get-ASInstanceState -ModuleName $moduleForMock -MockWith { return "active" }
+ Mock -CommandName Get-ASInstanceHealth -ModuleName $moduleForMock -MockWith { return "UNHEALTHY" }
+ Mock -CommandName Get-EC2InstancesByHostname -ModuleName $moduleForMock -MockWith {
+ $testTags = New-Object Collections.Generic.List[pscustomobject]
+ $testTags.Add([pscustomobject]@{Key = 'alk:env'; Value = 'prod' })
+ $ec2Instance = [pscustomobject]@{Tag = $testTags }
+ return $ec2Instance
+ }
+ It "If active, State returns active" {
+ Mock -CommandName Get-ASInstanceState -ModuleName $moduleForMock -MockWith { return "active" }
+ $result = Get-LoadBalancerStateAndHealth -Server "APP111111" -AwsProfileName "temp-fake" -AwsRegion "not-us-east-1"
+ $result.State | Should -Be "Active"
+ }
+ It "If UNHEALTHY, health returns UNHEALTHY" {
+ $result = Get-LoadBalancerStateAndHealth -Server "APP111111" -AwsProfileName "temp-fake" -AwsRegion "not-us-east-1"
+ $result.Health | Should -Be "UNHEALTHY"
+ }
+ }
+ Context "When called for a Nginx Staging Web server type" {
+ Mock -CommandName Get-EC2InstancesByHostname -ModuleName $moduleForMock -MockWith {
+ $testTags = New-Object Collections.Generic.List[pscustomobject]
+ $testTags.Add([pscustomobject]@{Key = 'alk:env'; Value = 'staging' })
+ $ec2Instance = [pscustomobject]@{Tag = $testTags }
+ return $ec2Instance
+ }
+ It "If active, state returns active" {
+ Mock -CommandName Get-NginxHostStates -ModuleName $moduleForMock -MockWith { return "Active" }
+ $result = Get-LoadBalancerStateAndHealth -Server "WEB111111" -AwsProfileName "temp-fake" -AwsRegion "not-us-east-1"
+ $result.State | Should -Be "Active"
+ }
+ It "If unhealthy, state returns active" {
+ Mock -CommandName Get-NginxHostStates -ModuleName $moduleForMock -MockWith { return "Unhealthy" }
+ $result = Get-LoadBalancerStateAndHealth -Server "WEB111111" -AwsProfileName "temp-fake" -AwsRegion "not-us-east-1"
+ $result.State | Should -Be "Active"
+ }
+ It "If unhealthy, health returns unhealthy" {
+ Mock -CommandName Get-NginxHostStates -ModuleName $moduleForMock -MockWith { return "Unhealthy" }
+ $result = Get-LoadBalancerStateAndHealth -Server "WEB111111" -AwsProfileName "temp-fake" -AwsRegion "not-us-east-1"
+ $result.State | Should -Be "Active"
+ }
+ It "If active, health returns healthy" {
+ Mock -CommandName Get-NginxHostStates -ModuleName $moduleForMock -MockWith { return "Active" }
+ $result = Get-LoadBalancerStateAndHealth -Server "WEB111111" -AwsProfileName "temp-fake" -AwsRegion "not-us-east-1"
+ $result.Health | Should -Be "healthy"
+ }
+ }
+ Context "When called for a Nginx prod Web server type" {
+ Mock -CommandName Get-EC2InstancesByHostname -ModuleName $moduleForMock -MockWith {
+ $testTags = New-Object Collections.Generic.List[pscustomobject]
+ $testTags.Add([pscustomobject]@{Key = 'alk:env'; Value = 'prod' })
+ $ec2Instance = [pscustomobject]@{Tag = $testTags }
+ return $ec2Instance
+ }
+ It "If active, state returns active" {
+ Mock -CommandName Get-NginxHostStates -ModuleName $moduleForMock -MockWith { return "Active" }
+ $result = Get-LoadBalancerStateAndHealth -Server "WEB111111" -AwsProfileName "temp-fake" -AwsRegion "not-us-east-1"
+ $result.State | Should -Be "Active"
+ }
+ It "If unhealthy, state returns active" {
+ Mock -CommandName Get-NginxHostStates -ModuleName $moduleForMock -MockWith { return "Unhealthy" }
+ $result = Get-LoadBalancerStateAndHealth -Server "WEB111111" -AwsProfileName "temp-fake" -AwsRegion "not-us-east-1"
+ $result.State | Should -Be "Active"
+ }
+ It "If unhealthy, health returns unhealthy" {
+ Mock -CommandName Get-NginxHostStates -ModuleName $moduleForMock -MockWith { return "Unhealthy" }
+ $result = Get-LoadBalancerStateAndHealth -Server "WEB111111" -AwsProfileName "temp-fake" -AwsRegion "not-us-east-1"
+ $result.State | Should -Be "Active"
+ }
+ It "If active, health returns healthy" {
+ Mock -CommandName Get-NginxHostStates -ModuleName $moduleForMock -MockWith { return "Active" }
+ $result = Get-LoadBalancerStateAndHealth -Server "WEB111111" -AwsProfileName "temp-fake" -AwsRegion "not-us-east-1"
+ $result.Health | Should -Be "healthy"
+ }
+ }
+ Context "When called with an incorrect server type" {
+ Mock -CommandName Get-NginxUpstreams -ModuleName $moduleForMock -MockWith { }
+ Mock -CommandName Get-ASInstanceState -ModuleName $moduleForMock -MockWith { }
+
+ It "returns null for wrong type" {
+ $result = Get-LoadBalancerStateAndHealth -Server "MIC111111" -AwsProfileName "temp-fake" -AwsRegion "not-us-east-1"
+ $result | Should -Be $null
+ }
+ }
+ Context "When called with an aws dev server type" {
+ Mock -CommandName Get-ASInstanceState -ModuleName $moduleForMock -MockWith { return "active" }
+ Mock -CommandName Get-ASInstanceHealth -ModuleName $moduleForMock -MockWith { return "UNHEALTHY" }
+ Mock -CommandName Get-EC2InstancesByHostname -ModuleName $moduleForMock -MockWith {
+ $testTags = New-Object Collections.Generic.List[pscustomobject]
+ $testTags.Add([pscustomobject]@{Key = 'alk:env'; Value = 'dev' })
+ $ec2Instance = [pscustomobject]@{Tag = $testTags }
+ return $ec2Instance
+ }
+ It "If active, State returns active" {
+ Mock -CommandName Get-ASInstanceState -ModuleName $moduleForMock -MockWith { return "active" }
+ $result = Get-LoadBalancerStateAndHealth -Server "APP111111" -AwsProfileName "temp-fake" -AwsRegion "not-us-east-1"
+ $result.State | Should -Be "Active"
+ }
+ It "If UNHEALTHY, health returns UNHEALTHY" {
+ $result = Get-LoadBalancerStateAndHealth -Server "APP111111" -AwsProfileName "temp-fake" -AwsRegion "not-us-east-1"
+ $result.Health | Should -Be "UNHEALTHY"
+ }
+ }
+ Context "When called with an aws qa server type" {
+ Mock -CommandName Get-ASInstanceState -ModuleName $moduleForMock -MockWith { return "active" }
+ Mock -CommandName Get-ASInstanceHealth -ModuleName $moduleForMock -MockWith { return "UNHEALTHY" }
+ Mock -CommandName Get-EC2InstancesByHostname -ModuleName $moduleForMock -MockWith {
+ $testTags = New-Object Collections.Generic.List[pscustomobject]
+ $testTags.Add([pscustomobject]@{Key = 'alk:env'; Value = 'qa' })
+ $ec2Instance = [pscustomobject]@{Tag = $testTags }
+ return $ec2Instance
+ }
+ It "If active, State returns active" {
+ Mock -CommandName Get-ASInstanceState -ModuleName $moduleForMock -MockWith { return "active" }
+ $result = Get-LoadBalancerStateAndHealth -Server "APP111111" -AwsProfileName "temp-fake" -AwsRegion "not-us-east-1"
+ $result.State | Should -Be "Active"
+ }
+ It "If UNHEALTHY, health returns UNHEALTHY" {
+ $result = Get-LoadBalancerStateAndHealth -Server "APP111111" -AwsProfileName "temp-fake" -AwsRegion "not-us-east-1"
+ $result.Health | Should -Be "UNHEALTHY"
+ }
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Public/Get-NginxAuthHeaders.ps1 b/Modules/Alkami.DevOps.Operations/Public/Get-NginxAuthHeaders.ps1
new file mode 100644
index 0000000..371eb59
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Get-NginxAuthHeaders.ps1
@@ -0,0 +1,62 @@
+function Get-NginxAuthHeaders {
+
+<#
+
+.SYNOPSIS
+ Builds the NGINX authorization headers from the specified SSM parameters
+
+.PARAMETER UserNameSSMPath
+ [string] The SSM paramter name for the username.
+
+.PARAMETER PasswordSSMPath
+ [string] The SSM paramter name for the password.
+
+.PARAMETER ProfileName
+ [string] Specific AWS CLI Profile to use in AWS API calls. Unused if not specified.
+
+.PARAMETER Region
+ [string] Specific AWS CLI Region to use in AWS API calls. Unused if not specified.
+
+#>
+
+ [CmdletBinding()]
+ param (
+ [Parameter(Mandatory = $false)]
+ [string]$UserNameSSMPath = "/teamcity/nginx-staging-username",
+
+ [Parameter(Mandatory = $false)]
+ [string]$PasswordSSMPath = "/teamcity/nginx-staging-password",
+
+ [Parameter(Mandatory = $false)]
+ [string]$ProfileName,
+
+ [Parameter(Mandatory = $false)]
+ [string]$Region
+ )
+
+ $logLead = (Get-LogLeadName)
+
+ Import-AWSModule # SSM
+
+ $splatParams = @{}
+
+ if (!([string]::IsNullOrEmpty($ProfileName))) {
+ $splatParams["ProfileName"] = "$ProfileName"
+ }
+
+ if (!([string]::IsNullOrEmpty($Region))) {
+ $splatParams["Region"] = "$Region"
+ }
+
+ Write-Verbose "$logLead : Getting secrets from SSM"
+ $Username = (Get-SSMParameter -Name $UserNameSSMPath @splatParams).Value
+ $Password = (Get-SSMParameter -Name $PasswordSSMPath -WithDecryption $true @splatParams).Value
+ Write-Verbose "$logLead : Done getting secrets from SSM"
+
+ if ($username -and $password) {
+ $GetAuthHeaders = @{Authorization = 'Basic ' + [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes("$($username):$($password)")) }
+ } else {
+ Write-Error "$logLead : Unable to retrieve username or password"
+ }
+ return $GetAuthHeaders
+}
diff --git a/Modules/Alkami.DevOps.Operations/Public/Get-NginxHostStates.ps1 b/Modules/Alkami.DevOps.Operations/Public/Get-NginxHostStates.ps1
new file mode 100644
index 0000000..6e75fda
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Get-NginxHostStates.ps1
@@ -0,0 +1,130 @@
+function Get-NginxHostStates {
+ <#
+ .Synopsis
+ Returns the current state of a host the specified environment
+
+ .Parameter targetEnvironment
+ Required. Must be "staging" or "prod"
+
+ .Parameter hostname
+ Requird. Hostname to toggle "webxxxxx"
+
+ .PARAMETER AwsProfileName
+ [string] Specific AWS CLI Profile to use in AWS API calls.
+
+ .PARAMETER AwsRegion
+ [string] Specific AWS CLI Region to use in AWS API calls.
+
+ .PARAMETER TreatNGINXUnhealthyAsUnhealthy
+ [switch] Should the server state unhealthy be returned as active or unhealthy?
+
+ .OUTPUTS
+ Return a normalized status (active/standby) if the server exists in NGINX
+ Returns an empty string if the server does not exist in NGINX
+ #>
+ [CmdletBinding()]
+ [OutputType([System.String])]
+ Param(
+ [Parameter(Mandatory = $false)]
+ [string]$Hostname = "localhost",
+
+ [Parameter(Mandatory = $false)]
+ [ValidateSet("staging","prod")]
+ [string]$TargetEnvironment = $null,
+
+ [Parameter(Mandatory = $false)]
+ [string]$AwsProfileName,
+
+ [Parameter(Mandatory = $false)]
+ [string]$AwsRegion,
+
+ [Parameter(Mandatory = $false)]
+ [switch]$TreatNGINXUnhealthyAsUnhealthy = $false
+ )
+
+ $logLead = Get-LogLeadName
+
+ # attempt to resolve host to toggle
+ #TODO: This should be changed to something mockable. Even just a wrapper.
+ # Currently, our tests rely on both resolving AND pinging "google.com"
+ # Created SRE-13329
+
+ if (Compare-StringToLocalMachineIdentifiers $Hostname) {
+ $alkHostIP = (Get-IpAddress);
+ } else {
+ $alkHostIP = @(Get-IPAddressesForName $Hostname)
+ if ($null -eq $alkHostIP[0] ) {
+ Write-Error "$logLead : unable to resolve IP address of $Hostname"
+ throw
+ }
+ }
+
+ # If the target environment isn't specified, construct it from the alk:env tag in the environment.
+ if ([string]::IsNullOrWhiteSpace($TargetEnvironment)) {
+ $tags = (invoke-command -ComputerName $Hostname -ScriptBlock { Get-CurrentInstanceTags });
+ foreach ($tag in $tags) {
+ if($tag.Key -eq "alk:env"){
+ $TargetEnvironment = $tag.Value
+ }
+ }
+ }
+
+ # Get Nginx server IP addresses
+ $nginxIpAddresses = Get-NginxIpAddresses -TargetEnvironment $TargetEnvironment -AwsProfileName $AwsProfileName -AwsRegion $AwsRegion
+ if (Test-IsCollectionNullOrEmpty -collection $nginxIpAddresses) {
+ Write-Error "$logLead : no Nginx Servers found"
+ throw
+ }
+
+ Write-Verbose "nginx servers $nginxIpAddresses"
+
+ # Pre-flight the NGINX server connectivity. We don't want to proceed unless they are all available.
+ foreach ($nginxIpAddress in $nginxIpAddresses) {
+ $tcpResult = Test-TcpConnection -ipAddress $nginxIpAddress -port 8080 -msTimeout 5000
+ if ($false -eq $tcpResult) {
+ # If TCP test fails, we do not want to proceed. NGINX servers may become out of sync
+ Write-Error "$logLead : This NGINX server was unreachable $nginxIpAddress"
+ throw
+ }
+ }
+
+ foreach ($nginxIpAddress in $nginxIpAddresses) {
+
+ Write-Verbose "Checking $nginxIpAddress"
+ # gets the current state
+ $getResults = Get-NginxUpstreams -NginxServer $nginxIpAddress -targetEnvironment $TargetEnvironment -ProfileName $AwsProfileName -Region $AwsRegion
+
+ # If getting the upstreams fails, but ping succeded, nginx may become out of sync
+ if (!$getResults) {
+ Write-Error "$logLead : Getting upstreams failed."
+ break
+ }
+
+ $resultsHash = $getResults | ConvertTo-Json -Depth 4 | ConvertFrom-JsonToHashtable
+ foreach ($serviceName in $resultsHash.Keys) {
+ Write-Verbose "$logLead : Looking for $Hostname in ServiceName : $serviceName"
+ foreach ($peer in $resultsHash["$serviceName"]["peers"]) {
+ Write-Verbose "$logLead : Looking for $Hostname in Peer : $($peer.name)"
+
+ Write-Verbose "$logLead : Testing if $($peer["server"]) matches '$($alkHostIP):*'"
+ #The $($varName) format is necessary to put a ":" after a variable, otherwise it thinks it's a scope variable
+ #Like $Global:varname
+ #Alternate matcher: '-match "^$($alkHostIp):"'
+ if ($peer["server"] -like "$($alkHostIp):*") {
+ $peerState = $peer["state"]
+ Write-Verbose "Nginx Server: $serviceName"
+ Write-Verbose "State: $peerState"
+ if ($null -eq $returnState) {
+ $returnState = $peerState
+ } elseif ($returnState -ne $peerState) {
+ # This variable is used in Install_Packages as a string match
+ $magicString = "$logLead : Got differing states for the same host from different NGINX servers. Investigate."
+ Write-Error $magicString
+ }
+ }
+ }
+ }
+ }
+ $normalizedReturnState = ConvertTo-NormalizedLoadBalancerState -LBType "NGINX" -InputState $returnState -TreatNGINXUnhealthyAsUnhealthy:$TreatNGINXUnhealthyAsUnhealthy
+ return $normalizedReturnState
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Public/Get-NginxHostStates.tests.ps1 b/Modules/Alkami.DevOps.Operations/Public/Get-NginxHostStates.tests.ps1
new file mode 100644
index 0000000..970ddc7
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Get-NginxHostStates.tests.ps1
@@ -0,0 +1,294 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ""
+
+Describe "Get-NginxHostStates" {
+ Mock -CommandName Write-Host -ModuleName $moduleForMock -MockWith { }
+ $upstreamJson = @"
+ {
+ "lane-fake-ssl": {
+ "peers": [
+ {
+ "id": 0,
+ "server": "10.10.10.10:443",
+ "name": "10.10.10.10:443",
+ "backup": false,
+ "weight": 1,
+ "state": "up",
+ "active": 0,
+ "requests": 300,
+ "header_time": 200,
+ "response_time": 200,
+ "responses": {
+ "1xx": 0,
+ "2xx": 300,
+ "3xx": 30,
+ "4xx": 1,
+ "5xx": 0,
+ "total": 331
+ },
+ "sent": 700000,
+ "received": 4000000,
+ "fails": 0,
+ "unavail": 0,
+ "health_checks": {
+ "checks": 0,
+ "fails": 0,
+ "unhealthy": 0
+ },
+ "downtime": 0,
+ "selected": "2021-02-25T23:06:38Z"
+ }
+ ],
+ "keepalive": 0,
+ "zombies": 0,
+ "zone": "lane-fake-ssl"
+ }
+ }
+"@
+ $unhealthyUpstreamJson = @"
+{
+ "lane-fake-ssl": {
+ "peers": [
+ {
+ "id": 0,
+ "server": "10.10.10.10:443",
+ "name": "10.10.10.10:443",
+ "backup": false,
+ "weight": 1,
+ "state": "Unhealthy",
+ "active": 0,
+ "requests": 300,
+ "header_time": 200,
+ "response_time": 200,
+ "responses": {
+ "1xx": 0,
+ "2xx": 300,
+ "3xx": 30,
+ "4xx": 1,
+ "5xx": 0,
+ "total": 331
+ },
+ "sent": 700000,
+ "received": 4000000,
+ "fails": 0,
+ "unavail": 0,
+ "health_checks": {
+ "checks": 0,
+ "fails": 0,
+ "unhealthy": 0
+ },
+ "downtime": 0,
+ "selected": "2021-02-25T23:06:38Z"
+ }
+ ],
+ "keepalive": 0,
+ "zombies": 0,
+ "zone": "lane-fake-ssl"
+ }
+}
+"@
+ $updownstreamJson = @"
+ {
+ "lane-fake-ssl": {
+ "peers": [
+ {
+ "id": 0,
+ "server": "10.10.10.10:443",
+ "name": "10.10.10.10:443",
+ "backup": false,
+ "weight": 1,
+ "state": "up",
+ "active": 0,
+ "requests": 300,
+ "header_time": 200,
+ "response_time": 200,
+ "responses": {
+ "1xx": 0,
+ "2xx": 300,
+ "3xx": 30,
+ "4xx": 1,
+ "5xx": 0,
+ "total": 331
+ },
+ "sent": 700000,
+ "received": 4000000,
+ "fails": 0,
+ "unavail": 0,
+ "health_checks": {
+ "checks": 0,
+ "fails": 0,
+ "unhealthy": 0
+ },
+ "downtime": 0,
+ "selected": "2021-02-25T23:06:38Z"
+ },
+ {
+ "id": 1,
+ "server": "10.10.10.10:443",
+ "name": "10.10.10.10:443",
+ "backup": false,
+ "weight": 1,
+ "state": "down",
+ "active": 0,
+ "requests": 300,
+ "header_time": 200,
+ "response_time": 200,
+ "responses": {
+ "1xx": 0,
+ "2xx": 300,
+ "3xx": 30,
+ "4xx": 1,
+ "5xx": 0,
+ "total": 331
+ },
+ "sent": 700000,
+ "received": 4000000,
+ "fails": 0,
+ "unavail": 0,
+ "health_checks": {
+ "checks": 0,
+ "fails": 0,
+ "unhealthy": 0
+ },
+ "downtime": 0,
+ "selected": "2021-02-25T23:06:38Z"
+ }
+ ],
+ "keepalive": 0,
+ "zombies": 0,
+ "zone": "lane-fake-ssl"
+ }
+ }
+"@
+
+ Context "When Provided with an un-findable IP address" {
+ Mock -CommandName Compare-StringToLocalMachineIdentifiers -ModuleName $moduleForMock -MockWith { return $false }
+ Mock -CommandName Get-IPAddressesForName -ModuleName $moduleForMock -MockWith { return $null }
+ Mock -CommandName Write-Error -ModuleName $moduleForMock -MockWith { }
+
+ It "Writes An Error" {
+ { Get-NginxHostStates -Hostname "255.255.255.255" -TargetEnvironment "staging" } | Should -Throw
+
+ Assert-MockCalled -CommandName Write-Error -Times 1 -Exactly -Scope Context -ModuleName $moduleForMock `
+ -ParameterFilter { $Message -match "unable to resolve IP address" }
+ }
+ }
+
+ Context "When Nginx Servers Cannot Be Found" {
+ Mock -CommandName Compare-StringToLocalMachineIdentifiers -ModuleName $moduleForMock -MockWith { return "localhost" }
+ Mock -CommandName Get-IpAddress -ModuleName $moduleForMock -MockWith { return "10.10.10.10" }
+ Mock -CommandName Get-NginxIpAddresses -ModuleName $moduleForMock -MockWith { return $null }
+ Mock -CommandName Write-Error -ModuleName $moduleForMock -MockWith { return }
+
+ It "Writes An Error" {
+ { Get-NginxHostStates -Hostname "255.255.255.255" -TargetEnvironment "staging" } | Should -Throw
+
+ Assert-MockCalled -CommandName Write-Error -Times 1 -Exactly -Scope Context -ModuleName $moduleForMock `
+ -ParameterFilter { $Message -match "no Nginx Servers found" }
+ }
+ }
+
+ Context "When Nginx Servers Cannot Be Reached" {
+ Mock -CommandName Compare-StringToLocalMachineIdentifiers -ModuleName $moduleForMock -MockWith { return "localhost" }
+ Mock -CommandName Get-IpAddress -ModuleName $moduleForMock -MockWith { return "10.10.10.10" }
+ Mock -CommandName Get-NginxIpAddresses -ModuleName $moduleForMock -MockWith { return @("1.1.1.1") }
+ Mock -CommandName Test-TcpConnection -ModuleName $moduleForMock -MockWith { return $false }
+ Mock -CommandName Write-Error -ModuleName $moduleForMock -MockWith { return }
+
+ It "Writes An Error" {
+ { Get-NginxHostStates -Hostname "255.255.255.255" -TargetEnvironment "staging" } | Should -Throw
+
+ Assert-MockCalled -CommandName Write-Error -Times 1 -Exactly -Scope Context -ModuleName $moduleForMock `
+ -ParameterFilter { $Message -match "This NGINX server was unreachable" }
+ }
+ }
+
+ Context "When there are no Nginx upstreams" {
+ Mock -CommandName Compare-StringToLocalMachineIdentifiers -ModuleName $moduleForMock -MockWith { return "localhost" }
+ Mock -CommandName Get-IpAddress -ModuleName $moduleForMock -MockWith { return "10.10.10.10" }
+ Mock -CommandName Get-IPAddressesForName -ModuleName $moduleForMock -MockWith { return $null }
+ Mock -CommandName Get-NginxIpAddresses -ModuleName $moduleForMock -MockWith { return @("1.1.1.1") }
+ Mock -CommandName Test-TcpConnection -ModuleName $moduleForMock -MockWith { return $true }
+ Mock -CommandName Write-Verbose -ModuleName $moduleForMock -MockWith { }
+ Mock -CommandName Get-NginxUpstreams -ModuleName $moduleForMock -MockWith { return $null }
+ Mock -CommandName Write-Error -ModuleName $moduleForMock -MockWith { return }
+ Mock -CommandName ConvertTo-NormalizedLoadBalancerState -ModuleName $moduleForMock -MockWith { return "Active" }
+
+ It "Writes An Error" {
+ Get-NginxHostStates -TargetEnvironment "staging"
+
+ Assert-MockCalled -CommandName Write-Error -Times 1 -Exactly -Scope Context -ModuleName $moduleForMock `
+ -ParameterFilter { $Message -match "Getting upstreams failed." }
+ }
+ }
+
+ Context "When called with a happy path" {
+ Mock -CommandName Compare-StringToLocalMachineIdentifiers -ModuleName $moduleForMock -MockWith { return "localhost" }
+ Mock -CommandName Get-IpAddress -ModuleName $moduleForMock -MockWith { return "10.10.10.10" }
+ Mock -CommandName Get-IPAddressesForName -ModuleName $moduleForMock -MockWith { return $null }
+ Mock -CommandName Get-NginxIpAddresses -ModuleName $moduleForMock -MockWith { return @("1.1.1.1") }
+ Mock -CommandName Test-TcpConnection -ModuleName $moduleForMock -MockWith { return $true }
+ Mock -CommandName Write-Verbose -ModuleName $moduleForMock -MockWith { }
+ Mock -CommandName Get-NginxUpstreams -ModuleName $moduleForMock -MockWith { return $upstreamJson | ConvertFrom-JsonToHashtable }
+
+ It "Returns State" {
+ $result = Get-NginxHostStates -TargetEnvironment "staging"
+
+ $result | Should -Be "Active"
+ }
+ }
+
+ Context "When NGINX servers have different states for the same host" {
+ Mock -CommandName Compare-StringToLocalMachineIdentifiers -ModuleName $moduleForMock -MockWith { return "localhost" }
+ Mock -CommandName Get-IpAddress -ModuleName $moduleForMock -MockWith { return "10.10.10.10" }
+ Mock -CommandName Get-IPAddressesForName -ModuleName $moduleForMock -MockWith { return $null }
+ Mock -CommandName Get-NginxIpAddresses -ModuleName $moduleForMock -MockWith { return @("1.1.1.1") }
+ Mock -CommandName Test-TcpConnection -ModuleName $moduleForMock -MockWith { return $true }
+ Mock -CommandName Write-Verbose -ModuleName $moduleForMock -MockWith { }
+ Mock -CommandName Get-NginxUpstreams -ModuleName $moduleForMock -MockWith { return $updownstreamJson | ConvertFrom-JsonToHashtable }
+ Mock -CommandName Write-Error -ModuleName $moduleForMock -MockWith { return }
+
+ It "Writes an error" {
+ Get-NginxHostStates -TargetEnvironment "staging"
+
+ # Look for the magic string
+ Assert-MockCalled -CommandName Write-Error -Times 1 -Exactly -Scope Context -ModuleName $moduleForMock `
+ -ParameterFilter { $Message -match " : Got differing states for the same host from different NGINX servers. Investigate." }
+ }
+ }
+ Context "When called with TreatNGINXUnhealthyAsUnhealthy when unhealthy" {
+ Mock -CommandName Compare-StringToLocalMachineIdentifiers -ModuleName $moduleForMock -MockWith { return "localhost" }
+ Mock -CommandName Get-IpAddress -ModuleName $moduleForMock -MockWith { return "10.10.10.10" }
+ Mock -CommandName Get-IPAddressesForName -ModuleName $moduleForMock -MockWith { return $null }
+ Mock -CommandName Get-NginxIpAddresses -ModuleName $moduleForMock -MockWith { return @("1.1.1.1") }
+ Mock -CommandName Test-TcpConnection -ModuleName $moduleForMock -MockWith { return $true }
+ Mock -CommandName Write-Verbose -ModuleName $moduleForMock -MockWith { }
+ Mock -CommandName Get-NginxUpstreams -ModuleName $moduleForMock -MockWith { return $unhealthyUpstreamJson | ConvertFrom-JsonToHashtable }
+
+ It "Returns State" {
+ $result = Get-NginxHostStates -TargetEnvironment "staging" -TreatNGINXUnhealthyAsUnhealthy
+
+ $result | Should -Be "Unhealthy"
+ }
+ }
+ Context "When called without TreatNGINXUnhealthyAsUnhealthy when unhealthy" {
+ Mock -CommandName Compare-StringToLocalMachineIdentifiers -ModuleName $moduleForMock -MockWith { return "localhost" }
+ Mock -CommandName Get-IpAddress -ModuleName $moduleForMock -MockWith { return "10.10.10.10" }
+ Mock -CommandName Get-IPAddressesForName -ModuleName $moduleForMock -MockWith { return $null }
+ Mock -CommandName Get-NginxIpAddresses -ModuleName $moduleForMock -MockWith { return @("1.1.1.1") }
+ Mock -CommandName Test-TcpConnection -ModuleName $moduleForMock -MockWith { return $true }
+ Mock -CommandName Write-Verbose -ModuleName $moduleForMock -MockWith { }
+ Mock -CommandName Get-NginxUpstreams -ModuleName $moduleForMock -MockWith { return $unhealthyUpstreamJson | ConvertFrom-JsonToHashtable }
+
+ It "Returns State" {
+ $result = Get-NginxHostStates -TargetEnvironment "staging"
+
+ $result | Should -Be "Active"
+ }
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Public/Get-NginxIpAddresses.ps1 b/Modules/Alkami.DevOps.Operations/Public/Get-NginxIpAddresses.ps1
new file mode 100644
index 0000000..7dd3ca4
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Get-NginxIpAddresses.ps1
@@ -0,0 +1,106 @@
+function Get-NginxIpAddresses {
+
+<#
+
+.SYNOPSIS
+ Retrieves the NGINX server IP addresses for a given environment.
+
+.DESCRIPTION
+ Retrieves the NGINX server IP addresses for a given environment. Currently only supports Production
+ and Staging since no other environments utilize NGINX.
+
+.PARAMETER TargetEnvironment
+ [string] The target environment for the NGINX servers. Must be prod or staging.
+
+.PARAMETER AwsProfileName
+ [string] Specific AWS CLI Profile to use in AWS API calls. Unused if not specified.
+
+.PARAMETER AwsRegion
+ [string] Specific AWS CLI Region to use in AWS API calls. Unused if not specified.
+
+.EXAMPLE
+ Get-NginxIpAddresses -TargetEnvironment staging -AwsProfileName Prod
+
+10.19.200.89
+10.19.201.76
+
+.INPUTS
+ None
+#>
+
+ [CmdletBinding()]
+
+ Param(
+ [Parameter(Mandatory = $true)]
+ [ValidateSet("prod", "staging", IgnoreCase = $true)]
+ [string]$TargetEnvironment,
+
+ [Parameter(Mandatory = $false)]
+ [string]$AwsProfileName,
+
+ [Parameter(Mandatory = $false)]
+ [string]$AwsRegion
+ )
+
+ $logLead = (Get-LogLeadName)
+
+ Import-AWSModule # ELBv2 and EC2
+
+ $actualTargetEnvironment = $TargetEnvironment.ToLowerInvariant()
+
+ # These are fixed values to match actual implementation.
+ $prodNginxTargetGroupName = 'Prod-NginxTrusted'
+ $stagingNginxTargetGroupName = 'Stage-NginxTrusted'
+
+ $splatParams = @{}
+
+ if (!([string]::IsNullOrEmpty($AwsProfileName))) {
+ $splatParams["ProfileName"] = "$AwsProfileName"
+ }
+
+ if (!([string]::IsNullOrEmpty($AwsRegion))) {
+ $splatParams["Region"] = "$AwsRegion"
+ }
+
+ $targetGroupName = switch ( $actualTargetEnvironment ) {
+ 'prod' { $prodNginxTargetGroupName }
+ 'staging' { $stagingNginxTargetGroupName }
+ }
+ Write-Verbose "$logLead : Computed ELB target group name as '$targetGroupName' for NGINX in $TargetEnvironment"
+
+ #SRE-17377 - CommandWithRetry to fix 429 'Rate Exceeded' error
+ $elbScript = {
+ param($sb_targetGroupName, $sb_splatParams)
+
+ $returnGroup = (Get-ELB2TargetGroup -Name $sb_targetGroupName @sb_splatParams)
+ return $returnGroup
+ }
+
+ $targetGroup = Invoke-CommandWithRetry -Arguments ($targetGroupName, $splatParams) -Seconds 2.5 -ScriptBlock $elbScript
+
+ if (Test-IsCollectionNullOrEmpty -collection $targetGroup) {
+
+ Write-Error "$logLead : No ELB target group named '$targetGroupName' found using profile '$AwsProfileName' in region '$AwsRegion'"
+ return $null
+
+ } elseif( $targetGroup.Count -gt 1 ) {
+
+ Write-Error "$logLead : ELB target group search returned more than one result."
+ return $null
+ }
+
+ $targetGroupArn = $targetGroup.TargetGroupArn
+ Write-Verbose "$logLead : Target group ARN is '$targetGroupArn'"
+
+ $targetGroupMembers = Get-ELB2TargetHealth -TargetGroupArn $targetGroupArn @splatParams
+ if (Test-IsCollectionNullOrEmpty -collection $targetGroupMembers) {
+
+ Write-Error "$logLead : No instances found in target group '$targetGroupName'"
+ return $null
+ }
+
+ $nginxInstanceIds = $targetGroupMembers.Target.Id
+ Write-Verbose "$logLead : Found $($nginxInstanceIds.Count) instances associated with '$targetGroupArn'"
+
+ return ( Get-EC2Instance -InstanceId $nginxInstanceIds @splatParams ).Instances.PrivateIpAddress
+}
diff --git a/Modules/Alkami.DevOps.Operations/Public/Get-NginxIpAddresses.tests.ps1 b/Modules/Alkami.DevOps.Operations/Public/Get-NginxIpAddresses.tests.ps1
new file mode 100644
index 0000000..75500f4
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Get-NginxIpAddresses.tests.ps1
@@ -0,0 +1,150 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ""
+
+Describe "Get-NginxIpAddresses" {
+
+ Context "Target Environment Should Not Be Null" {
+
+ It "Parameter Validation Should Throw" {
+ { Get-NginxIpAddresses -TargetEnvironment $null } | Should Throw
+ }
+ }
+
+ Context "Target Environment Should Not Be Empty" {
+
+ It "Parameter Validation Should Throw" {
+ { Get-NginxIpAddresses -TargetEnvironment "" } | Should Throw
+ }
+ }
+
+ Context "Target Environment Should Be In Validation Set" {
+
+ It "Parameter Validation Should Throw" {
+ { Get-NginxIpAddresses -TargetEnvironment "Qa" } | Should Throw
+ }
+ }
+
+ Context "Target Environment Is Case Insensitive" {
+
+ Mock -CommandName Write-Error -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Get-ELB2TargetGroup -ModuleName $moduleForMock -MockWith { return @{ 'TargetGroupArn' = 'Test' } }
+ Mock -CommandName Get-ELB2TargetHealth -ModuleName $moduleForMock -MockWith { return @() }
+ Mock -CommandName Get-EC2Instance -ModuleName $moduleForMock -MockWith { return @() }
+
+ It "Parameter Validation Should Not Throw" {
+ { Get-NginxIpAddresses -TargetEnvironment "PROD" } | Should Not Throw
+ }
+ }
+
+ Context "Writes Error if AWS Target Group Is Null" {
+
+ Mock -CommandName Write-Error -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Get-ELB2TargetGroup -ModuleName $moduleForMock -MockWith { return $null }
+
+ $result = ( Get-NginxIpAddresses -TargetEnvironment "prod" )
+
+ It "Called Get-ELB2TargetGroup" {
+ Assert-MockCalled -CommandName Get-ELB2TargetGroup -Times 1 -Exactly -Scope Context -ModuleName $moduleForMock
+ }
+
+ It "Called Write-Error With an Appropriate Error Message" {
+ Assert-MockCalled -CommandName Write-Error -Times 1 -Exactly -Scope Context -ModuleName $moduleForMock `
+ -ParameterFilter { $Message -match "No ELB target group" }
+ }
+
+ It "Returned Null" {
+ $result | Should -BeNull
+ }
+ }
+
+ Context "Writes Error if AWS Target Group Is Empty" {
+
+ Mock -CommandName Write-Error -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Get-ELB2TargetGroup -ModuleName $moduleForMock -MockWith { return @() }
+
+ $result = ( Get-NginxIpAddresses -TargetEnvironment "prod" )
+
+ It "Called Get-ELB2TargetGroup" {
+ Assert-MockCalled -CommandName Get-ELB2TargetGroup -Times 1 -Exactly -Scope Context -ModuleName $moduleForMock
+ }
+
+ It "Called Write-Error With an Appropriate Error Message" {
+ Assert-MockCalled -CommandName Write-Error -Times 1 -Exactly -Scope Context -ModuleName $moduleForMock `
+ -ParameterFilter { $Message -match "No ELB target group" }
+ }
+
+ It "Returned Null" {
+ $result | Should -BeNull
+ }
+ }
+
+ Context "Writes Error if AWS Target Group Returns More Than One Result" {
+
+ Mock -CommandName Write-Error -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Get-ELB2TargetGroup -ModuleName $moduleForMock -MockWith { return @("Test", "Test") }
+
+ $result = ( Get-NginxIpAddresses -TargetEnvironment "prod" )
+
+ It "Called Get-ELB2TargetGroup" {
+ Assert-MockCalled -CommandName Get-ELB2TargetGroup -Times 1 -Exactly -Scope Context -ModuleName $moduleForMock
+ }
+
+ It "Called Write-Error With an Appropriate Error Message" {
+ Assert-MockCalled -CommandName Write-Error -Times 1 -Exactly -Scope Context -ModuleName $moduleForMock `
+ -ParameterFilter { $Message -match "search returned more than one result." }
+ }
+
+ It "Returned Null" {
+ $result | Should -BeNull
+ }
+ }
+
+ Context "Writes Error if AWS Target Group Instance List Is Null" {
+
+ Mock -CommandName Write-Error -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Get-ELB2TargetGroup -ModuleName $moduleForMock -MockWith { return @{ 'TargetGroupArn' = 'Test' } }
+ Mock -CommandName Get-ELB2TargetHealth -ModuleName $moduleForMock -MockWith { return $null }
+
+ $result = ( Get-NginxIpAddresses -TargetEnvironment "prod" )
+
+ It "Called Get-ELB2TargetGroup" {
+ Assert-MockCalled -CommandName Get-ELB2TargetHealth -Times 1 -Exactly -Scope Context -ModuleName $moduleForMock
+ }
+
+ It "Called Write-Error With an Appropriate Error Message" {
+ Assert-MockCalled -CommandName Write-Error -Times 1 -Exactly -Scope Context -ModuleName $moduleForMock `
+ -ParameterFilter { $Message -match "No instances found" }
+ }
+
+ It "Returned Null" {
+ $result | Should -BeNull
+ }
+ }
+
+ Context "Writes Error if AWS Target Group Instance List Is Empty" {
+
+ Mock -CommandName Write-Error -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Get-ELB2TargetGroup -ModuleName $moduleForMock -MockWith { return @{ 'TargetGroupArn' = 'Test' } }
+ Mock -CommandName Get-ELB2TargetHealth -ModuleName $moduleForMock -MockWith { return @() }
+
+ $result = ( Get-NginxIpAddresses -TargetEnvironment "prod" )
+
+ It "Called Get-ELB2TargetGroup" {
+ Assert-MockCalled -CommandName Get-ELB2TargetHealth -Times 1 -Exactly -Scope Context -ModuleName $moduleForMock
+ }
+
+ It "Called Write-Error With an Appropriate Error Message" {
+ Assert-MockCalled -CommandName Write-Error -Times 1 -Exactly -Scope Context -ModuleName $moduleForMock `
+ -ParameterFilter { $Message -match "No instances found" }
+ }
+
+ It "Returned Null" {
+ $result | Should -BeNull
+ }
+ }
+}
diff --git a/Modules/Alkami.DevOps.Operations/Public/Get-NginxUpstreams.ps1 b/Modules/Alkami.DevOps.Operations/Public/Get-NginxUpstreams.ps1
new file mode 100644
index 0000000..9e181fe
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Get-NginxUpstreams.ps1
@@ -0,0 +1,59 @@
+function Get-NginxUpstreams {
+ <#
+.SYNOPSIS
+ Get the upstreams from NGINX via HTTP API for a given environment and Nginx server
+
+.PARAMETER NginxServer
+ The domain name part of the API URI to query
+
+.PARAMETER Port
+ The port part of the API URI to query
+
+.PARAMETER UpstreamAPIPartialPath
+ The path part of the API URI to query
+
+.PARAMETER TargetEnvironment
+ Which Environment to query; staging or prod
+
+.PARAMETER ProfileName
+ AWS ProfileName
+
+.PARAMETER Region
+ AWS Region
+
+#>
+ [CmdletBinding()]
+ param (
+ [Parameter(mandatory)]
+ [string]$NginxServer,
+
+ [string]$Port = "8080",
+
+ [string]$UpstreamAPIPartialPath = "/api/3/http/upstreams/",
+
+ [Parameter(Mandatory = $true)]
+ [ValidateSet("staging", "prod")]
+ [string]$TargetEnvironment,
+
+ [Parameter(Mandatory = $false)]
+ [string]$ProfileName,
+
+ [Parameter(Mandatory = $false)]
+ [string]$Region
+ )
+
+ $logLead = Get-LogLeadName
+
+ if ($TargetEnvironment -eq "staging") {
+ $getAuthHeaders = Get-NginxAuthHeaders -ProfileName $ProfileName -Region $Region
+ }
+
+ $uri = "$($NginxServer):$($Port)$($UpstreamAPIPartialPath)"
+ Write-Host "$logLead : Getting upstreams from $uri"
+ $results = Invoke-RestMethod -Uri $uri -Headers $getAuthHeaders -Method 'GET' -UseBasicParsing
+
+ if ($null -eq $results){
+ Write-Error "$logLead : No upsteams found at $uri"
+ }
+ return $results
+}
diff --git a/Modules/Alkami.DevOps.Operations/Public/Get-OrbAutoScalingGroup.ps1 b/Modules/Alkami.DevOps.Operations/Public/Get-OrbAutoScalingGroup.ps1
new file mode 100644
index 0000000..ee53c77
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Get-OrbAutoScalingGroup.ps1
@@ -0,0 +1,61 @@
+function Get-OrbAutoScalingGroup {
+<#
+.SYNOPSIS
+ Gets all of the Auto Scaling Groups that are ORB-specific ASGs.
+
+.DESCRIPTION
+ Will return ORB-specific ASGs using the following tag filters:
+ alk:service = orb
+
+ Returns a System.Collections.Generic.List of Amazon.AutoScaling.Model.AutoScalingGroup objects.
+
+.PARAMETER profileName
+ [string] AWS Profile. ("Sandbox", "prod")
+
+.PARAMETER awsRegion
+ [string] AWS Region. ("us-east-1")
+
+.EXAMPLE
+ Get-OrbAutoScalingGroup -profileName Sandbox -Region us-east-1
+#>
+ [CmdletBinding()]
+ [OutputType([System.Collections.Generic.List[Amazon.AutoScaling.Model.AutoScalingGroup]])]
+ Param (
+ [Parameter(Mandatory=$true)]
+ [string]$profileName,
+
+ [Alias("Region")]
+ [Parameter(Mandatory=$true)]
+ [string]$awsRegion
+ )
+ $logLead = (Get-LogLeadName)
+
+ Import-AWSModule # AS
+
+ # Get all of the AutoScalingGroups in AWS.
+ Write-Host "$logLead : Getting all ORB ASGs."
+
+ $asgList = (
+ Invoke-CommandWithRetry -Arguments ($profileName, $awsRegion) -MaxRetries 3 -Exponential -ScriptBlock {
+ param($sbProfileName, $sbAwsRegion)
+ return Get-ASAutoScalingGroup -ProfileName $sbProfileName -Region $sbAwsRegion
+ }
+ )
+
+ # Loop over the groups and filter out the asgs we don't need.
+ $filteredAsgList = [System.Collections.Generic.List[Amazon.AutoScaling.Model.AutoScalingGroup]]::new()
+
+ foreach($asg in $asgList){
+ # Loop to find all asgs with alk:service tags...
+ foreach($tag in $asg.Tags) {
+ if($tag.Key -eq "alk:service" -and $tag.Value -eq "orb") {
+ # Add this item to the main filtered list as it has tags we need.
+ $filteredAsgList.Add($asg)
+ Write-Verbose "$logLead : Found an ASG that matches: $($asg.AutoScalingGroupName)."
+ break
+ }
+ }
+ }
+
+ return $filteredAsgList
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Public/Get-PercentageServerCount.ps1 b/Modules/Alkami.DevOps.Operations/Public/Get-PercentageServerCount.ps1
new file mode 100644
index 0000000..061c6a3
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Get-PercentageServerCount.ps1
@@ -0,0 +1,47 @@
+function Get-PercentageServerCount {
+<#
+.SYNOPSIS
+ Returns a number of servers given a percentage of servers, by server list or count.
+ Any non-zero percentage will return at least 1 server.
+
+.PARAMETER ServerCount
+ Count of servers to consider to determine a percentage of servers.
+
+.PARAMETER Percentage
+ [0-100] Percentage of servers to return a server-count with.
+
+.OUTPUTS
+ Returns the number of servers. Any non-zero percentage will return at least 1 server
+#>
+ [CmdletBinding()]
+ [OutputType([System.Int32])]
+ param(
+ [Parameter(Mandatory = $true)]
+ [int]$ServerCount,
+ [Parameter(Mandatory = $true)]
+ [int]$Percentage
+ )
+
+ # Clamp the percentage between 0-100
+ if($Percentage -gt 100) {
+ $Percentage = 100
+ }
+
+ # If the percentage is less than or equal to 0, just return 0.
+ # We can't assume that they want a min of 1 server here.
+ if($Percentage -le 0) {
+ return 0;
+ }
+
+ # If the server count is zero, early out.
+ if($ServerCount -eq 0) {
+ return 0
+ }
+
+ # Figure out how many servers the percentage represents rounded down, and return.
+ $normalizedPercentage = [float]$Percentage / 100.0
+ $numServers = $ServerCount * $normalizedPercentage # Get number of servers
+ $numServers = [math]::Floor($numServers) # Round down
+ $numServers = [math]::Max($numServers, 1) # Minimum of 1 server
+ return $numServers
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Public/Get-PercentageServerCount.tests.ps1 b/Modules/Alkami.DevOps.Operations/Public/Get-PercentageServerCount.tests.ps1
new file mode 100644
index 0000000..07159ba
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Get-PercentageServerCount.tests.ps1
@@ -0,0 +1,77 @@
+#. $PSScriptRoot\..\..\Load-PesterModules.ps1
+#$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+#$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+#$functionPath = Join-Path -Path $here -ChildPath $sut
+#Write-Host "Overriding SUT: $functionPath"
+#Import-Module $functionPath -Force
+#$moduleForMock = ""
+
+Describe "Get-PercentageServerCount" {
+
+ Context "ByServerCount" {
+
+ It "0 Servers" {
+
+ $serverCount = 0
+ $percentage = 50
+ $result = Get-PercentageServerCount -ServerCount $serverCount -Percentage $percentage
+ $result | Should -Be 0
+ }
+
+ It "0% of 4 is 0" {
+ # Minimum server count of 1 doesn't apply to 0%
+ $serverCount = 4
+ $percentage = 0
+ $result = Get-PercentageServerCount -ServerCount $serverCount -Percentage $percentage
+ $result | Should -Be 0
+ }
+
+ It "Any >0% returns 1" {
+ # Make sure min server count applies.
+ $serverCount = 4
+ $percentage = 1
+ $result = Get-PercentageServerCount -ServerCount $serverCount -Percentage $percentage
+ $result | Should -Be 1
+ }
+
+ # Thresholds test.
+ $cases = @(
+ # 0% Threshold Tests
+ @{ ServerCount = 4; Percentage = -1; Expect = 0 },
+ @{ ServerCount = 4; Percentage = 0; Expect = 0 },
+ @{ ServerCount = 4; Percentage = 1; Expect = 1 },
+
+ # 25% Threshold Tests
+ @{ ServerCount = 4; Percentage = 24; Expect = 1 },
+ @{ ServerCount = 4; Percentage = 25; Expect = 1 },
+ @{ ServerCount = 4; Percentage = 26; Expect = 1 },
+
+ # 50% Threshold Tests
+ @{ ServerCount = 4; Percentage = 49; Expect = 1 },
+ @{ ServerCount = 4; Percentage = 50; Expect = 2 },
+ @{ ServerCount = 4; Percentage = 51; Expect = 2 },
+
+ # 75% Threshold Tests
+ @{ ServerCount = 4; Percentage = 74; Expect = 2 },
+ @{ ServerCount = 4; Percentage = 75; Expect = 3 },
+ @{ ServerCount = 4; Percentage = 76; Expect = 3 },
+
+ # 100% Threshold Tests
+ @{ ServerCount = 4; Percentage = 99; Expect = 3 },
+ @{ ServerCount = 4; Percentage = 100; Expect = 4 },
+ @{ ServerCount = 4; Percentage = 101; Expect = 4 }
+ )
+ It "Threshold -/+ Tests." -TestCases $cases {
+ param($ServerCount, $Percentage, $Expect)
+ $result = Get-PercentageServerCount -ServerCount $ServerCount -Percentage $Percentage
+ $result | Should -Be $Expect -Because "$($Percentage)% of $ServerCount servers should be $Expect servers";
+ }
+
+ It "Large Test: 25% of 400 is 100" {
+ $serverCount = 400
+ $percentage = 25
+ $result = Get-PercentageServerCount -ServerCount $serverCount -Percentage $percentage
+ $result | Should -Be 100
+ }
+ }
+}
diff --git a/Modules/Alkami.DevOps.Operations/Public/Get-ProviderMappingFilePath.ps1 b/Modules/Alkami.DevOps.Operations/Public/Get-ProviderMappingFilePath.ps1
new file mode 100644
index 0000000..93509bd
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Get-ProviderMappingFilePath.ps1
@@ -0,0 +1,29 @@
+function Get-ProviderMappingFilePath {
+
+<#
+.SYNOPSIS
+ Returns the full path to the embedded PackageFiltering-min JSON file
+
+.DESCRIPTION
+ Returns the full path to the embedded PackageFiltering-min JSON file. Uses the FileList as defined in
+ the module PSM1 for location
+
+.OUTPUTS
+ Returns the full path to the bundled minified file.
+#>
+
+ [CmdletBinding()]
+ [OutputType([string])]
+ param()
+
+ $logLead = Get-LogLeadName
+ $minifiedFile = $MyInvocation.MyCommand.Module.FileList | Where-Object {$_ -match "PackageFiltering-min.json"}
+
+ if ($null -eq $minifiedFile -or (-NOT (Test-Path -Path $minifiedFile))) {
+
+ Write-Warning "$logLead : Could not find bundled minified file. Check the module health or for build problems."
+ return $null
+ }
+
+ return $minifiedFile
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Public/Get-ProviderMappingFilePath.tests.ps1 b/Modules/Alkami.DevOps.Operations/Public/Get-ProviderMappingFilePath.tests.ps1
new file mode 100644
index 0000000..3ed15c5
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Get-ProviderMappingFilePath.tests.ps1
@@ -0,0 +1,26 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ""
+
+Describe "Get-ProviderMappingFilePath" {
+
+ Mock -CommandName Get-LogLeadName -ModuleName $moduleForMock -MockWith { "[$sut (Pester)]" }
+
+ Context "Tests" {
+
+ # Can't really test happy path, since we use a module-specifc construct to find the file
+ #
+ It "Writes a Warning and Returns Null if No File Found" {
+
+ Mock -ModuleName $moduleForMock -CommandName Write-Warning -MockWith {}
+
+ Get-ProviderMappingFilePath | Should -BeNullOrEmpty
+ Assert-MockCalled -CommandName Write-Warning -Times 1 -Exactly -Scope It `
+ -ModuleName $moduleForMock -ParameterFilter { $Message -match "Could not find bundled minified file." }
+ }
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Public/Get-ProviderMappingFileUnion.ps1 b/Modules/Alkami.DevOps.Operations/Public/Get-ProviderMappingFileUnion.ps1
new file mode 100644
index 0000000..43dc174
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Get-ProviderMappingFileUnion.ps1
@@ -0,0 +1,130 @@
+function Get-ProviderMappingFileUnion {
+
+<#
+.SYNOPSIS
+ Compares configured providers to the Vanguard mapping file, and identifies chocolatey packages which are used/unused based on configuration and user input
+
+.DESCRIPTION
+ Compares configured providers to the Vanguard mapping file, and identifies chocolatey packages which are used/unused based on configuration. Queries
+ provider information from all tenants in the specified master database, compares to the Vanguard mapping file, and returns provider and package information
+ for those which can be safely disabled or enabled, based on user input
+
+.PARAMETER MasterConnectionString
+ The full Master databse connection string. Defaults to the local ConnectionString from the machine.config file
+
+.PARAMETER OverrideEnabledProviderPackageIds
+ Array of PackageIds to include in being enabled. This will be combined with return value from Get-AlwaysEnabledProviderPackageIds to force these to be returned in the "MatchedProviders" list
+
+.OUTPUTS
+ Unmatched provider and package data based on configured providers in all tenants
+
+.LINK
+ Get-AlwaysEnabledProviderPackageIds
+#>
+
+ [CmdletBinding()]
+ [OutputType([System.Object[]])]
+ param(
+ [Parameter(Mandatory=$false)]
+ [string]$MasterConnectionString = (Get-ConnectionString "AlkamiMaster"),
+
+ [Parameter(Mandatory = $false)]
+ [string[]]$OverrideEnabledProviderPackageIds = @(),
+
+ [Parameter(Mandatory=$true, ParameterSetName = "MatchedProviders")]
+ [switch]$ReturnMatched,
+
+ [Parameter(Mandatory=$true, ParameterSetName = "UnmatchedProviders")]
+ [switch]$ReturnUnmatched
+ )
+
+ $logLead = Get-LogLeadName
+
+ if (Test-StringIsNullOrEmpty -Value $MasterConnectionString) {
+
+ Write-Warning "$logLead : Supply a valid Master database connection string to this function and rerun"
+ return $null
+ }
+
+ [PSObject[]]$providerMap = Read-ProviderMappingFile
+
+ if (Test-IsCollectionNullOrEmpty -Collection $providerMap) {
+
+ Write-Warning "$logLead : The provider mapping file returned no valid values. Exiting."
+ return
+ } else {
+
+ Write-Host "$logLead : $($providerMap.Count) providers read from the provider mapping file"
+ }
+
+ [PSObject[]]$providers = Read-ProviderConfigurationFromTenants -MasterConnectionString $MasterConnectionString
+ Write-Verbose "$logLead : $($providers.Count) unique providers read from the tenants"
+
+ [array]$alwaysEnabledProviderPackageIds = Get-AlwaysEnabledProviderPackageIds
+ [array]$packageIdsToAlwaysReturnMatched = $OverrideEnabledProviderPackageIds + $alwaysEnabledProviderPackageIds
+
+ foreach ($provider in $providers) {
+
+ # Yes, more than one package might use the same provider record
+ [array]$matchingProviders = $providerMap | Where-Object {
+ (
+ $_.ProviderName -eq $provider.ProviderName -and $_.ProviderType -eq $provider.ProviderType
+ ) -or
+ (
+ $_.PackageId -in $packageIdsToAlwaysReturnMatched
+ )
+ }
+
+ if ($null -ne $matchingProviders) {
+
+ # Log matched providers, but don't log if they're the always matched ones. That's just annoying
+ # Yes I know we loop over this again right after this, but I don't want any error in the functions there to screw up what should be a clean console log
+ $matchingProvidersWithoutAlwaysMatched = $matchingProviders | Where-Object {$_.PackageId -notin $packageIdsToAlwaysReturnMatched}
+
+ if (-NOT (Test-IsCollectionNullOrEmpty $matchingProvidersWithoutAlwaysMatched)) {
+ if ($matchingProvidersWithoutAlwaysMatched.Count -gt 1) {
+
+ Write-Host "$logLead : Matched provider [$($provider.ProviderName)] to multiple packages:"
+ $matchingProvidersWithoutAlwaysMatched | ForEach-Object { Write-Host "`t$($_.PackageId)"}
+
+ } else {
+
+ Write-Host "$logLead : Matched provider [$($provider.ProviderName)] to package [$($matchingProvidersWithoutAlwaysMatched | Select-Object -First 1 -ExpandProperty PackageId)]"
+ }
+ }
+
+ foreach ($matchingProvider in $matchingProviders) {
+
+ if ($null -eq $matchingProvider.PSObject.Properties["MatchCount"]) {
+
+ Add-Member -InputObject $matchingProvider -TypeName Int32 -Name MatchCount -Value 1 -MemberType NoteProperty -Force
+ } else {
+
+ $matchingProvider.MatchCount++
+ }
+ }
+ } else {
+
+ Write-Verbose "$logLead : The provider named [$($provider.ProviderName)] found in the databases was not matched to the mapping file by provider name and type, and will not be acted upon"
+ }
+ }
+
+ [array]$unmatchedProviders = $providerMap | Where-Object {$null -eq $_.MatchCount}
+ [array]$matchedProviders = $providerMap | Where-Object {$_.MatchCount -gt 0}
+
+ if ($ReturnMatched.IsPresent) {
+
+ Write-Host "$logLead : Found $($matchedProviders.Count) provider(s) to enable based on mapping file."
+ return $matchedProviders
+
+ } elseif ($ReturnUnmatched.IsPresent) {
+
+ Write-Host "$logLead : Found $($unmatchedProviders.Count) provider(s) to disable based on mapping file. $($matchedProviders.Count) valid provider(s) found and will remain enabled."
+ return $unmatchedProviders
+
+ } else {
+
+ Write-Warning "$logLead : How did you even get here? This needs to be reported as a bug, it should not be possible"
+ throw [System.NotImplementedException]::New("Neither Matched nor Unmatched was Specified")
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Public/Get-ProviderMappingFileUnion.tests.ps1 b/Modules/Alkami.DevOps.Operations/Public/Get-ProviderMappingFileUnion.tests.ps1
new file mode 100644
index 0000000..4b7bcc5
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Get-ProviderMappingFileUnion.tests.ps1
@@ -0,0 +1,90 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ""
+
+Describe "Get-ProviderMappingFileUnion" {
+
+ Mock -CommandName Get-LogLeadName -ModuleName $moduleForMock -MockWith { "[$sut (Pester)]" }
+ Mock -CommandName Read-ProviderMappingFile -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Read-ProviderConfigurationFromTenants -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Get-ConnectionString -ModuleName $moduleForMock -MockWith { return "FakeConnectionString" }
+
+ Context "General Tests" {
+
+ It "Writes a Warning and Exits Early if an Empty Master Connection String is Provided" {
+
+ Mock -CommandName Write-Warning -ModuleName $moduleForMock -MockWith { }
+
+ Get-ProviderMappingFileUnion -MasterConnectionString $null -ReturnUnmatched | Should -BeNullOrEmpty
+ Assert-MockCalled -CommandName Write-Warning -Times 1 -Exactly -Scope It `
+ -ModuleName $moduleForMock -ParameterFilter { $Message -match "Supply a valid Master database connection string" }
+ Assert-MockCalled -CommandName Read-ProviderMappingFile -Times 0 -Exactly -Scope It
+ }
+
+ It "Uses the Parameter Value for the MasterConnectionString to Lookup Providers" {
+
+ Mock -CommandName Read-ProviderMappingFile -ModuleName $moduleForMock -MockWith { return @("c", "is", "for", "cookie") }
+ Mock -CommandName Read-ProviderConfigurationFromTenants -ModuleName $moduleForMock -MockWith {}
+
+ $fakeConnectionString = "Server=localhost;InitialCatalog=LittleBobbyTables;"
+ Get-ProviderMappingFileUnion -MasterConnectionString $fakeConnectionString -ReturnUnmatched
+ Assert-MockCalled -CommandName Read-ProviderConfigurationFromTenants -Times 1 -Exactly -Scope It `
+ -ParameterFilter { $MasterConnectionString -eq $fakeConnectionString }
+ }
+ }
+
+ Context "Matching Logic" {
+
+ Mock -CommandName Read-ProviderMappingFile -ModuleName $moduleForMock -MockWith {
+
+ $providerA = @{
+ PackageId = "I.Cant.Believe.Its.Not.Butter";
+ ProviderName = "Margarine";
+ ProviderType = "SpreadableSSO";
+ }
+
+ $providerB = @{
+ PackageId = "2.Percent.Milk";
+ ProviderName = "Mooooooooooo";
+ ProviderType = "DoesABodyGood";
+ }
+
+ return @($providerA, $providerB)
+ }
+
+ Mock -CommandName Read-ProviderConfigurationFromTenants -ModuleName $moduleForMock -MockWith {
+
+ $providerA = New-Object PSObject -Property @{
+ ProviderType = "SpreadableSSO";
+ ProviderName = "Margarine";
+ }
+
+ $providerB = New-Object PSObject -Property @{
+ ProviderType = "Cheese";
+ ProviderName = "PepperJack";
+ }
+
+ return @($providerA, $providerB, $providerC)
+ }
+
+ It "Only Returns Providers that Match in File and the Database When Unmatched is selected" {
+
+ $results = Get-ProviderMappingFileUnion -ReturnUnmatched
+ $results | Should -HaveCount 1
+ $results.ProviderType | Should -BeExactly "DoesABodyGood"
+ $results.ProviderName | Should -BeExactly "Mooooooooooo"
+ }
+
+ It "Only Returns Providers that Match in File and the Database When Matched is selected" {
+
+ $results = Get-ProviderMappingFileUnion -ReturnMatched
+ $results | Should -HaveCount 1
+ $results.ProviderType | Should -BeExactly "SpreadableSSO"
+ $results.ProviderName | Should -BeExactly "Margarine"
+ }
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Public/Get-Route53HostedZoneIdList.ps1 b/Modules/Alkami.DevOps.Operations/Public/Get-Route53HostedZoneIdList.ps1
new file mode 100644
index 0000000..7f49eb5
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Get-Route53HostedZoneIdList.ps1
@@ -0,0 +1,149 @@
+function Get-Route53HostedZoneIdList {
+ <#
+.SYNOPSIS
+ Retrieves a list of Route 53 Hosted Zone IDs for a given AWS profile.
+
+.DESCRIPTION
+ Retrieves a list of Route 53 Hosted Zone IDs for a given AWS profile. Route 53 Hosted Zones are global within the account, so no region is required.
+
+.PARAMETER ProfileName
+ The AWS profile to use when making the request.
+
+.PARAMETER PrivacyStatusFilter
+ If provided, filters the results to include only public or only private Route 53 Hosted Zones.
+
+.PARAMETER IncludePoeManaged
+ If provided, will include Poe managed Hosted Zone IDs in the results. By default, Poe managed Hosted Zones are filtered from the result.
+
+.OUTPUTS
+ Return an array of Hosted Zone IDs.
+ Returns null if the AWS call throws an exception.
+
+.EXAMPLE
+ Get-Route53HostedZoneIdList -ProfileName 'temp-prod'
+
+/hostedzone/Z28CPXDEPL7DZU
+/hostedzone/ZW1IHU4IIBARF
+/hostedzone/ZXHA4L9S5JVFB
+/hostedzone/Z2DCTD3P7TUU22
+/hostedzone/Z2QAXNXCDIP0BG
+/hostedzone/Z2WPZKRBGG8D98
+/hostedzone/Z3TUFNB8OX3MZG
+/hostedzone/Z2YPL3QOGJ0UA
+/hostedzone/Z3JRWVSQT7US74
+/hostedzone/Z46IBKYBT9G0N
+/hostedzone/Z28YYZ58UUDGA
+/hostedzone/Z2H6O2L3OZDQ3M
+/hostedzone/Z3NHME8QKZEFFJ
+/hostedzone/Z2VCXJ6FU82AXY
+/hostedzone/ZFUYPMV7L13IF
+/hostedzone/Z1D2COO77E2O97
+/hostedzone/ZDM17LUHLTRB3
+/hostedzone/Z3CWPFCDELIINV
+/hostedzone/ZO8O2WS9VWYR2
+/hostedzone/Z043147535HEDWFO4YKLF
+/hostedzone/Z04465442W0EYEVZO1LN2
+/hostedzone/Z047070527YNZSP8SDPWR
+/hostedzone/Z0465462VSRRL839M32E
+/hostedzone/Z08230832FYFH11IJ3LPR
+/hostedzone/Z085622615BV9RWIT9NSK
+/hostedzone/Z06877481CPDZ38P7C22D
+
+.EXAMPLE
+ Get-Route53HostedZoneIdList -ProfileName 'temp-prod' -PrivacyStatusFilter $true
+
+/hostedzone/Z28CPXDEPL7DZU
+/hostedzone/ZW1IHU4IIBARF
+/hostedzone/ZXHA4L9S5JVFB
+/hostedzone/Z28YYZ58UUDGA
+/hostedzone/Z2H6O2L3OZDQ3M
+/hostedzone/Z3NHME8QKZEFFJ
+/hostedzone/ZFUYPMV7L13IF
+/hostedzone/ZDM17LUHLTRB3
+/hostedzone/Z3CWPFCDELIINV
+/hostedzone/Z043147535HEDWFO4YKLF
+/hostedzone/Z04465442W0EYEVZO1LN2
+/hostedzone/Z047070527YNZSP8SDPWR
+/hostedzone/Z0465462VSRRL839M32E
+/hostedzone/Z08230832FYFH11IJ3LPR
+/hostedzone/Z085622615BV9RWIT9NSK
+/hostedzone/Z06877481CPDZ38P7C22D
+
+.EXAMPLE
+ Get-Route53HostedZoneIdList -ProfileName 'temp-prod' -PrivacyStatusFilter $false
+
+/hostedzone/Z2DCTD3P7TUU22
+/hostedzone/Z2QAXNXCDIP0BG
+/hostedzone/Z2WPZKRBGG8D98
+/hostedzone/Z3TUFNB8OX3MZG
+/hostedzone/Z2YPL3QOGJ0UA
+/hostedzone/Z3JRWVSQT7US74
+/hostedzone/Z46IBKYBT9G0N
+/hostedzone/Z2VCXJ6FU82AXY
+/hostedzone/Z1D2COO77E2O97
+/hostedzone/ZO8O2WS9VWYR2
+#>
+
+ [OutputType([string[]])]
+ [CmdletBinding()]
+ param (
+ [Parameter(Mandatory = $true)]
+ [ValidateNotNullOrEmpty()]
+ [string] $ProfileName,
+
+ [Parameter(Mandatory = $false)]
+ [ValidateNotNullOrEmpty()]
+ [Nullable[bool]] $PrivacyStatusFilter = $null,
+
+ [Parameter(Mandatory = $false)]
+ [switch] $IncludePoeManaged
+ )
+
+ $result = $null
+ $logLead = Get-LogLeadName
+
+ Import-AWSModule
+
+ try {
+
+ # Attempt to retrieve the hosted zone list for the specified profile.
+ $hostedZoneList = Get-R53HostedZoneList -ProfileName $ProfileName
+
+ # If the user provided a privacy status filter, apply it to the list.
+ if ( $PSBoundParameters.ContainsKey( 'PrivacyStatusFilter' ) ) {
+
+ Write-Verbose "$logLead : Applying privacy status filter of '$PrivacyStatusFilter' to the results."
+ $hostedZoneList = $hostedZoneList | Where-Object { $PrivacyStatusFilter -eq $_.Config.PrivateZone }
+ }
+
+ if ( $IncludePoeManaged ) {
+
+ # If the user wanted all the hosted zones, including Poe managed zones,
+ # our job here is done; just give them back all the IDs.
+ [string[]]$result = $hostedZoneList.Id
+
+ } else {
+
+ # Unfortunately, we're excluding Poe managed Route 53 hosted zones.
+ # Poe managed zones have a special tag on them; AWS doesn't give us
+ # the tags in the hosted zone list result, so we have to crawl the zones
+ # looking for the tag.
+ # THANKS AWS! /s
+ [string[]] $result = @()
+ foreach ( $zone in $hostedZoneList ) {
+
+ $curTags = Get-Route53HostedZoneTagList -ProfileName $ProfileName -ZoneId $zone.Id
+ $filteredTags = $curTags | Where-Object { ($_.Key -eq 'alk:dnspropagation') -and ($_.Value -eq 'poemanaged') }
+ if (Test-IsCollectionNullOrEmpty $filteredTags) {
+ $result += $zone.Id
+ }
+ }
+ }
+
+ } catch {
+
+ Write-Warning ( "{0} : {1} : ProfileName = '{2}', PrivacyStatusFilter = '{3}', IncludePoeManaged = '{4}'" -f $logLead, $_, $ProfileName, $PrivacyStatusFilter, $IncludePoeManaged )
+ }
+
+ return $result
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Public/Get-Route53HostedZoneIdList.tests.ps1 b/Modules/Alkami.DevOps.Operations/Public/Get-Route53HostedZoneIdList.tests.ps1
new file mode 100644
index 0000000..7514a52
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Get-Route53HostedZoneIdList.tests.ps1
@@ -0,0 +1,157 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ''
+
+Describe 'Get-Route53HostedZoneIdList' {
+
+ $testPayload = @(
+ [PSCustomObject] @{
+ 'Id' = 'TestPrivate'
+ 'Config' = [PSCustomObject]@{
+ 'PrivateZone' = $true
+ }
+ },
+ [PSCustomObject]@{
+ 'Id' = 'TestPublic'
+ 'Config' = [PSCustomObject]@{
+ 'PrivateZone' = $false
+ }
+ }
+ )
+
+ $testNonPoeTags = New-Object Collections.Generic.List[pscustomobject]
+ $testNonPoeTags.Add(
+ [pscustomobject]@{
+ Key = 'alk:testkey1'
+ Value = 'alk:testvalue1'
+ }
+ )
+ $testNonPoeTags.Add(
+ [pscustomobject]@{
+ Key = 'alk:testkey2'
+ Value = 'alk:testvalue2'
+ }
+ )
+
+ $testPoeTags = New-Object Collections.Generic.List[pscustomobject]
+ $testPoeTags.Add(
+ [pscustomobject]@{
+ Key = 'alk:testkey1'
+ Value = 'alk:testvalue1'
+ }
+ )
+ $testPoeTags.Add(
+ [pscustomobject]@{
+ Key = 'alk:dnspropagation'
+ Value = 'poemanaged'
+ }
+ )
+
+ Mock -CommandName Import-AWSModule -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Get-LogLeadName -ModuleName $moduleForMock -MockWith { return 'Get-Route53HostedZoneIdList.tests' }
+ Mock -CommandName Write-Warning -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Get-R53HostedZoneList -ModuleName $moduleForMock -MockWith { return $testPayload }
+
+ Mock -CommandName Get-Route53HostedZoneTagList -ModuleName $moduleForMock -MockWith {
+ if ( $ZoneId -eq 'TestPrivate' ) {
+ return $testPoeTags
+ } else {
+ return $testNonPoeTags
+ }
+ }
+
+ Context 'Input Validation' {
+
+ It 'Profile Name Should Not Be Null' {
+
+ { Get-Route53HostedZoneIdList -ProfileName $null } | Should -Throw
+ }
+
+ It 'Profile Name Should Not Be Empty' {
+
+ { Get-Route53HostedZoneIdList -ProfileName '' } | Should -Throw
+ }
+
+ It 'Privacy Status Filter Should Not Be Null' {
+
+ { Get-Route53HostedZoneIdList -ProfileName 'Test' -PrivacyStatusFilter $null } | Should -Throw
+ }
+
+ It 'Privacy Status Filter Should Not Be Empty' {
+
+ { Get-Route53HostedZoneIdList -ProfileName 'Test' -PrivacyStatusFilter '' } | Should -Throw
+ }
+
+ It 'Privacy Status Filter Should Be Boolean' {
+
+ { Get-Route53HostedZoneIdList -ProfileName 'Test' -PrivacyStatusFilter 'Test' } | Should -Throw
+ }
+ }
+
+ Context 'Result Validation' {
+
+ It 'Should Write Warning If AWS Call Fails' {
+
+ Mock -CommandName Get-R53HostedZoneList -ModuleName $moduleForMock -MockWith { throw 'This is a test.' }
+
+ Get-Route53HostedZoneIdList -ProfileName 'Test' | Out-Null
+
+ Assert-MockCalled -CommandName Write-Warning -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Get-R53HostedZoneList -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+
+ Mock -CommandName Get-R53HostedZoneList -ModuleName $moduleForMock -MockWith { return $testPayload }
+ }
+
+ It 'Should Return Null If AWS Call Fails' {
+
+ Mock -CommandName Get-R53HostedZoneList -ModuleName $moduleForMock -MockWith { throw 'This is a test.' }
+
+ ( Get-Route53HostedZoneIdList -ProfileName 'Test' ) | Should -BeNull
+
+ Mock -CommandName Get-R53HostedZoneList -ModuleName $moduleForMock -MockWith { return $testPayload }
+ }
+
+ It 'Should Not Write Warning If AWS Call Succeeds' {
+
+ Get-Route53HostedZoneIdList -ProfileName 'Test' | Out-Null
+
+ Assert-MockCalled -CommandName Write-Warning -Times 0 -Exactly -Scope It -ModuleName $moduleForMock
+ }
+
+ It 'Should Return All Results When Privacy Status Filter Is Not Provided' {
+
+ $result = Get-Route53HostedZoneIdList -ProfileName 'Test' -IncludePoeManaged
+
+ $result | Should -Contain 'TestPrivate'
+ $result | Should -Contain 'TestPublic'
+ }
+
+ It 'Should Return Only Private Results When Privacy Status Filter Is True' {
+
+ $result = Get-Route53HostedZoneIdList -ProfileName 'Test' -PrivacyStatusFilter $true -IncludePoeManaged
+
+ $result | Should -Contain 'TestPrivate'
+ $result | Should -Not -Contain 'TestPublic'
+ }
+
+ It 'Should Return Only Public Results When Privacy Status Filter Is False' {
+
+ $result = Get-Route53HostedZoneIdList -ProfileName 'Test' -PrivacyStatusFilter $false -IncludePoeManaged
+
+ $result | Should -Not -Contain 'TestPrivate'
+ $result | Should -Contain 'TestPublic'
+ }
+
+ It 'Should Not Return Poe Managed Zones By Default' {
+
+ $result = Get-Route53HostedZoneIdList -ProfileName 'Test'
+
+ $result | Should -Not -Contain 'TestPrivate'
+ $result | Should -Contain 'TestPublic'
+ }
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Public/Get-Route53HostedZoneTagList.ps1 b/Modules/Alkami.DevOps.Operations/Public/Get-Route53HostedZoneTagList.ps1
new file mode 100644
index 0000000..d3abf31
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Get-Route53HostedZoneTagList.ps1
@@ -0,0 +1,62 @@
+function Get-Route53HostedZoneTagList {
+ <#
+.SYNOPSIS
+ Retrieves a list of tags for a Route 53 Hosted Zone in a given AWS profile.
+
+.DESCRIPTION
+ Retrieves a list of tags for a Route 53 Hosted Zone in a given AWS profile. Route 53 Hosted Zones are global within the account, so no region is required.
+
+.PARAMETER ProfileName
+ The AWS profile to use when making the request.
+
+.PARAMETER ZoneId
+ The target Route53 Hosted Zone ID.
+
+.OUTPUTS
+ Return a list of tag objects (ref: https://docs.aws.amazon.com/sdkfornet/v3/apidocs/items/Route53/TTag.html)
+ Returns null if the AWS call throws an exception.
+
+.EXAMPLE
+ Get-Route53HostedZoneTagList -ProfileName 'temp-mgmt' -ZoneId '/hostedzone/Z0733762FO2Y6VVLR7A'
+
+Key Value
+--- -----
+alk:role infratools
+alk:env mgmt
+alk:project infra
+alk:service ec2
+#>
+
+ [OutputType([System.Collections.Generic.List[PSObject]])]
+ [CmdletBinding()]
+ param (
+ [Parameter(Mandatory = $true)]
+ [ValidateNotNullOrEmpty()]
+ [string] $ProfileName,
+
+ [Parameter(Mandatory = $true)]
+ [ValidateNotNullOrEmpty()]
+ [string] $ZoneId
+ )
+
+ $result = $null
+ $logLead = Get-LogLeadName
+
+ Import-AWSModule
+
+ try {
+
+ # In most of their API responses, the Hosted Zone ID returned by AWS is in the format
+ # /hostedzone/AAAAAAAAAAAAAAAAAAAAA
+ # For this endpoint, AWS doesn't want that prefix applied, so strip it down to match their expectation.
+ $resourceId = ($ZoneId -split '/')[-1]
+
+ $result = (Get-R53TagsForResource -ResourceId $resourceId -ResourceType 'hostedzone' -ProfileName $ProfileName).Tags
+
+ } catch {
+
+ Write-Warning ( "{0} : {1} : ProfileName = '{2}', ZoneId = '{3}'" -f $logLead, $_, $ProfileName, $ZoneId )
+ }
+
+ return $result
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Public/Get-Route53HostedZoneTagList.tests.ps1 b/Modules/Alkami.DevOps.Operations/Public/Get-Route53HostedZoneTagList.tests.ps1
new file mode 100644
index 0000000..7df3c20
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Get-Route53HostedZoneTagList.tests.ps1
@@ -0,0 +1,103 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ''
+
+Describe 'Get-Route53HostedZoneTagList' {
+
+ $testTags = New-Object Collections.Generic.List[pscustomobject]
+ $testTags.Add(
+ [pscustomobject]@{
+ Key = 'alk:testkey1'
+ Value = 'alk:testvalue1'
+ }
+ )
+ $testTags.Add(
+ [pscustomobject]@{
+ Key = 'alk:testkey2'
+ Value = 'alk:testvalue2'
+ }
+ )
+
+ $testResult = [pscustomobject]@{ Tags = $testTags }
+
+ Mock -CommandName Import-AWSModule -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Get-LogLeadName -ModuleName $moduleForMock -MockWith { return 'Get-Route53HostedZoneTagList.tests' }
+ Mock -CommandName Write-Warning -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Get-R53TagsForResource -ModuleName $moduleForMock -MockWith { return $testResult }
+
+ Context 'Input Validation' {
+
+ It 'Profile Name Should Not Be Null' {
+
+ { Get-Route53HostedZoneTagList -ProfileName $null } | Should -Throw
+ }
+
+ It 'Profile Name Should Not Be Empty' {
+
+ { Get-Route53HostedZoneTagList -ProfileName '' } | Should -Throw
+ }
+
+ It 'Zone ID Should Not Be Null' {
+
+ { Get-Route53HostedZoneTagList -ProfileName 'Test' -ZoneId $null } | Should -Throw
+ }
+
+ It 'Zone ID Should Not Be Empty' {
+
+ { Get-Route53HostedZoneTagList -ProfileName 'Test' -ZoneId '' } | Should -Throw
+ }
+ }
+
+ Context 'Result Validation' {
+
+ It 'Should Write Warning If AWS Call Fails' {
+
+ Mock -CommandName Get-R53TagsForResource -ModuleName $moduleForMock -MockWith { throw 'This is a test.' }
+
+ Get-Route53HostedZoneTagList -ProfileName 'Test' -ZoneId 'TestZoneId' | Out-Null
+
+ Assert-MockCalled -CommandName Write-Warning -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Get-R53TagsForResource -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+
+ Mock -CommandName Get-R53TagsForResource -ModuleName $moduleForMock -MockWith { return $testResult }
+ }
+
+ It 'Should Return Null If AWS Call Fails' {
+
+ Mock -CommandName Get-R53TagsForResource -ModuleName $moduleForMock -MockWith { throw 'This is a test.' }
+
+ ( Get-Route53HostedZoneTagList -ProfileName 'Test' -ZoneId 'TestZoneId' ) | Should -BeNull
+
+ Assert-MockCalled -CommandName Get-R53TagsForResource -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+
+ Mock -CommandName Get-R53TagsForResource -ModuleName $moduleForMock -MockWith { return $testResult }
+ }
+
+ It 'Should Not Write Warning If AWS Call Succeeds' {
+
+ Get-Route53HostedZoneTagList -ProfileName 'Test' -ZoneId 'TestZoneId' | Out-Null
+
+ Assert-MockCalled -CommandName Write-Warning -Times 0 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Get-R53TagsForResource -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+ }
+
+ It 'Should Return a List Containing All Tags' {
+
+ $result = Get-Route53HostedZoneTagList -ProfileName 'Test' -ZoneId 'TestZoneId'
+
+ $result | Should -HaveCount $testTags.Count
+ }
+
+ It 'Should Format the Input Zone ID to Appease AWS' {
+
+ Get-Route53HostedZoneTagList -ProfileName 'Test' -ZoneId '/hostedzone/TestZoneId' | Out-Null
+
+ Assert-MockCalled -CommandName Get-R53TagsForResource -Times 1 -Exactly -Scope It -ModuleName $moduleForMock `
+ -ParameterFilter { $Resourceid -eq 'TestZoneId' }
+ }
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Public/Get-SupportedAwsRegions.ps1 b/Modules/Alkami.DevOps.Operations/Public/Get-SupportedAwsRegions.ps1
new file mode 100644
index 0000000..af8b7e0
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Get-SupportedAwsRegions.ps1
@@ -0,0 +1,11 @@
+function Get-SupportedAwsRegions {
+ <#
+ .SYNOPSIS
+ Returns a list of AWS regions currently considered as supported. This function does not return a complete list of every AWS region.
+ #>
+ [CmdletBinding()]
+ [OutputType([System.Object[]])]
+ param()
+
+ return @("us-east-1", "us-west-2")
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Public/Get-TenantTimeZone.ps1 b/Modules/Alkami.DevOps.Operations/Public/Get-TenantTimeZone.ps1
new file mode 100644
index 0000000..476ecbc
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Get-TenantTimeZone.ps1
@@ -0,0 +1,53 @@
+function Get-TenantTimeZone {
+ <#
+.SYNOPSIS
+ This function queries the time zone name (TimeZoneInfo) for a FI given a tenant connection string.
+
+.PARAMETER TenantConnectionString
+ [string] The connection string for the tenant FI we want to query a timezone from.
+ #>
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory=$true)]
+ [string]$TenantConnectionString
+ )
+
+ try {
+ $sqlConnection = New-Object System.Data.SqlClient.SqlConnection $TenantConnectionString
+ $sqlConnection.Open()
+
+ [System.Data.SqlClient.SqlCommand]$command = $sqlConnection.CreateCommand()
+
+ # Define query to get the bank timezone name.
+ $command.CommandText = "select top 1 TimeZoneInfo from core.Bank"
+
+ # Execute the command and parse the results out to $data
+ [System.Data.SqlClient.SqlDataReader]$reader = $command.ExecuteReader()
+ $data = @()
+ while ($reader.Read()) {
+ $data += @{
+ TimeZoneInfo = $reader[0]
+ }
+ }
+
+ # Each query only has one row result.
+ $record = $data[0]
+ if([string]::IsNullOrWhiteSpace($record.TimeZoneInfo)) {
+ Write-Error "$loglead : Could not find a configured timezone for FI $($tenant.Name)"
+ } else {
+ # Return the TimeZoneInfo name.
+ return $record.TimeZoneInfo
+ }
+ } catch {
+ Write-Error "$logLead : An error occurred querying for the timezone from tenant $($tenant.Name)"
+ Write-Host $_.Exception.Message
+ } finally {
+ # Clean up database connection.
+ if($null -ne $reader) {
+ $reader.Dispose()
+ }
+ if($null -ne $sqlConnection) {
+ $sqlConnection.Dispose();
+ }
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Public/Get-VpcIdList.ps1 b/Modules/Alkami.DevOps.Operations/Public/Get-VpcIdList.ps1
new file mode 100644
index 0000000..12d2f71
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Get-VpcIdList.ps1
@@ -0,0 +1,73 @@
+function Get-VpcIdList {
+
+<#
+.SYNOPSIS
+ Retrieves a list VPC IDs for a given AWS region and profile.
+
+.PARAMETER ProfileName
+ [string] The AWS profile to use when making the request.
+
+.PARAMETER Region
+ [string] The AWS region to use when making the request.
+
+.PARAMETER IncludeDefault
+ [switch] Flag indicating to include the default VPC in the output. By default, the default VPC is omitted as it should be unused (if it exists).
+
+.EXAMPLE
+ Get-VpcIdList -ProfileName 'temp-dev' -Region 'us-east-1'
+
+vpc-f7cc3a8c
+vpc-01d65e5a60e36e8b4
+#>
+
+ [OutputType([string[]])]
+ [CmdletBinding()]
+ param (
+ [Parameter(Mandatory = $true)]
+ [ValidateNotNullOrEmpty()]
+ [string] $ProfileName,
+
+ [Parameter(Mandatory = $true)]
+ [ValidateScript({$_ -in (Get-AWSRegion).region})]
+ [string] $Region,
+
+ [Parameter(Mandatory = $false)]
+ [switch] $IncludeDefault
+ )
+
+ $result = $null
+ $logLead = (Get-LogLeadName)
+
+ Import-AWSModule
+
+ $filters = @()
+
+ # Only include available VPCs.
+ $stateFilter = (New-Object Amazon.EC2.Model.Filter)
+ $stateFilter.Name = "state"
+ $stateFilter.Value = "available"
+ $filters += $stateFilter
+
+ if ( $false -eq $IncludeDefault.IsPresent ) {
+
+ # Omit the default VPC unless the user specifically asked for it.
+ # Alkami should not be using the default VPC; in fact, the default VPC shouldn't even exist.
+ Write-Verbose "$logLead : Applying isDefault filter to the results."
+ $defaultfilter = (New-Object Amazon.EC2.Model.Filter)
+ $defaultfilter.Name = "isDefault"
+ $defaultfilter.Value = "false"
+ $filters += $defaultfilter
+ }
+
+ try {
+
+ # Attempt to retrieve the VPC ID list for the specified profile and region.
+ $result = ( Get-EC2Vpc -ProfileName $ProfileName -Region $Region -Filter $filters ).VpcId
+
+ } catch {
+
+ Write-Warning ( "{0} : {1} : ProfileName = '{2}', Region = '{3}'" -f $logLead, $_, $ProfileName, $Region )
+ }
+
+ return $result
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Public/Get-VpcIdList.tests.ps1 b/Modules/Alkami.DevOps.Operations/Public/Get-VpcIdList.tests.ps1
new file mode 100644
index 0000000..1dabf0b
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Get-VpcIdList.tests.ps1
@@ -0,0 +1,80 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+
+Describe "Get-VpcIdList" {
+
+ Mock -CommandName Get-AWSRegion -ModuleName Alkami.DevOps.Operations -MockWith {
+ return @(
+ @{ 'Region' = 'us-east-1' },
+ @{ 'Region' = 'us-west-2' }
+ )
+ }
+
+ Mock -CommandName Import-AWSModule -ModuleName Alkami.DevOps.Operations -MockWith {}
+ Mock -CommandName Get-LogLeadName -ModuleName Alkami.DevOps.Operations -MockWith { return 'Get-VpcIdList.tests' }
+
+ Context "Input Validation" {
+
+ It "Profile Name Should Not Be Null" {
+
+ { Get-VpcIdList -ProfileName $null } | Should -Throw
+ }
+
+ It "Profile Name Should Not Be Empty" {
+
+ { Get-VpcIdList -ProfileName '' } | Should -Throw
+ }
+
+ It "Region Should Not Be Null" {
+
+ { Get-VpcIdList -ProfileName 'Test' -Region $null } | Should -Throw
+ }
+
+ It "Region Should Not Be Empty" {
+
+ { Get-VpcIdList -ProfileName 'Test' -Region '' } | Should -Throw
+ }
+
+ It "Region Should Be In Supported Regions List" {
+
+ { Get-VpcIdList -ProfileName 'Test' -Region 'Test' } | Should -Throw
+ }
+ }
+
+ Context "Result Validation" {
+
+ It "Should Write Warning If AWS Call Fails" {
+
+ Mock -CommandName Get-EC2Vpc -ModuleName Alkami.DevOps.Operations -MockWith { throw "This is a test." }
+ Mock -CommandName Write-Warning -ModuleName Alkami.DevOps.Operations -MockWith {}
+
+ Get-VpcIdList -ProfileName 'Test' -Region 'us-east-1' | Out-Null
+
+ Assert-MockCalled -CommandName Write-Warning -Times 1 -Exactly -Scope It -ModuleName Alkami.DevOps.Operations
+ }
+
+ It "Should Return Null If AWS Call Fails" {
+
+ Mock -CommandName Get-EC2Vpc -ModuleName Alkami.DevOps.Operations -MockWith { throw "This is a test." }
+ Mock -CommandName Write-Warning -ModuleName Alkami.DevOps.Operations -MockWith {}
+
+ ( Get-VpcIdList -ProfileName 'Test' -Region 'us-east-1' ) | Should -BeNull
+ }
+
+ It "Should Not Write Warning If AWS Call Succeeds" {
+
+ Mock -CommandName Get-EC2Vpc -ModuleName Alkami.DevOps.Operations -MockWith { return @( @{ 'VPCId' = 'Test' } ) }
+ Mock -CommandName Write-Warning -ModuleName Alkami.DevOps.Operations -MockWith {}
+
+ Get-VpcIdList -ProfileName 'Test' -Region 'us-east-1' | Out-Null
+
+ Assert-MockCalled -CommandName Write-Warning -Times 0 -Exactly -Scope It -ModuleName Alkami.DevOps.Operations
+ }
+
+ It "Should Return Success When AWS Call Succeeds" {
+
+ Mock -CommandName Get-EC2Vpc -ModuleName Alkami.DevOps.Operations -MockWith { return @( @{ 'VPCId' = 'Test' } ) }
+
+ ( Get-VpcIdList -ProfileName 'Test' -Region 'us-east-1' ) | Should -Contain 'Test'
+ }
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Public/Install-FailedChocoPackages.ps1 b/Modules/Alkami.DevOps.Operations/Public/Install-FailedChocoPackages.ps1
new file mode 100644
index 0000000..d25a82a
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Install-FailedChocoPackages.ps1
@@ -0,0 +1,86 @@
+function Install-FailedChocoPackages {
+ <#
+.SYNOPSIS
+ Install chocolatey packages that should be on a host, but are missing for whatever reason
+
+.PARAMETER ChocoPackagesThatShouldBeInstalled
+ Chocolatey packages that are expected to be installed
+
+.PARAMETER BorgUri
+ Uri for BoRG if you want to query BoRG for what packages should be installed
+
+#>
+ [CmdletBinding()]
+ Param(
+ [Parameter(Mandatory = $false)]
+ [string[]]$ChocoPackagesThatShouldBeInstalled = @(),
+
+ [Parameter(Mandatory = $false)]
+ [string]$BorgUri
+ )
+
+ $failedToInstall = @()
+ $reallyFailedToInstall = @()
+ $logLead = Get-LogLeadName
+
+ if (Test-IsCollectionNullOrEmpty -Collection $ChocoPackagesThatShouldBeInstalled) {
+ Write-Verbose "$logLead : [$($providerStopWatch.Elapsed)] : Reading Platform Elements Details from BoRG"
+ $ChocoPackagesThatShouldBeInstalled = Get-PlatformElementInventory -BorgUri $BorgUri
+ if (!($ChocoPackagesThatShouldBeInstalled.count -gt 0)) {
+ Write-Warning "$logLead : BoRG contents is empty!"
+ return
+ }
+ Write-Verbose "$logLead : [$($providerStopWatch.Elapsed)] : Done Reading Platform Elements Details from BoRG"
+ }
+
+ try {
+ $installedChocoPackages = Get-LocallyInstalledChocoPackages -LimitOutput
+ foreach ($package in $chocoPackagesThatShouldBeInstalled) {
+ # Ignore blank lines from the deployChocoPackages file and verify if package version matches
+ if (!([string]::IsNullOrWhiteSpace($package)) -and !($installedChocoPackages -contains $package)) {
+ $failedToInstall += $package
+ }
+ }
+
+ #region InstallMissingPackages
+ if ($failedToInstall.count -gt 0) {
+ Write-Host "$logLead : The following package(s) failed to install and will now be remediated: "
+ foreach ($package in $failedToInstall) {
+ Write-Host "$logLead : $package"
+ }
+
+ $packagesToInstall = $failedToInstall -replace '\|', ' ' | Out-String # Out-String as Invoke-ChocoInstallPackages takes a String Argument
+ $EnvironmentKey = Get-AppSetting -appSettingKey "Environment.Name"
+ If ($null -eq $EnvironmentKey) {
+ Write-Error "$logLead : Unable to get Environment"
+ } else {
+ Invoke-ChocoInstallPackages -packagesText $packagesToInstall -environment $EnvironmentKey
+ }
+
+ # Get the updated installed package list
+ $installedChocoPackages = Get-LocallyInstalledChocoPackages -LimitOutput
+
+ foreach ($package in $failedToInstall) {
+ # Ignore blank lines from the deployChocoPackages file and verify if package version matches
+ if (!([string]::IsNullOrWhiteSpace($package)) -and !($installedChocoPackages -contains $package)) {
+ $reallyFailedToInstall += $package
+ }
+ }
+
+ if ($reallyFailedToInstall.Length -gt 0) {
+ Write-Warning "$logLead : You'll need to manually remediate the following packages:"
+ foreach ($package in $reallyFailedToInstall) {
+ Write-Host ("$logLead : $package")
+ }
+ } else {
+ Write-Host "$logLead : Remediation successful."
+ }
+
+ } else {
+ Write-Host "$logLead : Hooray! There don't appear to be any missed package installs on this server."
+ }
+ #endregion InstallMissingPackages
+ } catch {
+ Write-Error "$logLead : Unexpected error - $($_.Exception.Message)"
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Public/Install-WinTest.ps1 b/Modules/Alkami.DevOps.Operations/Public/Install-WinTest.ps1
new file mode 100644
index 0000000..92a1ff7
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Install-WinTest.ps1
@@ -0,0 +1,48 @@
+function Install-WinTest {
+<#
+.SYNOPSIS
+ Backup Current WinTest
+ Copy New WinTest
+ Clean out old WinTest
+#>
+
+ [CmdletBinding()]
+ Param (
+ [Parameter(Mandatory=$false)]
+ [string]$targetDirectory = "C:\Tools\WinTest",
+
+ [Parameter(Mandatory=$false)]
+ [string]$sourceDirectory = "C:\temp\deploy\WinTest"
+ )
+
+ $logLead = (Get-LogLeadName)
+ $installDirectory = Join-Path -Path $targetDirectory -ChildPath "Current"
+ $cleanupAgeDays = 90
+ $cleanupDate = (Get-Date).AddDays(-$cleanupAgeDays)
+
+ # Exit early
+ if (!(Test-Path -Path $sourceDirectory)) {
+ Write-Warning "New WinTest deploy folder not found - no action taken."
+ return
+ }
+
+ Backup-WinTest
+
+ # Copy new folder
+ if (Test-Path $installDirectory) {
+ Write-Warning "$logLead : Current WinTest already exists, SKIPPING install"
+ } else {
+ Write-Verbose ("$logLead : Copy WinTest files from {0} to {1}" -f $sourceDirectory,$installDirectory)
+ Copy-Item -Path $sourceDirectory -Destination $installDirectory -Recurse
+ }
+ # Clean old folders (wrong order?)
+ Write-Verbose ("$logLead : Clean up old WinTest folders")
+ $folders = Get-ChildItem -Path $targetDirectory -Exclude "Current"
+ foreach ($folder in $folders) {
+ if ($folder.LastWriteTime -lt $cleanupDate) {
+ Write-Verbose ("$logLead : Retiring {0} due to age" -f $folder.FullName)
+ Remove-Item -Path $folder.FullName -Recurse -Force
+ }
+ }
+}
+
diff --git a/Modules/Alkami.DevOps.Operations/Public/Invoke-RollingScriptBlock.ps1 b/Modules/Alkami.DevOps.Operations/Public/Invoke-RollingScriptBlock.ps1
new file mode 100644
index 0000000..7a524fe
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Invoke-RollingScriptBlock.ps1
@@ -0,0 +1,1102 @@
+function Invoke-RollingScriptBlock {
+ <#
+ .SYNOPSIS
+ Executes an arbitrary script block against target servers.
+ Correctly handles load balancing, and application restarts, depending on the type of server.
+
+ .PARAMETER Servers
+ The list of servers to apply the rolling script block against.
+
+ .PARAMETER RemoteScriptBlock
+ Script block to execute against each of the servers.
+
+ .PARAMETER AgentScriptBlock
+ Script to run on the initiating machine.
+
+ .PARAMETER ShouldRunScriptBlock
+ Script block to determine if the operation is required against a server. Executes on the remote machine.
+ If the script block returns true the Script will be executed against the server, or false to skip the server.
+ This is useful to speed up re-runs of a script.
+ This is required if the $ReRun parameter is set.
+
+ .PARAMETER ShouldForceRunScriptBlockNumTimes
+ Number of times to Force the scripts to run.
+ Overrides return value from $ShouldRunScriptBlock up to $MaxReRuns times
+
+ .PARAMETER Arguments
+ Arguments to pass to the -Script parameter script.
+
+ .PARAMETER ReRun
+ If true, will re-evaluate the ShouldRunScriptBlock to determine if the agent/remote script blocks need to be run multiple times.
+
+ .PARAMETER CycleLoadBalancer
+ Set to true to take the server out of the load balancer, run the script, and put it back in.
+
+ .PARAMETER CycleOrb
+ Set to true to stop Alkami Services on the server, run the script, and start the services back up.
+
+ .PARAMETER CycleOrbLeaveStopped
+ Overrides the "start the services" part of CycleOrb parameter. Alkami Services will still be stopped, but will be left stopped after the script is run.
+
+ .PARAMETER IgnoreNag
+ Set to true to ignore nag considerations. Defaults to false. If Nag is running critical jobs at the time we execute the server, the rolling script will fail.
+
+ .PARAMETER IgnoreServicesFailures
+ A list (array) of services to not throw/fail on in Ping-AlkamiServices.
+
+ .PARAMETER IgnoreWebTests
+ Set to true to disable web tier testing and validation.
+
+ .PARAMETER ServerParallelism
+ The number of servers to run the script against at a time. If the level of parallelism is higher than the number of servers, the script will fail unless -Force is used.
+
+ .PARAMETER ServerParallelismIsPercentage
+ Re-interprets the ServerParallelism parameter to be a percentage instead of a flat server count.
+
+ .PARAMETER FilterToRunningServers
+ Filters the provided server list down to the machines that are running in EC2.
+
+ .PARAMETER RebootMachine
+ Set to true to reboot the machine prior to starting services and putting the server back into the load balancer.
+
+ .PARAMETER RebootTimeoutMinutes
+ The timeout in minutes to wait for the machine to reboot.
+
+ .PARAMETER MaxReRuns
+ Reruns will be evaluated this many times, before throwing an exception. ShouldRunScriptBlock must return false as an exit-condition.
+
+ .PARAMETER AwsProfile
+ The Aws Profile that can manage the environment pointed at by $Servers.
+ This is a required field if web tier tests are running.
+
+ .PARAMETER Force
+ Specify the force parameter to ignore the level of parallelism safety check.
+
+ .PARAMETER SlackHookUrl
+ URL for Slack integration.
+
+ .PARAMETER SlackChannels
+ CSV string of Slack channels to send messages to.
+
+ .PARAMETER SitesToSkip
+ A list (array) of sites to skip web testing on. Any leading http/https values from the supplied sites will be trimmed where this is used.
+
+ .PARAMETER SiteThreads
+ Number of parallel threads to run when testing sites on a server.
+
+ .PARAMETER WidgetThreads
+ Number of parallel threads to run when testing widgets on a site.
+
+ .PARAMETER MaximumSitesToTest
+ Number of sites to run web-tests on a web server. Will randomly pick sites if the number of configured sites on a server is
+ larger than this parameter.
+
+ .PARAMETER SlackUsername
+ Specify Slack username to post as.
+
+ .PARAMETER WebTestFailureMessageLink
+ String of text that links to the Confluence article regarding Web Test Failures.
+ #>
+ [CmdletBinding()]
+ [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseUsingScopeModifierInNewRunspaces', '', Scope = 'Function', Justification = 'Per https://github.com/PowerShell/PSScriptAnalyzer/issues/1504 this is a known regression, skip this validation step')]
+ Param(
+ [Parameter(Mandatory = $true)]
+ [string[]]$Servers,
+ [Parameter(Mandatory = $false)]
+ [ScriptBlock]$RemoteScriptBlock = $null,
+ [Parameter(Mandatory = $false)]
+ [ScriptBlock]$AgentScriptBlock = $null,
+ [Parameter(Mandatory = $false)]
+ [ScriptBlock]$ShouldRunScriptBlock = $null,
+ [Parameter(Mandatory = $false)]
+ [int]$ShouldForceRunScriptBlockNumTimes = 0,
+ [Parameter(Mandatory = $false)]
+ [object[]]$Arguments = $null,
+ [Parameter(Mandatory = $false)]
+ [bool]$ReRun = $false,
+ [Parameter(Mandatory = $false)]
+ [bool]$CycleLoadBalancer = $true,
+ [Parameter(Mandatory = $false)]
+ [bool]$CycleOrb = $true,
+ [Parameter(Mandatory = $false)]
+ [bool]$CycleOrbLeaveStopped = $false,
+ [Parameter(Mandatory = $false)]
+ [bool]$IgnoreNag = $false,
+ [Parameter(Mandatory = $false)]
+ [bool]$IgnoreWebTests = $false,
+ [Parameter(Mandatory = $false)]
+ [bool]$IgnoreWebTestFailures = $false,
+ [Parameter(Mandatory = $false)]
+ [string[]]$IgnoreTestServers = $null,
+ [Parameter(Mandatory = $false)]
+ [int]$ServerParallelism = 1,
+ [Parameter(Mandatory = $false)]
+ [switch]$ServerParallelismIsPercentage,
+ [Parameter(Mandatory = $false)]
+ [switch]$FilterToRunningServers,
+ [Parameter(Mandatory = $false)]
+ [switch]$RebootMachine,
+ [Parameter(Mandatory = $false)]
+ [int]$RebootTimeoutMinutes = 60,
+ [Parameter(Mandatory = $false)]
+ [int]$MaxReRuns = 3,
+ [Parameter(Mandatory = $false)]
+ [string]$AwsProfile = $null,
+ [Parameter(Mandatory = $false)]
+ [switch]$Force,
+ [Parameter(Mandatory = $false)]
+ [string]$SlackHookUrl,
+ [Parameter(Mandatory = $false)]
+ [string]$SlackChannels,
+ [Parameter(Mandatory = $false)]
+ [string[]]$SitesToSkip,
+ [Parameter(Mandatory = $false)]
+ [int]$SiteThreads = 4,
+ [Parameter(Mandatory = $false)]
+ [int]$WidgetThreads = 8,
+ [Parameter(Mandatory = $false)]
+ [int]$MaximumSitesToTest = 20,
+ [Parameter(Mandatory = $false)]
+ [string[]]$IgnoreServicesFailures,
+ [Parameter(Mandatory = $false)]
+ [string]$WebTestFailureMessageLink = "https://confluence.alkami.com/x/vpPuBQ",
+ [Parameter(Mandatory = $false)]
+ [string]$SlackUsername = "Rolling Patching"
+ )
+
+ $stopwatch = [system.diagnostics.stopwatch]::StartNew()
+ $loglead = (Get-LogLeadName)
+
+ if ($IgnoreServicesFailures.Length -ne $IgnoreServicesFailures.Count) {
+ Write-Warning "IgnoreServicesFailures variable is a string, not an array"
+ }
+
+ # Sanity checks.
+ # Make sure there is actually a server list.
+ if (Test-IsCollectionNullOrEmpty $Servers) {
+ Write-Warning "$loglead : Empty server list. Returning."
+ return
+ }
+
+ # TC squashes arrays. Re-inflate it into an array if it exists.
+ Write-Host "$logLead : Sites to skip param: $SitesToSkip"
+
+ # Make sure that if any of the Slack params are set, that the other Slack parameters are filled in.
+ $slackEnabled = $false
+ $slackHookUrlProvided = ![string]::IsNullOrWhitespace($SlackHookUrl)
+ $slackChannelsProvided = ![string]::IsNullOrWhitespace($SlackChannels)
+ if ($slackHookUrlProvided -or $slackChannelsProvided) {
+ if (!($slackHookUrlProvided -and $slackChannelsProvided)) {
+ throw "Slack Hook URL and Channels must both be provided to produce Slack output."
+ } else {
+ $slackEnabled = $true
+ }
+ }
+
+ # Make sure that if we are running web tests, that an aws profile is specified.
+ # We need it to properly identify the FI's / URL endpoints to test.
+ if (($IgnoreWebTests -eq $false) -and ([string]::IsNullOrWhiteSpace($AwsProfile))) {
+ throw "$loglead : AwsProfile must be specified if web tests are being executed. If you don't care, use -IgnoreWebTests:`$true"
+ }
+
+ # Filter servers to the machines that are running, or write an error.
+ [array]$runningServers = Select-RunningEC2InstancesByHostname -Servers $Servers -ProfileName $AwsProfile
+ if ($FilterToRunningServers) {
+ $Servers = $runningServers
+ } elseif ($runningServers.Count -ne $Servers.Count) {
+ Write-Error "$loglead : Not all servers provided are running in EC2. Did you mean to -FilterToRunningServers?"
+ }
+
+ # Get lists of the types of servers for convenience.
+ [array]$webServers = Get-ServerByType -Server $Servers -Type Web
+ [array]$appServers = Get-ServerByType -Server $Servers -Type App
+ $hasWebServers = !(Test-IsCollectionNullOrEmpty $webServers)
+ $hasAppServers = !(Test-IsCollectionNullOrEmpty $appServers)
+
+ # If ServerParallelismIsPercentage is specified, distill the $ServerParallelism 0-100 percentage into a server count.
+ if ($ServerParallelismIsPercentage) {
+ #TODO: Do not overwrite Input params with new values keyed off of Input params. Create and use new Variable
+ $ServerParallelism = Get-PercentageServerCount -ServerCount $Servers.Count -Percentage $ServerParallelism
+ Write-Host "$loglead : Parallelism set to $ServerParallelism servers."
+ }
+
+ # Pick a server to query information from.
+ $firstServer = $Servers | Select-Object -First 1
+
+ # Determine the environment type, and figure out if it's a Development or QA environment.
+ $environmentType = Get-AppSetting -Key "Environment.Type" -ComputerName $firstServer
+ $isDevOrQaEnvironment = ($environmentType -eq "Development") -or ($environmentType -eq "Qa")
+ $isStagingEnvironment = ($environmentType -like "*Staging*")
+ $isProductionEnvironment = ($environmentType -like "*Production*")
+
+ #region Get_InstanceData
+ # Figure out which server runs nag, which servers we have turned on, and which servers are overflow servers.
+ Write-Host "$loglead : Fetching instance/tag data from the servers."
+ $instanceData = Invoke-Command -ComputerName $Servers -ScriptBlock {
+
+ # Get instance data.
+ $computername = (Get-FullyQualifiedServerName)
+ $instanceId = (Get-CurrentInstanceId)
+ $tags = (Get-CurrentInstanceTags)
+ $instanceRegion = (Get-CurrentInstanceRegion)
+
+ # Figure out if this machine is the nag server.
+ $isNagServer = (Test-IsAppServer) -and (Test-IsNagServer)
+
+ # Figure out if this machine is an overflow server.
+ $overflowTagKey = "alk:overflow"
+ $overflowTag = $tags | Where-Object { ($_.Key -eq $overflowTagKey) -and ($_.Value -eq "true") }
+ $isOverflowServer = $null -ne $overflowTag
+
+ # Figure out if this machine was started up for patching.
+ $startedMachineTagKey = "alk:startedByPatchJob"
+ $startedMachineTag = $tags | Where-Object { ($_.Key -eq $startedMachineTagKey) -and ($_.Value -eq "true") }
+ $isStartedMachine = ($null -ne $startedMachineTag)
+
+ <#
+ $isMicOrFabServer (A) $isStartedMachine (B) $willGoIntoLoadBalancer $A -and $B $A -or $B ~$A -and $B ~$A -and ~$B
+ $false $false $true $false $false $false $true
+ $false $true $false $false $true $true $false
+ $true $false $false $false $true $false $false
+ $true $true $false $true $true $false $false
+ #>
+
+ # Determine if the machine will go back into the load balancer in the end.
+ $isMicOrFabServer = (Test-IsMicroServer -ComputerName $computername) -or (Test-IsServiceFabricServer -ComputerName $computername)
+ # If we didn't start it for this job, and it's not a fab or a mic, it can go into the load balancer (mic|fab don't _have_ a LB at all)
+ $willGoIntoLoadBalancer = !$isStartedMachine -and !$isMicOrFabServer
+
+ # If this is an app server, determine if the host file has a loopback or redirect for SymconnectMultiplexer
+ $FailedSymLoopBackCheck = $null
+ if (Test-IsAppServer -ComputerName $computername) {
+ $loadbalancerState = Get-LoadBalancerState $env:COMPUTERNAME
+
+ $TestSymConnectNonLoopbackExists = Test-SymConnectNonLoopbackExists
+ if ($TestSymConnectNonLoopbackExists -eq $true ) {
+ Write-Warning "[SymLoopbackCheck] : SymconnectMultiplexer does NOT loopback!!!!!!!!"
+ if ($loadbalancerState -eq "Active") {
+ Write-Warning "[SymLoopbackCheck] : Server is in the loadbalancer, but will be left out !!!!!!!!"
+ $FailedSymLoopBackCheck = $true
+ } else {
+ Write-Warning "[SymLoopbackCheck] : Server is in not the loadbalancer, and will be left out !!!!!!!!"
+ $FailedSymLoopBackCheck = $false
+ $willGoIntoLoadBalancer = $false
+ }
+ } elseif ($TestSymConnectNonLoopbackExists -eq $false) {
+ Write-Host "[SymLoopbackCheck] : SymconnectMultiplexer does loopback, This is happy path."
+ $FailedSymLoopBackCheck = $false
+ } else {
+ Write-Warning "[SymLoopbackCheck] : Was unable to determine if host line is loopback or not!!!!!!!! "
+ $FailedSymLoopBackCheck = $true
+ Write-Warning "[SymLoopbackCheck] : Set FailedSymLoopBackCheck flag!!!!!!!!"
+ }
+ } else {
+ Write-Host "[SymLoopbackCheck] : Server is not an app server, Loopback does not matter."
+ $FailedSymLoopBackCheck = $false
+ }
+
+ if ($null -eq $FailedSymLoopBackCheck) {
+ Write-Warning "[SymLoopbackCheck] : Fell through SymConnect Loopback Checks without setting state, check logic!!!!!!!! "
+ $FailedSymLoopBackCheck = $true
+ Write-Warning "[SymLoopbackCheck] : Set FailedSymLoopBackCheck flag!!!!!!!!"
+ }
+
+ # Write out the results in a PSObject
+ $results = @{
+ ComputerName = $computername
+ InstanceId = $instanceId
+ Region = $instanceRegion
+ Tags = $tags
+ IsNagServer = $isNagServer
+ IsOverflowServer = $isOverflowServer
+ MachineWasOff = $isStartedMachine
+ BelongsInLoadBalancer = $willGoIntoLoadBalancer
+ FailedSymLoopBackCheck = $FailedSymLoopBackCheck
+ StartingLoadbalancerState = $loadbalancerState
+ }
+ $results = New-Object PSObject -Property $results
+ return $results
+ }
+ #endregion Get_InstanceData
+
+
+ # If this is an app server, determine if we are going to allow execution to contiune or throw a fit
+ if (($isDevOrQaEnvironment -eq $false) -and ($isStagingEnvironment -eq $false)) {
+ $ThrowForFailedLoopbackCheck = $false
+ $loopbackCheckData = $instanceData.FailedSymLoopBackCheck -contains $true
+ if ($loopbackCheckData) {
+ foreach ($instance in $instanceData) {
+ if ($instance.FailedSymLoopBackCheck -eq $true) {
+ Write-Warning "$loglead[SymLoopbackCheck] : $($instance.ComputerName) failed loopback check"
+ $ThrowForFailedLoopbackCheck = $true
+ }
+ }
+ if ($ThrowForFailedLoopbackCheck) {
+ throw "$loglead : An app tier instance has failed the SymconnectMultiplexer loopback check"
+ }
+ }
+ } else {
+ Write-Host "$loglead[SymLoopbackCheck] : Not checking sym loopback fail results for dev/qa/staging"
+ }
+
+ # Get the region, number of overflow servers, and number of formerly offline servers.
+ $awsRegion = $instanceData[0].Region
+ $numOverflowServers = ([array]($instanceData | Where-Object { $_.IsOverflowServer })).Count
+ $numOfflineServers = ([array]($instanceData | Where-Object { $_.MachineWasOff })).Count
+
+ # Figure out if there is a dedicated nag server.
+ $hasDedicatedNagServer = $false
+ if ($hasAppServers -and ($appServers.Count - $numOverflowServers) -gt 3) {
+ $hasDedicatedNagServer = $true
+ }
+
+ # If there is a dedicated nag server, find the server in the instance data
+ # and make sure it doesn't go back into the load balancer.
+ if ($hasDedicatedNagServer) {
+ $nagInstanceData = $instanceData | Where-Object { $_.IsNagServer }
+ if ($nagInstanceData) {
+ $nagInstanceData.BelongsInLoadBalancer = $false
+ }
+ }
+
+ #region Handle_FullParallelism_Or_FullOffline_DisableLoadBalancerHandling
+ # If the environment is a 1x1x1, or the entire environment was offline, disable load balancer handling.
+ # This includes environments that were scaled down to a 1x1x1
+ $numActiveServers = $Servers.Count - $numOfflineServers
+ if ($numActiveServers -eq 1) {
+ Write-Host "$loglead : Operating on 1 live server. Disabling load balancer handling."
+ $CycleLoadBalancer = $false
+ Write-Host "$logLead : OVERRIDE - Leave Orb Stopped and Ignore Web Tests"
+ #HACK: We should NOT be overriding input params like this, but we don't know before this step
+ # if we're operating on a 1x1x1 or scaled-in designation.
+ # This will probably bite us later.
+ #INFO: Just because THIS tier only has 1 active server doesn't mean we're in a 1x1x1
+ #INFO: If we do have only 1 active server, we'll still patch it with 100% parallelism
+ $CycleOrbLeaveStopped = $true
+ $IgnoreWebTests = $true
+ $ServerParallelism = $Servers.Count #sigh this is ungood; fix it in refactor story
+ #HACK: I do NOT like putting this here, but
+ # We don't know in TeamCity scriptblocks if we're on a 1x1x1 or if we were scaled to 1x1x1
+ # This was supposed to be a flag from in TeamCity that we're explicitly doing 100% parallelism and
+ # to override "normal" behaviors. I'm not sure this will ONLY do what we want.
+ Write-Host "##teamcity[setParameter name='enableBounce' value='true']"
+
+ } elseif ($numOfflineServers -eq $Servers.Count) {
+ Write-Host "$logLead : The entire environment was offline prior to Invoke-RollingScriptBlock. Disabling load balancer handling."
+ $CycleLoadBalancer = $false
+ } elseif ($ServerParallelism -ge $numActiveServers) {
+ # The environment has more than 1 active server, and so we care about the env going down.
+ # Make sure that parallelism is not set higher than the number of active servers.
+ if ($Force.IsPresent) {
+ Write-Host "$loglead : Parallelism is higher than the number of servers, and -Force is specified. Executing against all servers."
+ $CycleLoadBalancer = $false
+ } else {
+ throw "$loglead : Parallelism is set higher than the number of servers. Use -Force to force this to run on all servers without load balancing."
+ }
+ }
+ #endregion Handle_FullParallelism_Or_FullOffline_DisableLoadBalancerHandling
+
+ Write-Host "$loglead : Starting rolling script block."
+ Write-Host "$loglead : Machines: $($Servers -join ", ")"
+ Write-Host "$loglead : Handle Load Balancers and Orb: $CycleLoadBalancer"
+ Write-Host "$loglead : Rebooting Machines: $RebootMachine"
+ Write-Host "$loglead : Rebooting Timeout: $($RebootTimeoutMinutes)m"
+ Write-Host "$loglead : ReRun: $ReRun"
+ Write-Host "$loglead : Max ReRuns: $MaxReRuns"
+ Write-Host "$loglead : Ignore Web Tests: $IgnoreWebTests"
+ Write-Host "$loglead : Ignore Services Failures: $IgnoreServicesFailures"
+ Write-Host "$loglead : AwsProfile: $AwsProfile"
+ Write-Host "$loglead : AWSRegion: $awsRegion "
+ Write-Host "$loglead : Querying Data From $firstServer"
+ Write-Host "$loglead : Environment Type: $environmentType"
+ Write-Host "$loglead : Num Overflow Servers: $numOverflowServers"
+ Write-Host "$loglead : Has Dedicated Nag: $hasDedicatedNagServer"
+ Write-Host "$loglead : Force shouldRunScriptBlock Times: $ShouldForceRunScriptBlockNumTimes"
+
+ #region Check_ASGHasEnoughRoom
+ # If we are manipulating the load balancer, check if there is enough room in the ASG to support this rolling operation.
+ # Only check for room in the ASG for web servers if it is a dev or QA environment.
+ if ($CycleLoadBalancer -and ($hasAppServers -or ($hasWebServers -and $isDevOrQaEnvironment))) {
+
+ Write-Host "$logLead : Determining if the AutoScaling Group has enough room to support the parallelism of the Invoke-RollingScriptBlock operation."
+
+ # Get autoscaling group configuration.
+ $firstServerInstanceData = $instanceData | Where-Object { $_.ComputerName -eq $firstServer }
+
+ # Get the autoscaling group for the server.
+ $autoscalingGroupTagKey = "aws:autoscaling:groupName"
+
+ # Find the EC2 instances in the ASG
+ $autoScalingGroupName = $firstServerInstanceData.Tags | Where-Object { $_.Key -eq $autoscalingGroupTagKey } | Select-Object -First 1 -ExpandProperty Value
+ $autoScalingGroupMembers = (Get-InstancesByTag -tags @{ $autoscalingGroupTagKey = $autoScalingGroupName } -ProfileName $AwsProfile -Region $awsRegion).InstanceId
+
+ Write-Verbose "Autoscaling Group Members:"
+ $autoScalingGroupMembers | ForEach-Object { Write-Verbose "$loglead : $_" }
+
+ # Get the autoscaling group and its configuration.
+ $asg = (Get-ASAutoScalingGroup -AutoScalingGroupName $autoScalingGroupName -Region $awsRegion -ProfileName $AwsProfile)
+
+ # Get the instances currently in the ASG
+ $instances = (Get-ASAutoScalingInstance -InstanceID $autoScalingGroupMembers -Region $awsRegion -ProfileName $AwsProfile)
+
+ # Figure out which services are in service vs standby.
+ [array]$inServiceInstances = $instances | Where-Object { ($_.LifecycleState -eq "InService") } | Select-Object -ExpandProperty InstanceId
+ [array]$standbyInstances = $instances | Where-Object { $inServiceInstances -notcontains $_.InstanceId } | Select-Object -ExpandProperty InstanceId
+
+ # Get final asg config.
+ $asgConfig = @{
+ Min = $asg.MinSize
+ Max = $asg.MaxSize
+ InServiceInstances = $inServiceInstances
+ StandbyInstances = $standbyInstances
+ InServiceCount = $inServiceInstances.Count
+ InStandbyCount = $standbyInstances.Count
+ }
+
+ # Assume the servers that will go back into the load balancer is the desired asg count.
+ # This count includes the overflow / nag servers being removed from the count.
+ # Note: This is not the desired count from the ASG console.
+ # The ASG console desired count allows machines to be in standby without affecting the desired count, and
+ # this represents the machines that will be put into the load balancer by the end of the Invoke-RollingScriptBlock.
+ $targetCount = ([array]($instanceData | Where-Object { $_.BelongsInLoadBalancer })).Count
+
+ # Validate that there is enough room in the load balancer to handle the cycling in/out.
+ $netAsgInstances = ($targetCount - $ServerParallelism) - $asgConfig.Min
+ $hasEnoughRoom = $netAsgInstances -ge 0
+
+ if (!$hasEnoughRoom) {
+ throw "$logLead : The autoscaling group $($autoScalingGroupName) does not have enough room to support a server parallelism of $ServerParallelism.`n
+ Min: $($asgConfig.Min)`n
+ Max: $($asgConfig.Max)`n
+ Parallelism: $ServerParallelism`n
+ InService Target Count: $targetCount`n
+ Dedicated Nag: $hasDedicatedNagServer`n
+ Server Deficit: Need room for $(-$netAsgInstances) server(s)."
+ }
+ } else {
+ Write-Host "$logLead : Skipping AutoScaling Group parallelism min/max room check."
+ }
+ #endregion Check_ASGHasEnoughRoom
+
+ # If rerun is true, validate that a $ShouldRunScriptBlock is set.
+ if ($ReRun -and ($null -eq $ShouldRunScriptBlock)) {
+ throw "$logLead : If ReRun is `$true, the ShouldRunScriptBlock argument must be specified to provide an exit condition."
+ }
+
+ $isRunningInTeamCity = (Test-IsTeamCityProcess)
+ if ($isRunningInTeamCity) {
+ Write-Host "$logLead : Determined that this is executing inside a TeamCity process."
+ }
+
+ #region Set_InstanceHealth
+ # Set the health states of web/app servers to healthy.
+ if ($CycleLoadBalancer) {
+ $instancesToSetHealthy = @()
+ if ($hasWebServers -and (!$hasAppServers)) {
+ $instancesToSetHealthy = $webServers
+ } elseif ($hasAppServers -and (!$hasWebServers)) {
+ $instancesToSetHealthy = $appServers
+ } else {
+ $instancesToSetHealthy = [array]$webServers + [array]$appServers
+ }
+
+ # Disable cycling the load balancer for webs if it's a DR environment, because they don't have a nginx server set up.
+ $serverToQuery = $instancesToSetHealthy | Select-Object -First 1
+ $isDR = $false
+ if ($null -ne $serverToQuery) {
+ $environmentName = Get-AppSetting -Key "Environment.Name" -ComputerName $serverToQuery
+ $isDR = $environmentName -match "\bDR\b"
+ }
+ if ($isDR -and $hasWebServers) {
+ Write-Warning "$loglead : Disabling load balancer handling in DR because Set-NginxHostState does not know how to find DR nginx servers."
+ $CycleLoadBalancer = $false
+ } elseif (!(Test-IsCollectionNullOrEmpty $instancesToSetHealthy)) {
+ Write-Host "$loglead : Setting ASG instance states to Healthy for hosts: $($instancesToSetHealthy -join ", ")"
+ Reset-ASInstanceHealth -Servers $instancesToSetHealthy -ProfileName $AwsProfile
+ }
+ }
+ #endregion Set_InstanceHealth
+
+ # Execute script against servers.
+ $serverScript = {
+ param($Server, $inputArguments)
+
+ # Manually named because Get-LogLeadName won't work inside this script block.
+ $loglead = "[Invoke-RollingScriptBlock]"
+
+ #region Argument_Handling
+ # Pull the user script & their arguments out of the arguments above.
+ $sbRemoteScript = $inputArguments.RemoteScriptBlock
+ $sbAgentScript = $inputArguments.AgentScriptBlock
+ $sbShouldRunScript = $inputArguments.ShouldRunScriptBlock
+ $sbShouldForceRunScriptNumTimes = $inputArguments.ShouldForceRunScriptBlockNumTimes
+ [array]$sbArguments = $inputArguments.Arguments
+ $sbCycleLoadBalancer = $inputArguments.CycleLoadBalancer
+ $sbCycleOrb = $inputArguments.CycleOrb
+ $sbCycleOrbLeaveStopped = $inputArguments.CycleOrbLeaveStopped
+ $sbIgnoreNag = $inputArguments.IgnoreNag
+ $sbIgnoreWebTests = $inputArguments.IgnoreWebTests
+ $sbIgnoreTestServers = $inputArguments.IgnoreTestServers
+ $sbRebootMachine = $inputArguments.RebootMachine
+ $sbRebootTimeoutMinutes = $inputArguments.RebootTimeoutMinutes
+ $sbReRun = $inputArguments.ReRun
+ $sbMaxReRuns = $inputArguments.MaxReRuns
+ $sbAwsProfile = $inputArguments.AwsProfile
+ $sbAwsRegion = $inputArguments.AwsRegion
+ $sbIsRunningInTeamCity = $inputArguments.IsRunningInTeamCity
+ $sbInstanceData = $inputArguments.InstanceData
+ $sbIgnoreWebTestFailures = $inputArguments.IgnoreWebTestFailures
+ $sbIsProductionEnvironment = $inputArguments.IsProductionEnvironment
+ $sbSlackHookUrl = $inputArguments.SlackHookUrl
+ $sbSlackChannels = $inputArguments.SlackChannels
+ $sbIsSlackEnabled = $inputArguments.IsSlackEnabled
+ $sbWebTestFailureMessageLink = $inputArguments.WebTestFailureMessageLink
+ $sbSlackUsername = $inputArguments.SlackUsername
+ $sbSitesToSkip = $inputArguments.SitesToSkip
+ $sbSiteThreads = $inputArguments.SiteThreads
+ $sbWidgetThreads = $inputArguments.WidgetThreads
+ $sbMaximumSitesToTest = $inputArguments.MaximumSitesToTest
+ [array]$sbIgnoreServicesFailures = $inputArguments.IgnoreServicesFailures
+
+ # Deserialize the scripts into runnable script-blocks.
+ if ($null -ne $sbAgentScript) {
+ $sbAgentScript = [scriptblock]::Create($sbAgentScript)
+ }
+ if ($null -ne $sbRemoteScript) {
+ $sbRemoteScript = [scriptblock]::Create($sbRemoteScript)
+ }
+ if ($null -ne $sbShouldRunScript) {
+ $sbShouldRunScript = [scriptblock]::Create($sbShouldRunScript)
+ }
+
+ # Modify the user arguments to include the server being operated on as the first argument.
+ $sbArguments = @($Server) + $sbArguments
+ #endregion Argument_Handling
+
+ try {
+ # Write TeamCity open-block to give a dropdown breakdown by server.
+ if ($sbIsRunningInTeamCity) {
+ Write-Host "##teamcity[blockOpened name='$Server']"
+ }
+
+ # Get the EnvironmentName from $server
+ $environmentName = Get-AppSetting -Key "Environment.Name" -ComputerName $Server
+
+ # Figure out if the server ultimately belongs in the load balancer or not.
+ $serverInstanceData = $sbInstanceData | Where-Object { $Server -eq $_.ComputerName } | Select-Object -First 1
+ if ($null -eq $serverInstanceData) {
+ throw "$loglead : Could not find instance data for server $Server. Is it missing a domain name?"
+ }
+
+ $rebootedMachine = $false
+ $numReruns = 0
+ $shouldExecuteScripts = $true
+ do {
+ #region ShouldExecuteScripts
+ # Determine if we should run the script against the server.
+ $shouldExecuteScripts = $true
+ if ($null -ne $sbShouldRunScript) {
+ $shouldExecuteScripts = Invoke-Command -ComputerName $Server -ScriptBlock $sbShouldRunScript -ArgumentList ($sbArguments)
+ if ($shouldExecuteScripts -eq $true) {
+ Write-Host "$loglead : Determined that we are executing the agent/remote scripts against $Server"
+ } else {
+ Write-Host "$loglead : Determined that the agent/remote scripts do not need to be executed against $Server"
+ }
+ }
+
+ # Execute the scripts if we need to.
+ # Regardless of if we want to execute the script, the server will be started and put back into the load balancer.
+ # This is to handle cases where if a script fails while the server is out of the load balancer, that re-running the job will put that server back into the pool.
+ if (!$shouldExecuteScripts) {
+ if ($sbShouldForceRunScriptNumTimes -gt $numReruns) {
+ Write-Warning "OVERRIDE - Return value from ShouldRunScriptBlock being overridden"
+ Write-Warning "Forcing scripts to run this many times : $sbShouldForceRunScriptNumTimes"
+ $shouldExecuteScripts = $true
+ } else {
+ Write-Host "$logLead : Not executing script against $server."
+ break
+ }
+ }
+ #endregion ShouldExecuteScripts
+
+ # Write rerun text.
+ if ($sbReRun) {
+ Write-Host "$logLead : Starting Run ($($numReruns+1)/$sbMaxReRuns)"
+ }
+
+ #region Set_LoadBalancerState_Down
+
+ $lbStateResult = $null
+ # Take the server out of the load balancer. Don't bother taking the server out of the LB after the first script execution.
+ if ($sbCycleLoadBalancer -and ($numReruns -eq 0) -and $serverInstanceData.BelongsInLoadBalancer) {
+ # If the LB change fails, this should *always* throw. Kill the whole process with fire.
+ try {
+ Write-Host "$logLead : Removing $server from the load balancer."
+ $lbStateResult = Set-LoadBalancerState -server $server -desiredState "down" -AwsProfileName $sbAwsProfile -AwsRegion $sbAwsRegion -Force
+ } catch {
+ throw "Setting Load Balancer State to Down failed. Throwing, explictly. `n$_"
+ }
+
+ # The underlying signature of Set-LoadBalancerState changed, so it's not throwing. Handle the failure state here.
+ if ($lbStateResult -eq "Fail") {
+ throw "Setting Load Balancer State to Down failed. Throwing, explictly."
+ }
+ # Slack notification out of load balancer.
+ if ($sbIsSlackEnabled) {
+ # If slack fails, we should *not* fail. Messages are nice, but not a hill to die on.
+ try {
+ $messageText = "$environmentName [$Server] - Removed from load balancer"
+ $slackMessage = Format-SlackMessage -MessageText $messageText -IconEmoji ":teamcity:" -UserName $sbSlackUsername
+ Publish-MessageToSlack -MessageBody $slackMessage -slackHookUrl $sbSlackHookUrl -channels $sbSlackChannels
+ } catch {
+ Write-Host "Error caught attempting to send Out Load Balancer message to Slack! `n$_"
+ }
+ }
+ }
+
+ #endregion Set_LoadBalancerState_Down
+
+ #region Stop_AlkamiPlatform
+ # Stop services on the server.
+ # Continue to stop orb after multiple re-runs, incase the machine is rebooted and we need to stop automatic start services.
+ if ($sbCycleOrb) {
+
+ #region Nag_Safety
+ $serverIsAppServer = ($null -ne (Select-AlkamiAppServers $Server))
+ if ($serverIsAppServer -and (!$sbIgnoreNag)) {
+
+ Write-Host "$logLead : Checking if Nag is running prior to stopping server $Server."
+ $nagJobsRunning = $false
+
+ # Only do Nag checks in Production environments.
+ if (!$sbIsProductionEnvironment) {
+ Write-Host "$logLead : We do not care if Nag is running in non-production environments. Continuing.."
+ } elseif (Test-IsNagServer -Server $Server) {
+ Write-Host "$logLead : Determined that server $Server is the Nag server."
+
+ # Grab the master connection string for nag checks.
+ $masterConnectionString = (Get-ConnectionString -name "AlkamiMaster" -ComputerName $Server)
+
+ # Determine if Nag is within the time window where we do not care about force-restarting it for all FI's.
+ Write-Host "$logLead : Checking if all tenants are inside the 12:00am-6:00am time window where we do not care about restarting nag."
+ $canRestartNag = Test-IsCurrentTimeInsideTenantTimeRange -MasterConnectionString $masterConnectionString -StartTimeHour 0 -EndTimeHour 6
+
+ if ($canRestartNag) {
+ Write-Host "$logLead : All FI's are within the 12:00-6:00am time window. Restarting Nag."
+ } else {
+ # If we can't summarily restart nag, figure out if critical nag jobs are running.
+
+ # Figure out if nag jobs are running on the remote machine.
+ # Note: Test-AreCriticalNagJobsRunning is poorly named. No jobs are running if it returns $true.
+ $serverIsNotRunningNagJobs = (Test-AreCriticalNagJobsRunning -masterConnectionString $masterConnectionString)
+
+ # Test-AreCriticalNagJobsRunning returns $true or $null. Turn this into a proper bool.
+ if ($null -eq $serverIsNotRunningNagJobs) {
+ $serverIsNotRunningNagJobs = $false
+ }
+ $nagJobsRunning = !$serverIsNotRunningNagJobs
+
+ if ($nagJobsRunning) {
+ throw "Cannot deploy to Nag server: $Server; there are critical jobs scheduled or running."
+ } else {
+ Write-Host "$logLead : There are no Nag jobs running. The server can be safely stopped"
+ }
+ }
+ }
+ }
+ #endregion Nag_Safety
+
+ Write-Host "$logLead : Stopping services and clearing logs on $Server"
+ # Slack notification Stopping server.
+ if ($sbIsSlackEnabled) {
+ try {
+ $messageText = "$environmentName [$Server] - Stopping services and clearing logs"
+ $slackMessage = Format-SlackMessage -MessageText $messageText -IconEmoji ":teamcity:" -UserName $sbSlackUsername
+ Publish-MessageToSlack -MessageBody $slackMessage -slackHookUrl $sbSlackHookUrl -channels $sbSlackChannels
+ } catch {
+ Write-Host "Error caught attempting to send Stopping Services message to Slack!"
+ }
+ }
+
+
+ #region Stop_AlkamiPlatform_NestedSb_SuppressErrors
+ $nestedSbArguments = @{
+ RebootMachine = $sbRebootMachine
+ IsRunningInTeamCity = $sbIsRunningInTeamCity
+ }
+ Invoke-Command -ComputerName $Server -ArgumentList $nestedSbArguments -ScriptBlock {
+ param($nestedSbInputArguments)
+ $nestedSbRebootMachine = $nestedSbInputArguments.RebootMachine
+ $nestedSbIsRunningInTeamCity = $nestedSbInputArguments.IsRunningInTeamCity
+ if ($nestedSbRebootMachine) {
+ #TODO: scope problem?
+ #region Stop_AlkamiPlatform_NestedSb_SuppressErrors
+ Write-Warning "SUPPRESSING ERRORS - reboot will follow 'Stop-Platform' process"
+ #do things
+ $params = @{}
+ $params.ErrorAction = "SilentlyContinue"
+ $params.ErrorVariable = "SuppressedErrors"
+
+ # Stop services.
+ try {
+ Stop-IISAndServices @params
+ } catch {
+ $exceptionInfo = $_
+ Write-Warning "Errors have been CAUGHT from something called by Stop-IISAndServices"
+ Write-Warning $exceptionInfo
+ }
+ if ($SuppressedErrors) {
+ Write-Warning "Errors in Stop-IISAndServices have been suppressed:"
+ $SuppressedErrors.ForEach( {
+ Write-Warning $_
+ })
+ # Reset ErrorVariable
+ $SuppressedErrors = $null
+ }
+
+
+ # Log everyone out of the host
+ try {
+ Revoke-LogonUsers @params
+ } catch {
+ $exceptionInfo = $_
+ Write-Warning "Errors have been CAUGHT from something called by Revoke-LogonUsers"
+ Write-Warning $exceptionInfo
+ }
+ if ($SuppressedErrors) {
+ Write-Warning "Errors in Revoke-LogonUsers have been suppressed:"
+ $SuppressedErrors.ForEach( {
+ Write-Warning $_
+ })
+ # Reset ErrorVariable
+ $SuppressedErrors = $null
+ }
+
+
+ # Remove any locks to the filesystem
+ try {
+ Close-SMBApplicationLocks @params
+ } catch {
+ $exceptionInfo = $_
+ Write-Warning "Errors have been CAUGHT from something called by Close-SMBApplicationLocks"
+ Write-Warning $exceptionInfo
+ }
+ if ($SuppressedErrors) {
+ Write-Warning "Errors in Close-SMBApplicationLocks have been suppressed:"
+ $SuppressedErrors.ForEach( {
+ Write-Warning $_
+ })
+ # Reset ErrorVariable
+ $SuppressedErrors = $null
+ }
+
+
+ # Clean up .NET temps
+ try {
+ Remove-DotNetTemporaryFiles @params
+ } catch {
+ $exceptionInfo = $_
+ Write-Warning "Errors have been CAUGHT from something called by Remove-DotNetTemporaryFiles"
+ Write-Warning $exceptionInfo
+ }
+ if ($SuppressedErrors) {
+ Write-Warning "Errors in Remove-DotNetTemporaryFiles have been suppressed:"
+ $SuppressedErrors.ForEach( {
+ Write-Warning $_
+ })
+ # Reset ErrorVariable
+ $SuppressedErrors = $null
+ }
+
+
+ # Clean up our logs dir
+ try {
+ Backup-ORBLogFiles -SkipActive @params
+ } catch {
+ $exceptionInfo = $_
+ Write-Warning "Errors have been CAUGHT from something called by Backup-ORBLogFiles"
+ Write-Warning $exceptionInfo
+ }
+ if ($SuppressedErrors) {
+ Write-Warning "Errors in Backup-ORBLogFiles have been suppressed:"
+ $SuppressedErrors.ForEach( {
+ Write-Warning $_
+ })
+ # Reset ErrorVariable
+ $SuppressedErrors = $null
+ }
+ #endregion Stop_AlkamiPlatform_NestedSb_SuppressErrors
+ } else {
+ Write-Warning "Not suppressing errors - reboot not planned"
+ Stop-IISAndServices
+ Revoke-LogonUsers
+ Close-SMBApplicationLocks
+ Remove-DotNetTemporaryFiles
+ Backup-ORBLogFiles -SkipActive -ErrorAction "Continue"
+ }
+
+ if ($nestedSbIsRunningInTeamCity) {
+ Write-Host "##teamcity[setParameter name='didStopAlkamiPlatform' value='YES']"
+ }
+ }
+ #endregion Stop_AlkamiPlatform_NestedSb_SuppressErrors
+ }
+ #endregion Stop_AlkamiPlatform
+
+ # Execute the agent script.
+ if ($null -ne $sbAgentScript) {
+ Write-Host "$logLead : Executing agent script block for $server"
+ Invoke-Command -ScriptBlock $sbAgentScript -NoNewScope -ArgumentList ($sbArguments)
+ }
+
+ # Execute the remote script.
+ if ($null -ne $sbRemoteScript) {
+ Write-Host "$logLead : Executing remote script block on $server"
+ Invoke-Command -ScriptBlock $sbRemoteScript -ComputerName $Server -ArgumentList ($sbArguments)
+ }
+
+ #region RebootMachine
+ # Reboot the machine.
+ if ($sbRebootMachine) {
+ Write-Host "$logLead : Rebooting $server"
+ $rebootedMachine = $true
+ # Slack notification Restart server.
+ if ($sbIsSlackEnabled) {
+ try {
+ $messageText = "$environmentName [$Server] - Rebooting server"
+ $slackMessage = Format-SlackMessage -MessageText $messageText -IconEmoji ":teamcity:" -UserName $sbSlackUsername
+ Publish-MessageToSlack -MessageBody $slackMessage -slackHookUrl $sbSlackHookUrl -channels $sbSlackChannels
+ } catch {
+ Write-Host "Error caught attempting to send Restart Server message to Slack!"
+ }
+ }
+
+ Restart-Computer -ComputerName $Server -Wait -Timeout ($sbRebootTimeoutMinutes * 60) -Delay 10 -Protocol WSMan -Force
+ }
+ #endregion RebootMachine
+
+ # If we are doing reruns, keep looping.
+ } while ($sbReRun -and ((++$numReruns) -lt $sbMaxReRuns))
+
+ #region TooManyRetries
+ # If we reached the max rerun attempts, and $shouldExecuteScripts is true, figure out if the job thinks it has finished.
+ if ($sbReRun -and ($shouldExecuteScripts -eq $true)) {
+ $shouldExecuteScripts = Invoke-Command -ComputerName $Server -ScriptBlock $sbShouldRunScript -ArgumentList ($sbArguments)
+ if ($shouldExecuteScripts) {
+ throw "$logLead : Maximum $sbMaxReRuns retry attempts reached. Please re-run the job, or investigate the ShouldRunScriptBlock exit-condition. The server has not been started back up."
+ }
+ }
+ #endregion TooManyRetries
+
+ #region Start_AlkamiPlatform
+ # Start services on the server.
+ # Skip if CycleOrbLeaveStopped is TRUE (I dislike this logic inversion. Naming is hard and this should be the - hopefully rare - exception)
+ # Changing to "-not" notation to make them stick out more. The number of flags and beacons we have going on
+ # here makes reasoning about it hard enough without needing to spot "!" hiding next to "("
+ # using ALLCAPS for the same visibility reasoning
+ # Don't bother starting the server if it was off prior to Invoke-RollingScriptBlock.
+ if ($sbCycleOrb -and -NOT $sbCycleOrbLeaveStopped -and -NOT $serverInstanceData.MachineWasOff) {
+ Write-Host "$logLead : Starting services on $server"
+
+ # Slack notification Starting server.
+ if ($sbIsSlackEnabled) {
+ try {
+ $messageText = "$environmentName [$Server] - Starting services"
+ $slackMessage = Format-SlackMessage -MessageText $messageText -IconEmoji ":teamcity:" -UserName $sbSlackUsername
+ Publish-MessageToSlack -MessageBody $slackMessage -slackHookUrl $sbSlackHookUrl -channels $sbSlackChannels
+ } catch {
+ Write-Host "Error caught attempting to send Starting Server message to Slack!"
+ }
+ }
+
+ # Define a script to start up the server.
+ # In the case of a user-script failure, the server will be started and put back into the load balancer on re-runs.
+ $startupScript = {
+ param (
+ $nestedSb_logLead,
+ $nestedSbIgnoreServicesFailures
+ )
+
+ Write-Host "$nestedSb_logLead : StartupScript : Prepare to run"
+
+ Clear-GMSAPasswords
+ Start-IISAndServices
+ if (Test-IsWebServer) {
+ Ping-AlkamiWebSites
+ Start-Sleep -Seconds 60
+ } elseif (Test-IsAppServer) {
+ $pingAlkServices = Ping-AlkamiServices
+ foreach ($serviceResult in $pingAlkServices) {
+ if ($serviceResult.WebAppName -in $nestedSbIgnoreServicesFailures) {
+ Write-Host "$nestedSb_logLead : Skipping evaluation of ping results for $($serviceResult.WebAppName) because it was given as an IgnoreServicesFailures parameter value"
+ continue
+ }
+
+ if ($serviceResult.Success -eq $false) {
+ $description = ConvertTo-SafeTeamCityMessage -InputText "$($serviceResult.Url) failed the ping check, was not set for ignoring"
+ $identity = ConvertTo-SafeTeamCityMessage -InputText "$($serviceResult.WebAppName)_pingFailed"
+
+ Write-Host "##teamcity[buildProblem description='$description' identity='$identity']"
+ }
+ }
+ }
+ }
+
+ # If the machine was rebooted we want a full restart-tier to make sure the environment is in a good state.
+ if ($rebootedMachine) {
+ $startupScript = {
+ param (
+ $nestedSb_logLead,
+ $nestedSbIgnoreServicesFailures
+ )
+ Write-Host "$nestedSb_logLead : StartupScript-RebootedMachine : Prepare to run"
+ Clear-GMSAPasswords
+ $pingServicesServiceResults = Restart-Tier
+ if (Test-IsAppServer) {
+ foreach ($serviceResult in $pingServicesServiceResults) {
+ if ($serviceResult.WebAppName -in $nestedSbIgnoreServicesFailures) {
+ Write-Host "$nestedSb_logLead : Skipping evaluation of ping results for $($serviceResult.WebAppName) because it was given as an IgnoreServicesFailures parameter value"
+ continue
+ }
+
+ if ($serviceResult.Success -eq $false) {
+ $description = ConvertTo-SafeTeamCityMessage -InputText "$($serviceResult.Url) failed the ping check, was not set for ignoring"
+ $identity = ConvertTo-SafeTeamCityMessage -InputText "$($serviceResult.WebAppName)_pingFailed"
+
+ Write-Host "##teamcity[buildProblem description='$description' identity='$identity']"
+ }
+ }
+ } else {
+ # Just dump the results as it would have done instead, so we can see them
+ $pingServicesServiceResults
+ }
+ if (Test-IsWebServer) {
+ Start-Sleep -Seconds 60
+ }
+ }
+ }
+
+ Invoke-Command -ComputerName $Server -ScriptBlock $startupScript -ArgumentList @($loglead, $sbIgnoreServicesFailures)
+ } elseif ($sbCycleOrb -and $sbCycleOrbLeaveStopped -and -NOT $serverInstanceData.MachineWasOff) {
+ Write-Warning -Message "$logLead : OVERRIDE - NOT starting Alkami Services due to passed param -CycleOrbLeaveStopped"
+ if ($sbIsRunningInTeamCity) {
+ Write-Host "##teamcity[setParameter name='didLeaveAlkamiPlatformStopped' value='YES']"
+ }
+ }
+ #endregion Start_AlkamiPlatform
+
+ #region Invoke_WebTests
+ # If it's a web server, we are running the web tests, the machine wasn't off prior, and the server is not in the ignore-test list.
+ $serverIsWebServer = ($null -ne (Select-AlkamiWebServers $Server))
+ if ($serverIsWebServer -and (!$sbIgnoreWebTests) -and (!$serverInstanceData.MachineWasOff) -and (!($sbIgnoreTestServers -contains $Server))) {
+ $testsPassed = (Invoke-WebTests -Servers $Server `
+ -AwsProfile $sbAwsProfile `
+ -SitesToSkip $sbSitesToSkip `
+ -MaximumSitesToTest $sbMaximumSitesToTest `
+ -SiteThreads $sbSiteThreads `
+ -WidgetThreads $sbWidgetThreads)
+ # Only throw if we want to ignore test failures and the tests failed.
+ if (!$sbIgnoreWebTestFailures -and !$testsPassed) {
+ # Slack output Web Tests Failed.
+ if ($sbIsSlackEnabled) {
+ try {
+ $WebTestFailureUsername = $sbSlackUsername + " Failure"
+ $messageText = "$environmentName [$Server] - FAILED web tests! For guidance and Q&A, see <$sbWebTestFailureMessageLink|$sbSlackUsername Job Documentation>"
+ $slackMessage = Format-SlackMessage -MessageText $messageText -IconEmoji ":alert:" -UserName $WebTestFailureUsername
+ $webFailSlackChannels = $sbSlackChannels
+
+ Publish-MessageToSlack -MessageBody $slackMessage -slackHookUrl $sbSlackHookUrl -channels $webFailSlackChannels
+ } catch {
+ Write-Host "Error caught attempting to send Web Tests Failed message to Slack!"
+ }
+ }
+
+ throw "$logLead : Web tier testing of server $Server failed. Investigate! For guidance and Q&A, see $sbWebTestFailureMessageLink"
+ }
+
+ # Take a break and give the server time to settle after the tests, before putting back into the LB.
+ Start-Sleep -Seconds 60
+ }
+ #endregion Invoke_WebTests
+
+ #region Set_LoadBalancerState_Up
+ # Put the server back into the load balancer.
+ $lbStateResult = $null
+ if ($sbCycleLoadBalancer -and $serverInstanceData.BelongsInLoadBalancer) {
+ # If the LB change fails, this should *always* throw. Kill the whole process with fire.
+ try {
+ Write-Host "$logLead : Adding $server back into the load balancer"
+ $lbStateResult = Set-LoadBalancerState -server $server -desiredState "up" -AwsProfileName $sbAwsProfile -AwsRegion $sbAwsRegion -Force
+ } catch {
+ throw "Setting Load Balancer State to Up failed. Throwing, explictly. `n$_"
+ }
+
+ # The underlying signature of Set-LoadBalancerState changed, so it's not throwing. Handle the failure state here.
+ if ($lbStateResult -eq "Fail") {
+ throw "Setting Load Balancer State to Down failed. Throwing, explictly."
+ }
+ # Slack output added back into Load Balancer.
+ if ($sbIsSlackEnabled) {
+ # If slack fails, we should *not* fail. Messages are nice, but not a hill to die on.
+ try {
+ $messageText = "$environmentName [$Server] - Added to load balancer"
+ $slackMessage = Format-SlackMessage -MessageText $messageText -IconEmoji ":teamcity:" -UserName $sbSlackUsername
+ Publish-MessageToSlack -MessageBody $slackMessage -slackHookUrl $sbSlackHookUrl -channels $sbSlackChannels
+ } catch {
+ Write-Host "Error caught attempting to send In Load Balancer message to Slack! `n$_"
+ }
+ }
+ }
+ #endregion Set_LoadBalancerState_Up
+ } finally {
+ if ($sbIsRunningInTeamCity) {
+ Write-Host "##teamcity[blockClosed name='$Server']"
+ }
+ }
+ }
+
+ #region Argument_Building
+ # NOTE: Using a hashtable allows us to pass a gazillion parameters without worrying about missing one
+ # or putting them in the wrong order. Plus, until we explicitly make use of it in the scriptblock, it
+ # does NOTHING. Beautiful.
+ $serverScriptArguments = @{
+ RemoteScriptBlock = $RemoteScriptBlock
+ AgentScriptBlock = $AgentScriptBlock
+ ShouldRunScriptBlock = $ShouldRunScriptBlock
+ ShouldForceRunScriptBlockNumTimes = $ShouldForceRunScriptBlockNumTimes
+ Arguments = $Arguments
+ CycleLoadBalancer = $CycleLoadBalancer
+ CycleOrb = $CycleOrb
+ CycleOrbLeaveStopped = $CycleOrbLeaveStopped
+ IgnoreNag = $IgnoreNag
+ IgnoreWebTests = $IgnoreWebTests
+ IgnoreTestServers = $IgnoreTestServers
+ RebootMachine = $RebootMachine
+ RebootTimeoutMinutes = $RebootTimeoutMinutes
+ ReRun = $ReRun
+ MaxReRuns = $MaxReRuns
+ AwsProfile = $AwsProfile
+ AwsRegion = $awsRegion
+ IsRunningInTeamCity = $isRunningInTeamCity
+ InstanceData = $instanceData
+ IgnoreWebTestFailures = $ignoreWebTestFailures
+ IsProductionEnvironment = $isProductionEnvironment
+ SlackHookUrl = $SlackHookUrl
+ SlackChannels = $SlackChannels
+ IsSlackEnabled = $slackEnabled
+ SlackUsername = $SlackUsername
+ WebTestFailureMessageLink = $WebTestFailureMessageLink
+ SitesToSkip = $SitesToSkip
+ MaximumSitesToTest = $MaximumSitesToTest
+ SiteThreads = $SiteThreads
+ WidgetThreads = $WidgetThreads
+ IgnoreServicesFailures = $IgnoreServicesFailures
+ }
+ #endregion Argument_Building
+ [array]$results = Invoke-Parallel2 -Objects $Servers -NumThreads $ServerParallelism -ThreadPerObject -StopProcessingJobsOnError -Arguments $serverScriptArguments -Script $serverScript
+ if ((!(Test-IsCollectionNullOrEmpty $results)) -and ($results.Success -contains $false)) {
+ Write-Error "$loglead : Script block execution failed."
+ }
+
+ Write-Host "$loglead : Rolling script execution completed in [$($stopwatch.Elapsed)]"
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Public/Move-Route53HostedZone.ps1 b/Modules/Alkami.DevOps.Operations/Public/Move-Route53HostedZone.ps1
new file mode 100644
index 0000000..b860b48
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Move-Route53HostedZone.ps1
@@ -0,0 +1,182 @@
+function Move-Route53HostedZone {
+
+<#
+.SYNOPSIS
+ Move a Route53 Hosted Zone to another Route53 Hosted Zone.
+
+.DESCRIPTION
+ This function can be used to rename an existing Route53 Hosted Zone in the same account, or to move a Route53 Hosted Zone to a new account.
+ All record sets will be persisted during the move.
+
+ Note that this function will attempt to register all Alkami VPCs with the newly created zone; to facilitate that process,
+ call this function only if you have current AWS CLI credentials in each Alkami account.
+
+ Use this function with caution; the source Route53 Hosted Zone WILL BE DELETED during the move.
+
+.PARAMETER TargetHostedZoneName
+ [string] The desired name of the new Route53 Hosted Zone (e.g. 'thing.alkami.net').
+
+.PARAMETER TargetProfileName
+ [string] The AWS profile where the Route53 Hosted Zone will be created (e.g. 'temp-prod').
+
+.PARAMETER SourceHostedZoneName
+ [string] The name of the existing Route53 Hosted Zone to relocate (e.g. 'thing.alkami.net').
+
+.PARAMETER SourceProfileName
+ [string] The AWS profile where the existing Route53 Hosted Zone is located (e.g. 'temp-prod').
+
+.OUTPUTS
+ [string] The target Route53 Hosted Zone ID.
+
+.EXAMPLE
+ Move-Route53HostedZone -TargetHostedZoneName 'test.alkami.net' -TargetProfileName 'temp-sandbox' -SourceHostedZoneName 'test.alkami.net' -SourceProfileName 'temp-prod'
+
+[Move-Route53HostedZone] : Looking for pre-existing hosted zone named 'test.alkami.net' in profile 'temp-sandbox'.
+[Move-Route53HostedZone] : Looking for source hosted zone 'test.alkami.net' in profile 'temp-prod'
+[Move-Route53HostedZone] : Determining baseline target VPC in 'temp-sandbox'
+[Move-Route53HostedZone] : Using VPC ID 'vpc-1234'
+[Move-Route53HostedZone] : Pulling records from source hosted zone 'test.alkami.net' in profile 'temp-prod'.
+[Move-Route53HostedZone] : Creating hosted zone 'test.alkami.net' in profile 'temp-sandbox'.
+[Move-Route53HostedZone] : Target hosted zone ID is 'ZONE9999'
+[Move-Route53HostedZone] : Copying resource records from source hosted zone to target.
+[Move-Route53HostedZone] : Removing resource records from source hosted zone 'test.alkami.net' in profile 'temp-prod'.
+[Move-Route53HostedZone] : Removing hosted zone 'test.alkami.net' from profile 'temp-prod'
+[Move-Route53HostedZone] : Creating VPC associations for 'ZONE9999'.
+
+[Move-Route53HostedZone] : Target hosted zone 'test.alkami.net' (zone ID 'ZONE9999') successfully recreated in profile 'temp-sandbox'.
+ZONE9999
+#>
+
+ [OutputType([string])]
+ [CmdletBinding()]
+ param (
+ [Parameter(Mandatory = $true)]
+ [ValidateNotNullOrEmpty()]
+ [string] $TargetHostedZoneName,
+
+ [Parameter(Mandatory = $true)]
+ [ValidateNotNullOrEmpty()]
+ [string] $TargetProfileName,
+
+ [Parameter(Mandatory = $true)]
+ [ValidateNotNullOrEmpty()]
+ [string] $SourceHostedZoneName,
+
+ [Parameter(Mandatory = $true)]
+ [ValidateNotNullOrEmpty()]
+ [string] $SourceProfileName
+ )
+
+ $logLead = (Get-LogLeadName);
+ $defaultRegion = 'us-east-1'
+ $sourceRecordSetsToMigrate = $null
+
+ Import-AWSModule
+
+ # Validate that a hosted zone named the specified name does not already exist.
+ # Amazon is fine with this as long as the VPC associations don't overlap; Alkami is less fine with it . . .
+ Write-Host "$logLead : Looking for pre-existing hosted zone named '$TargetHostedZoneName' in profile '$TargetProfileName'."
+ $targetHostedZoneList = Get-R53HostedZonesByName -DNSName $TargetHostedZoneName -ProfileName $TargetProfileName
+ $targetHostedZone = $targetHostedZoneList | Where-Object { $_.Name -match $TargetHostedZoneName } | Select-Object -First 1
+ if ( $null -ne $targetHostedZone ) {
+
+ Write-Error "$logLead : Hosted zone named '$TargetHostedZoneName' already exists using profile '$TargetProfileName'; aborting."
+ return $targetHostedZone.Id
+ }
+
+ # Validate that the source hosted zone exists.
+ Write-Host "$logLead : Looking for source hosted zone '$SourceHostedZoneName' in profile '$SourceProfileName'"
+ $sourceHostedZoneList = Get-R53HostedZonesByName -DNSName $SourceHostedZoneName -ProfileName $SourceProfileName
+ $sourceHostedZone = $sourceHostedZoneList | Where-Object { $_.Name -match $SourceHostedZoneName } | Select-Object -First 1
+ if ( $null -eq $sourceHostedZone ) {
+
+ # If we didn't find the hosted zone in the source profile, abort with error.
+ Write-Error "$logLead : Hosted zone named '$SourceHostedZoneName' not found using profile '$SourceProfileName'; aborting."
+ return $null
+ }
+
+ $sourceHostedZoneId = $sourceHostedZone.Id
+
+ # Route53 hosted zone creation requires an initial VPC association. Look for one using the target profile.
+ Write-Host "$logLead : Determining baseline target VPC in '$TargetProfileName'"
+ $tempVpc = Get-EC2Vpc -ProfileName $TargetProfileName -Region $defaultRegion | Select-Object -First 1
+ if ( $null -eq $tempVpc ) {
+
+ Write-Error "$logLead : No VPC found using profile '$TargetProfileName'; a VPC is required for Route53 hosted zone creation."
+ return $null
+ }
+
+ # Get record sets to migrate from existing hosted zone.
+ # Only migrate non-SOA and non-NS records (if any).
+ Write-Host "$logLead : Pulling records from source hosted zone '$SourceHostedZoneName' in profile '$SourceProfileName'."
+ $sourceResourceRecordSets = Get-R53ResourceRecordSet -HostedZoneId $sourceHostedZoneId -ProfileName $SourceProfileName
+ $sourceRecordSetsToMigrate = $sourceResourceRecordSets.ResourceRecordSets | Where-Object { ( $_.Type -ne 'SOA' ) -and ( $_.Type -ne "NS" ) }
+
+ # Create new hosted zone.
+ Write-Host "$logLead : Creating hosted zone '$TargetHostedZoneName' in profile '$TargetProfileName'."
+ $targetHostedZone = New-R53HostedZone -Name $TargetHostedZoneName -CallerReference ( Get-Date -Format o ) -ProfileName $TargetProfileName `
+ -HostedZoneConfig_PrivateZone $true -VPC_VPCId $tempVpc.VpcId -VPC_VPCRegion $defaultRegion
+ if ( $null -eq $targetHostedZone ) {
+
+ # Well, we tried . . .
+ Write-Error "$logLead : Creation of hosted zone '$TargetHostedZoneName' failed."
+ return $null
+ }
+
+ Wait-Route53ChangeStatus -ChangeId $targetHostedZone.ChangeInfo.Id -AwsProfileName $TargetProfileName
+ $targetZoneId = $targetHostedZone.HostedZone.Id
+ Write-Host "$logLead : Target hosted zone ID is '$targetZoneId'"
+
+ # Add record sets to the newly created hosted zone (if applicable).
+ if ( $false -eq (Test-IsCollectionNullOrEmpty -collection $sourceRecordSetsToMigrate)) {
+
+ $changeSet = @()
+
+ foreach ( $record in $sourceRecordSetsToMigrate ) {
+
+ $curChange = New-Object Amazon.Route53.Model.Change
+ $curChange.Action = "CREATE"
+ $curChange.ResourceRecordSet = $record
+
+ $changeSet += $curChange
+ }
+
+ Write-Host "$logLead : Copying resource records from source hosted zone to target."
+ $changeOutput = Edit-R53ResourceRecordSet -HostedZoneId $targetZoneId -ChangeBatch_Comment "Migrating records to new hosted zone" -ChangeBatch_Change $changeSet -ProfileName $TargetProfileName
+ Wait-Route53ChangeStatus -ChangeId $changeOutput.Id -AwsProfileName $TargetProfileName
+ }
+
+ # To associate VPCs with the newly created hosted zone, we must first delete the source hosted zone.
+ # Remove record sets from the source zone prior to deletion.
+ if ( $false -eq (Test-IsCollectionNullOrEmpty -collection $sourceRecordSetsToMigrate)) {
+
+ $changeSet = @()
+
+ foreach ( $record in $sourceRecordSetsToMigrate ) {
+
+ $curChange = New-Object Amazon.Route53.Model.Change
+ $curChange.Action = "DELETE"
+ $curChange.ResourceRecordSet = $record
+
+ $changeSet += $curChange
+ }
+
+ Write-Host "$logLead : Removing resource records from source hosted zone '$sourceHostedZoneId' in profile '$SourceProfileName'."
+ $changeOutput = Edit-R53ResourceRecordSet -HostedZoneId $sourceHostedZoneId -ProfileName $SourceProfileName `
+ -ChangeBatch_Comment "Removing records for hosted zone migration" `
+ -ChangeBatch_Change $changeSet
+ Wait-Route53ChangeStatus -ChangeId $changeOutput.Id -AwsProfileName $SourceProfileName
+ }
+
+ Write-Host "$logLead : Removing hosted zone '$sourceHostedZoneId' from profile '$SourceProfileName'"
+ $changeOutput = Remove-R53HostedZone -Id $sourceHostedZoneId -ProfileName $SourceProfileName -Force
+ Wait-Route53ChangeStatus -ChangeId $changeOutput.Id -AwsProfileName $SourceProfileName
+
+ # Create the VPC associations.
+ Write-Host "$logLead : Creating VPC associations for '$targetZoneId'."
+ Set-Route53HostedZoneVpcAssocations -TargetProfile $TargetProfileName -TargetHostedZoneId $targetZoneId
+
+ # We made it! Let the user know the good news.
+ Write-Host "$logLead : Target hosted zone '$TargetHostedZoneName' (zone ID '$targetZoneId') successfully recreated in profile '$TargetProfileName'."
+ return $targetZoneId
+}
diff --git a/Modules/Alkami.DevOps.Operations/Public/Move-Route53HostedZone.tests.ps1 b/Modules/Alkami.DevOps.Operations/Public/Move-Route53HostedZone.tests.ps1
new file mode 100644
index 0000000..e0cc1ab
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Move-Route53HostedZone.tests.ps1
@@ -0,0 +1,173 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ""
+
+Describe "Move-Route53HostedZone" {
+
+ Mock -CommandName Get-AWSRegion -ModuleName $moduleForMock -MockWith {
+ return @(
+ @{ 'Region' = 'us-east-1' },
+ @{ 'Region' = 'us-west-2' }
+ )
+ }
+
+ Mock -CommandName Import-AWSModule -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Get-LogLeadName -ModuleName $moduleForMock -MockWith { return 'Move-Route53HostedZone.tests' }
+
+ Context "Input Validation" {
+
+ It "Target Hosted Zone Name Should Not Be Null" {
+
+ { Move-Route53HostedZone -TargetHostedZoneName $null } | Should -Throw
+ }
+
+ It "Target Hosted Zone Name Should Not Be Empty" {
+
+ { Move-Route53HostedZone -TargetHostedZoneName '' } | Should -Throw
+ }
+
+ It "Target Profile Name Should Not Be Null" {
+
+ { Move-Route53HostedZone -TargetHostedZoneName 'test' -TargetProfileName $null } | Should -Throw
+ }
+
+ It "Target Profile Name Should Not Be Empty" {
+
+ { Move-Route53HostedZone -TargetHostedZoneName 'test' -TargetProfileName '' } | Should -Throw
+ }
+
+ It "Source Hosted Zone Name Should Not Be Null" {
+
+ { Move-Route53HostedZone -TargetHostedZoneName 'test' -TargetProfileName 'Test' -SourceHostedZoneName $null } | Should -Throw
+ }
+
+ It "Source Hosted Zone Name Should Not Be Empty" {
+
+ { Move-Route53HostedZone -TargetHostedZoneName 'test' -TargetProfileName 'Test' -SourceHostedZoneName '' } | Should -Throw
+ }
+
+ It "Source Profile Name Should Not Be Null" {
+
+ { Move-Route53HostedZone -TargetHostedZoneName 'test1' -TargetProfileName 'Test' -SourceHostedZoneName 'test2' -SourceProfileName $null } | Should -Throw
+ }
+
+ It "Source Profile Name Should Not Be Empty" {
+
+ { Move-Route53HostedZone -TargetHostedZoneName 'test1' -TargetProfileName 'Test' -SourceHostedZoneName 'test2' -SourceProfileName '' } | Should -Throw
+ }
+ }
+
+ Context "Result Validation" {
+
+ It "Should Write Error If Target Hosted Zone Already Exists" {
+
+ Mock -CommandName Get-R53HostedZonesByName -ModuleName $moduleForMock -MockWith { return @( @{ 'Name' = 'test1'; 'Id' = 'test1' } ) }
+ Mock -CommandName Write-Error -ModuleName $moduleForMock -MockWith {}
+
+ $result = Move-Route53HostedZone -TargetHostedZoneName 'test1' -TargetProfileName 'test1' -SourceHostedZoneName 'test2' -SourceProfileName 'test2'
+ $result | Should -BeExactly 'test1'
+
+ Assert-MockCalled -CommandName Get-R53HostedZonesByName -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Write-Error -Times 1 -Exactly -Scope It -ModuleName $moduleForMock `
+ -ParameterFilter { $Message -match "Hosted zone named .* already exists" }
+ }
+
+ It "Should Write Error If Source Hosted Zone Does Not Exist" {
+
+ Mock -CommandName Get-R53HostedZonesByName -ModuleName $moduleForMock -MockWith { return @( @{ 'Name' = 'test3'; 'Id' = 'test3' } ) }
+ Mock -CommandName Write-Error -ModuleName $moduleForMock -MockWith {}
+
+ $result = Move-Route53HostedZone -TargetHostedZoneName 'Test1' -TargetProfileName 'Test1' -SourceHostedZoneName 'test2' -SourceProfileName 'test2'
+ $result | Should -BeNull
+
+ Assert-MockCalled -CommandName Get-R53HostedZonesByName -Times 2 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Write-Error -Times 1 -Exactly -Scope It -ModuleName $moduleForMock `
+ -ParameterFilter { $Message -match "Hosted zone named .* not found" }
+ }
+
+ It "Should Write Error If No Default VPC Exists" {
+
+ Mock -CommandName Get-R53HostedZonesByName -ModuleName $moduleForMock -MockWith { return @( @{ 'Name' = 'test2'; 'Id' = 'test2' } ) }
+# Mock -CommandName Test-VpcExists -ModuleName $moduleForMock -MockWith { return $true }
+ Mock -CommandName Get-EC2Vpc -ModuleName $moduleForMock -MockWith { return $null }
+ Mock -CommandName Write-Error -ModuleName $moduleForMock -MockWith {}
+
+ $result = Move-Route53HostedZone -TargetHostedZoneName 'test1' -TargetProfileName 'test1' -SourceHostedZoneName 'test2' -SourceProfileName 'test2'
+ $result | Should -BeNull
+
+ Assert-MockCalled -CommandName Get-R53HostedZonesByName -Times 2 -Exactly -Scope It -ModuleName $moduleForMock
+# Assert-MockCalled -CommandName Test-VpcExists -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Get-EC2Vpc -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Write-Error -Times 1 -Exactly -Scope It -ModuleName $moduleForMock `
+ -ParameterFilter { $Message -match "a VPC is required for Route53 hosted zone creation" }
+ }
+
+ It "Should Write Error If Hosted Zone Creation Fails" {
+
+ Mock -CommandName Get-R53HostedZonesByName -ModuleName $moduleForMock -MockWith { return @( @{ 'Name' = 'test2'; 'Id' = 'test2' } ) }
+# Mock -CommandName Test-VpcExists -ModuleName $moduleForMock -MockWith { return $true }
+ Mock -CommandName Get-EC2Vpc -ModuleName $moduleForMock -MockWith { return @{ 'VpcId' = 'Test' } }
+ Mock -CommandName New-R53HostedZone -ModuleName $moduleForMock -MockWith { return $null }
+ Mock -CommandName Get-R53ResourceRecordSet -ModuleName $moduleForMock -MockWith { return @{ 'ResourceRecordSets' = @() } }
+ Mock -CommandName Write-Error -ModuleName $moduleForMock -MockWith {}
+
+ $result = Move-Route53HostedZone -TargetHostedZoneName 'test1' -TargetProfileName 'test1' -SourceHostedZoneName 'test2' -SourceProfileName 'test2'
+ $result | Should -BeNull
+
+ Assert-MockCalled -CommandName Get-R53HostedZonesByName -Times 2 -Exactly -Scope It -ModuleName $moduleForMock
+# Assert-MockCalled -CommandName Test-VpcExists -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Get-EC2Vpc -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+# Assert-MockCalled -CommandName Test-VpcExists -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Get-R53ResourceRecordSet -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Write-Error -Times 1 -Exactly -Scope It -ModuleName $moduleForMock `
+ -ParameterFilter { $Message -match "Creation of hosted zone .* failed." }
+ }
+
+ It "Should Return Hosted Zone ID on Success" {
+
+ Mock -CommandName New-R53HostedZone -ModuleName $moduleForMock -MockWith {
+ return @{
+ 'ChangeInfo' = @{ 'Id' = 'Test' }
+ 'HostedZone' = @{ 'Id' = 'SuccessTest' }
+ }
+ }
+
+ Mock -CommandName Get-R53ResourceRecordSet -ModuleName $moduleForMock -MockWith {
+ return @{
+ 'ResourceRecordSets' = @(
+ @{}
+ )
+ }
+ }
+
+ Mock -CommandName Get-R53HostedZonesByName -ModuleName $moduleForMock -MockWith { return @( @{ 'Name' = 'test2'; 'Id' = 'test2' } ) }
+# Mock -CommandName Test-VpcExists -ModuleName $moduleForMock -MockWith { return $true }
+ Mock -CommandName Get-EC2Vpc -ModuleName $moduleForMock -MockWith { return @{ 'VpcId' = 'Test1' } }
+ Mock -CommandName Edit-R53ResourceRecordSet -ModuleName $moduleForMock -MockWith { return @{ 'Id' = 'Test' } }
+# Mock -CommandName Unregister-R53VPCFromHostedZone -ModuleName $moduleForMock -MockWith { return @{ 'Id' = 'Test' } }
+ Mock -CommandName Remove-R53HostedZone -ModuleName $moduleForMock -MockWith { return @{ 'Id' = 'Test' } }
+ Mock -CommandName Wait-Route53ChangeStatus -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Set-Route53HostedZoneVpcAssocations -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Write-Error -ModuleName $moduleForMock -MockWith {}
+
+ $result = Move-Route53HostedZone -TargetHostedZoneName 'test1' -TargetProfileName 'test1' -SourceHostedZoneName 'test2' -SourceProfileName 'test2'
+ $result | Should -BeExactly 'SuccessTest'
+
+ Assert-MockCalled -CommandName Get-R53HostedZonesByName -Times 2 -Exactly -Scope It -ModuleName $moduleForMock
+# Assert-MockCalled -CommandName Test-VpcExists -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Get-EC2Vpc -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Get-R53ResourceRecordSet -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Edit-R53ResourceRecordSet -Times 2 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName New-R53HostedZone -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Remove-R53HostedZone -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Wait-Route53ChangeStatus -Times 4 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Set-Route53HostedZoneVpcAssocations -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+# Assert-MockCalled -CommandName Unregister-R53VPCFromHostedZone -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Write-Error -Times 0 -Exactly -Scope It -ModuleName $moduleForMock
+ }
+ }
+}
diff --git a/Modules/Alkami.DevOps.Operations/Public/New-Route53HostedZone.ps1 b/Modules/Alkami.DevOps.Operations/Public/New-Route53HostedZone.ps1
new file mode 100644
index 0000000..729add1
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/New-Route53HostedZone.ps1
@@ -0,0 +1,95 @@
+function New-Route53HostedZone {
+
+<#
+.SYNOPSIS
+ Create a new Route53 Hosted Zone.
+
+.DESCRIPTION
+ Create a new Route53 Hosted Zone. The newly created Route53 Hosted Zone will be empty and ready to populate with records.
+
+ Note that this function will attempt to register all Alkami VPCs with the newly created zone; to facilitate that process,
+ call this function only if you have current AWS CLI credentials in each Alkami account.
+
+.PARAMETER HostedZoneName
+ [string] The desired name of the new Route53 Hosted Zone (e.g. 'thing.alkami.net').
+
+.PARAMETER ProfileName
+ [string] The AWS profile where the Route53 Hosted Zone will be created (e.g. 'temp-prod').
+
+.OUTPUTS
+ [string] The target Route53 Hosted Zone ID.
+
+.EXAMPLE
+ New-Route53HostedZone -HostedZoneName 'sandbox-test.alkami.net' -ProfileName 'temp-sandbox'
+
+[New-Route53HostedZone] : Looking for pre-existing hosted zone named 'sandbox-test.alkami.net' in profile 'temp-sandbox'.
+[New-Route53HostedZone] : Determining baseline VPC in 'temp-sandbox'
+[New-Route53HostedZone] : Creating hosted zone 'sandbox-test.alkami.net' in profile 'temp-sandbox'.
+[Wait-Route53ChangeStatus] : Route53 Change ID '/change/C0629667BL5165ZI58FZ' has status 'INSYNC' after 63.3634024 seconds.
+[New-Route53HostedZone] : Hosted Zone ID is '/hostedzone/Z03772702VVYXD5ER64X2'
+[New-Route53HostedZone] : Creating VPC associations for '/hostedzone/Z03772702VVYXD5ER64X2'.
+
+[New-Route53HostedZone] : Hosted zone 'sandbox-test.alkami.net' (zone ID '/hostedzone/Z03772702VVYXD5ER64X2') successfully created in profile 'temp-sandbox'.
+/hostedzone/Z03772702VVYXD5ER64X2
+#>
+
+ [OutputType([string])]
+ [CmdletBinding()]
+ param (
+ [Parameter(Mandatory = $true)]
+ [ValidateNotNullOrEmpty()]
+ [string] $HostedZoneName,
+
+ [Parameter(Mandatory = $true)]
+ [ValidateNotNullOrEmpty()]
+ [string] $ProfileName
+ )
+
+ Import-AWSModule
+
+ $logLead = (Get-LogLeadName)
+ $defaultRegion = 'us-east-1'
+
+ # Validate that a hosted zone named the specified name does not already exist.
+ # Amazon is fine with this as long as the VPC associations don't overlap; Alkami is less fine with it . . .
+ Write-Host "$logLead : Looking for pre-existing hosted zone named '$HostedZoneName' in profile '$ProfileName'."
+ $hostedZoneList = Get-R53HostedZonesByName -DNSName $HostedZoneName -ProfileName $ProfileName
+ $hostedZone = $hostedZoneList | Where-Object { $_.Name -match $HostedZoneName } | Select-Object -First 1
+ if ( $null -ne $hostedZone ) {
+
+ Write-Error "$logLead : Hosted zone named '$HostedZoneName' already exists using profile '$ProfileName'; aborting."
+ return $hostedZone.Id
+ }
+
+ # Route53 hosted zone creation requires an initial VPC association. Look for one using the provided profile.
+ Write-Host "$logLead : Determining baseline VPC in '$ProfileName'"
+ $tempVpc = Get-EC2Vpc -ProfileName $ProfileName -Region $defaultRegion | Select-Object -First 1
+ if ( $null -eq $tempVpc ) {
+
+ Write-Error "$logLead : No VPC found using profile '$ProfileName'; a VPC is required for Route53 hosted zone creation."
+ return $null
+ }
+
+ # Create new hosted zone.
+ Write-Host "$logLead : Creating hosted zone '$HostedZoneName' in profile '$ProfileName'."
+ $hostedZone = New-R53HostedZone -Name $HostedZoneName -CallerReference ( Get-Date -Format o ) -ProfileName $ProfileName `
+ -HostedZoneConfig_PrivateZone $true -VPC_VPCId $tempVpc.VpcId -VPC_VPCRegion $defaultRegion
+ if ( $null -eq $hostedZone ) {
+
+ # Well, we tried . . .
+ Write-Error "$logLead : Creation of hosted zone '$HostedZoneName' failed."
+ return $null
+ }
+
+ Wait-Route53ChangeStatus -ChangeId $hostedZone.ChangeInfo.Id -AwsProfileName $ProfileName
+ $zoneId = $hostedZone.HostedZone.Id
+ Write-Host "$logLead : Hosted Zone ID is '$zoneId'"
+
+ # Create the VPC associations.
+ Write-Host "$logLead : Creating VPC associations for '$zoneId'."
+ Set-Route53HostedZoneVpcAssocations -TargetProfile $ProfileName -TargetHostedZoneId $zoneId
+
+ # We made it! Let the user know the good news.
+ Write-Host "$logLead : Hosted zone '$HostedZoneName' (zone ID '$zoneId') successfully created in profile '$ProfileName'."
+ return $zoneId
+}
diff --git a/Modules/Alkami.DevOps.Operations/Public/New-Route53HostedZone.tests.ps1 b/Modules/Alkami.DevOps.Operations/Public/New-Route53HostedZone.tests.ps1
new file mode 100644
index 0000000..ce56ae5
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/New-Route53HostedZone.tests.ps1
@@ -0,0 +1,125 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ""
+
+Describe "New-Route53HostedZone" {
+
+ Mock -CommandName Get-AWSRegion -ModuleName $moduleForMock -MockWith {
+ return @(
+ @{ 'Region' = 'us-east-1' },
+ @{ 'Region' = 'us-west-2' }
+ )
+ }
+
+ Mock -CommandName Import-AWSModule -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Get-LogLeadName -ModuleName $moduleForMock -MockWith { return 'New-Route53HostedZone.tests' }
+
+ Context "Input Validation" {
+
+ It "Hosted Zone Name Should Not Be Null" {
+
+ { New-Route53HostedZone -HostedZoneName $null } | Should -Throw
+ }
+
+ It "Hosted Zone Name Should Not Be Empty" {
+
+ { New-Route53HostedZone -HostedZoneName '' } | Should -Throw
+ }
+
+ It "Profile Name Should Not Be Null" {
+
+ { New-Route53HostedZone -HostedZoneName 'test' -ProfileName $null } | Should -Throw
+ }
+
+ It "Profile Name Should Not Be Empty" {
+
+ { New-Route53HostedZone -HostedZoneName 'test' -ProfileName '' } | Should -Throw
+ }
+ }
+
+ Context "Result Validation" {
+
+ It "Should Write Error If Hosted Zone Already Exists" {
+
+ Mock -CommandName Get-R53HostedZonesByName -ModuleName $moduleForMock -MockWith { return @( @{ 'Name' = 'TestName'; 'Id' = 'TestId' } ) }
+ Mock -CommandName Write-Error -ModuleName $moduleForMock -MockWith {}
+
+ $result = New-Route53HostedZone -HostedZoneName 'TestName' -ProfileName 'Test'
+ $result | Should -BeExactly 'TestId'
+
+ Assert-MockCalled -CommandName Get-R53HostedZonesByName -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Write-Error -Times 1 -Exactly -Scope It -ModuleName $moduleForMock `
+ -ParameterFilter { $Message -match "Hosted zone named .* already exists" }
+ }
+
+ It "Should Write Error If No Default VPC Exists" {
+
+ Mock -CommandName Get-R53HostedZonesByName -ModuleName $moduleForMock -MockWith { return @() }
+# Mock -CommandName Test-VpcExists -ModuleName $moduleForMock -MockWith { return $true }
+ Mock -CommandName Get-EC2Vpc -ModuleName $moduleForMock -MockWith { return $null }
+ Mock -CommandName Write-Error -ModuleName $moduleForMock -MockWith {}
+
+ $result = New-Route53HostedZone -HostedZoneName 'Test' -ProfileName 'Test'
+ $result | Should -BeNull
+
+ Assert-MockCalled -CommandName Get-R53HostedZonesByName -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+# Assert-MockCalled -CommandName Test-VpcExists -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Get-EC2Vpc -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Write-Error -Times 1 -Exactly -Scope It -ModuleName $moduleForMock `
+ -ParameterFilter { $Message -match "a VPC is required for Route53 hosted zone creation" }
+ }
+
+ It "Should Write Error If Hosted Zone Creation Fails" {
+
+ Mock -CommandName Get-R53HostedZonesByName -ModuleName $moduleForMock -MockWith { return @() }
+# Mock -CommandName Test-VpcExists -ModuleName $moduleForMock -MockWith { return $true }
+ Mock -CommandName Get-EC2Vpc -ModuleName $moduleForMock -MockWith { return @{ 'VpcId' = 'Test' } }
+ Mock -CommandName New-R53HostedZone -ModuleName $moduleForMock -MockWith { return $null }
+ Mock -CommandName Write-Error -ModuleName $moduleForMock -MockWith {}
+
+ $result = New-Route53HostedZone -HostedZoneName 'Test' -ProfileName 'Test'
+ $result | Should -BeNull
+
+ Assert-MockCalled -CommandName Get-R53HostedZonesByName -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+# Assert-MockCalled -CommandName Test-VpcExists -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Get-EC2Vpc -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName New-R53HostedZone -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Write-Error -Times 1 -Exactly -Scope It -ModuleName $moduleForMock `
+ -ParameterFilter { $Message -match "Creation of hosted zone .* failed." }
+ }
+
+ It "Should Return Hosted Zone ID on Success" {
+
+ Mock -CommandName New-R53HostedZone -ModuleName $moduleForMock -MockWith {
+ return @{
+ 'ChangeInfo' = @{ 'Id' = 'Test' }
+ 'HostedZone' = @{ 'Id' = 'SuccessTest' }
+ }
+ }
+
+ Mock -CommandName Get-R53HostedZonesByName -ModuleName $moduleForMock -MockWith { return @() }
+# Mock -CommandName Test-VpcExists -ModuleName $moduleForMock -MockWith { return $true }
+ Mock -CommandName Get-EC2Vpc -ModuleName $moduleForMock -MockWith { return @{ 'VpcId' = 'Test1' } }
+# Mock -CommandName Unregister-R53VPCFromHostedZone -ModuleName $moduleForMock -MockWith { return @{ 'Id' = 'Test' } }
+ Mock -CommandName Wait-Route53ChangeStatus -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Set-Route53HostedZoneVpcAssocations -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Write-Error -ModuleName $moduleForMock -MockWith {}
+
+ $result = New-Route53HostedZone -HostedZoneName 'Test' -ProfileName 'Test'
+ $result | Should -BeExactly 'SuccessTest'
+
+ Assert-MockCalled -CommandName Get-R53HostedZonesByName -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+# Assert-MockCalled -CommandName Test-VpcExists -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Get-EC2Vpc -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName New-R53HostedZone -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Wait-Route53ChangeStatus -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Set-Route53HostedZoneVpcAssocations -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+# Assert-MockCalled -CommandName Unregister-R53VPCFromHostedZone -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Write-Error -Times 0 -Exactly -Scope It -ModuleName $moduleForMock
+ }
+ }
+}
diff --git a/Modules/Alkami.DevOps.Operations/Public/New-TcpSocket.ps1 b/Modules/Alkami.DevOps.Operations/Public/New-TcpSocket.ps1
new file mode 100644
index 0000000..ed8643c
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/New-TcpSocket.ps1
@@ -0,0 +1,22 @@
+function New-TcpSocket {
+ <#
+.SYNOPSIS
+ When given an object of type System.Net.IPAddress and , returns System.IO.StreamWriter object.
+.EXAMPLE
+ $socket = New-TcpSocket -IpAddress $IpAddress -Port $port
+.INPUTS
+ IpAddress
+ Port
+.OUTPUTS
+ System.Net.Sockets.TCPClient
+#>
+ [cmdletbinding()]
+ param(
+ $IpAddress,
+ $Port
+ )
+
+ $socket = New-Object System.Net.Sockets.TCPClient
+ $socket.Connect($IpAddress, $Port)
+ return $socket
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Public/New-TcpStreamWriter.ps1 b/Modules/Alkami.DevOps.Operations/Public/New-TcpStreamWriter.ps1
new file mode 100644
index 0000000..cc22304
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/New-TcpStreamWriter.ps1
@@ -0,0 +1,18 @@
+function New-TcpStreamWriter {
+ <#
+.SYNOPSIS
+ When given an object of type System.Net.Sockets.TCPClient, returns System.IO.StreamWriter object.
+.EXAMPLE
+ $writer = New-TcpStreamWriter -Socket $socket
+.INPUTS
+ Socket
+.OUTPUTS
+ System.IO.StreamWriter
+.NOTES
+ Input Socket object should already be in connected state, will be connected if socket is created by New-TcpSocket.
+#>
+ [cmdletbinding()]
+ param($Socket)
+ $Stream = $Socket.GetStream()
+ return New-Object System.IO.StreamWriter($Stream)
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Public/Open-ChocolateyLibDir.ps1 b/Modules/Alkami.DevOps.Operations/Public/Open-ChocolateyLibDir.ps1
new file mode 100644
index 0000000..feb3e22
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Open-ChocolateyLibDir.ps1
@@ -0,0 +1,10 @@
+function Open-ChocolateyLibDir {
+ <#
+ .SYNOPSIS
+ Opens the chocolate lib directory in file explorer.
+ #>
+ [CmdletBinding()]
+ param()
+ $chocoPath = Join-Path (Get-ChocolateyInstallPath) "lib";
+ Invoke-Item $chocoPath;
+}
diff --git a/Modules/Alkami.DevOps.Operations/Public/Open-HostsFile.ps1 b/Modules/Alkami.DevOps.Operations/Public/Open-HostsFile.ps1
new file mode 100644
index 0000000..562a7fc
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Open-HostsFile.ps1
@@ -0,0 +1,23 @@
+Function Open-HostsFile {
+<#
+.SYNOPSIS
+ Opens the Windows hosts file in Notepad++
+#>
+ [CmdletBinding()]
+ param(
+
+ )
+
+ $logLead = Get-LogLeadName
+ $path = "$($env:windir)\System32\Drivers\etc\hosts"
+
+ if(Test-Path $path)
+ {
+ $txtEditorPath = Get-TextEditorPath
+ Start-Process $txtEditorPath $path
+ }
+ else
+ {
+ Write-Error "$logLead : The hosts file at '$path' does not exist!";
+ }
+}
diff --git a/Modules/Alkami.DevOps.Operations/Public/Open-MachineConfig.ps1 b/Modules/Alkami.DevOps.Operations/Public/Open-MachineConfig.ps1
new file mode 100644
index 0000000..e6a17c1
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Open-MachineConfig.ps1
@@ -0,0 +1,24 @@
+Function Open-MachineConfig {
+<#
+.SYNOPSIS
+ Opens the .NET machine config in Notepad++
+#>
+ [CmdletBinding()]
+ Param(
+ [Parameter(Mandatory=$false)]
+ [switch] $use32bit
+ )
+
+ $logLead = Get-LogLeadName
+ $path = Get-DotNetConfigPath -use64Bit (!$use32bit.IsPresent)
+
+ if(Test-Path $path)
+ {
+ $txtEditorPath = Get-TextEditorPath
+ Start-Process $txtEditorPath $path
+ }
+ else
+ {
+ Write-Error "$logLead : The machine.config file at '$path' does not exist!"
+ }
+}
diff --git a/Modules/Alkami.DevOps.Operations/Public/Open-NagConfig.ps1 b/Modules/Alkami.DevOps.Operations/Public/Open-NagConfig.ps1
new file mode 100644
index 0000000..3d8cf9d
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Open-NagConfig.ps1
@@ -0,0 +1,21 @@
+Function Open-NagConfig {
+<#
+.SYNOPSIS
+ Opens the Nag config file in Notepad++
+#>
+ [CmdletBinding()]
+ Param ()
+
+ $logLead = Get-LogLeadName
+ $path = (Join-Path(Get-OrbPath) "Nag\Alkami.App.Nag.Host.Service.exe.config")
+
+ if(Test-Path $path)
+ {
+ $txtEditorPath = Get-TextEditorPath
+ Start-Process $txtEditorPath $path
+ }
+ else
+ {
+ Write-Error "$logLead : The Nag config file at '$path' does not exist!";
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Public/Open-PowerShellModulesDir.ps1 b/Modules/Alkami.DevOps.Operations/Public/Open-PowerShellModulesDir.ps1
new file mode 100644
index 0000000..9e7f755
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Open-PowerShellModulesDir.ps1
@@ -0,0 +1,9 @@
+function Open-PowerShellModulesDir {
+ <#
+ .SYNOPSIS
+ Opens the PowerShell modules directory in file explorer.
+ #>
+ [CmdletBinding()]
+ param()
+ Invoke-Item "C:\Program Files\WindowsPowerShell\Modules";
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Public/Ping-EntrustServices.ps1 b/Modules/Alkami.DevOps.Operations/Public/Ping-EntrustServices.ps1
new file mode 100644
index 0000000..35a6507
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Ping-EntrustServices.ps1
@@ -0,0 +1,105 @@
+function Ping-EntrustServices {
+
+<#
+.SYNOPSIS
+ Warms up Entrust services
+
+.EXAMPLE
+ Ping-EntrustServices
+ [Get-ElbHealthcheckEndpoints] : Name computed as 0-0-Sandbox-entrust-alb
+
+ URL Success Elapsed
+ --- ------- -------
+ https://localhost:8444/identityguardadminservice/services/adminservicev11 True 00:00:00.0414551
+ https://localhost:8443/identityguardauthservice/services/authenticationservicev11 True 00:00:00.0410411
+
+.PARAMETER skipOutput
+ Optional flag to suppress output.
+
+#>
+
+ [CmdletBinding()]
+ Param(
+ [Parameter(Mandatory=$false)]
+ [Alias("NoOutput")]
+ [switch]$skipOutput
+ )
+
+ $logLead = (Get-LogLeadName);
+
+ if ($false -eq (Test-IsEntrustServer)) {
+ Write-Warning "$logLead : This function is only valid on Entrust servers."
+ return $null
+ }
+
+ $maxJobs = 3
+ $testPattern = "Entrust IdentityGuard"
+ $functionStopWatch = [System.Diagnostics.Stopwatch]::StartNew()
+
+ $endpointsToWarmUp = Get-ElbHealthcheckEndpoints
+ if (Test-IsCollectionNullOrEmpty -collection $endpointsToWarmUp) {
+
+ Write-Warning "$logLead : Result returned NULL or empty value for endpoints"
+ return $null
+ }
+
+ $scriptBlock = {
+
+ param ($endpoint, $arguments)
+ $testPattern = $arguments[0]
+
+ $scriptResult = New-Object -TypeName PSObject -Property @{
+ URL = $endpoint
+ Success = $false
+ StatusCode = $null
+ Elapsed = $null
+ }
+
+ $stopWatch = New-Object -TypeName System.Diagnostics.Stopwatch
+
+ try {
+ add-type @"
+ using System.Net;
+ using System.Security.Cryptography.X509Certificates;
+ public class TrustAllCertsPolicy : ICertificatePolicy {
+ public bool CheckValidationResult(
+ ServicePoint srvPoint, X509Certificate certificate,
+ WebRequest request, int certificateProblem) {
+ return true;
+ }
+ }
+"@
+
+ [System.Net.ServicePointManager]::CertificatePolicy = New-Object TrustAllCertsPolicy
+ $stopWatch.Start()
+ $response = Invoke-WebRequest -Uri $endpoint -UseBasicParsing
+ $stopWatch.Stop()
+ $scriptResult.StatusCode = $response.StatusCode
+ $scriptResult.Success = ($response.StatusCode -eq 200 -and $response.Content -match $testPattern)
+
+ } catch {
+
+ if ($stopWatch.IsRunning) {
+ $stopWatch.Stop()
+ }
+ }
+ $scriptResult.Elapsed = $stopWatch.Elapsed.ToString()
+
+ return $scriptResult
+ }
+
+ $endpointResults = Invoke-Parallel -objects $endpointsToWarmUp -arguments ($testPattern) -script $scriptBlock -numThreads $maxJobs -returnObjects
+
+ $functionStopWatch.Stop()
+
+ if ($skipOutput) {
+ return $endpointResults
+ } else {
+ if ($null -ne ($endpointResults | Where-Object {$_.Success -eq $false})) {
+ Write-Warning "$logLead : One or more URLs failed the test case:`n"
+ }
+
+ $endpointResults | Sort-Object -Property Success | Format-Table -Property @{Label="URL";Width=85;e={$_.URL};Alignment="Left"}, @{Label="Success";Width=15;e={$_.Success};Alignment="Right"},@{Label="Elapsed";Width=25;e={$_.Elapsed};Alignment="Right"} | Out-String
+ Write-Host ("Total Execution Time: {0}" -f $functionStopWatch.Elapsed.ToString())
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Public/Ping-EntrustServices.tests.ps1 b/Modules/Alkami.DevOps.Operations/Public/Ping-EntrustServices.tests.ps1
new file mode 100644
index 0000000..34f2f9f
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Ping-EntrustServices.tests.ps1
@@ -0,0 +1,69 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$global:functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$global:moduleForMock = ""
+
+Describe "Ping-EntrustServices" {
+
+
+ Context "Tier Validation" {
+
+ It "Should Be Run on an Entrust Machine" {
+
+ Mock -CommandName Test-IsEntrustServer -ModuleName $moduleForMock -MockWith { return $false }
+ Mock -CommandName Write-Warning -ModuleName $moduleForMock -MockWith {}
+
+ $result = ( Ping-EntrustServices )
+
+ Assert-MockCalled -CommandName Test-IsEntrustServer -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Write-Warning -Times 1 -Exactly -Scope It `
+ -ModuleName $moduleForMock -ParameterFilter { $Message -match "This function is only valid on Entrust servers." }
+
+ $result | Should -BeNullOrEmpty
+ }
+ }
+
+ InModuleScope -ModuleName Alkami.DevOps.Operations -ScriptBlock {
+ Write-Host "InModuleScope - Overriding SUT: $($global:functionPath)"
+ Import-Module $global:functionPath -Force
+ $inScopeModuleForAssert = "Alkami.DevOps.Operations"
+
+ Context "Endpoint Validation" {
+
+ It "Get-ELBHealthcheckEndpoints should not return a null value when Test-IsEntrustServer is true" {
+
+ Mock -CommandName Test-IsEntrustServer -ModuleName $inScopeModuleForAssert -MockWith { return $true }
+ Mock -CommandName Get-ELBHealthcheckEndpoints -ModuleName $inScopeModuleForAssert -MockWith { return $null }
+ Mock -CommandName Write-Warning -ModuleName $inScopeModuleForAssert -MockWith {}
+
+ $result = ( Ping-EntrustServices )
+
+ Assert-MockCalled -CommandName Get-ELBHealthcheckEndpoints -Times 1 -Exactly -Scope It -ModuleName $inScopeModuleForAssert
+ Assert-MockCalled -CommandName Write-Warning -Times 1 -Exactly -Scope It `
+ -ModuleName $inScopeModuleForAssert -ParameterFilter { $Message -match "Result returned NULL or empty value for endpoints" }
+
+ $result | Should -BeNullOrEmpty
+ }
+
+ It "Get-ELBHealthcheckEndpoints should not return a empty value when Test-IsEntrustServer is true" {
+
+ Mock -CommandName Test-IsEntrustServer -ModuleName $inScopeModuleForAssert -MockWith { return $true }
+ Mock -CommandName Get-ELBHealthcheckEndpoints -ModuleName $inScopeModuleForAssert -MockWith { return @() }
+ Mock -CommandName Write-Warning -ModuleName $inScopeModuleForAssert -MockWith {}
+
+ $result = ( Ping-EntrustServices )
+
+ Assert-MockCalled -CommandName Get-ELBHealthcheckEndpoints -Times 1 -Exactly -Scope It -ModuleName $inScopeModuleForAssert
+ Assert-MockCalled -CommandName Write-Warning -Times 1 -Exactly -Scope It `
+ -ModuleName $inScopeModuleForAssert -ParameterFilter { $Message -match "Result returned NULL or empty value for endpoints" }
+
+ $result | Should -BeNullOrEmpty
+ }
+ }
+
+ }
+
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Public/Ping-Host.ps1 b/Modules/Alkami.DevOps.Operations/Public/Ping-Host.ps1
new file mode 100644
index 0000000..15a9286
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Ping-Host.ps1
@@ -0,0 +1,25 @@
+function Ping-Host {
+ <#
+.SYNOPSIS
+ Ping a given host with a given timeout
+
+.PARAMETER HostToPing
+ What host to ping
+
+.PARAMETER Timeout
+ How long to wait for a response
+#>
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory = $true)]
+ [string]$HostToPing,
+ [Parameter(Mandatory = $false)]
+ [string]$Timeout = 100
+ )
+ $logLead = Get-LogLeadName
+
+ $ping = New-Object System.Net.NetworkInformation.Ping
+ $response = $ping.Send($HostToPing, $Timeout)
+ Write-Verbose "$logLead : Ping result : $($response.Status)"
+ return $response
+}
diff --git a/Modules/Alkami.DevOps.Operations/Public/Push-TcpStreamWriterBuffer.ps1 b/Modules/Alkami.DevOps.Operations/Public/Push-TcpStreamWriterBuffer.ps1
new file mode 100644
index 0000000..b386dde
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Push-TcpStreamWriterBuffer.ps1
@@ -0,0 +1,15 @@
+function Push-TcpStreamWriterBuffer {
+ <#
+.SYNOPSIS
+ Flushes all data in the Stream Writer buffer. When given an object of type System.IO.StreamWriter, executes Flush().
+.EXAMPLE
+ Push-TcpStreamWriterBuffer -Writer $Writer
+.INPUTS
+ Writer
+#>
+ [cmdletbinding()]
+ param(
+ $Writer
+ )
+ $Writer.Flush()
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Public/Read-ProviderConfigurationFromTenants.ps1 b/Modules/Alkami.DevOps.Operations/Public/Read-ProviderConfigurationFromTenants.ps1
new file mode 100644
index 0000000..1e451d1
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Read-ProviderConfigurationFromTenants.ps1
@@ -0,0 +1,55 @@
+function Read-ProviderConfigurationFromTenants {
+
+<#
+.SYNOPSIS
+ Connects to a master database and aggregates configured provider information for all tenants
+
+.DESCRIPTION
+ Connects to a master database and aggregates configured provider information for all tenants. Calls Get-ProvidersFromTenantDatabase for
+ each tenant and returns the unique values from all tenants
+
+.PARAMETER MasterConnectionString
+ The full Master databse connection string. Defaults to the local ConnectionString from the machine.config file
+
+.OUTPUTS
+ Returns a list of unique providers from all queried databases, with provider type included
+#>
+
+ [CmdletBinding()]
+ [OutputType([System.Object[]])]
+ param(
+ [Parameter(Mandatory=$false)]
+ [string]$MasterConnectionString = (Get-ConnectionString "AlkamiMaster")
+ )
+
+ $logLead = Get-LogLeadName
+
+ if ([String]::IsNullOrEmpty($MasterConnectionString)) {
+
+ Write-Warning "$logLead : Supply a valid Master database connection string to this function and rerun"
+ return $null
+ }
+
+ Write-Verbose "$logLead : Reading provider configuration using master database connection string: [$MasterConnectionString]"
+ [array]$tenants = Get-FullTenantListFromServer -connectionString $MasterConnectionString
+
+ $validProviderConfiguration = @()
+ foreach ($tenant in $tenants) {
+
+ Write-Host "$logLead : Reading provider configuration from tenant [$($tenant.Name)]"
+ [PSObject[]]$providers = Get-ProvidersFromTenantDatabase -TenantConnectionString $tenant.ConnectionString -ValidOnly
+
+ Write-Verbose "$logLead : $($providers.Count) valid providers returned"
+
+ foreach ($provider in $providers) {
+
+ $validProviderConfiguration += New-Object PSObject -Property @{
+
+ ProviderType = $provider.ProviderType;
+ ProviderName = $provider.ProviderName;
+ }
+ }
+ }
+
+ return $validProviderConfiguration | Sort-Object -Property ProviderType,ProviderName -Unique
+}
diff --git a/Modules/Alkami.DevOps.Operations/Public/Read-ProviderConfigurationFromTenants.tests.ps1 b/Modules/Alkami.DevOps.Operations/Public/Read-ProviderConfigurationFromTenants.tests.ps1
new file mode 100644
index 0000000..9f2db34
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Read-ProviderConfigurationFromTenants.tests.ps1
@@ -0,0 +1,108 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ""
+
+Describe "Read-ProviderConfigurationFromTenants" {
+
+ Mock -CommandName Get-LogLeadName -ModuleName $moduleForMock -MockWith { "[$sut (Pester)]" }
+ Mock -CommandName Get-ProvidersFromTenantDatabase -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Get-FullTenantListFromServer -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Get-ConnectionString -ModuleName $moduleForMock -MockWith { return "TotesLegitConStr" }
+
+ Context "Tests" {
+
+ It "Writes a Warning and Exits Early if an Empty Master Connection String is Provided" {
+
+ Mock -CommandName Write-Warning -ModuleName $moduleForMock -MockWith { }
+
+ Read-ProviderConfigurationFromTenants -MasterConnectionString $null | Should -BeNullOrEmpty
+ Assert-MockCalled -CommandName Write-Warning -Times 1 -Exactly -Scope It `
+ -ModuleName $moduleForMock -ParameterFilter { $Message -match "Supply a valid Master database connection string" }
+ Assert-MockCalled -CommandName Get-ProvidersFromTenantDatabase -Times 0 -Exactly -Scope It
+ }
+
+ It "Uses the Parameter Value for the MasterConnectionString to Lookup Tenants" {
+
+ $fakeConnectionString = "Server=localhost;InitialCatalog=LittleBobbyTables;"
+ Read-ProviderConfigurationFromTenants -MasterConnectionString $fakeConnectionString
+ Assert-MockCalled -CommandName Get-FullTenantListFromServer -Times 1 -Exactly -Scope It `
+ -ParameterFilter { $connectionString -eq $fakeConnectionString }
+ }
+
+ It "Returns only Unique Provider Data by ProviderType and ProviderName (Single Provider)" {
+
+ Mock -CommandName Get-FullTenantListFromServer -ModuleName $moduleForMock -MockWith {
+
+ return @{
+ ConnectionString = "foo";
+ Name = "bar";
+ }
+ }
+
+ Mock -CommandName Get-ProvidersFromTenantDatabase -ModuleName $moduleForMock -MockWith {
+
+ $providerA = New-Object PSObject -Property @{
+ ProviderType = "Fake";
+ ProviderName = "Provider";
+ }
+
+ $providerB = New-Object PSObject -Property @{
+ ProviderType = "Fake";
+ ProviderName = "Provider";
+ }
+
+ return @($providerA, $providerB)
+ }
+
+ [PSObject[]]$results = Read-ProviderConfigurationFromTenants
+ $results | Should -HaveCount 1
+ $results | Should -Not -BeNullOrEmpty
+ $results.ProviderType | Should -BeExactly "Fake"
+ $results.ProviderName | Should -BeExactly "Provider"
+ }
+
+ It "Uses both ProviderName and ProviderType to Determine Provider Uniqueness" {
+
+ Mock -CommandName Get-FullTenantListFromServer -ModuleName $moduleForMock -MockWith {
+
+ return @{
+ ConnectionString = "foo";
+ Name = "bar";
+ }
+ }
+
+ Mock -CommandName Get-ProvidersFromTenantDatabase -ModuleName $moduleForMock -MockWith {
+
+
+ $providerA = New-Object PSObject -Property @{
+ ProviderType = "Fake";
+ ProviderName = "Provider";
+ }
+
+ $providerB = New-Object PSObject -Property @{
+ ProviderType = "Real";
+ ProviderName = "Provider";
+ }
+
+ $providerC = New-Object PSObject -Property @{
+ ProviderType = "Real";
+ ProviderName = "Talk";
+ }
+
+ $providerD = New-Object PSObject -Property @{
+ ProviderType = "Fake";
+ ProviderName = "Talk";
+ }
+
+ return @($providerA, $providerB, $providerC, $providerD)
+ }
+
+ [PSObject[]]$results = Read-ProviderConfigurationFromTenants
+ $results | Should -HaveCount 4
+ }
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Public/Read-ProviderMappingFile.ps1 b/Modules/Alkami.DevOps.Operations/Public/Read-ProviderMappingFile.ps1
new file mode 100644
index 0000000..9981c74
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Read-ProviderMappingFile.ps1
@@ -0,0 +1,49 @@
+function Read-ProviderMappingFile {
+
+<#
+.SYNOPSIS
+ Parses the Vanguard supplied provider mapping file and returns the results as an object array
+
+.DESCRIPTION
+ Parses the Vanguard supplied provider mapping file returned from Get-ProviderMappingFilePath and returns the results as an object array
+
+.PARAMETER ProviderMappingFilePath
+ The path to the provider mapping file supplied by Vanguard. Defaults to the result of Get-ProviderMappingFilePath, which returns the file
+ bundled in the module
+
+.OUTPUTS
+ Returns objects hydrated from the JSON file.
+#>
+
+ [CmdletBinding()]
+ [OutputType([System.Object[]])]
+ param(
+ [Parameter(Mandatory=$false)]
+ [string]$ProviderMappingFilePath = (Get-ProviderMappingFilePath)
+ )
+
+ $logLead = Get-LogLeadName
+
+ if ([String]::IsNullOrEmpty($ProviderMappingFilePath)) {
+
+ Write-Warning "$logLead : Could not find the mapping file at $ProviderMappingFilePath. Execution cannot continue"
+ return $null
+ }
+
+ Write-Verbose "$logLead : Reading Provider Mapping File from $ProviderMappingFilePath"
+ $fileContent = Get-Content -Path $ProviderMappingFilePath
+
+ try {
+
+ [array]$providerObjects = ($fileContent | ConvertFrom-Json).mappings
+
+ } catch {
+
+ Write-Warning "$logLead : An error occurred hydrating objects from file. Check file content and re-run."
+ Write-Host "$logLead : Error: $($_.Exception.Message)"
+ return $null
+ }
+
+ Write-Verbose "$logLead : $($providerObjects.Count) objects hydrated"
+ return $providerObjects
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Public/Read-ProviderMappingFile.tests.ps1 b/Modules/Alkami.DevOps.Operations/Public/Read-ProviderMappingFile.tests.ps1
new file mode 100644
index 0000000..0b22277
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Read-ProviderMappingFile.tests.ps1
@@ -0,0 +1,45 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ""
+
+Describe "Read-ProviderMappingFile" {
+
+ Mock -CommandName Get-LogLeadName -ModuleName $moduleForMock -MockWith { "[$sut (Pester)]" }
+
+ Context "Tests" {
+
+ Mock -ModuleName $moduleForMock -CommandName Write-Warning -MockWith {}
+
+ It "Writes a Warning and Returns Null if No File Found" {
+
+ Mock -ModuleName $moduleForMock -CommandName Get-ProviderMappingFilePath -MockWith { return $null }
+ Read-ProviderMappingFile | Should -BeNullOrEmpty
+ Assert-MockCalled -CommandName Write-Warning -Times 1 -Exactly -Scope It `
+ -ModuleName $moduleForMock -ParameterFilter { $Message -match "Could not find the mapping file" }
+ }
+
+ It "Writes a Warning and Returns Null if An Exception Occurs Parsing the File" {
+
+ Mock -ModuleName $moduleForMock -CommandName Get-ProviderMappingFilePath -MockWith { return "Z:\OMG" }
+ Mock -ModuleName $moduleForMock -CommandName Get-Content -MockWith { return "thIs-AInT-jsoN-broHeiM" }
+
+ Read-ProviderMappingFile | Should -BeNullOrEmpty
+ Assert-MockCalled -CommandName Write-Warning -Times 1 -Exactly -Scope It `
+ -ModuleName $moduleForMock -ParameterFilter { $Message -match "An error occurred hydrating objects from file" }
+ }
+
+ It "Returns Hydrated Objects" {
+
+ Mock -ModuleName $moduleForMock -CommandName Get-ProviderMappingFilePath -MockWith { return "Z:\OMG" }
+ Mock -ModuleName $moduleForMock -CommandName Get-Content -MockWith { return '{"mappings":[{"Leave":"Oops.I.Did.It.Again","Britney":"I.Played.With.Your.Heart","Alone":"Got.Lost.In.The.Game"}]}' }
+
+ $results = Read-ProviderMappingFile
+ $results | Should -Not -BeNullOrEmpty
+ $results.Leave | Should -BeExactly "Oops.I.Did.It.Again"
+ }
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Public/Read-RadiumPurgeScript.ps1 b/Modules/Alkami.DevOps.Operations/Public/Read-RadiumPurgeScript.ps1
new file mode 100644
index 0000000..d8f2ee6
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Read-RadiumPurgeScript.ps1
@@ -0,0 +1,58 @@
+function Read-RadiumPurgeScript {
+
+ <#
+ .SYNOPSIS
+ Returns the full content of the embedded Radium purge T-SQL script
+
+ .DESCRIPTION
+ Returns the full content of the embedded Radium purge T-SQL script Uses the FileList as defined in
+ the module PSM1 for location
+
+ .PARAMETER UseJobFilterScriptVariant
+ When true will return the script variant that filters on job type. This is more impactful to SQL Server and so defaults to false
+
+ .PARAMETER RadiumPurgeScriptPath
+ When supplied will override the automatically determined script path to the bundled module resources. Any value for UseJobFilterScriptVariant is ignored on this path
+
+ .OUTPUTS
+ Returns the content of the bundled purge script.
+ #>
+
+ [CmdletBinding()]
+ [OutputType([string[]])]
+ param(
+
+ [Parameter(Mandatory = $false)]
+ [bool]$UseJobFilterScriptVariant = $false,
+
+ [Parameter(Mandatory = $false)]
+ [string]$RadiumPurgeScriptPath = ""
+ )
+
+ $logLead = Get-LogLeadName
+
+ if (-NOT (Test-StringIsNullOrEmpty -Value $RadiumPurgeScriptPath)) {
+
+ $radiumPurgeScriptFilePath = $RadiumPurgeScriptPath
+
+ }
+ else {
+
+ if ($UseJobFilterScriptVariant) {
+
+ $radiumPurgeScriptFilePath = $MyInvocation.MyCommand.Module.FileList | Where-Object { $_ -match "RadiumPurgeScriptWithJobFilter.sql" }
+
+ } else {
+
+ $radiumPurgeScriptFilePath = $MyInvocation.MyCommand.Module.FileList | Where-Object { $_ -match "RadiumPurgeScript.sql" }
+ }
+ }
+
+ if ((Test-StringIsNullOrEmpty -Value $radiumPurgeScriptFilePath) -or (-NOT (Test-Path -Path $radiumPurgeScriptFilePath))) {
+
+ Write-Warning "$logLead : Could not find bundled purge script. Check the module health or for build problems."
+ return $null
+ }
+
+ return (Get-Content -Path $radiumPurgeScriptFilePath -Raw)
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Public/Read-RadiumPurgeScript.tests.ps1 b/Modules/Alkami.DevOps.Operations/Public/Read-RadiumPurgeScript.tests.ps1
new file mode 100644
index 0000000..eabfbaa
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Read-RadiumPurgeScript.tests.ps1
@@ -0,0 +1,61 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ""
+
+# TODO - I can't find a way to effectively mock logic that relies on the module manifest for files. If a way is discovered in the future, add tests here
+
+Describe "Read-RadiumPurgeScript" {
+
+ # Defining this function due to a "bug" in Pester where calls to the Get-Content mock results in error: "A parameter cannot be found that matches parameter name 'Raw'"
+ # Presumably because Raw is a Dynamic Parameter
+ function Get-Content {
+
+ param (
+ [Parameter()]
+ [string]$Path,
+ [Parameter()]
+ [switch]$Raw
+ )
+ }
+
+ Mock -CommandName Get-Content -ModuleName $moduleForMock -MockWith {}
+ $fakePath = "Z:\OMG"
+
+ Context "Logic" {
+
+ Mock -CommandName Test-Path -ModuleName $moduleForMock -MockWith { return $true }
+
+ It "Uses the location supplied via argument to locate the purge script" {
+
+ Read-RadiumPurgeScript -RadiumPurgeScriptPath "$fakePath"
+ Assert-MockCalled -CommandName Get-Content -Times 1 -Exactly -Scope It `
+ -ModuleName $moduleForMock -ParameterFilter { $Path -eq $fakePath }
+ }
+
+ It "Ignores the UseJobFilterScriptVariant Argument if a FilePath is provided" {
+
+ Read-RadiumPurgeScript -RadiumPurgeScriptPath "$fakePath" -UseJobFilterScriptVariant $true
+ Assert-MockCalled -CommandName Get-Content -Times 1 -Exactly -Scope It `
+ -ModuleName $moduleForMock -ParameterFilter { $Path -eq $fakePath }
+ }
+ }
+
+ Context "Error Handling" {
+
+ It "Writes a Warning and Returns Null if No File Found" {
+
+ Mock -CommandName Test-Path -ModuleName $moduleForMock -MockWith { return $false }
+ Mock -CommandName Write-Warning -ModuleName $moduleForMock -MockWith {}
+
+ Read-RadiumPurgeScript | Should -BeNullOrEmpty
+ Assert-MockCalled -CommandName Get-Content -Times 0 -Exactly -Scope It
+
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Write-Warning -Times 1 -Exactly -Scope It `
+ -ParameterFilter { $Message -Match "Could not find bundled purge script" }
+ }
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Public/Remove-ADUserProfiles.ps1 b/Modules/Alkami.DevOps.Operations/Public/Remove-ADUserProfiles.ps1
new file mode 100644
index 0000000..510eb9e
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Remove-ADUserProfiles.ps1
@@ -0,0 +1,89 @@
+function Remove-ADUserProfiles {
+ <#
+.SYNOPSIS
+ Deletes CORP or FH user profiles from the specified system(s).
+
+.DESCRIPTION
+ Deletes CORP or FH user profiles from the specified system(s). To dry run the operation, specify the '-WhatIf' parameter.
+
+ To confirm each entry before removal, omit the '-WhatIf' parameter and specify '-Confirm:$true'.
+
+ To act like a mad person and blindly delete any matching user profiles without the opportunity to evaluate each entry, specify
+ the '-Confirm:$false' parameter or omit that parameter entirely.
+ NOTE: This is extremely risky and you may cause unknown repercussions. CAVEAT EMPTOR!
+
+.PARAMETER Servers
+ The name of the server(s) where the operation should be performed. If omitted, defaults to the current host.
+
+.PARAMETER SkipSizeCheck
+ Flag that will skip user profile disk size calculation (which is only provided as a convenience so you know
+ how much disk space you recovered from the operation). Passing this flag makes the execution faster, but you
+ get less information as a trade-off. Choose wisely.
+
+.PARAMETER Domains
+ Array of domains to process. If omitted, defaults to both CORP and FH.
+#>
+ [CmdLetBinding(
+ SupportsShouldProcess,
+ ConfirmImpact = 'Low'
+ )]
+ param(
+ [Parameter(Mandatory = $false)]
+ [ValidateNotNullOrEmpty()]
+ [string[]] $Servers = @(Get-FullyQualifiedServerName),
+
+ [Parameter(Mandatory = $false)]
+ [switch] $SkipSizeCheck,
+
+ [Parameter(Mandatory = $false)]
+ [ValidateSet('CORP', 'FH')]
+ [string[]] $Domains = @('CORP', 'FH')
+ )
+
+ $logLead = Get-LogLeadName
+
+ foreach ( $server in $Servers ) {
+
+ Write-Host "$logLead : Processing server $server"
+
+ # Determine if we are executing remotely.
+ $splatParams = @{}
+ if ( -not (Compare-StringToLocalMachineIdentifiers -stringToCheck $server )) {
+
+ $splatParams['ComputerName'] = "$server"
+ }
+
+ $totalMb = 0
+ $toProcess = Get-ADUserProfileListToRemove -ComputerName $server -Domains $Domains | Sort-Object LocalPath
+
+ # Phase 1: Always print out what we would do, whether the user is in dry run mode or not.
+ foreach ( $entry in $toProcess ) {
+
+ Write-Warning "$logLead : Identified user profile removal candidate '$($entry.LocalPath)'."
+ }
+
+ # Phase 2: Run the actual delete logic (as long as we aren't in dry run mode).
+ if ( -not $WhatIfPreference ) {
+ foreach ( $entry in $toProcess ) {
+
+ $profilePath = $entry.LocalPath
+ if ($PSCmdlet.ShouldProcess($profilePath, 'Remove User Profile')) {
+
+ if ( -not $SkipSizeCheck ) {
+
+ $mbSize = Get-FolderSizeMb -Path $profilePath -ComputerName $server
+ Write-Host "$logLead : Removal of $profilePath frees up $mbSize Mbs."
+ $totalMb += $mbSize
+ }
+
+ Remove-CimInstance -InputObject $entry @splatParams
+ }
+ }
+
+ if ( -not $SkipSizeCheck ) {
+
+ Write-Host "$logLead : Total filesystem Mbs freed = $totalMb."
+ }
+ }
+ }
+}
diff --git a/Modules/Alkami.DevOps.Operations/Public/Remove-ADUserProfiles.tests.ps1 b/Modules/Alkami.DevOps.Operations/Public/Remove-ADUserProfiles.tests.ps1
new file mode 100644
index 0000000..4ed241b
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Remove-ADUserProfiles.tests.ps1
@@ -0,0 +1,139 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$global:functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+
+InModuleScope -ModuleName Alkami.DevOps.Operations -ScriptBlock {
+ Write-Host "InModuleScope - Overriding SUT: $($global:functionPath)"
+ Import-Module $global:functionPath -Force
+ $moduleForMock = ''
+ $inScopeModuleForAssert = 'Alkami.DevOps.Operations'
+
+ Describe 'Remove-ADUserProfiles' {
+
+ $testServer = 'server1.test.local'
+
+ $testUser1 = [Microsoft.Management.Infrastructure.CimInstance]::new('Win32_UserProfile')
+ $testUser1.CimInstanceProperties.Add([Microsoft.Management.Infrastructure.CimProperty]::Create('LocalPath', 'TestDrive:\Test\test1', [cimtype]::String, 'Property, ReadOnly'))
+ $testUser2 = [Microsoft.Management.Infrastructure.CimInstance]::new('Win32_UserProfile')
+ $testUser2.CimInstanceProperties.Add([Microsoft.Management.Infrastructure.CimProperty]::Create('LocalPath', 'TestDrive:\Test\test2', [cimtype]::String, 'Property, ReadOnly'))
+ $testUsers = @( $testUser1, $testUser2)
+
+ Mock -CommandName Get-FullyQualifiedServerName -ModuleName $moduleForMock -MockWith { return $testServer }
+ Mock -CommandName Get-LogLeadName -ModuleName $moduleForMock -MockWith { return 'Remove-ADUserProfiles.test' }
+ Mock -CommandName Compare-StringToLocalMachineIdentifiers -ModuleName $moduleForMock -MockWith { return $true }
+ Mock -CommandName Get-FolderSizeMb -ModuleName $moduleForMock -MockWith { return 1 }
+ Mock -CommandName Remove-CimInstance -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Get-ADUserProfileListToRemove -ModuleName $moduleForMock -MockWith { return $testUsers }
+ Mock -CommandName Write-Warning -ModuleName $moduleForMock -MockWith {}
+
+ Context 'Input Validation' {
+
+ It 'Throws if Servers is null' {
+
+ { Remove-ADUserProfiles -Servers $null } | Should -Throw
+ }
+
+ It 'Throws if Servers is empty' {
+
+ { Remove-ADUserProfiles -Servers @() } | Should -Throw
+ }
+
+ It 'Throws if Domain list contains invalid value' {
+
+ { Remove-ADUserProfiles -Domains @('Test') } | Should -Throw
+ }
+ }
+
+ Context 'Logic' {
+
+ It 'Performs command on localhost by default' {
+
+ Remove-ADUserProfiles -WhatIf | Out-Null
+
+ Assert-MockCalled -ModuleName $inScopeModuleForAssert -CommandName Get-FullyQualifiedServerName -Times 1 -Exactly -Scope It
+ Assert-MockCalled -ModuleName $inScopeModuleForAssert -CommandName Compare-StringToLocalMachineIdentifiers -Times 1 -Exactly -Scope It `
+ -ParameterFilter { $stringToCheck -eq $testServer }
+ Assert-MockCalled -ModuleName $inScopeModuleForAssert -CommandName Get-ADUserProfileListToRemove -Times 1 -Exactly -Scope It `
+ -ParameterFilter { $ComputerName -eq $testServer }
+ }
+
+ It 'Performs command on localhost if Servers array contains current server' {
+
+ Remove-ADUserProfiles -Servers @($testServer) -WhatIf | Out-Null
+
+ Assert-MockCalled -ModuleName $inScopeModuleForAssert -CommandName Get-FullyQualifiedServerName -Times 0 -Exactly -Scope It
+ Assert-MockCalled -ModuleName $inScopeModuleForAssert -CommandName Compare-StringToLocalMachineIdentifiers -Times 1 -Exactly -Scope It `
+ -ParameterFilter { $stringToCheck -eq $testServer }
+ Assert-MockCalled -ModuleName $inScopeModuleForAssert -CommandName Get-ADUserProfileListToRemove -Times 1 -Exactly -Scope It `
+ -ParameterFilter { $ComputerName -eq $testServer }
+ }
+
+ It 'Performs command on all remote servers if Servers array does not contain current server' {
+
+ Mock -CommandName Compare-StringToLocalMachineIdentifiers -ModuleName $moduleForMock -MockWith { return $false }
+
+ $testServers = @( 'server2.test.local', 'server3.test.local' )
+
+ Remove-ADUserProfiles -Servers $testServers -WhatIf | Out-Null
+
+ Assert-MockCalled -ModuleName $inScopeModuleForAssert -CommandName Get-FullyQualifiedServerName -Times 0 -Exactly -Scope It
+ Assert-MockCalled -ModuleName $inScopeModuleForAssert -CommandName Compare-StringToLocalMachineIdentifiers -Times 2 -Exactly -Scope It `
+ -ParameterFilter { $testServers -contains $stringToCheck }
+ Assert-MockCalled -ModuleName $inScopeModuleForAssert -CommandName Get-ADUserProfileListToRemove -Times 2 -Exactly -Scope It `
+ -ParameterFilter { $testServers -contains $ComputerName }
+
+ Mock -CommandName Compare-StringToLocalMachineIdentifiers -ModuleName $moduleForMock -MockWith { return $true }
+ }
+
+ It 'Writes warning and aborts if WhatIf flag is present' {
+
+ Remove-ADUserProfiles -WhatIf | Out-Null
+
+ Assert-MockCalled -ModuleName $inScopeModuleForAssert -CommandName Remove-CimInstance -Times 0 -Exactly -Scope It
+ Assert-MockCalled -ModuleName $inScopeModuleForAssert -CommandName Write-Warning -Times $testUsers.Length -Exactly -Scope It `
+ -ParameterFilter { $Message -match 'Identified user profile removal candidate' }
+ }
+
+ It 'Writes warning and continues if WhatIf flag is not present' {
+
+ Remove-ADUserProfiles -Confirm:$false | Out-Null
+
+ Assert-MockCalled -ModuleName $inScopeModuleForAssert -CommandName Remove-CimInstance -Times 1 -Scope It
+ Assert-MockCalled -ModuleName $inScopeModuleForAssert -CommandName Write-Warning -Times $testUsers.Length -Exactly -Scope It `
+ -ParameterFilter { $Message -match 'Identified user profile removal candidate' }
+ }
+
+ It 'Uses Domain parameter if provided' {
+
+ $test = @('CORP')
+ Remove-ADUserProfiles -Confirm:$false -Domains $test | Out-Null
+
+ Assert-MockCalled -ModuleName $inScopeModuleForAssert -CommandName Get-ADUserProfileListToRemove -Times 1 -Exactly -Scope It `
+ -ParameterFilter { $null -eq (Compare-Object $Domains $test ) }
+ }
+
+ It 'Removes user profiles' {
+
+ Remove-ADUserProfiles -Confirm:$false | Out-Null
+
+ Assert-MockCalled -ModuleName $inScopeModuleForAssert -CommandName Remove-CimInstance -Times $testUsers.Length -Exactly -Scope It
+ }
+
+ It 'Calculates size of profile directories by default' {
+
+ Remove-ADUserProfiles -Confirm:$false | Out-Null
+
+ Assert-MockCalled -ModuleName $inScopeModuleForAssert -CommandName Get-FolderSizeMb -Times $testUsers.Length -Exactly -Scope It
+ }
+
+ It 'Does not calculate size of profile directories if SkipSizeCheck is present' {
+
+ Remove-ADUserProfiles -Confirm:$false -SkipSizeCheck | Out-Null
+
+ Assert-MockCalled -ModuleName $inScopeModuleForAssert -CommandName Get-FolderSizeMb -Times 0 -Exactly -Scope It
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Public/Repair-LoadBalancerState.ps1 b/Modules/Alkami.DevOps.Operations/Public/Repair-LoadBalancerState.ps1
new file mode 100644
index 0000000..6880a18
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Repair-LoadBalancerState.ps1
@@ -0,0 +1,56 @@
+function Repair-LoadBalancerState {
+<#
+.SYNOPSIS
+ This is used to look to see if a server is on per EC2, then to ensure the loadbalancer state matches the on/off status of the machine.
+ This does not try to see if any site or server functionality is available, it just resets the states.
+
+.PARAMETER ServerList
+ [string[]] This is a list of servernames to connect to for resetting
+
+.PARAMETER ProfileName
+ [string] AWS Profile name
+
+.PARAMETER Region
+ [string] AWS Region
+#>
+ [CmdletBinding()]
+ param (
+ [Parameter(Mandatory = $true)]
+ [string[]]$ServerList,
+ [string]$ProfileName,
+ [string]$Region
+ )
+
+ # if there are no servers, skip it!
+ if(Test-IsCollectionNullOrEmpty $ServerList)
+ {
+ Write-Host "No servers to act on. Skipping step."
+ return
+ }
+
+ #Loop over the servers and set Load Balancer to reflect current on status of machine.
+ $sb = {
+ param ($hostname, $arguments)
+
+ $loadbalancerScript = {
+ param($sb_Hostname, $sb_state, $sb_ProfileName, $sb_Region)
+ Set-LoadBalancerState -server $sb_Hostname -DesiredState $sb_state -AwsProfileName $sb_ProfileName -AwsRegion $sb_Region
+ }
+
+ $isServerOn = (Test-ComputerIsAvailable -ComputerName $hostname)
+ $state = 'down'
+ if ($isServerOn) {
+ $state = 'up'
+ }
+
+ try {
+ Invoke-CommandWithRetry -Arguments ($hostname, $state, $arguments.ProfileName, $arguments.Region) -MaxRetries 3 -Exponential -ScriptBlock $loadbalancerScript
+ Write-Host "Success. $hostname is back in rotation."
+ } catch {
+ Write-Host "An Error occurred while putting $hostname into rotation"
+ Write-Warning $_
+ }
+ }
+
+ (Invoke-Parallel2 -Objects $ServerList -Arguments @{ProfileName = $ProfileName; Region = $Region;} -Script $sb) | Out-Null
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Public/Restart-Tier.ps1 b/Modules/Alkami.DevOps.Operations/Public/Restart-Tier.ps1
new file mode 100644
index 0000000..264c45a
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Restart-Tier.ps1
@@ -0,0 +1,92 @@
+function Restart-Tier {
+
+ <#
+ .SYNOPSIS
+ Restarts IIS and Alkami Windows Services. Archives Alkami and Chocolatey logs on disk and clears .NET temporary files
+
+ .DESCRIPTION
+ Restarts IIS and Alkami Windows Services. Archives Alkami and Chocolatey logs on disk and clears .NET temporary files.
+ Max service start parallelism defaults to 10 and can be set by specifying a positive int for the parameter maxParallel.
+ Admin websites will be opened in the user's default browser if -openAdmin is specified
+
+ .PARAMETER openAdmin
+ Opens admin websites in the users default browser prior to running site pinger. Only valid on Web servers
+
+ .PARAMETER maxParallel
+ The maximum number of services to start in parallel. Defaults to 10
+
+ .PARAMETER ArchiveLogFiles
+ Enable archiving of logfiles to zip files. By default, we do not Archive Log Files and only delete current logs
+ and expired archives.
+
+ .EXAMPLE
+ Restart-Tier -maxParallel 2
+
+[Stop-IISOnly] : Stopping IIS...
+[Stop-IISOnly] : Done
+[Stop-IISAndServices] : Sleeping for 5 seconds...
+[Get-ChocolateyServices] : Finding services installed out of the chocolatey path.
+[Get-ChocolateyServices] : Found 1 chocolatey services.
+Stopping Service AlkamiOnboardEventBroker
+..
+[Stop-ProcessIfFound] : Stopping 1 instance(s) of process SMSvcHost
+[Remove-DotNetTemporaryFiles] : Deleting ASP.NET Temporary files
+[Backup-ORBLogFiles] : Backing up *.log* files to C:\ORBLogs\Archive
+Compressing Files
+C:\ORBLogs\20170725041214\Alkami.App.Audit.Host.log
+C:\ORBLogs\20170725042246\Alkami.App.Audit.Host.log
+[Backup-ORBLogFiles] : Backup Complete
+[Remove-OldArchivedLogFiles] : Looking for old files to delete
+[Remove-OldArchivedLogFiles] : Looking for empty folders to delete
+[Backup-LogFiles] : Backing up / Archiving logs in "C:\ProgramData\chocolatey\logs"
+[Backup-LogFiles] : Backup complete.
+[Remove-OldArchivedLogFiles] : Looking for old files to delete
+[Remove-OldArchivedLogFiles] : Looking for empty folders to delete
+[Start-IISOnly] : Starting IIS...
+[Start-IISOnly] : Done
+[Start-IISAndServices] : Sleeping for 5 seconds...
+Starting Service AlkamiOnboardEventBroker
+.
+[Get-ChocolateyServices] : Finding services installed out of the chocolatey path.
+[Get-ChocolateyServices] : Found 1 chocolatey services.
+[Start-IISAndServices] : No Services Found to Start
+[Ping-AlkamiWebSites] : Starting Site Warmup
+ #>
+
+ [CmdletBinding()]
+ Param(
+ [Parameter(Mandatory = $false)]
+ [switch]$openAdmin,
+
+ [Parameter(Mandatory = $false)]
+ [ValidateRange(1, [int]::MaxValue)]
+ [int]$maxParallel = 10,
+
+ [Parameter(Mandatory = $false)]
+ [switch]$ArchiveLogFiles
+ )
+
+ $logLead = (Get-LogLeadName);
+
+ Stop-IISAndServices
+ Move-LogsAndDeleteDotNetTemps -ArchiveLogFiles:$ArchiveLogFiles
+ Start-IISAndServices -maxParallel $maxParallel
+
+ if (Test-IsWebServer) {
+
+ if ( $openAdmin ) {
+
+ Write-Verbose "$logLead : Opening Admin Sites in Default Browser"
+ Open-AlkamiSites -adminSitesOnly
+ }
+
+ Ping-AlkamiWebSites
+
+ } elseif (Test-IsAppServer) {
+
+ Ping-AlkamiServices
+ } else {
+
+ Write-Warning "$logLead : Could Not Determine Tier. No Service Warmup Performed"
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Public/Restart-Tier.tests.ps1 b/Modules/Alkami.DevOps.Operations/Public/Restart-Tier.tests.ps1
new file mode 100644
index 0000000..312cef3
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Restart-Tier.tests.ps1
@@ -0,0 +1,119 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ""
+
+Describe "Restart-Tier" {
+
+ Mock -ModuleName $moduleForMock -CommandName Stop-IISAndServices -MockWith {}
+ Mock -ModuleName $moduleForMock -CommandName Move-LogsAndDeleteDotNetTemps -MockWith {}
+ Mock -ModuleName $moduleForMock -CommandName Start-IISAndServices -MockWith {}
+ Mock -ModuleName $moduleForMock -CommandName Open-AlkamiSites -MockWith {}
+ Mock -ModuleName $moduleForMock -CommandName Ping-AlkamiWebSites -MockWith {}
+ Mock -ModuleName $moduleForMock -CommandName Ping-AlkamiServices -MockWith {}
+ Mock -ModuleName $moduleForMock -CommandName Write-Warning -MockWith {}
+
+ Context "Parameter Validation" {
+
+ It "Defaults to 10 Parallel Services" {
+
+ Restart-Tier
+ Assert-MockCalled -CommandName Start-IISAndServices -Times 1 -Exactly -Scope It `
+ -ModuleName $moduleForMock -ParameterFilter { $maxParallel -eq 10 }
+ }
+
+ It "Uses the Supplied MaxParallel Parameter in the Call to Start-IISAndServices" {
+
+ Restart-Tier -maxParallel 5
+ Assert-MockCalled -CommandName Start-IISAndServices -Times 1 -Exactly -Scope It `
+ -ModuleName $moduleForMock -ParameterFilter { $maxParallel -eq 5 }
+ }
+
+ It "Defaults to Not Opening Websites" {
+
+ Restart-Tier
+ Assert-MockCalled -CommandName Open-AlkamiSites -Times 0 -Exactly -Scope It -ModuleName $moduleForMock
+ }
+
+ It "Calls Open-AlkamiSites When -openAdmin is Supplied And Test-IsWebServer is True" {
+
+ Mock -ModuleName $moduleForMock -CommandName Test-IsWebServer -MockWith { return $true }
+ Restart-Tier -openAdmin
+ Assert-MockCalled -CommandName Open-AlkamiSites -Times 1 -Exactly -Scope It `
+ -ModuleName $moduleForMock -ParameterFilter { $adminSitesOnly.IsPresent }
+ }
+
+ It "Does Not Call Open-AlkamiSites When -openAdmin is Supplied And Test-IsWebServer is False" {
+
+ Mock -ModuleName $moduleForMock -CommandName Test-IsWebServer -MockWith { return $false }
+ Restart-Tier -openAdmin
+ Assert-MockCalled -CommandName Open-AlkamiSites -Times 0 -Exactly -Scope It `
+ -ModuleName $moduleForMock -ParameterFilter { $adminSitesOnly.IsPresent }
+ }
+
+ It "Defaults to skipping the zipping" {
+ Restart-Tier
+ Assert-MockCalled -CommandName Move-LogsAndDeleteDotNetTemps -ModuleName $moduleForMock -Times 1 -Exactly -Scope It -ParameterFilter {$ArchiveLogFiles -eq $false}
+ }
+
+ It "Passes the switch to zip log files" {
+ Restart-Tier -ArchiveLogFiles
+ Assert-MockCalled -CommandName Move-LogsAndDeleteDotNetTemps -ModuleName $moduleForMock -Times 1 -Exactly -Scope It -ParameterFilter {$ArchiveLogFiles -eq $true}
+ }
+ }
+
+ Context "Tier Handling" {
+
+ It "Calls Ping-AlkamiServices When Test-IsAppServer is True" {
+
+ Mock -ModuleName $moduleForMock -CommandName Test-IsWebServer -MockWith { return $false}
+ Mock -ModuleName $moduleForMock -CommandName Test-IsAppServer -MockWith { return $true }
+ Restart-Tier -openAdmin
+ Assert-MockCalled -CommandName Ping-AlkamiServices -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+ }
+
+ It "Does Not Call Ping-AlkamiServices When Test-IsAppServer is False" {
+
+ Mock -ModuleName $moduleForMock -CommandName Test-IsWebServer -MockWith { return $false }
+ Mock -ModuleName $moduleForMock -CommandName Test-IsAppServer -MockWith { return $false }
+ Restart-Tier -openAdmin
+ Assert-MockCalled -CommandName Ping-AlkamiServices -Times 0 -Exactly -Scope It -ModuleName $moduleForMock
+ }
+
+ It "Calls Ping-AlkamiWebSites When Test-IsWebServer is True and Open-AlkamiSites is Not Passed" {
+
+ Mock -ModuleName $moduleForMock -CommandName Test-IsWebServer -MockWith { return $true }
+ Mock -ModuleName $moduleForMock -CommandName Test-IsAppServer -MockWith { return $false }
+ Restart-Tier
+ Assert-MockCalled -CommandName Ping-AlkamiWebSites -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+ }
+
+ It "Calls Ping-AlkamiWebSites When Test-IsWebServer is True and Open-AlkamiSites is Passed" {
+
+ Mock -ModuleName $moduleForMock -CommandName Test-IsWebServer -MockWith { return $true }
+ Mock -ModuleName $moduleForMock -CommandName Test-IsAppServer -MockWith { return $false }
+ Restart-Tier -openAdmin
+ Assert-MockCalled -CommandName Ping-AlkamiWebSites -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+ }
+
+ It "Does Not Call Ping-AlkamiWebSites When Test-IsWebServer is False" {
+
+ Mock -ModuleName $moduleForMock -CommandName Test-IsWebServer -MockWith { return $false }
+ Mock -ModuleName $moduleForMock -CommandName Test-IsAppServer -MockWith { return $false }
+ Restart-Tier -openAdmin
+ Assert-MockCalled -CommandName Ping-AlkamiWebSites -Times 0 -Exactly -Scope It -ModuleName $moduleForMock
+ }
+
+ It "Writes a Warning When Both Test-IsWebServer and Test-IsAppServer are False" {
+
+ Mock -ModuleName $moduleForMock -CommandName Test-IsWebServer -MockWith { return $false }
+ Mock -ModuleName $moduleForMock -CommandName Test-IsAppServer -MockWith { return $false }
+ Restart-Tier
+ Assert-MockCalled -CommandName Write-Warning -Times 1 -Exactly -Scope It `
+ -ModuleName $moduleForMock -ParameterFilter { $Message -match "Could Not Determine Tier" }
+ }
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Public/Select-RunningEC2InstancesByHostname.ps1 b/Modules/Alkami.DevOps.Operations/Public/Select-RunningEC2InstancesByHostname.ps1
new file mode 100644
index 0000000..3f5fd16
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Select-RunningEC2InstancesByHostname.ps1
@@ -0,0 +1,35 @@
+function Select-RunningEC2InstancesByHostname {
+ <#
+ .SYNOPSIS
+ Filters the list of Servers by which servers are running in AWS EC2.
+
+ .PARAMETER Servers
+ The server list of hostnames to filter by.
+
+ .PARAMETER ProfileName
+ The AWS profile name to use when querying for EC2 instances.
+ #>
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory=$true)]
+ $Servers,
+ [Parameter(Mandatory=$true)]
+ $ProfileName
+ )
+
+ [array]$instances = Get-EC2InstancesByHostname -Servers $Servers -ProfileName $ProfileName
+
+ # Only return the servers that are in a running state.
+ $runningServers = @()
+ $counter = 0
+ foreach($server in $Servers) {
+ $instance = $instances[$counter]
+ if($instance.State.Name -eq "running") {
+ $runningServers += $server
+ } else {
+ Write-Warning "$loglead : Machine $server is not running."
+ }
+ $counter++
+ }
+ return $runningServers
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Public/Send-Counter.Tests.ps1 b/Modules/Alkami.DevOps.Operations/Public/Send-Counter.Tests.ps1
new file mode 100644
index 0000000..ce405e3
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Send-Counter.Tests.ps1
@@ -0,0 +1,66 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ""
+
+Describe "Send-Counter" {
+ Mock -CommandName Send-Metric -MockWith { return $null } -ModuleName $moduleForMock
+ Mock -commandName Write-Verbose -MockWith { return $null }
+
+ Context "Input Validation" {
+
+ It "MetricName Cannot be empty" {
+ { Send-Counter -MetricName '' } | Should -Throw
+ }
+ It "Count Cannot be empty" {
+ { Send-Counter -count ' ' } | Should -Throw
+ }
+ }
+ Context "Param Passing is honored" {
+
+ It "Supplied Ip is passed to Send-Metric" {
+ Send-Counter -MetricName "bork" -count 8 -Ip "666.666.666.666"
+ Assert-MockCalled Send-Metric -ParameterFilter { $Ip -eq "666.666.666.666" } -ModuleName $moduleForMock
+ }
+ It "Supplied Port is passed to Send-Metric" {
+ Send-Counter -MetricName "bork" -count 8 -Port 666 -verbose
+ Assert-MockCalled Send-Metric -ParameterFilter { $Port -eq "666" } -ModuleName $moduleForMock
+ }
+ It "Supplied StatsdUrl is passed to Send-Metric" {
+ Send-Counter -MetricName "bork" -count 8 -StatsdUrl "fake.fake.fake"
+ Assert-MockCalled Send-Metric -ParameterFilter { $StatsdUrl -eq "fake.fake.fake" } -ModuleName $moduleForMock
+ }
+ }
+ Context "Metric String Concat" {
+
+ It "Metric is concatenated correctly and sent to Send-Metric" {
+ Send-Counter -MetricName "bork" -count 8
+ Assert-MockCalled Send-Metric -ParameterFilter { $Metric -eq "bork:8|c" } -ModuleName $moduleForMock
+ }
+ }
+ Context "Metric Counter" {
+
+ It "accurate increment value is sent to Send-Counter" {
+ Send-Counter -MetricName "bork" -count 1
+ Assert-MockCalled Send-Metric -ParameterFilter { $Metric -eq "bork:1|c" } -ModuleName $moduleForMock
+ }
+ It "accurate increment string value is sent to Send-Counter" {
+ Send-Counter -MetricName "bork" -count "1"
+ Assert-MockCalled Send-Metric -ParameterFilter { $Metric -eq "bork:1|c" } -ModuleName $moduleForMock
+ }
+ It "accurate decrement value is sent to Send-Counter" {
+ Send-Counter -MetricName "bork" -count -1
+ Assert-MockCalled Send-Metric -ParameterFilter { $Metric -eq "bork:1|c" } -ModuleName $moduleForMock
+ }
+ It "accurate decrement string value is sent to Send-Counter" {
+ Send-Counter -MetricName "bork" -count "-1"
+ Assert-MockCalled Send-Metric -ParameterFilter { $Metric -eq "bork:1|c" } -ModuleName $moduleForMock
+ }
+ It "suppling string as count throws" {
+ { Send-Counter -MetricName "bork" -count "fake" } | Should -Throw
+ }
+ }
+}
diff --git a/Modules/Alkami.DevOps.Operations/Public/Send-Counter.ps1 b/Modules/Alkami.DevOps.Operations/Public/Send-Counter.ps1
new file mode 100644
index 0000000..75b5aa7
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Send-Counter.ps1
@@ -0,0 +1,47 @@
+function Send-Counter {
+ <#
+.SYNOPSIS
+ Sends a Counter Metric to StatsD service.
+.EXAMPLE
+ Send-Counter -MetricName "bork" -Count 8 -verbose
+.INPUTS
+ Metric: namespace of metric
+ Count: amount to increment metric
+ StatsdUrl: overrides default url
+ Ip: overrides dns resolution of url
+ Port : overrides default Port in Send-Metric
+#>
+
+ [CmdletBinding()]
+ param(
+ [ValidateNotNullOrEmpty()]
+ $MetricName,
+ [ValidateNotNullOrEmpty()]
+ [int]$Count,
+ $StatsdUrl,
+ $Ip,
+ $Port
+ )
+ $logLead = Get-LogLeadName
+
+ [string]$metric = $MetricName + ":" + "$Count" + "|c"
+
+ Write-Verbose "$logLead : Metric:$metric"
+
+ $params = @{ }
+ if (![system.string]::IsNullOrEmpty($StatsdUrl)) {
+ Write-Verbose "StatsdUrl:$StatsdUrl"
+ $params += @{'statsdUrl' = $StatsdUrl }
+ }
+ if (![system.string]::IsNullOrEmpty($Port)) {
+ Write-Verbose "Port:$Port"
+ $params += @{'Port' = $Port }
+ }
+ if (![system.string]::IsNullOrEmpty($Ip)) {
+ Write-Verbose "Ip:$Ip"
+ $params += @{'Ip' = $Ip }
+ }
+
+ Send-Metric -Metric $metric @params -Verbose:$VerbosePreference
+
+}
diff --git a/Modules/Alkami.DevOps.Operations/Public/Send-DeploymentPackageCounter.Tests.ps1 b/Modules/Alkami.DevOps.Operations/Public/Send-DeploymentPackageCounter.Tests.ps1
new file mode 100644
index 0000000..7a1e92b
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Send-DeploymentPackageCounter.Tests.ps1
@@ -0,0 +1,106 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ""
+
+Describe "Send-DeploymentPackageCounter" {
+ Mock -CommandName Send-Counter -MockWith { return $null } -ModuleName $moduleForMock
+ Mock -commandName Write-Verbose -MockWith { return $null }
+
+ Context "Input Validation" {
+
+ It "Metric Param Cannot be empty" {
+
+ { Send-DeploymentPackageCounter -metric '' } | Should -Throw
+ }
+ It "EnvType Param Cannot be empty" {
+
+ { Send-DeploymentPackageCounter -EnvType '' } | Should -Throw
+ }
+ It "Designation Param Cannot be empty" {
+
+ { Send-DeploymentPackageCounter -Designation '' } | Should -Throw
+ }
+ It "PackageName Param Cannot be empty" {
+
+ { Send-DeploymentPackageCounter -PackageName '' } | Should -Throw
+ }
+ It "Count Param Cannot be empty" {
+
+ { Send-DeploymentPackageCounter -Count ' ' } | Should -Throw
+ }
+ }
+ Context "Metric Counter" {
+
+ It "accurate increment value is sent to Send-Counter" {
+ Send-DeploymentPackageCounter -EnvType "fake" -Designation "fake" -PackageName "orb" -Count 1 -Ip "666.666.666.666"
+ Assert-MockCalled Send-Counter -ParameterFilter { $Count -eq "1" } -ModuleName $moduleForMock
+ }
+ It "accurate increment string value is sent to Send-Counter" {
+ Send-DeploymentPackageCounter -EnvType "fake" -Designation "fake" -PackageName "orb" -Count "10" -Ip "666.666.666.666"
+ Assert-MockCalled Send-Counter -ParameterFilter { $Count -eq "10" } -ModuleName $moduleForMock
+ }
+ It "accurate decrement value is sent to Send-Counter" {
+ Send-DeploymentPackageCounter -EnvType "fake" -Designation "fake" -PackageName "orb" -Count -1 -Port "666"
+ Assert-MockCalled Send-Counter -ParameterFilter { $Count -eq "-1" } -ModuleName $moduleForMock
+ }
+ It "accurate decrement string value is sent to Send-Counter" {
+ Send-DeploymentPackageCounter -EnvType "fake" -Designation "fake" -PackageName "orb" -Count "-10" -Port "666"
+ Assert-MockCalled Send-Counter -ParameterFilter { $Count -eq "-10" } -ModuleName $moduleForMock
+ }
+ It "suppling string as count throws " {
+ { Send-DeploymentPackageCounter -EnvType "fake" -Designation "fake" -PackageName "orb" -Count "fake" } | Should -Throw
+ }
+ It "99 string value is sent to Send-Counter" {
+ Send-DeploymentPackageCounter -EnvType "fake" -Designation "fake" -PackageName "orb" -Count "99" -Port "666"
+ Assert-MockCalled Send-Counter -ParameterFilter { $Count -eq "99" } -ModuleName $moduleForMock
+ }
+ It "1f should not be valid count" {
+ { Send-DeploymentPackageCounter -EnvType "fake" -Designation "fake" -PackageName "orb" -Count "1F" } | Should -Throw
+
+ }
+ It "-999 decrement string value is sent to Send-Counter" {
+ Send-DeploymentPackageCounter -EnvType "fake" -Designation "fake" -PackageName "orb" -Count "-999" -Port "666"
+ Assert-MockCalled Send-Counter -ParameterFilter { $Count -eq "-999" } -ModuleName $moduleForMock
+ }
+ It "100,000 should be valid count" {
+ Send-DeploymentPackageCounter -EnvType "fake" -Designation "fake" -PackageName "orb" -Count "100,000" -Port "666"
+ Assert-MockCalled Send-Counter -ParameterFilter { $Count -eq "100,000" } -ModuleName $moduleForMock
+ }
+ It "0.000000009 should not be valid count" {
+ Send-DeploymentPackageCounter -EnvType "fake" -Designation "fake" -PackageName "orb" -Count "0.000000000" -Port "666"
+ Assert-MockCalled Send-Counter -ParameterFilter { $Count -eq "0.000000000" } -ModuleName $moduleForMock
+ }
+ It "10:00 should not be valid count" {
+ { Send-DeploymentPackageCounter -EnvType "fake" -Designation "fake" -PackageName "orb" -Count "10:00" } | Should -Throw
+ }
+ It "z23 should not be valid count" {
+ { Send-DeploymentPackageCounter -EnvType "fake" -Designation "fake" -PackageName "orb" -Count "z23" } | Should -Throw
+ }
+ }
+ Context "Param Passing is honored" {
+
+ It "Supplied Ip is passed to Send-Counter" {
+ Send-DeploymentPackageCounter -EnvType "fake" -Designation "fake" -PackageName "orb" -Count 1 -Ip "666.666.666.666"
+ Assert-MockCalled Send-Counter -ParameterFilter { $Ip -eq "666.666.666.666" } -ModuleName $moduleForMock
+ }
+ It "Supplied Port is passed to Send-Counter" {
+ Send-DeploymentPackageCounter -EnvType "fake" -Designation "fake" -PackageName "orb" -Count 1 -Port "666"
+ Assert-MockCalled Send-Counter -ParameterFilter { $Port -eq "666" } -ModuleName $moduleForMock
+ }
+ It "Supplied StatsdUrl is passed to Send-Counter" {
+ Send-DeploymentPackageCounter -EnvType "fake" -Designation "fake" -PackageName "orb" -Count 1 -StatsdUrl "fake.fake.fake"
+ Assert-MockCalled Send-Counter -ParameterFilter { $StatsdUrl -eq "fake.fake.fake" } -ModuleName $moduleForMock
+ }
+ }
+ Context "Metric String Concat" {
+
+ It "Designation Metric is concatenated correctly and sent to Send-Counter" {
+ Send-DeploymentPackageCounter -EnvType "fake" -Designation "fake" -PackageName "orb" -Count 1
+ Assert-MockCalled Send-Counter -ParameterFilter { $MetricName -eq "fake.fake.pipeline.deployment.package.count.orb" } -ModuleName $moduleForMock
+ }
+ }
+}
diff --git a/Modules/Alkami.DevOps.Operations/Public/Send-DeploymentPackageCounter.ps1 b/Modules/Alkami.DevOps.Operations/Public/Send-DeploymentPackageCounter.ps1
new file mode 100644
index 0000000..13d3362
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Send-DeploymentPackageCounter.ps1
@@ -0,0 +1,51 @@
+function Send-DeploymentPackageCounter {
+ <#
+.SYNOPSIS
+ increments or decrements a designation's deployment package count counter
+.EXAMPLE
+ Send-DeploymentPackageCounter -EnvType "dev" -Designation "ci1" -DeploymenType "orb" -Count 1 -Verbose
+ Send-DeploymentPackageCounter -EnvType "dev" -Designation "ci1" -DeploymenType "Alkami.MicroService.fake.service.host" -Count 1 -Verbose
+.INPUTS
+ PackageName: namespace of metric
+ Count: amount to increment metric
+ EnvType: pull from machine.config "Environment.UserPrefix"
+ Designation: pull from machine.config "Environment.NameSafeDesignation"
+ StatsdUrl: overrides default url
+ Ip: overrides dns resolution of url
+ Port : overrides default Port in Send-Metric
+#>
+ [CmdletBinding()]
+ param(
+ [ValidateNotNullOrEmpty()]
+ $EnvType,
+ [ValidateNotNullOrEmpty()]
+ $Designation,
+ [ValidateNotNullOrEmpty()]
+ $PackageName,
+ [ValidateNotNullOrEmpty()]
+ [int]$Count = 1,
+ $StatsdUrl,
+ $Ip,
+ $Port
+ )
+ $logLead = Get-LogLeadName
+
+ $deploymentNamespace = "pipeline.deployment.package.count"
+ [string]$designationMetricName = "$EnvType.$Designation.$deploymentNamespace.$PackageName"
+
+ Write-Verbose "$logLead : MetricName:$designationMetricName Count:$Count"
+
+ $params = @{ }
+ if (![system.string]::IsNullOrEmpty($StatsdUrl)) {
+ $params += @{'statsdUrl' = $StatsdUrl }
+ }
+ if (![system.string]::IsNullOrEmpty($Port)) {
+ $params += @{'Port' = $Port }
+ }
+ if (![system.string]::IsNullOrEmpty($Ip)) {
+ $params += @{'Ip' = $Ip }
+ }
+
+ Send-Counter -MetricName $designationMetricName -Count $Count @params -Verbose:$VerbosePreference
+
+}
diff --git a/Modules/Alkami.DevOps.Operations/Public/Send-DeploymentPackageSizeOrbGauge.ps1 b/Modules/Alkami.DevOps.Operations/Public/Send-DeploymentPackageSizeOrbGauge.ps1
new file mode 100644
index 0000000..598afd3
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Send-DeploymentPackageSizeOrbGauge.ps1
@@ -0,0 +1,44 @@
+function Send-DeploymentPackageSizeOrbGauge {
+ <#
+.SYNOPSIS
+ Sends the size of the orb binary as a metric
+.EXAMPLE
+ Send-DeploymentPackageSizeOrbGauge -Release "Release.2020.1.1" -Gauge 11111111111 -verbose
+.INPUTS
+ Release: orb verison
+ Gauge: value of orb size, in bytes
+ StatsdUrl: overrides default url
+ Ip: overrides dns resolution of url
+ Port : overrides default Port in Send-Metric
+#>
+ [CmdletBinding()]
+ param(
+ [ValidateNotNullOrEmpty()]
+ $Release,
+ [ValidateNotNullOrEmpty()]
+ [int]$Gauge,
+ $StatsdUrl,
+ $Ip,
+ $Port
+ )
+ $logLead = Get-LogLeadName
+
+ $deploymentNamespace = "size.orb"
+ [string]$designationMetricName = "$deploymentNamespace.$Release"
+
+ Write-Verbose "$logLead : MetricName:$designationMetricName Gauge:$Gauge"
+
+ $params = @{ }
+ if (![system.string]::IsNullOrEmpty($StatsdUrl)) {
+ $params += @{'statsdUrl' = $StatsdUrl }
+ }
+ if (![system.string]::IsNullOrEmpty($Port)) {
+ $params += @{'Port' = $Port }
+ }
+ if (![system.string]::IsNullOrEmpty($Ip)) {
+ $params += @{'Ip' = $Ip }
+ }
+
+ Send-Gauge -MetricName $designationMetricName -Gauge $Gauge @params -Verbose:$VerbosePreference
+
+}
diff --git a/Modules/Alkami.DevOps.Operations/Public/Send-DeploymentTypeCounter.Tests.ps1 b/Modules/Alkami.DevOps.Operations/Public/Send-DeploymentTypeCounter.Tests.ps1
new file mode 100644
index 0000000..855bf6c
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Send-DeploymentTypeCounter.Tests.ps1
@@ -0,0 +1,79 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ""
+
+Describe "Send-DeploymentTypeCounter" {
+ Mock -CommandName Send-Counter -MockWith { return $null } -ModuleName $moduleForMock
+ Mock -commandName Write-Verbose -MockWith { return $null }
+
+ Context "Input Validation" {
+
+ It "Metric Param Cannot be empty" {
+
+ { Send-DeploymentTypeCounter -metric '' } | Should -Throw
+ }
+ It "EnvType Param Cannot be empty" {
+
+ { Send-DeploymentTypeCounter -EnvType '' } | Should -Throw
+ }
+ It "Designation Param Cannot be empty" {
+
+ { Send-DeploymentTypeCounter -Designation '' } | Should -Throw
+ }
+ It "DeploymentType Param Cannot be empty" {
+
+ { Send-DeploymentTypeCounter -DeploymentType '' } | Should -Throw
+ }
+ It "Count Param Cannot be empty" {
+ { Send-DeploymentTypeCounter -Count '' } | Should -Throw
+ }
+ }
+ Context "Metric Counter" {
+
+ It "accurate increment value is sent to Send-Counter" {
+ Send-DeploymentTypeCounter -EnvType "fake" -Designation "fake" -DeploymentType "downtime" -Count 1 -Ip "666.666.666.666"
+ Assert-MockCalled Send-Counter -ParameterFilter { $Count -eq "1" } -ModuleName $moduleForMock
+ }
+ It "accurate increment string value is sent to Send-Counter" {
+ Send-DeploymentTypeCounter -EnvType "fake" -Designation "fake" -DeploymentType "downtime" -Count "10" -Ip "666.666.666.666"
+ Assert-MockCalled Send-Counter -ParameterFilter { $Count -eq "10" } -ModuleName $moduleForMock
+ }
+ It "accurate decrement value is sent to Send-Counter" {
+ Send-DeploymentTypeCounter -EnvType "fake" -Designation "fake" -DeploymentType "downtime" -Count -1 -Port "666"
+ Assert-MockCalled Send-Counter -ParameterFilter { $Count -eq "-1" } -ModuleName $moduleForMock
+ }
+ It "accurate decrement string value is sent to Send-Counter" {
+ Send-DeploymentTypeCounter -EnvType "fake" -Designation "fake" -DeploymentType "downtime" -Count "-10" -Port "666"
+ Assert-MockCalled Send-Counter -ParameterFilter { $Count -eq "-10" } -ModuleName $moduleForMock
+ }
+ It "suppling string as count throws " {
+ { Send-DeploymentTypeCounter -EnvType "fake" -Designation "fake" -DeploymentType "downtime" -Count "fake" } | Should -Throw
+ }
+ }
+ Context "Param Passing is honored" {
+
+ It "Supplied Ip is passed to Send-Counter" {
+ Send-DeploymentTypeCounter -EnvType "fake" -Designation "fake" -DeploymentType "downtime" -Count 1 -Ip "666.666.666.666"
+ Assert-MockCalled Send-Counter -ParameterFilter { $Ip -eq "666.666.666.666" } -ModuleName $moduleForMock
+ }
+ It "Supplied Port is passed to Send-Counter" {
+ Send-DeploymentTypeCounter -EnvType "fake" -Designation "fake" -DeploymentType "downtime" -Count 1 -Port "666"
+ Assert-MockCalled Send-Counter -ParameterFilter { $Port -eq "666" } -ModuleName $moduleForMock
+ }
+ It "Supplied StatsdUrl is passed to Send-Counter" {
+ Send-DeploymentTypeCounter -EnvType "fake" -Designation "fake" -DeploymentType "downtime" -Count 1 -StatsdUrl "fake.fake.fake"
+ Assert-MockCalled Send-Counter -ParameterFilter { $StatsdUrl -eq "fake.fake.fake" } -ModuleName $moduleForMock
+ }
+ }
+ Context "Metric String Concat" {
+
+ It "Designation Metric is concatenated correctly and sent to Send-Counter" {
+ Send-DeploymentTypeCounter -EnvType "fake" -Designation "fake" -DeploymentType "downtime" -Count 1
+ Assert-MockCalled Send-Counter -ParameterFilter { $MetricName -eq "fake.fake.pipeline.deployment.type.downtime" } -ModuleName $moduleForMock
+ }
+ }
+}
diff --git a/Modules/Alkami.DevOps.Operations/Public/Send-DeploymentTypeCounter.ps1 b/Modules/Alkami.DevOps.Operations/Public/Send-DeploymentTypeCounter.ps1
new file mode 100644
index 0000000..18e65f6
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Send-DeploymentTypeCounter.ps1
@@ -0,0 +1,50 @@
+function Send-DeploymentTypeCounter {
+ <#
+.SYNOPSIS
+ increments or decrements a designation's deployment type counter
+.EXAMPLE
+ Send-DeploymentTypeCounter -EnvType "dev" -Designation "ci1" -DeploymentType "downtime" -Count 1 -Verbose
+.INPUTS
+ DeploymentType: namespace of metric
+ Count: amount to increment metric
+ EnvType: pull from machine.config "Environment.UserPrefix"
+ Designation: pull from machine.config "Environment.NameSafeDesignation"
+ StatsdUrl: overrides default url
+ Ip: overrides dns resolution of url
+ Port : overrides default Port in Send-Metric
+#>
+ [CmdletBinding()]
+ param(
+ [ValidateNotNullOrEmpty()]
+ $EnvType,
+ [ValidateNotNullOrEmpty()]
+ $Designation,
+ [ValidateNotNullOrEmpty()]
+ $DeploymentType,
+ [ValidatePattern("\d")] # maybe make this just non-zero integers ?
+ $Count,
+ $StatsdUrl,
+ $Ip,
+ $Port
+ )
+ $logLead = Get-LogLeadName
+
+ $deploymentNamespace = "pipeline.deployment.type"
+ [string]$designationMetricName = "$EnvType.$Designation.$deploymentNamespace.$DeploymentType"
+
+ Write-Verbose "$logLead : MetricName:$metricName Count:$Count"
+
+ $params = @{ }
+ if (![system.string]::IsNullOrEmpty($StatsdUrl)) {
+ $params += @{'statsdUrl' = $StatsdUrl }
+ }
+ if (![system.string]::IsNullOrEmpty($Port)) {
+ $params += @{'Port' = $Port }
+ }
+ if (![system.string]::IsNullOrEmpty($Ip)) {
+ $params += @{'Ip' = $Ip }
+ }
+
+ Send-Counter -MetricName $designationMetricName -Count $Count @params -Verbose:$VerbosePreference
+
+}
diff --git a/Modules/Alkami.DevOps.Operations/Public/Send-DeploymentTypeTimer.ps1 b/Modules/Alkami.DevOps.Operations/Public/Send-DeploymentTypeTimer.ps1
new file mode 100644
index 0000000..3f5f315
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Send-DeploymentTypeTimer.ps1
@@ -0,0 +1,50 @@
+function Send-DeploymentTypeTimer {
+ <#
+.SYNOPSIS
+ Sends the amount of time taken for a deployment type
+.EXAMPLE
+ Send-DeploymentTypeTimer -EnvType "dev" -Designation "ci1" -DeploymentType "downtime" -Time 1 -Verbose
+.INPUTS
+ DeploymenType: namespace of metric
+ Time: value in Milliseconds
+ EnvType: pull from machine.config "Environment.UserPrefix"
+ Designation: pull from machine.config "Environment.NameSafeDesignation"
+ StatsdUrl: overrides default url
+ Ip: overrides dns resolution of url
+ Port : overrides default Port in Send-Metric
+#>
+ [CmdletBinding()]
+ param(
+ [ValidateNotNullOrEmpty()]
+ $EnvType,
+ [ValidateNotNullOrEmpty()]
+ $Designation,
+ [ValidateNotNullOrEmpty()]
+ $DeploymentType,
+ [ValidatePattern("\d")] # maybe make this just non-zero integers ?
+ $Time,
+ $StatsdUrl,
+ $Ip,
+ $Port
+ )
+ $logLead = Get-LogLeadName
+
+ $deploymentNamespace = "pipeline.deployment.time"
+ [string]$designationMetricName = "$EnvType.$Designation.$deploymentNamespace.$DeploymentType"
+
+ Write-Verbose "$logLead : designationMetricName:$designationMetricName Time:$Time"
+
+ $params = @{ }
+ if (![system.string]::IsNullOrEmpty($StatsdUrl)) {
+ $params += @{'statsdUrl' = $StatsdUrl }
+ }
+ if (![system.string]::IsNullOrEmpty($Port)) {
+ $params += @{'Port' = $Port }
+ }
+ if (![system.string]::IsNullOrEmpty($Ip)) {
+ $params += @{'Ip' = $Ip }
+ }
+
+ Send-Timer -MetricName $designationMetricName -Time $Time @params -Verbose:$VerbosePreference
+
+}
diff --git a/Modules/Alkami.DevOps.Operations/Public/Send-Gauge.ps1 b/Modules/Alkami.DevOps.Operations/Public/Send-Gauge.ps1
new file mode 100644
index 0000000..a84e5e0
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Send-Gauge.ps1
@@ -0,0 +1,47 @@
+function Send-Gauge {
+ <#
+.SYNOPSIS
+ Sends a Gauge Metric to StatsD service.
+.EXAMPLE
+ Send-Gauge -MetricName "bork" -Gauge 8 -verbose
+.INPUTS
+ Metric: string metric name
+ Gauge: value of metric
+ StatsdUrl: overrides default url
+ Ip: overrides dns resolution of url
+ Port : overrides default Port in Send-Metric
+#>
+
+ [CmdletBinding()]
+ param(
+ [ValidateNotNullOrEmpty()]
+ $MetricName,
+ [ValidateNotNullOrEmpty()]
+ [int]$Gauge,
+ $StatsdUrl,
+ $Ip,
+ $Port
+ )
+ $logLead = Get-LogLeadName
+
+ [string]$metric = $MetricName + ":" + "$Gauge" + "|g"
+
+ Write-Verbose "$logLead : Metric:$metric"
+
+ $params = @{ }
+ if (![system.string]::IsNullOrEmpty($StatsdUrl)) {
+ Write-Verbose "StatsdUrl:$StatsdUrl"
+ $params += @{'statsdUrl' = $StatsdUrl }
+ }
+ if (![system.string]::IsNullOrEmpty($Port)) {
+ Write-Verbose "Port:$Port"
+ $params += @{'Port' = $Port }
+ }
+ if (![system.string]::IsNullOrEmpty($Ip)) {
+ Write-Verbose "Ip:$Ip"
+ $params += @{'Ip' = $Ip }
+ }
+
+ Send-Metric -Metric $metric @params -Verbose:$VerbosePreference
+
+}
diff --git a/Modules/Alkami.DevOps.Operations/Public/Send-IncidentSnsMessage.ps1 b/Modules/Alkami.DevOps.Operations/Public/Send-IncidentSnsMessage.ps1
new file mode 100644
index 0000000..f242c76
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Send-IncidentSnsMessage.ps1
@@ -0,0 +1,198 @@
+Function Send-IncidentSnsMessage {
+ <#
+ .SYNOPSIS
+ Sends an SNS message to create a new StatusPage.Io Incident.
+
+ .PARAMETER Message
+ Message to package in the SNS Message. Not especially important to the automation, but useful for human reading and logging.
+
+ .PARAMETER StatusfactionId
+ Part of the Dynamo PK. Guid. Can be supplied when creating an incident. Required when updating incidents.
+
+ .PARAMETER Subject
+ Used for determining what to do with the message.
+
+ .PARAMETER TopicArn
+ Arn of the SNS topic to send the message to.
+
+ .PARAMETER ProfileName
+ AWS Creds.
+
+ .PARAMETER Region
+ Aws Region.
+ #>
+ [CmdletBinding()]
+ [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseLiteralInitializerForHashtable', '', Scope='Function', Justification='.Net hashtables are case sensitive by default, and we need a .Net hashtable here.')]
+ [OutputType([string])]
+ param(
+ [Parameter(Mandatory = $true)]
+ [hashtable]$Incident,
+ [Parameter(Mandatory = $true)]
+ [string]$Message,
+ [Parameter(Mandatory = $false)]
+ [string]$StatusfactionId,
+ [Parameter(Mandatory = $true)]
+ [string]$Subject,
+ [Parameter(Mandatory = $true)]
+ [string]$TopicArn,
+ [Parameter(Mandatory = $false)]
+ [string]$ProfileName,
+ [Parameter(Mandatory = $false)]
+ [string]$Region
+ )
+ $logLead = (Get-LogLeadName)
+
+ Import-AWSModule
+
+ #Region Dynamo
+ if($Subject -eq "CreateIncident")
+ {
+ Write-Verbose "$logLead : Building Dynamo Item"
+ # Build Item to put into dynamo.
+ # If a guid was supplied, use that. Otherwise, create one.
+ if([string]::IsNullOrEmpty($statusfactionId)){
+ $statusfactionId = New-Guid
+ }
+
+ # Build TTL value.
+ $ttl = (Get-Date).ToUniversalTime().AddDays(45)
+ $unixTtl = [int64](Get-Date($ttl) -UFormat %s)
+
+ $item = New-Object -TypeName 'System.Collections.Generic.Dictionary[string,Amazon.DynamoDBv2.Model.AttributeValue]'
+ $item.Add("Environment", ($Incident.ComponentName | AsDynamoMessageValue))
+ $item.Add("Id", ($statusfactionId | AsDynamoMessageValue))
+ $item.Add("Origin", ($Incident.Origin | AsDynamoMessageValue))
+ $item.Add("TTL", ($unixTtl | AsDynamoMessageValue))
+
+ Write-Verbose "$logLead : Building Dynamo Put Object"
+ # Create new record in StatusfactionIncidents.
+ $putObject = New-Object -TypeName Amazon.DynamoDBv2.Model.Put
+ $putObject.TableName = "StatusfactionIncident" # This shouldn't change
+ $putObject.Item = $item
+
+ $transaction = New-Object -TypeName Amazon.DynamoDBv2.Model.TransactWriteItem
+ $transaction.Put = $putObject
+
+ Write-Verbose "$logLead : Send Dynamo Item Create"
+ try {
+ $params = @{
+ TransactItem = $transaction
+ }
+
+ if (!([string]::IsNullOrEmpty($ProfileName))) {
+ $params["ProfileName"] = $ProfileName
+ }
+
+ if (!([string]::IsNullOrEmpty($Region))) {
+ $params["Region"] = $Region
+ }
+ $dynamoResults = Write-DDBItemTransactionally @params
+
+ Write-Verbose "$loglead : $($dynamoResults.HttpStatusCode)"
+ }
+ catch {
+ Write-Warning "$loglead : $_"
+ Write-Warning "$loglead : Failed to Create incident in Dynamo. SNS WAS sent, StatusPageIo incident WAS Created."
+ }
+
+ } elseif ($Subject -eq "UpdateIncident"){
+ # Update StatusfactionIncidents with new status
+
+ if([string]::IsNullOrWhiteSpace($StatusfactionId)){
+ Write-Error "$loglead : StatusfactionId is required for incident updates. Cannot continue."
+ }
+
+ # Setup Update Object
+ $updateObject = New-Object -TypeName Amazon.DynamoDBv2.Model.Update
+ $updateObject.TableName = "StatusfactionIncident" # This shouldn't change
+ $updateObject.Key = New-Object -TypeName 'System.Collections.Generic.Dictionary[string,Amazon.DynamoDBv2.Model.AttributeValue]'
+ $updateObject.Key.Add("Environment", ($Incident.ComponentName | AsDynamoMessageValue))
+ $updateObject.Key.Add("Id", ($statusfactionId | AsDynamoMessageValue))
+
+ # Setup Update Expression
+ $updateObject.UpdateExpression = "SET #IncidentStatus = :incidentStatus"
+
+ # Names are strings. Values are [AttributeValue]s
+ $updateObject.ExpressionAttributeNames = New-Object -TypeName 'System.Collections.Generic.Dictionary[string,string]'
+ $updateObject.ExpressionAttributeValues = New-Object -TypeName 'System.Collections.Generic.Dictionary[string,Amazon.DynamoDBv2.Model.AttributeValue]'
+
+ # Add Status
+ $updateObject.ExpressionAttributeNames.Add("#IncidentStatus", "IncidentStatus")
+ $updateObject.ExpressionAttributeValues.Add(":incidentStatus", ($Incident.IncidentStatus | AsDynamoMessageValue))
+
+ $transaction = New-Object -TypeName Amazon.DynamoDBv2.Model.TransactWriteItem
+ $transaction.Update = $updateObject
+
+ try {
+ $params = @{
+ TransactItem = $transaction
+ }
+
+ if (!([string]::IsNullOrEmpty($ProfileName))) {
+ $params["ProfileName"] = $ProfileName
+ }
+
+ if (!([string]::IsNullOrEmpty($Region))) {
+ $params["Region"] = $Region
+ }
+ $dynamoResults = Write-DDBItemTransactionally @params
+
+ Write-Verbose "$loglead : Dynamo Result - $($dynamoResults.HttpStatusCode)"
+ }
+ catch {
+ Write-Warning "$loglead : $_"
+ Write-Warning "$loglead : Failed to write updated incident to Dynamo. SNS WAS sent, StatusPageIo incident WAS updated."
+ }
+ } elseif ($Subject -eq "CloseAllIncidents"){
+ # Since we're trying to close all open incidents, we'll not do anything with Dynamo here.
+ # The SNS message will find and close all open incidents by environment type.
+ Write-Verbose "$logLead : Subject $Subject"
+ } else {
+ Write-Error "$logLead : Unexpected Subject ( $subject ) type provided. Cannot continue."
+ }
+
+ #EndRegion Dynamo
+
+ #Region SNS
+ # Build Message Attributes
+ $messageAttributes = [System.Collections.HashTable]::new(@{
+ Name = $Incident.IncidentName | AsSNSAttribute
+ Status = $Incident.IncidentStatus | AsSNSAttribute
+ PageId = $Incident.PageId | AsSNSAttribute
+ Component = $Incident.ComponentName | AsSNSAttribute
+ ComponentId = $Incident.ComponentId | AsSNSAttribute
+ ComponentStatus = $Incident.ComponentStatus | AsSNSAttribute
+ Origin = $Incident.Origin | AsSNSAttribute
+ })
+
+ # Always add our Id.
+ $messageAttributes.Add("StatusfactionId", ($statusfactionId | AsSNSAttribute))
+
+ # Send SNS Message
+ try {
+ $params = @{
+ Message = $Message
+ MessageAttribute = $MessageAttributes
+ Subject = $Subject
+ TopicArn = $TopicArn
+ }
+
+ if (!([string]::IsNullOrEmpty($ProfileName))) {
+ $params["ProfileName"] = $ProfileName
+ }
+
+ if (!([string]::IsNullOrEmpty($Region))) {
+ $params["Region"] = $Region
+ }
+
+ # This is fire and forget. You either get back a message ID, or it fails. We throw if it fails.
+ Write-Verbose "$loglead : Publishing SNS Message"
+ $response = Publish-SNSMessage @params
+ return $response
+
+ # Throw if it fails.
+ } catch {
+ Write-Error "$logLead : Exception occurred when publishing SNS message: $($_.exception)"
+ }
+ #EndRegion SNS
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Public/Send-Metric.Tests.ps1 b/Modules/Alkami.DevOps.Operations/Public/Send-Metric.Tests.ps1
new file mode 100644
index 0000000..403c742
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Send-Metric.Tests.ps1
@@ -0,0 +1,56 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ""
+
+Describe "Send-Metric" {
+ Mock -CommandName New-TcpSocket -MockWith { return $null } -ModuleName $moduleForMock
+ Mock -CommandName New-TcpStreamWriter -MockWith { return $null } -ModuleName $moduleForMock
+ Mock -CommandName Write-TcpSocketStreamWriter -MockWith { return $null } -ModuleName $moduleForMock
+ Mock -CommandName Push-TcpStreamWriterBuffer -MockWith { return $null } -ModuleName $moduleForMock
+ Mock -CommandName Close-TcpSocket -MockWith { return $null } -ModuleName $moduleForMock
+ Mock -CommandName Close-TcpStreamWriter -MockWith { return $null } -ModuleName $moduleForMock
+ Mock -CommandName Format-IpAddress -MockWith { return $null } -ModuleName $moduleForMock
+ Mock -CommandName Get-IPAddressesForName -MockWith { return $null } -ModuleName $moduleForMock
+ Mock -commandName Write-Warning -MockWith { return $null }
+ Mock -commandName Write-Verbose -MockWith { return $null }
+ Mock -commandName Write-Host -MockWith { return $null }
+
+ Context "Input Validation" {
+
+ It "Metric Param Cannot be empty" {
+ { Send-Metric -metric '' } | Should -Throw
+ }
+ It "Ip address override should override default url" {
+ Send-Metric -metric 'MockCounter:1|c' -Ip "127.0.0.1"
+ Assert-MockCalled Format-IpAddress -ParameterFilter { $Ip -eq "127.0.0.1" } -ModuleName $moduleForMock
+ }
+ It "defult URL should be overriden with supplied url" {
+ Send-Metric -metric 'MockCounter:1|c' -StatsdUrl "mockedURL.mock.domain.com"
+ Assert-MockCalled Get-IPAddressesForName -ParameterFilter { $hostname -eq "mockedURL.mock.domain.com" } -ModuleName $moduleForMock
+ }
+ It "defult URL should be used if no param supplied" {
+ Send-Metric -metric 'MockCounter:1|c'
+ Assert-MockCalled Get-IPAddressesForName -ParameterFilter { $hostname -eq "agg.os-logstash.haystack.prod.alkami.net" } -ModuleName $moduleForMock
+ }
+ It "defult IP should be used if empty string supplied" {
+ Send-Metric -metric 'MockCounter:1|c' -Ip ""
+ Assert-MockCalled Get-IPAddressesForName -ParameterFilter { $hostname -eq "agg.os-logstash.haystack.prod.alkami.net" } -ModuleName $moduleForMock
+ }
+ It "defult IP should be overidden if ip supplied" {
+ Send-Metric -metric 'MockCounter:1|c' -Ip "666.666.666.666"
+ Assert-MockCalled Format-IpAddress -ParameterFilter { $Ip -eq "666.666.666.666" } -ModuleName $moduleForMock
+ }
+ It "defult Port should be used if empty string supplied" {
+ Send-Metric -metric 'MockCounter:1|c' -Port ""
+ Assert-MockCalled New-TcpSocket -ParameterFilter { $Port -eq "2233" } -ModuleName $moduleForMock
+ }
+ It "defult port should be overidden if ip supplied" {
+ Send-Metric -metric 'MockCounter:1|c' -Port "66666"
+ Assert-MockCalled New-TcpSocket -ParameterFilter { $Port -eq "66666" } -ModuleName $moduleForMock
+ }
+ }
+}
diff --git a/Modules/Alkami.DevOps.Operations/Public/Send-Metric.ps1 b/Modules/Alkami.DevOps.Operations/Public/Send-Metric.ps1
new file mode 100644
index 0000000..9929aab
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Send-Metric.ps1
@@ -0,0 +1,61 @@
+function Send-Metric {
+ <#
+.SYNOPSIS
+ sends a metric to StatsD service over TCP.
+.EXAMPLE
+ Send-Metric -Metric "bork.test:1|c" -verbose
+.INPUTS
+ Metric: string of metric data to send
+ StatsdUrl: overrides default url
+ Ip: overrides dns resolution of url
+ Port : overrides default Port
+#>
+
+ [CmdletBinding()]
+ param(
+ [ValidateNotNullOrEmpty()]
+ $Metric,
+ $StatsdUrl = "agg.os-logstash.haystack.prod.alkami.net",
+ $Ip,
+ $Port = 2233
+ )
+ $logLead = Get-LogLeadName
+
+ if ([system.string]::IsNullOrEmpty($Ip)) {
+ Write-Verbose "$loglead : Using default ip."
+ $ipAddressString = @(Get-IPAddressesForName -hostname $StatsdUrl)[0]
+ } else {
+ Write-Warning "$loglead : Ip Address overridden with $Ip"
+ $ipAddressString = $Ip
+ }
+
+ try {
+ Write-Verbose "$logLead : parsing ip $ipAddressString"
+ $ipAddress = Format-IpAddress -Ip $ipAddressString
+ } catch {
+ Write-Error "Failed to parse IP: $ipAddressString"
+ }
+
+ try {
+ Write-Verbose "$logLead : Connecting TCP Socket"
+ $socket = New-TcpSocket -IpAddress $IpAddress -Port $port
+ Write-Verbose "$logLead : Opening Stream"
+ $writer = New-TcpStreamWriter -Socket $socket
+ } catch {
+ Write-Error "Failed to open socket and create stream: IpAddress $ipAddress Port $Port"
+ Write-Error $_
+ }
+
+ try {
+ Write-Verbose "$logLead : Attempting Write"
+ Write-TcpSocketStreamWriter -Writer $writer -Value $metric
+ Write-Verbose "$logLead : Attempting flush"
+ Push-TcpStreamWriterBuffer -Writer $writer
+ } finally {
+ Write-Verbose "$logLead : Closing Stream"
+ Close-TcpStreamWriter -Writer $writer
+ Write-Verbose "$logLead : Closing Socket"
+ Close-TcpSocket -Socket $Socket
+ }
+ Write-Host "$logLead : Sent $metric to $ipAddressString"
+}
diff --git a/Modules/Alkami.DevOps.Operations/Public/Send-NewIncidentSnsMessage.ps1 b/Modules/Alkami.DevOps.Operations/Public/Send-NewIncidentSnsMessage.ps1
new file mode 100644
index 0000000..1782772
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Send-NewIncidentSnsMessage.ps1
@@ -0,0 +1,58 @@
+Function Send-NewIncidentSnsMessage {
+ <#
+.SYNOPSIS
+ Sends an SNS message to create a new StatusPage.Io Incident.
+
+.PARAMETER Incident
+ Incident to create.
+
+.PARAMETER Message
+ Message to package in the SNS Message. Not especially important to the automation, but useful for human reading and logging.
+
+.PARAMETER StatusfactionId
+ Part of the Dynamo PK. Guid. Will be created if not supplied.
+
+.PARAMETER TopicArn
+ Arn of the SNS topic to send the message to.
+
+.PARAMETER ProfileName
+ AWS Creds.
+
+.PARAMETER Region
+ Aws Region.
+#>
+ [CmdletBinding()]
+ [OutputType([string])]
+ param(
+ [Parameter(Mandatory = $true)]
+ [hashtable]$Incident,
+ [Parameter(Mandatory = $false)]
+ [string]$Message = "Creating New Incident",
+ [Parameter(Mandatory = $false)]
+ [string]$StatusfactionId,
+ [Parameter(Mandatory = $true)]
+ [string]$TopicArn,
+ [Parameter(Mandatory = $false)]
+ [string]$ProfileName,
+ [Parameter(Mandatory = $false)]
+ [string]$Region
+ )
+ $logLead = (Get-LogLeadName)
+ # Build SNS Message
+
+ # Required value to correctly parse this on the lambda side.
+ $subject = "CreateIncident"
+
+ $params = @{
+ Message = $Message
+ Incident = $Incident
+ Subject = $subject
+ StatusfactionId = $StatusfactionId
+ TopicArn = $TopicArn
+ ProfileName = $ProfileName
+ Region = $Region
+ }
+
+ Write-Verbose "$logLead : Sending Sns message."
+ return Send-IncidentSnsMessage @params
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Public/Send-ResolveAllIncidentsSnsMessage.ps1 b/Modules/Alkami.DevOps.Operations/Public/Send-ResolveAllIncidentsSnsMessage.ps1
new file mode 100644
index 0000000..8e51a65
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Send-ResolveAllIncidentsSnsMessage.ps1
@@ -0,0 +1,51 @@
+Function Send-ResolveAllIncidentsSnsMessage {
+ <#
+ .SYNOPSIS
+ Sends an SNS message to resolve all existing StatusPage.Io Incidents for an environment.
+
+ .PARAMETER Incident
+ Incident to modify.
+
+ .PARAMETER Message
+ Message to package in the SNS Message. Not especially important to the automation, but useful for human reading and logging.
+
+ .PARAMETER TopicArn
+ Arn of the SNS topic to send the message to.
+
+ .PARAMETER ProfileName
+ AWS Creds.
+
+ .PARAMETER Region
+ Aws Region.
+ #>
+ [CmdletBinding()]
+ [OutputType([string])]
+ param(
+ [Parameter(Mandatory = $true)]
+ [hashtable]$Incident,
+ [Parameter(Mandatory = $false)]
+ [string]$Message = "Resolving All Open Incidents",
+ [Parameter(Mandatory = $true)]
+ [string]$TopicArn,
+ [Parameter(Mandatory = $false)]
+ [string]$ProfileName,
+ [Parameter(Mandatory = $false)]
+ [string]$Region
+ )
+ # Build SNS Message
+
+ # Required value to correctly parse this on the lambda side.
+ $subject = "CloseAllIncidents"
+
+ $params = @{
+ Message = $Message
+ Incident = $Incident
+ StatusFactionId = [GUID]::Empty
+ Subject = $subject
+ TopicArn = $TopicArn
+ ProfileName = $ProfileName
+ Region = $Region
+ }
+
+ return Send-IncidentSnsMessage @params
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Public/Send-Timer.Tests.ps1 b/Modules/Alkami.DevOps.Operations/Public/Send-Timer.Tests.ps1
new file mode 100644
index 0000000..e4f0b8b
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Send-Timer.Tests.ps1
@@ -0,0 +1,69 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ""
+
+Describe "Send-Timer" {
+ Mock -CommandName Send-Metric -MockWith { return $null } -ModuleName $moduleForMock
+ Mock -commandName Write-Verbose -MockWith { return $null }
+
+ Context "Input Validation" {
+
+ It "MetricName Cannot be empty" {
+
+ { Send-Timer -MetricName '' } | Should -Throw
+ }
+ It "Time Cannot be empty" {
+
+ { Send-Timer -Time ' ' } | Should -Throw
+ }
+
+ }
+ Context "Param Passing is honored" {
+
+ It "Supplied Ip is passed to Send-Metric" {
+ Send-Timer -MetricName "bork" -time 8 -Ip "666.666.666.666"
+ Assert-MockCalled Send-Metric -ParameterFilter { $Ip -eq "666.666.666.666" } -ModuleName $moduleForMock
+ }
+ It "Supplied Port is passed to Send-Metric" {
+ Send-Timer -MetricName "bork" -time 8 -Port 666 -verbose
+ Assert-MockCalled Send-Metric -ParameterFilter { $Port -eq "666" } -ModuleName $moduleForMock
+ }
+ It "Supplied StatsdUrl is passed to Send-Metric" {
+ Send-Timer -MetricName "bork" -time 8 -StatsdUrl "fake.fake.fake"
+ Assert-MockCalled Send-Metric -ParameterFilter { $StatsdUrl -eq "fake.fake.fake" } -ModuleName $moduleForMock
+ }
+ }
+ Context "Metric String Concat" {
+
+ It "Metric is concatenated correctly and sent to Send-Metric" {
+ Send-Timer -MetricName "bork" -Time 8
+ Assert-MockCalled Send-Metric -ParameterFilter { $Metric -eq "bork:8|ms" } -ModuleName $moduleForMock
+ }
+ }
+ Context "Metric Counter" {
+
+ It "accurate increment value is sent to Send-Counter" {
+ Send-Timer -MetricName "bork" -Time 1 -Ip "666.666.666.666"
+ Assert-MockCalled Send-Metric -ParameterFilter { $Metric -eq "bork:1|ms" } -ModuleName $moduleForMock
+ }
+ It "accurate increment string value is sent to Send-Counter" {
+ Send-Timer -MetricName "bork" -Time "1" -Ip "666.666.666.666"
+ Assert-MockCalled Send-Metric -ParameterFilter { $Metric -eq "bork:1|ms" } -ModuleName $moduleForMock
+ }
+ It "accurate decrement value is sent to Send-Counter" {
+ Send-Timer -MetricName "bork" -Time -1 -Port "666"
+ Assert-MockCalled Send-Metric -ParameterFilter { $Metric -eq "bork:1|ms" } -ModuleName $moduleForMock
+ }
+ It "accurate decrement string value is sent to Send-Counter" {
+ Send-Timer -MetricName "bork" -Time "-1" -Port "666"
+ Assert-MockCalled Send-Metric -ParameterFilter { $Metric -eq "bork:1|ms" } -ModuleName $moduleForMock
+ }
+ It "suppling string as count throws " {
+ { Send-Timer -MetricName "bork" -Time "fake" } | Should -Throw
+ }
+ }
+}
diff --git a/Modules/Alkami.DevOps.Operations/Public/Send-Timer.ps1 b/Modules/Alkami.DevOps.Operations/Public/Send-Timer.ps1
new file mode 100644
index 0000000..24f35b3
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Send-Timer.ps1
@@ -0,0 +1,46 @@
+function Send-Timer {
+ <#
+.SYNOPSIS
+ Sends a Timer Metric to StatsD service. Time is in milliseconds.
+.EXAMPLE
+ Send-timer -MetricName "bork" -time 8 -verbose
+.INPUTS
+ Metric: namespace of metric
+ Time: value in Milliseconds
+ StatsdUrl: overrides default url
+ Ip: overrides dns resolution of url
+ Port : overrides default ip in Send-Metric
+#>
+ [CmdletBinding()]
+ param(
+ [ValidateNotNullOrEmpty()]
+ $MetricName,
+ [ValidateNotNullOrEmpty()]
+ [int]$Time,
+ $StatsdUrl,
+ $Ip,
+ $Port
+ )
+ $logLead = Get-LogLeadName
+
+ [string]$metric = $MetricName + ":" + "$time" + "|ms"
+
+ Write-Verbose "$logLead : Metric:$metric"
+
+ $params = @{ }
+ if (![system.string]::IsNullOrEmpty($StatsdUrl)) {
+ Write-Verbose "StatsdUrl:$StatsdUrl"
+ $params += @{'statsdUrl' = $StatsdUrl }
+ }
+ if (![system.string]::IsNullOrEmpty($Port)) {
+ Write-Verbose "Port:$Port"
+ $params += @{'Port' = $Port }
+ }
+ if (![system.string]::IsNullOrEmpty($Ip)) {
+ Write-Verbose "Ip:$Ip"
+ $params += @{'Ip' = $Ip }
+ }
+
+ Send-Metric -Metric $metric @params -Verbose:$VerbosePreference
+
+}
diff --git a/Modules/Alkami.DevOps.Operations/Public/Send-UpdateIncidentSnsMessage.ps1 b/Modules/Alkami.DevOps.Operations/Public/Send-UpdateIncidentSnsMessage.ps1
new file mode 100644
index 0000000..475526b
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Send-UpdateIncidentSnsMessage.ps1
@@ -0,0 +1,56 @@
+Function Send-UpdateIncidentSnsMessage {
+ <#
+ .SYNOPSIS
+ Sends an SNS message to modify an existing StatusPage.Io Incident.
+
+ .PARAMETER Incident
+ Incident to modify.
+
+ .PARAMETER Message
+ Message to package in the SNS Message. Not especially important to the automation, but useful for human reading and logging.
+
+ .PARAMETER StatusfactionId
+ Part of the Dynamo PK. Guid
+
+ .PARAMETER TopicArn
+ Arn of the SNS topic to send the message to.
+
+ .PARAMETER ProfileName
+ AWS Creds.
+
+ .PARAMETER Region
+ Aws Region.
+ #>
+ [CmdletBinding()]
+ [OutputType([string])]
+ param(
+ [Parameter(Mandatory = $true)]
+ [hashtable]$Incident,
+ [Parameter(Mandatory = $false)]
+ [string]$Message = "Updating Incident",
+ [Parameter(Mandatory = $true)]
+ [string]$StatusfactionId,
+ [Parameter(Mandatory = $true)]
+ [string]$TopicArn,
+ [Parameter(Mandatory = $false)]
+ [string]$ProfileName,
+ [Parameter(Mandatory = $false)]
+ [string]$Region
+ )
+ # Build SNS Message
+
+ # Required value to correctly parse this on the lambda side.
+ $subject = "UpdateIncident"
+
+ $params = @{
+ Message = $Message
+ Incident = $Incident
+ StatusFactionId = $StatusfactionId
+ Subject = $subject
+ TopicArn = $TopicArn
+ ProfileName = $ProfileName
+ Region = $Region
+ }
+
+ return Send-IncidentSnsMessage @params
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Public/Set-ASInstanceState.ps1 b/Modules/Alkami.DevOps.Operations/Public/Set-ASInstanceState.ps1
new file mode 100644
index 0000000..5136e44
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Set-ASInstanceState.ps1
@@ -0,0 +1,300 @@
+function Set-ASInstanceState {
+<#
+.SYNOPSIS
+ Sets an EC2 Instance to Active or Standby on the ASG. In Prod/Staging, this function can only be run against an App tier or Entrust tier AWS server. In Dev/QA Webs are acceptable as well.
+
+.DESCRIPTION
+ Sets an EC2 Instance to Active or Standby in the ASG after first setting the instance health on the ASG to Healthy if required. Setting the instance to standby removes it from the NLB and setting it to active reinstates it.
+ May run against a single remote machine. If no machine name parameter is specified, executes against localhost.
+ In Prod/Staging this function can only be run against an App tier or Entrust tier AWS server. In Dev/QA Web tier servers are also valid targets.
+ Will only set the instance to Standby if the other remaining instances in the ASG are healthy, unless -Force is used.
+
+.PARAMETER ComputerName
+ Optional FQDN of the machine to set to active or standby
+
+.PARAMETER InstanceId
+ Optional instance id of the machine to set to active or standby
+
+.PARAMETER ProfileName
+ Optional profile name. Preferred that it be provided.
+
+.PARAMETER Region
+ Optional region name. Preferred that it be provided.
+
+.PARAMETER Timeout
+ The length of time in minutes to wait for a server to respond to Get-ASInstanceState. Defaults to 20 (minutes)
+
+.PARAMETER Force
+ Optional boolean to force setting the instance to Standby regardless of the health state of other instances in the ASG.
+
+.EXAMPLE
+ Set-ASInstanceState -standby
+
+ [Set-ASInstanceState] : Attempting to Set Computer 127.0.0.1 to Standby
+ [Set-ASInstanceState] : Setting Instance i-0e5760981561dac91 to Standby
+ [Set-ASInstanceState] : Standby Request Returned a Status of InProgress. Moving EC2 instance to Standby: i-0e5760981561dac91
+
+.EXAMPLE
+ Set-ASInstanceState -computerName foobar.fh.local -standby
+
+ [Set-ASInstanceState] : Creating Session to Remote Computer foobar.fh.local
+ [Set-ASInstanceState] : Attempting to Set Computer foobar.fh.local to Standby
+ [Set-ASInstanceState] : Setting Instance i-0fd7b8a51b4d39906 to Standby
+ [Set-ASInstanceState] : Standby Request Returned a Status of InProgress. Moving EC2 instance to Standby: i-0fd7b8a51b4d39906
+
+.EXAMPLE
+ Set-ASInstanceState -computerName foobar.fh.local -standby
+
+ [Set-ASInstanceState] : Creating Session to Remote Computer foobar.fh.local
+ [Set-ASInstanceState] : Attempting to Set Computer foobar.fh.local to Standby
+ WARNING: [Set-ASInstanceState] : Stopping because at least one remaining instance in the ASG 0.2_Sandbox_app-20190612164333406500000006 must be healthy before changing instance status to Standby. Use -Force to ignore warning and continue.
+ WARNING: [Set-ASInstanceState] : Null response received from script block. Review the output for errors.
+
+.EXAMPLE
+ Set-ASInstanceState -ComputerName foobar.fh.local -Standby -Force
+
+ [Set-ASInstanceState] : Creating Session to Remote Computer foobar.fh.local
+ [Set-ASInstanceState] : Attempting to Set Computer foobar.fh.local to Standby
+ WARNING: [Set-ASInstanceState] : At least one remaining instance in the ASG 0.2_Sandbox_app-20190612164333406500000006 must be healthy before changing instance status to Standby. -Force was used so continuing.
+ [Set-ASInstanceState] : Setting Instance i-0e5760981561dac91 to Standby
+ [Set-ASInstanceState] : Standby Request Returned a Status of InProgress. Moving EC2 instance to Standby: i-0fd7b8a51b4d39906
+
+.EXAMPLE
+ Set-ASInstanceState -active
+
+ [Set-ASInstanceState] : Attempting to Set Computer 127.0.0.1 to Active
+ [Set-ASInstanceState] : Setting Instance i-0e5760981561dac91 to Active
+ [Set-ASInstanceState] : Active Request Returned a Status of PreInService. Moving EC2 instance out of Standby: i-0e5760981561dac91
+
+.EXAMPLE
+ Set-ASInstanceState -computerName foobar.fh.local -active
+
+ [Set-ASInstanceState] : Creating Session to Remote Computer foobar.fh.local
+ [Set-ASInstanceState] : Attempting to Set Computer foobar.fh.local to Active
+ [Set-ASInstanceState] : Updating Instance i-0e5760981561dac91 Status from UNHEALTHY to Healthy
+ [Set-ASInstanceState] : Setting Instance i-0e5760981561dac91 to Active
+ [Set-ASInstanceState] : Active Request Returned a Status of PreInService. Moving EC2 instance out of Standby: i-0e5760981561dac91
+#>
+
+ [CmdletBinding()]
+ [OutputType([System.Collections.Hashtable])]
+ param(
+
+ [Parameter(ParameterSetName = 'StandbyParameterSet', Mandatory = $true)]
+ [Parameter(ParameterSetName = "ComputerName", Mandatory = $false)]
+ [Parameter(ParameterSetName = "InstanceId", Mandatory = $false)]
+ [Alias("Offline")]
+ [switch]$Standby,
+
+ [Parameter(ParameterSetName = 'ActiveParameterSet', Mandatory = $true)]
+ [Parameter(ParameterSetName = "ComputerName", Mandatory = $false)]
+ [Parameter(ParameterSetName = "InstanceId", Mandatory = $false)]
+ [Alias("Online")]
+ [switch]$Active,
+
+ [Parameter(ParameterSetName = 'ActiveParameterSet', Mandatory = $true)]
+ [Parameter(ParameterSetName = 'StandbyParameterSet', Mandatory = $true)]
+ [Parameter(ParameterSetName = 'ComputerName', Mandatory = $false)]
+ [Alias("MachineName", "ServerName")]
+ [string]$ComputerName,
+
+ [Parameter(ParameterSetName = 'ActiveParameterSet', Mandatory = $true)]
+ [Parameter(ParameterSetName = 'StandbyParameterSet', Mandatory = $true)]
+ [Parameter(ParameterSetName = "InstanceId", Mandatory = $true)]
+ [string]$InstanceId,
+
+ [Parameter(Mandatory = $false)]
+ [string]$ProfileName,
+
+ [Parameter(Mandatory = $false)]
+ [string]$Region,
+
+ [Parameter(Mandatory = $false)]
+ [int]$Timeout = 20,
+
+ [Parameter(Mandatory = $false)]
+ [Alias("f")]
+ [switch]$Force
+ )
+
+ $logLead = Get-LogLeadName
+
+ Import-AWSModule # AS
+
+ if ($Standby) {
+ $stateText = "Standby"
+ } elseif($Active) {
+ $stateText = "Active"
+ } else {
+ Write-Warning "Either the -Active or -Standby switch is required."
+ throw New-Object System.ArgumentException "Either the -Active or -Standby switch is required."
+ }
+
+ if ([string]::IsNullOrWhiteSpace($ComputerName)) {
+ # Not sure if this is better or just $env:ComputerName
+ $ComputerName = Get-FullyQualifiedServerName
+ }
+
+ # TODO: TechDebt ticket to fix this?
+ $IsValid = (
+ (Test-IsWebServer -ComputerName $ComputerName) -or
+ (Test-IsAppServer -ComputerName $ComputerName) -or
+ (Test-IsEntrustServer)
+ )
+ $IsAwsValidTarget = (Test-IsAws) -or $IsValid
+ if (!$IsAwsValidTarget) {
+ Write-Warning "$logLead : This function can only be run on an AWS App or Entrust server. Was targeted for [$computerName]"
+ return
+ }
+
+ $instanceRegion = $Region
+
+ if ([string]::IsNullOrWhiteSpace($instanceRegion)) {
+ $instanceRegion = Get-AwsRegionByHostname -ComputerName $ComputerName
+ }
+
+ if ([string]::IsNullOrWhiteSpace($InstanceId)) {
+ $InstanceId = @(Get-EC2InstancesByHostname -Servers $ComputerName -ProfileName $ProfileName)[0].InstanceId
+ }
+
+ Write-Host "$logLead : Getting Current AutoScalingInstance."
+ $autoScalingInstance = Get-ASAutoScalingInstance -InstanceID $InstanceId -Region $instanceRegion -ProfileName $ProfileName
+
+ if ($null -eq $autoScalingInstance) {
+ Write-Warning "$logLead : Could not retrieve the parent ASG Instance with ID $InstanceId."
+ return
+ }
+
+ $healthStatus = $autoScalingInstance.HealthStatus
+ $asgName = $autoScalingInstance.AutoScalingGroupName
+
+ Write-Host "$logLead : ASG $asgName Reports Instance is $healthStatus"
+
+ $desiredHealthStatus = "Healthy"
+ if ($healthStatus -ine $desiredHealthStatus) {
+
+ Write-Host "$logLead : Updating Instance $InstanceId Status from $healthStatus to $desiredHealthStatus"
+ Set-ASInstanceHealth -InstanceId $InstanceId -HealthStatus $desiredHealthStatus -Force -Region $instanceRegion -ProfileName $ProfileName -ShouldRespectGracePeriod $false -ErrorAction Stop
+
+ Write-Verbose "$logLead : Sleeping for 5 Seconds"
+ Start-Sleep -Seconds 5
+ }
+
+ # When setting to Standby, Check the health of all of the other instances in the ASG and fail if the only remaining instances are Unhealthy so outages don't occur.
+ if ($Standby) {
+ $autoscalingGroupTagKey = "aws:autoscaling:groupName"
+
+ $currentInstanceTags = (Get-EC2Instance -InstanceId $InstanceId -Region $instanceRegion -ProfileName $ProfileName).Instances[0].Tags
+ $autoScalingGroupName = $currentInstanceTags | Where-Object { $_.Key -eq $autoscalingGroupTagKey } | Select-Object -First 1 -ExpandProperty Value
+ $groupMembers = (Get-InstancesByTag -tags @{ $autoscalingGroupTagKey = $autoScalingGroupName } -Region $instanceRegion -ProfileName $ProfileName).InstanceId
+
+ Write-Host "$loglead : Found the following instances on ASG $asgName :"
+ foreach ($groupMember in $groupMembers) {
+ Write-Host "$loglead : $groupMember"
+ }
+
+ $remainingInstances = (Get-ASAutoScalingInstance -InstanceID $groupMembers -Region $instanceRegion -ProfileName $ProfileName) | Where-Object { ($_.InstanceId -ine $InstanceId) -and ($_.HealthStatus -eq $desiredHealthStatus) }
+
+ if ($remainingInstances.Count -eq 0) {
+
+ if ($Force) {
+ Write-Warning "$loglead : At least one remaining instance in the ASG $asgName must be healthy before changing instance status to Standby. -Force was used so continuing."
+ } else {
+ Write-Error "$loglead : Stopping - at least one remaining instance in the ASG $asgName must be healthy before changing instance status to Standby. Use -Force to ignore warning and continue."
+ return
+ }
+ } else {
+ Write-Host "$logLead : At least one remaining instance in the ASG $asgName is healthy."
+ }
+ }
+
+ Write-Host "$logLead : Setting Instance $InstanceId to $stateText"
+
+ $stopWatch = [System.Diagnostics.StopWatch]::StartNew()
+
+ ## attempt to remediate if the state isn't what we expect before we go into setting the final state
+ $existingState = Get-ASInstanceState -InstanceId $InstanceId -ProfileName $ProfileName
+ $wasPending = $existingState -match 'Pending'
+ if ($wasPending) {
+ Write-Warning "$logLead : Current instance state is [$existingState], waiting to see if it changes to non-pending on a 10s looping interval"
+ }
+ while ($existingState -match 'Pending') {
+ Start-Sleep -Seconds 10
+
+ if ($stopWatch.Elapsed.TotalMinutes -gt $Timeout) {
+ Write-Warning "$logLead : The server check for Get-ASInstanceState for $computerName took longer than $Timeout minutes to complete."
+ throw "$logLead : Took longer than $Timeout minutes to remove server $server from the load balancer. Investigate."
+ }
+
+ $existingState = Get-ASInstanceState -InstanceId $InstanceId -ProfileName $ProfileName
+ }
+ $wasPendingSeconds = 0
+ if ($wasPending) {
+ Write-Warning "$logLead : Left pending state, current state now [$existingState]"
+ $wasPendingSeconds = $stopWatch.Elapsed.TotalSeconds
+ }
+
+ ## We ensured we weren't in the Pending states, so we can only be in Active, Standby, or idk-state
+ if (@('Active', 'Standby') -notContains $existingState) {
+ $errorMessage = "Please investigate the health of the ASG [$asgName] instance [$InstanceId] in the [$instanceRegion] Region"
+ Write-Warning "$logLead : Get-ASInstanceState returns [$existingState]"
+ Write-Warning "$logLead : Get-ASInstanceState expected results were either Active or Standby"
+ Write-Warning "$logLead : $errorMessage"
+ Write-Warning "$logLead : Failing case, can not continue. Aborting via throw"
+ throw $errorMessage
+ }
+
+ if ($existingState -eq $stateText) {
+ Write-Warning "$logLead : Was asked to put instance into current state [$existingState]. Nothing to do, state change not required."
+ return @{ StatusMessage = "Was asked to put instance into current state [$existingState]. Nothing to do, state change not required."; Description = "No work to do"; StatusCode = "nothing to do (Alkami)" }
+ }
+
+ ## At this point, we only have two remaining states
+ ## Standby -> Should be Active
+ ## Active -> Should be Standby
+ $readyForActive = ($Standby -and ($existingState -eq 'Active'))
+ $readyForStandby = (!$Standby -and ($existingState -eq 'Standby'))
+
+ if (!$readyForActive -and !$readyForStandby) {
+ ## In order to be _not_ ready for active, and also _not_ ready for standby, something has to be wrong
+ ## We should have already gotten to that state above
+ ## We handled "current state is still pending for some reason"
+ ## We handled "current state wasn't one of the two expected states"
+ ## We handled "current state is what was asked for"
+ ## So at this point, if we trip this metric, I don't even know what to fail on. Just dump a lot of debug info and let's see what we can discern.
+ Write-Warning "$logLead : Please investigate the health of the ASG [$asgName] instance [$InstanceId] in the [$instanceRegion] Region"
+ Write-Warning "$logLead : Requested state was [$stateText]. Existing state determined to be [$existingState]. SetStandby: $Standby"
+ Write-Warning "$logLead : Health is '$(Get-ASInstanceHealth)'"
+ Write-Warning "$logLead : -"
+ Write-Warning "$logLead : Logic problem determined in code, can not determine how to proceed. Please open a ticket with SRE-RelEng with this information and what you found during investigation."
+ }
+
+ $actionResult = $null
+
+ if ($Standby) {
+ $actionResult = Enter-ASStandby -AutoScalingGroupName $asgName -InstanceId $InstanceId -ShouldDecrementDesiredCapacity $true -Region $instanceRegion -ProfileName $ProfileName -ErrorAction "Continue"
+ } else {
+ $actionResult = Exit-ASStandby -AutoScalingGroupName $asgName -InstanceId $InstanceId -Region $instanceRegion -ProfileName $ProfileName -ErrorAction "Continue"
+ }
+
+ ## Get-ASInstanceState will grab the local server if none are specified
+ while ($stateText -ne (Get-ASInstanceState -InstanceId $InstanceId -ProfileName $ProfileName)) {
+ Start-Sleep -Seconds 10
+
+ if($stopWatch.Elapsed.TotalMinutes -gt $Timeout) {
+ Write-Warning "$logLead : The server check for Get-ASInstanceState for $computerName took longer than $Timeout minutes to complete."
+ if ($wasPending) {
+ Write-Warning "$logLead : Part of the above timeout ($wasPendingSeconds seconds) was from waiting on the pending status change"
+ }
+ throw "$logLead : Took longer than $Timeout minutes to remove server $server from the load balancer. Investigate."
+ }
+ }
+
+ if ($null -ne $actionResult) {
+ $coalesceResult = Test-IsNull $actionResult.StatusMessage $actionResult.Description -Strict
+ Write-Warning ("$logLead : $stateText Request Returned a Status of $($actionResult.StatusCode). {0}" -f $coalesceResult)
+ }
+ else {
+ Write-Warning "$logLead : Null response received from script block. Review the output for errors."
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Public/Set-ASInstanceState.tests.ps1 b/Modules/Alkami.DevOps.Operations/Public/Set-ASInstanceState.tests.ps1
new file mode 100644
index 0000000..76fab29
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Set-ASInstanceState.tests.ps1
@@ -0,0 +1,64 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ""
+
+Describe "Set-ASInstanceState" {
+
+ Mock -CommandName Write-Warning -MockWith {} -ModuleName $moduleForMock
+ #Mock -CommandName Write-Host -MockWith {} -ModuleName $moduleForMock
+ Mock -CommandName Write-Verbose -MockWith {} -ModuleName $moduleForMock
+
+ Mock -CommandName Get-LogLeadName -MockWith {} -ModuleName $moduleForMock
+ Mock -CommandName Import-AWSModule -MockWith {} -ModuleName $moduleForMock
+ Mock -CommandName Get-FullyQualifiedServerName -MockWith { return "my.fakeserver.name"} -ModuleName $moduleForMock
+ Mock -CommandName Test-IsWebServer -MockWith {} -ModuleName $moduleForMock
+ Mock -CommandName Test-IsAppServer -MockWith {} -ModuleName $moduleForMock
+ Mock -CommandName Test-IsEntrustServer -MockWith {} -ModuleName $moduleForMock
+ Mock -CommandName Test-IsAws -MockWith {} -ModuleName $moduleForMock
+ Mock -CommandName Get-AwsRegionByHostname -MockWith {} -ModuleName $moduleForMock
+ Mock -CommandName Get-EC2InstancesByHostname -MockWith {} -ModuleName $moduleForMock
+ Mock -CommandName Get-ASAutoScalingInstance -MockWith {} -ModuleName $moduleForMock
+ Mock -CommandName Set-ASInstanceHealth -MockWith {} -ModuleName $moduleForMock
+ Mock -CommandName Start-Sleep -MockWith {} -ModuleName $moduleForMock
+ Mock -CommandName Get-EC2Instance -MockWith {} -ModuleName $moduleForMock
+ Mock -CommandName Get-InstancesByTag -MockWith {} -ModuleName $moduleForMock
+ Mock -CommandName Get-ASInstanceState -MockWith {} -ModuleName $moduleForMock
+ Mock -CommandName Enter-ASStandby -MockWith {} -ModuleName $moduleForMock
+ Mock -CommandName Test-IsNull -MockWith {} -ModuleName $moduleForMock
+
+ Context "When Neither Standby Nor Active Switch Is Provided" {
+ It "Throws" {
+ { Set-ASInstanceState } | Should -Throw
+ }
+ }
+
+ Context "When ComputerName is null" {
+ It "Gets the servername" {
+ Set-ASInstanceState -Active
+ Assert-MockCalled Get-FullyQualifiedServerName -ModuleName $moduleForMock
+ }
+ }
+
+ Context "When ComputerName is empty" {
+ It "Gets the servername" {
+ Set-ASInstanceState -Active -ComputerName " "
+ Assert-MockCalled Get-FullyQualifiedServerName -ModuleName $moduleForMock
+ }
+ }
+
+ Context "When Is Not A Valid AWS Target"{
+ Mock -CommandName Test-IsWebServer -MockWith {return $false} -ModuleName $moduleForMock
+ Mock -CommandName Test-IsAppServer -MockWith {return $false} -ModuleName $moduleForMock
+ Mock -CommandName Test-IsEntrustServer -MockWith {return $false} -ModuleName $moduleForMock
+
+ It "Writes a Warning"{
+ Set-ASInstanceState -Active -ComputerName " "
+ Assert-MockCalled Write-Warning -ModuleName $moduleForMock -ParameterFilter { $Message -like "*This function can only be run on an AWS App or Entrust server. Was targeted for*" }
+
+ }
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Public/Set-EC2InstanceTag.Tests.ps1 b/Modules/Alkami.DevOps.Operations/Public/Set-EC2InstanceTag.Tests.ps1
new file mode 100644
index 0000000..e969b2c
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Set-EC2InstanceTag.Tests.ps1
@@ -0,0 +1,77 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ""
+
+Describe "Get-EC2InstanceTagObject" {
+
+ Mock -CommandName Get-EC2InstancesByHostname -MockWith {} -ModuleName $moduleForMock
+ Mock -CommandName Get-CurrentInstanceId -MockWith {} -ModuleName $moduleForMock
+ Mock -CommandName Write-Warning -MockWith {} -ModuleName $moduleForMock
+ Mock -CommandName New-EC2Tag -MockWith {} -ModuleName $moduleForMock
+ Mock -CommandName Import-AWSModule -MockWith {} -ModuleName $moduleForMock
+ Mock -CommandName Write-Error -MockWith {} -ModuleName $moduleForMock
+ Mock -CommandName Get-SupportedAwsRegions -MockWith {} -ModuleName $moduleForMock
+ Mock -CommandName Test-IsAws -MockWith {} -ModuleName $moduleForMock
+ Mock -CommandName New-Object -MockWith {} -ModuleName $moduleForMock
+ Mock -CommandName Get-LogLeadName -MockWith {} -ModuleName $moduleForMock
+ Mock -CommandName Compare-StringToLocalMachineIdentifiers -MockWith {return $false} -ModuleName $moduleForMock
+
+ It "Not AWS and ProfileName is empty it throws" {
+ Mock -CommandName Test-IsAws -MockWith {return $false} -ModuleName $moduleForMock
+ $InstanceId = "FakeInstanceId"
+ $Key = "Test"
+ $Value = "TestValue"
+ $ProfileName = ""
+ $Region = "FakeRegion"
+ { Set-EC2InstanceTag -InstanceId $InstanceId -Key $Key -Value $Value -ProfileName $ProfileName -Region $Region } | Should -Throw
+ }
+
+ It "Region isn't in supported AWS regions it throws" {
+ Mock -CommandName Test-IsAws -MockWith {return $true} -ModuleName $moduleForMock
+ Mock -CommandName Get-SupportedAwsRegions -MockWith {return "TestRegion"} -ModuleName $moduleForMock
+ $InstanceId = "FakeInstanceId"
+ $Key = "Test"
+ $Value = "TestValue"
+ $ProfileName = ""
+ $Region = "FakeRegion"
+ { Set-EC2InstanceTag -InstanceId $InstanceId -Key $Key -Value $Value -ProfileName $ProfileName -Region $Region } | Should -Throw
+ }
+
+ It "ComputerName is populated and it equals localhost " {
+ Mock -CommandName Test-IsAws -MockWith {return $true} -ModuleName $moduleForMock
+ Mock -CommandName Get-SupportedAwsRegions -MockWith {return "TestRegion"} -ModuleName $moduleForMock
+ Mock -CommandName Get-CurrentInstanceId -MockWith {return "TestId"} -ModuleName $moduleForMock
+ Mock -CommandName Compare-StringToLocalMachineIdentifiers -MockWith {return $true} -ModuleName $moduleForMock
+
+ $ComputerName = "localhost"
+ $Key = "Test"
+ $Value = "TestValue"
+ $ProfileName = "TestProfileName"
+ $Region = "TestRegion"
+ Set-EC2InstanceTag -ComputerName $ComputerName -Key $Key -Value $Value -ProfileName $ProfileName -Region $Region
+ Assert-MockCalled Get-CurrentInstanceId -ModuleName $moduleForMock
+ }
+
+ It "ComputerName is populated and it doesn't equal localhost " {
+ Mock -CommandName Test-IsAws -MockWith {return $true} -ModuleName $moduleForMock
+ Mock -CommandName Get-SupportedAwsRegions -MockWith {return "TestRegion"} -ModuleName $moduleForMock
+ Mock -CommandName Get-EC2InstancesByHostname -MockWith {
+ return @{InstanceId = 'fakeInstanceId' }
+ } -ModuleName $moduleForMock
+ Mock -CommandName Compare-StringToLocalMachineIdentifiers -MockWith {return $false} -ModuleName $moduleForMock
+
+ $ComputerName = "TestName"
+ $Key = "Test"
+ $Value = "TestValue"
+ $ProfileName = "TestProfileName"
+ $Region = "TestRegion"
+ Set-EC2InstanceTag -ComputerName $ComputerName -Key $Key -Value $Value -ProfileName $ProfileName -Region $Region
+ Assert-MockCalled Get-EC2InstancesByHostname -ModuleName $moduleForMock
+ }
+
+
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Public/Set-EC2InstanceTag.ps1 b/Modules/Alkami.DevOps.Operations/Public/Set-EC2InstanceTag.ps1
new file mode 100644
index 0000000..c0fe47e
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Set-EC2InstanceTag.ps1
@@ -0,0 +1,99 @@
+function Set-EC2InstanceTag {
+<#
+.SYNOPSIS
+ Returns EC2 instance objects by computername or instance id.
+
+.PARAMETER ComputerName
+ The Computer Name to search for.
+
+.PARAMETER InstanceId
+ The Instance ID to search for.
+
+.PARAMETER ProfileName
+ The AWS profile name to use when querying for EC2 instances. ProfileName needed for 'ByInstanceId' if not on an AWS machine. Also note, if a ProfileName is not supplied, that instance's default ProfileName may not have the right permissions to add a tag or view instances.
+
+.PARAMETER Region
+ The Region to use when applying a tag to an EC2 instance.
+
+.PARAMETER Key
+ The key to assign to the new tag.
+
+.PARAMETER Value
+ The value to assign to the new tag.
+
+.EXAMPLE
+
+ By InstanceId: Set-EC2InstanceTag -InstanceId "i-0d62fcd1a9ad41ff1" -Key "testing" -Value "wow" -Region "us-east-1" -ProfileName "temp-dev"
+
+ By InstanceId on AWS Machine: Set-EC2InstanceTag -InstanceId "i-0d62fcd1a9ad41ff1" -Key "testing" -Value "wow" -Region "us-east-1"
+
+ By ComputerName: Set-EC2InstanceTag -ComputerName "mic27788" -Key "testing2" -Value "wow2" -Region "us-east-1" -ProfileName "temp-dev"
+
+#>
+
+ [CmdletBinding()]
+ param(
+ [Parameter(ParameterSetName = 'ByComputerName', Mandatory = $true)]
+ $ComputerName,
+ [Parameter(ParameterSetName = 'ByInstanceId', Mandatory = $true)]
+ $InstanceId,
+ [Parameter(ParameterSetName = 'ByComputerName', Mandatory = $true)]
+ [Parameter(ParameterSetName = 'ByInstanceId', Mandatory = $false)]
+ $ProfileName,
+ [Parameter(ParameterSetName = 'ByComputerName', Mandatory = $true)]
+ [Parameter(ParameterSetName = 'ByInstanceId', Mandatory = $true)]
+ $Region,
+ [Parameter(ParameterSetName = 'ByComputerName', Mandatory = $true)]
+ [Parameter(ParameterSetName = 'ByInstanceId', Mandatory = $true)]
+ $Key,
+ [Parameter(ParameterSetName = 'ByComputerName', Mandatory = $true)]
+ [Parameter(ParameterSetName = 'ByInstanceId', Mandatory = $true)]
+ $Value
+ )
+
+ $logLead = Get-LogLeadName
+
+ if (!(Test-IsAws)) {
+ if ([string]::IsNullOrEmpty($ProfileName)) {
+ Write-Error "$logLead : ProfileName not supplied on non-AWS machine, please supply a ProfileName parameter"
+ throw
+ }
+ }
+ if ($Region -notin (Get-SupportedAwsRegions)) {
+ Write-Error "$logLead : Region not valid, please supply a valid Region"
+ throw
+ }
+
+
+ if (!([string]::IsNullOrEmpty($ComputerName))) {
+ if (Compare-StringToLocalMachineIdentifiers -stringToCheck $ComputerName) {
+ $InstanceId = Get-CurrentInstanceId
+ } else {
+ $tempInstanceObject = Get-EC2InstancesByHostname -Servers $ComputerName -ProfileName $ProfileName
+ $InstanceId = $tempInstanceObject.InstanceId
+ }
+ }
+
+ if ([string]::IsNullOrEmpty($InstanceId)) {
+ Write-Error "$logLead : InstanceId not found"
+ throw
+ }
+
+ Import-AWSModule
+ $tag = New-Object Amazon.EC2.Model.Tag -Property @{ Key = "$Key"; Value = "$Value" }
+
+ try {
+ if ([string]::IsNullOrEmpty($ProfileName)) {
+ New-EC2Tag -Resource $InstanceId -Tag $tag -Region $Region
+ } else {
+ New-EC2Tag -Resource $InstanceId -Tag $tag -ProfileName $ProfileName -Region $Region
+ }
+ $success = $true
+ } catch {
+ $tempException = $_
+ Write-Warning "Could not set New EC2 Tag on Instance ID [$InstanceId]"
+ Write-Warning $tempException.Exception.Message
+ $success = $false
+ }
+ return $success
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Public/Set-LoadBalancerState.ps1 b/Modules/Alkami.DevOps.Operations/Public/Set-LoadBalancerState.ps1
new file mode 100644
index 0000000..9ae723b
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Set-LoadBalancerState.ps1
@@ -0,0 +1,126 @@
+function Set-LoadBalancerState {
+ <#
+ .SYNOPSIS
+ Handles taking the server in/out of the load balancer appropriate to the type of server.
+ Supports Web/App servers. Mic/Fab servers are no-ops.
+
+ .PARAMETER Server
+ The string to get the server type string from.
+
+ .PARAMETER DesiredState
+ The load balancer state to put the server in. Up or Down.
+
+ .PARAMETER AwsProfileName
+ The AWS Profile the Server is in.
+
+ .PARAMETER AwsRegion
+ The AWS Region the Server is in.
+ #>
+ [CmdletBinding()]
+ [OutputType([System.String])]
+ param(
+ [Parameter(Mandatory = $true)]
+ [string]$Server,
+ [Parameter(Mandatory = $true)]
+ [ValidateSet("down", "up")]
+ [string]$DesiredState,
+ [Parameter(Mandatory = $false)]
+ [string]$AwsProfileName,
+ [Parameter(Mandatory = $false)]
+ [string]$AwsRegion,
+ [Parameter(Mandatory = $false)]
+ [switch]$Force
+ )
+
+ $logLead = (Get-LogLeadName)
+
+ $arguments = @{
+ desiredState = $DesiredState
+ hostname = $Server
+ AwsProfileName = $AwsProfileName
+ AwsRegion = $AwsRegion
+ force = $Force
+ }
+ # Get the string type of the server.
+ $serverType = Get-ServerTypeByHostname -ComputerName $Server
+
+ # Early out if it's a mic or fab server. These don't use load balancers.
+ if (($serverType -eq "Mic") -or ($serverType -eq "Fab")) {
+ Write-Host "$logLead : $serverType servers do not use a load balancer. Exiting."
+ return
+ }
+
+ # Throw if this is an unsupported/unknown server type. If it's not a Web or App.
+ if (!(($serverType -eq "Web") -or ($serverType -eq "App"))) {
+ throw "$logLead : Setting a load balancer on server type $serverType for host $Server is unsupported."
+ }
+
+ # Determine what type of load balancer the environment uses.
+ # Only Staging/Production web servers use nginx, and everything else is AWS load balancers.
+ $environmentType = Get-AppSetting -Key "Environment.Type" -ComputerName $Server
+
+ # If we can't get the Environment type, then either the server is off, it doesn't exist, or it's misconfigured.
+ # In any case, we should remove it from the LB.
+ if ($null -eq $environmentType) {
+ Write-Warning "$logLead : Could not retrieve environment type. Removing $Server from any potential loadbalancers."
+
+ # If this is a web server in Prod/Staging, it's handled by Nginx. We should attempt to remove it from the LB there first.
+ try {
+ $arguments["desiredState"] = "down"
+ Write-Host "$loglead : Attempting to set Nginx to Down"
+ $setNginxHostStateResult = Set-NginxHostState @arguments
+ } catch {
+ # If the Nginx call failed, then we try AWS load balancers.
+ try {
+ Write-Warning "$logLead : Changing nginx state of host $Server failed."
+ Write-Host "$logLead : Attempting to remove from AWS."
+ Set-ASInstanceState -Standby -ComputerName $Server -ProfileName $AwsProfileName -Region $AwsRegion
+ } catch {
+ Write-Warning "$logLead : Could not remove $Server from either load balancer. Check that the server exists in a load balancer."
+ Write-Warning "$($_.Exception.Message)"
+ return "Fail"
+ }
+ }
+ } else {
+ $useNginx = $serverType -eq "Web" -and (($environmentType -eq "Staging") -or ($environmentType -eq "Production"))
+ $loadBalancerType = if ($useNginx) { "NGINX" } else { "AWS" }
+ Write-Host "$logLead : Determined that server uses an $loadBalancerType load balancer."
+
+ # Write friendly state change message.
+ if ($arguments.desiredState -eq "down") {
+ Write-Host "$logLead : Removing server $Server from the load balancer."
+ } else {
+ Write-Host "$logLead : Adding server $Server to the load balancer."
+ }
+
+ if ($useNginx) {
+ # Handle nginx load balancers.
+ $setNginxHostStateResult = Set-NginxHostState @arguments
+ if ($setNginxHostStateResult -eq "Fail") {
+ Write-Warning "$logLead : Changing nginx state of host $Server failed."
+ return "Fail"
+ }
+ } else {
+ try {
+ # Handle AWS load balancers.
+ # Do not pass down a profile name / region because the commands are run from the remote machine.
+ if ($arguments.desiredState -eq "down") {
+ Set-ASInstanceState -Standby -ComputerName $Server -ProfileName $AwsProfileName -Region $AwsRegion
+ } else {
+ Set-ASInstanceState -Active -ComputerName $Server -ProfileName $AwsProfileName -Region $AwsRegion
+ }
+ } catch {
+ Write-Warning "$logLead : Changing aws LB state of host $Server failed."
+ Write-Warning "$($_.Exception.Message)"
+ return "Fail"
+ }
+ }
+
+ # Write friendly completion message.
+ if ($arguments.desiredState -eq "down") {
+ Write-Host "$logLead : $Server successfully removed from the load balancer."
+ } else {
+ Write-Host "$logLead : $Server successfully added to the load balancer."
+ }
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Public/Set-LoadBalancerState.tests.ps1 b/Modules/Alkami.DevOps.Operations/Public/Set-LoadBalancerState.tests.ps1
new file mode 100644
index 0000000..aa26e2e
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Set-LoadBalancerState.tests.ps1
@@ -0,0 +1,316 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ""
+
+Describe "Set-LoadBalancerState" {
+ Mock Get-LogLeadName -ModuleName $moduleForMock
+ Mock Write-Host -ModuleName $moduleForMock
+ Mock Write-Warning -ModuleName $moduleForMock
+ Mock Set-NginxHostState -ModuleName $moduleForMock
+ Mock Set-ASInstanceState -ModuleName $moduleForMock
+ Mock Write-Error -ModuleName $moduleForMock
+
+ $paramList = @{
+ desiredState = $DesiredState
+ hostname = $Server
+ AwsProfileName = $AwsProfileName
+ AwsRegion = $AwsRegion
+ force = $Force
+ }
+ Context "Early returns" {
+ It "returns early with wrong server type: mic" {
+ Set-LoadBalancerState -Server "mic666.notfh.remote" -DesiredState "up" | Should -Be $null
+ }
+ It "DesiredState down, returns early with wrong server type: mic" {
+ Set-LoadBalancerState -Server "mic666.notfh.remote" -DesiredState "down" | Should -Be $null
+ }
+ It "returns early with wrong server type: fab" {
+ Set-LoadBalancerState -Server "fab666.notfh.remote" -DesiredState "up" | Should -Be $null
+ }
+ It "DesiredState down, returns early with wrong server type: fab" {
+ Set-LoadBalancerState -Server "fab666.notfh.remote" -DesiredState "down" | Should -Be $null
+ }
+ It "not a valid server type" {
+ { Set-LoadBalancerState -Server "bork666.notfh.remote" -DesiredState "up" } | Should -Throw
+ }
+ It "DesiredState down, not a valid server type" {
+ { Set-LoadBalancerState -Server "bork666.notfh.remote" -DesiredState "down" } | Should -Throw
+ }
+ }
+ Context "null Env.type" {
+ $paramList = @{
+ desiredState = "up"
+ hostname = "web666.notfh.remote"
+ AwsProfileName = $null
+ AwsRegion = $null
+ force = $false
+ }
+ Mock Get-AppSetting { return $null } -ModuleName $moduleForMock
+
+ It "warns user that server is being pulled from LBs" {
+ Set-LoadBalancerState -Server $paramList.hostname -DesiredState $paramList.desiredState
+ Assert-MockCalled Write-Warning -ModuleName $moduleForMock -ParameterFilter { $Message -eq " : Could not retrieve environment type. Removing $($paramList.hostname) from any potential loadbalancers." }
+ }
+ It "nginx called with down params, even if Set-LBS called with UP" {
+ Set-LoadBalancerState -Server $paramList.hostname -DesiredState $paramList.desiredState
+ Assert-MockCalled Set-NginxHostState -ModuleName $moduleForMock -ParameterFilter { $desiredState -eq "down" }
+ }
+ It "nginx called with down params, even if Set-LBS not called with UP" {
+ Set-LoadBalancerState -Server $paramList.hostname -DesiredState $paramList.desiredState
+ Assert-MockCalled Set-NginxHostState -ModuleName $moduleForMock -Times 0 -ParameterFilter { $desiredState -eq "up" }
+ }
+ It "Set-ASIS called with standby params, even if Set-LBS called with UP" {
+ Mock Set-NginxHostState { throw } -ModuleName $moduleForMock
+ Set-LoadBalancerState -Server $paramList.hostname -DesiredState $paramList.desiredState
+ Assert-MockCalled Set-ASInstanceState -ModuleName $moduleForMock -ParameterFilter { $Standby.IsPresent -eq $true -and $computername -eq $paramList.hostname }
+ }
+ It "Set-ASIS not called with active params, even if Set-LBS called with UP" {
+ Mock Set-NginxHostState { throw } -ModuleName $moduleForMock
+ Set-LoadBalancerState -Server $paramList.hostname -DesiredState $paramList.desiredState
+ Assert-MockCalled Set-ASInstanceState -ModuleName $moduleForMock -Times 0 -ParameterFilter { $Actve.IsPresent -eq $false -and $computername -eq $paramList.hostname }
+ }
+ It "IF Set-ASIS throws, we return 'Fail'" {
+ Mock Set-NginxHostState { throw } -ModuleName $moduleForMock
+ Mock Set-ASInstanceState { throw } -ModuleName $moduleForMock
+ Set-LoadBalancerState -Server $paramList.hostname -DesiredState $paramList.desiredState | should -be "Fail"
+ }
+ }
+ Context "staging nginx code path" {
+ $paramList = @{
+ desiredState = "up"
+ hostname = "web666.notfh.remote"
+ AwsProfileName = $null
+ AwsRegion = $null
+ force = $false
+ }
+ Mock Get-AppSetting { return "Staging" } -ModuleName $moduleForMock
+
+ It "Set-NHS called with up params,if Set-LBS called with UP" {
+ Set-LoadBalancerState -Server $paramList.hostname -DesiredState $paramList.desiredState
+ Assert-MockCalled Set-NginxHostState -ModuleName $moduleForMock -ParameterFilter { $desiredState -eq "up" }
+ }
+ It "if Set-LBS called with UP, Set-NHS not called with down params" {
+ Set-LoadBalancerState -Server $paramList.hostname -DesiredState $paramList.desiredState
+ Assert-MockCalled Set-NginxHostState -ModuleName $moduleForMock -Times 0 -ParameterFilter { $desiredState -eq "down" }
+ }
+ It "Set-NHS returns 'Fail' Set-LBS also returns 'Fail'" {
+ Mock Set-NginxHostState { return "Fail" } -ModuleName $moduleForMock
+ Set-LoadBalancerState -Server $paramList.hostname -DesiredState $paramList.desiredState | should -be "Fail"
+ }
+ }
+ Context "Production nginx code path" {
+ $paramList = @{
+ desiredState = "up"
+ hostname = "web666.notfh.remote"
+ AwsProfileName = $null
+ AwsRegion = $null
+ force = $false
+ }
+ Mock Get-AppSetting { return "Production" } -ModuleName $moduleForMock
+
+ It "Set-NHS called with up params,if Set-LBS called with UP" {
+ Set-LoadBalancerState -Server $paramList.hostname -DesiredState $paramList.desiredState
+ Assert-MockCalled Set-NginxHostState -ModuleName $moduleForMock -ParameterFilter { $desiredState -eq "up" }
+ }
+ It "Set-NHS not called with down params, if Set-LBS called with UP" {
+ Set-LoadBalancerState -Server $paramList.hostname -DesiredState $paramList.desiredState
+ Assert-MockCalled Set-NginxHostState -ModuleName $moduleForMock -Times 0 -ParameterFilter { $desiredState -eq "down" }
+ }
+ It "Set-NHS returns 'Fail' Set-LBS also returns 'Fail'" {
+ Mock Set-NginxHostState { return "Fail" } -ModuleName $moduleForMock
+ Set-LoadBalancerState -Server $paramList.hostname -DesiredState $paramList.desiredState | should -be "Fail"
+ }
+ }
+ Context "staging aws lb code path" {
+ BeforeEach{
+ $paramList = @{
+ desiredState = $null
+ hostname = "app666.notfh.remote"
+ AwsProfileName = $null
+ AwsRegion = $null
+ force = $false
+ }
+ }
+
+ Mock Get-AppSetting { return "Staging" }
+
+ It "if Set-LBS called up, Set-ASIS called Active" {
+ $paramList.desiredState = "up"
+ Set-LoadBalancerState -Server $paramList.hostname -DesiredState $paramList.desiredState
+ Assert-MockCalled Set-ASInstanceState -ModuleName $moduleForMock -ParameterFilter { $Active.IsPresent -eq $true -and $ComputerName -eq $paramList.hostname }
+ }
+ It "if Set-LBS called up, Set-ASIS does not call standy" {
+ $paramList.desiredState = "up"
+ Set-LoadBalancerState -Server $paramList.hostname -DesiredState $paramList.desiredState
+ Assert-MockCalled Set-ASInstanceState -ModuleName $moduleForMock -Times 0 -ParameterFilter { $standby.IsPresent -eq $false -and $ComputerName -eq $paramList.hostname }
+ }
+ It "if Set-LBS called down, Set-ASIS called Standby" {
+ $paramList.desiredState = "down"
+ Set-LoadBalancerState -Server $paramList.hostname -DesiredState $paramList.desiredState
+ Assert-MockCalled Set-ASInstanceState -ModuleName $moduleForMock -ParameterFilter { $Standby.IsPresent -eq $true -and $ComputerName -eq $paramList.hostname }
+ }
+ It "if Set-LBS called down, Set-ASIS does not call active" {
+ $paramList.desiredState = "down"
+ Set-LoadBalancerState -Server $paramList.hostname -DesiredState $paramList.desiredState
+ Assert-MockCalled Set-ASInstanceState -ModuleName $moduleForMock -Times 0 -ParameterFilter { $Active.IsPresent -eq $false -and $ComputerName -eq $paramList.hostname }
+ }
+ It "if Set-LBS called up, Set-ASIS called Active, with aws profile arg check" {
+ $paramList.AwsProfileName = "expectedAWSProfileName"
+ $paramList.AwsRegion = "expectedAWSRegion"
+ $paramList.desiredState = "up"
+ Set-LoadBalancerState -Server $paramList.hostname -DesiredState $paramList.desiredState -AwsProfileName $paramList.AwsProfileName
+ Assert-MockCalled Set-ASInstanceState -ModuleName $moduleForMock -ParameterFilter { $Active.IsPresent -eq $true -and $ComputerName -eq $paramList.hostname -and $ProfileName -eq $paramList.AwsProfileName }
+ }
+ It "if Set-LBS called down, Set-ASIS called Standby, with aws profile arg check" {
+ $paramList.desiredState = "down"
+ $paramList.AwsProfileName = "expectedAWSProfileName"
+ Set-LoadBalancerState -Server $paramList.hostname -DesiredState $paramList.desiredState -AwsProfileName $paramList.AwsProfileName
+ Assert-MockCalled Set-ASInstanceState -ModuleName $moduleForMock -ParameterFilter { $Standby.IsPresent -eq $true -and $ComputerName -eq $paramList.hostname -and $ProfileName -eq $paramList.AwsProfileName }
+ }
+ It "if Set-LBS called up, Set-ASIS called Active, with aws region arg check" {
+ $paramList.AwsRegion = "expectedAWSRegion"
+ $paramList.AwsProfileName = "expectedAWSProfileName"
+ $paramList.desiredState = "up"
+ Set-LoadBalancerState -Server $paramList.hostname -DesiredState $paramList.desiredState -AwsRegion $paramList.AwsRegion
+ Assert-MockCalled Set-ASInstanceState -ModuleName $moduleForMock -ParameterFilter { $Active.IsPresent -eq $true -and $ComputerName -eq $paramList.hostname -and $Region -eq $paramList.AwsRegion }
+ }
+ It "if Set-LBS called down, Set-ASIS called Standby, with aws region arg check" {
+ $paramList.desiredState = "down"
+ $paramList.AwsRegion = "expectedAWSRegion"
+ Set-LoadBalancerState -Server $paramList.hostname -DesiredState $paramList.desiredState -AwsRegion $paramList.AwsRegion
+ Assert-MockCalled Set-ASInstanceState -ModuleName $moduleForMock -ParameterFilter { $Standby.IsPresent -eq $true -and $ComputerName -eq $paramList.hostname -and $Region -eq $paramList.AwsRegion }
+ }
+ It "if Set-LBS called up, Set-ASIS called Active, with aws region and profile arg check" {
+ $paramList.AwsRegion = "expectedAWSRegion"
+ $paramList.AwsProfileName = "expectedAWSProfileName"
+ $paramList.desiredState = "up"
+ Set-LoadBalancerState -Server $paramList.hostname -DesiredState $paramList.desiredState -AwsRegion $paramList.AwsRegion -AwsProfileName $paramList.AwsProfileName
+ Assert-MockCalled Set-ASInstanceState -ModuleName $moduleForMock -ParameterFilter { $Active.IsPresent -eq $true -and $ComputerName -eq $paramList.hostname -and $Region -eq $paramList.AwsRegion -and $ProfileName -eq $paramList.AwsProfileName }
+ }
+ It "if Set-LBS called up, Set-ASIS called Active, with aws region and profile arg check" {
+ $paramList.desiredState = "down"
+ $paramList.AwsRegion = "expectedAWSRegion"
+ $paramList.AwsProfileName = "expectedAWSProfileName"
+ Set-LoadBalancerState -Server $paramList.hostname -DesiredState $paramList.desiredState -AwsRegion $paramList.AwsRegion -AwsProfileName $paramList.AwsProfileName
+ Assert-MockCalled Set-ASInstanceState -ModuleName $moduleForMock -ParameterFilter { $Active.IsPresent -eq $true -and $ComputerName -eq $paramList.hostname -and $Region -eq $paramList.AwsRegion -and $ProfileName -eq $paramList.AwsProfileName }
+ }
+ It "Set-ASIS throws, Set-LBS also returns 'Fail'" {
+ $paramList.desiredState = "up"
+ Mock Set-ASInstanceState { throw } -ModuleName $moduleForMock
+ Set-LoadBalancerState -Server $paramList.hostname -DesiredState $paramList.desiredState | should -be "Fail"
+ }
+ It "Set-ASIS throws, Set-LBS also returns 'Fail'" {
+ $paramList.desiredState = "down"
+ Mock Set-ASInstanceState { throw } -ModuleName $moduleForMock
+ Set-LoadBalancerState -Server $paramList.hostname -DesiredState $paramList.desiredState | should -be "Fail"
+ }
+ It "warns user that server is being pulled from LBs" {
+ $paramList.desiredState = "up"
+ Set-LoadBalancerState -Server $paramList.hostname -DesiredState $paramList.desiredState
+ Assert-MockCalled Write-Warning -ModuleName $moduleForMock -ParameterFilter { $Message -eq " : Changing aws LB state of host $($paramList.hostname) failed." }
+ }
+ It "warns user that server is being pulled from LBs" {
+ $paramList.desiredState = "down"
+ Set-LoadBalancerState -Server $paramList.hostname -DesiredState $paramList.desiredState
+ Assert-MockCalled Write-Warning -ModuleName $moduleForMock -ParameterFilter { $Message -eq " : Changing aws LB state of host $($paramList.hostname) failed." }
+ }
+ }
+ Context "Production aws lb code path" {
+ BeforeEach{
+ $paramList = @{
+ desiredState = $null
+ hostname = "app666.notfh.remote"
+ AwsProfileName = $null
+ AwsRegion = $null
+ force = $false
+ }
+ }
+ Mock Get-AppSetting { return "Production" }
+
+ It "if Set-LBS called up, Set-ASIS called Active" {
+ $paramList.desiredState = "up"
+ Set-LoadBalancerState -Server $paramList.hostname -DesiredState $paramList.desiredState
+ Assert-MockCalled Set-ASInstanceState -ModuleName $moduleForMock -ParameterFilter { $Active.IsPresent -eq $true -and $ComputerName -eq $paramList.hostname }
+ }
+ It "if Set-LBS called up, Set-ASIS does not call standby" {
+ $paramList.desiredState = "up"
+ Set-LoadBalancerState -Server $paramList.hostname -DesiredState $paramList.desiredState
+ Assert-MockCalled Set-ASInstanceState -ModuleName $moduleForMock -Times 0 -ParameterFilter { $standby.IsPresent -eq $false -and $ComputerName -eq $paramList.hostname }
+ }
+ It "if Set-LBS called down, Set-ASIS called Standby" {
+ $paramList.desiredState = "down"
+ Set-LoadBalancerState -Server $paramList.hostname -DesiredState $paramList.desiredState
+ Assert-MockCalled Set-ASInstanceState -ModuleName $moduleForMock -ParameterFilter { $Standby.IsPresent -eq $true -and $ComputerName -eq $paramList.hostname }
+ }
+ It "if Set-LBS called down, Set-ASIS does not call active" {
+ $paramList.desiredState = "down"
+ Set-LoadBalancerState -Server $paramList.hostname -DesiredState $paramList.desiredState
+ Assert-MockCalled Set-ASInstanceState -ModuleName $moduleForMock -Times 0 -ParameterFilter { $Active.IsPresent -eq $false -and $ComputerName -eq $paramList.hostname }
+ }
+ It "if Set-LBS called up, Set-ASIS called Active, with aws profile arg check" {
+ $paramList.AwsProfileName = "expectedAWSProfileName"
+ $paramList.AwsRegion = "expectedAWSRegion"
+ $paramList.desiredState = "up"
+ Set-LoadBalancerState -Server $paramList.hostname -DesiredState $paramList.desiredState -AwsProfileName $paramList.AwsProfileName
+ Assert-MockCalled Set-ASInstanceState -ModuleName $moduleForMock -ParameterFilter { $Active.IsPresent -eq $true -and $ComputerName -eq $paramList.hostname -and $ProfileName -eq $paramList.AwsProfileName }
+ }
+ It "if Set-LBS called down, Set-ASIS called Standby, with aws profile arg check" {
+ $paramList.desiredState = "down"
+ $paramList.AwsProfileName = "expectedAWSProfileName"
+ Set-LoadBalancerState -Server $paramList.hostname -DesiredState $paramList.desiredState -AwsProfileName $paramList.AwsProfileName
+ Assert-MockCalled Set-ASInstanceState -ModuleName $moduleForMock -ParameterFilter { $Standby.IsPresent -eq $true -and $ComputerName -eq $paramList.hostname -and $ProfileName -eq $paramList.AwsProfileName }
+ }
+ It "if Set-LBS called up, Set-ASIS called Active, with aws region arg check" {
+ $paramList.AwsRegion = "expectedAWSRegion"
+ $paramList.AwsProfileName = "expectedAWSProfileName"
+ $paramList.desiredState = "up"
+ Set-LoadBalancerState -Server $paramList.hostname -DesiredState $paramList.desiredState -AwsRegion $paramList.AwsRegion
+ Assert-MockCalled Set-ASInstanceState -ModuleName $moduleForMock -ParameterFilter { $Active.IsPresent -eq $true -and $ComputerName -eq $paramList.hostname -and $Region -eq $paramList.AwsRegion }
+ }
+ It "if Set-LBS called down, Set-ASIS called Standby, with aws region arg check" {
+ $paramList.desiredState = "down"
+ $paramList.AwsRegion = "expectedAWSRegion"
+ Set-LoadBalancerState -Server $paramList.hostname -DesiredState $paramList.desiredState -AwsRegion $paramList.AwsRegion
+ Assert-MockCalled Set-ASInstanceState -ModuleName $moduleForMock -ParameterFilter { $Standby.IsPresent -eq $true -and $ComputerName -eq $paramList.hostname -and $Region -eq $paramList.AwsRegion }
+ }
+ It "if Set-LBS called up, Set-ASIS called Active, with aws region and profile arg check" {
+ $paramList.AwsRegion = "expectedAWSRegion"
+ $paramList.AwsProfileName = "expectedAWSProfileName"
+ $paramList.desiredState = "up"
+ Set-LoadBalancerState -Server $paramList.hostname -DesiredState $paramList.desiredState -AwsRegion $paramList.AwsRegion -AwsProfileName $paramList.AwsProfileName
+ Assert-MockCalled Set-ASInstanceState -ModuleName $moduleForMock -ParameterFilter { $Active.IsPresent -eq $true -and $ComputerName -eq $paramList.hostname -and $Region -eq $paramList.AwsRegion -and $ProfileName -eq $paramList.AwsProfileName }
+ }
+ It "if Set-LBS called up, Set-ASIS called Active, with aws region and profile arg check" {
+ $paramList.desiredState = "down"
+ $paramList.AwsRegion = "expectedAWSRegion"
+ $paramList.AwsProfileName = "expectedAWSProfileName"
+ Set-LoadBalancerState -Server $paramList.hostname -DesiredState $paramList.desiredState -AwsRegion $paramList.AwsRegion -AwsProfileName $paramList.AwsProfileName
+ Assert-MockCalled Set-ASInstanceState -ModuleName $moduleForMock -ParameterFilter { $Active.IsPresent -eq $true -and $ComputerName -eq $paramList.hostname -and $Region -eq $paramList.AwsRegion -and $ProfileName -eq $paramList.AwsProfileName }
+ }
+ It "Set-ASIS throws, Set-LBS also returns 'Fail'" {
+ $paramList.desiredState = "up"
+ Mock Set-ASInstanceState { throw } -ModuleName $moduleForMock
+ Set-LoadBalancerState -Server $paramList.hostname -DesiredState $paramList.desiredState | should -be "Fail"
+ }
+ It "Set-ASIS throws, Set-LBS also returns 'Fail'" {
+ $paramList.desiredState = "down"
+ Mock Set-ASInstanceState { throw } -ModuleName $moduleForMock
+ Set-LoadBalancerState -Server $paramList.hostname -DesiredState $paramList.desiredState | should -be "Fail"
+ }
+ It "warns user that server is being pulled from LBs" {
+ $paramList.desiredState = "up"
+ Set-LoadBalancerState -Server $paramList.hostname -DesiredState $paramList.desiredState
+ Assert-MockCalled Write-Warning -ModuleName $moduleForMock -ParameterFilter { $Message -eq " : Changing aws LB state of host $($paramList.hostname) failed." }
+ }
+ It "warns user that server is being pulled from LBs" {
+ $paramList.desiredState = "down"
+ Set-LoadBalancerState -Server $paramList.hostname -DesiredState $paramList.desiredState
+ Assert-MockCalled Write-Warning -ModuleName $moduleForMock -ParameterFilter { $Message -eq " : Changing aws LB state of host $($paramList.hostname) failed." }
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Public/Set-MicroserviceConfigurationBasedState.ps1 b/Modules/Alkami.DevOps.Operations/Public/Set-MicroserviceConfigurationBasedState.ps1
new file mode 100644
index 0000000..1ec0da8
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Set-MicroserviceConfigurationBasedState.ps1
@@ -0,0 +1,215 @@
+function Set-MicroserviceConfigurationBasedState {
+
+<#
+.SYNOPSIS
+ Sets services to the desired state and starttype based on provider data and user input
+
+.DESCRIPTION
+ Sets services to the desired state and starttype based on provider data and user input. Running and manual when enabled
+ is specified, or stopped and disabled when disable is specified
+
+.PARAMETER ProviderData
+ Provider matching data used to determine services / packages to act upon
+
+.PARAMETER Enable
+ Enables and starts necessary services, when specified
+
+.PARAMETER Disable
+ Disables and stops unnecessary services, when specified
+
+.PARAMETER PrintDetailedResults
+ When specified, prints a detailed accounting of services acted upon
+
+.LINK
+ Disable-UnnecessaryMicroservices
+ Enable-NecessaryMicroservices
+ Get-ProviderMappingFileUnion
+#>
+
+ [CmdletBinding(SupportsShouldProcess)]
+ param(
+ [Parameter(Mandatory = $true)]
+ [object[]]$ProviderData,
+
+ [Parameter(Mandatory=$true, ParameterSetName = "EnableServices")]
+ [switch]$Enable,
+
+ [Parameter(Mandatory=$true, ParameterSetName = "DisableServices")]
+ [switch]$Disable,
+
+ [Parameter(Mandatory = $false)]
+ [switch]$PrintDetailedResults
+ )
+
+ $logLead = Get-LogLeadName
+
+ $notInstalledCount = 0
+ $stateChangedCount = 0
+ $tier0StateChangedCount = 0
+ $invalidCount = 0
+ $statusChangedCount = 0
+ $tier0Services = Get-ServicesByTier -Tier 0
+
+ $serviceParams = if ($Enable.IsPresent) {
+ @{
+ EnableAction = $true
+ DesiredState = "Running";
+ ActionableState = "Stopped";
+ DesiredStateVerb = "Started";
+ DesiredStateActionVerb = "Starting";
+ DesiredStartMode = "Manual";
+ }
+ } else {
+ @{
+ EnableAction = $false
+ DesiredState = "Stopped";
+ ActionableState = "Running";
+ DesiredStateVerb = "Stopped";
+ DesiredStateActionVerb = "Stopping";
+ DesiredStartMode = "Disabled";
+ }
+ }
+
+ # Choco list local packages. Exit early if not installed for speed purposes
+ # Saves a bunch of no-op service lookups by choco package name, which is slow
+ Write-Host "$logLead : Pulling local chocolatey packages"
+ $localChocos = (Get-ChocoState -LocalOnly).Name
+
+ # Optional Detailed Output Driven by Switch
+ [PSObject[]]$detailedResults = @()
+
+ # If any services fail to stop, we'll print them
+ [string[]]$serviceStateChangeFailures = @()
+
+ # Track service count for tracking during execution
+ $serviceIndex = 1
+ $totalProviderCount = $ProviderData.Count
+
+ # Iterate over each unmatched provider and lookup the service, disable the service, and stop the service
+ foreach ($providerRecord in $ProviderData) {
+ $actionTakenOnService = $false
+ $chocoPackageName = $providerRecord.PackageId
+ Write-Host "$logLead : [$serviceIndex / $totalProviderCount] : Looking for choco package $chocoPackageName"
+
+ if ($localChocos -notcontains $chocoPackageName) {
+ Write-Host "$logLead : [$serviceIndex / $totalProviderCount] : Chocolatey package $chocoPackageName is not installed locally. Skipping service lookup."
+ $notInstalledCount++
+ $serviceIndex++
+ continue
+ }
+
+ Write-Host "$logLead : [$serviceIndex / $totalProviderCount] : Looking for a service for choco package $chocoPackageName"
+ $svc = Get-ServiceByChocoName -ChocolateyName $chocoPackageName -IncludeDisabled -WarningAction SilentlyContinue
+
+ if ($null -eq $svc) {
+ Write-Warning "$logLead : [$serviceIndex / $totalProviderCount] : Unable to find a matching service for package $chocoPackageName"
+ $invalidCount++
+ $serviceIndex++
+ continue
+ }
+
+ # Tier 0 is hard coded to Automatic - Delayed -- SRE-17490
+ if($tier0Services -Contains $chocoPackageName -and $serviceParams.DesiredStartMode -ne "Disabled") {
+ # Due to some shenanigans around Get-Service not properly returning information about Automatic vs Automatic-delayed, we are ALWAYS setting these tier 0 services to Automatic-delayed
+ # If this becomes an issue, we'll complicate Get-ServiceByChocoName, or do some complicated lookups here.
+ Write-Host ("$logLead : Service '{0}' is Tier0. Setting to Automatic-Delayed." -f $service.Name)
+
+ $params = @("config", $service.Name, "start=delayed-auto")
+ Invoke-SCExe $params
+
+ $tier0StateChangedCount++
+ $actionTakenOnService = $true
+ } else {
+ if ($svc.StartMode -ne $serviceParams.DesiredStartMode) {
+ Write-Host "$logLead : [$serviceIndex / $totalProviderCount] : Setting Service [$($svc.Name)] to $($serviceParams.DesiredStartMode)"
+ Set-Service -Name $svc.Name -StartupType $serviceParams.DesiredStartMode -WhatIf:$WhatIfPreference
+ $stateChangedCount++
+ $actionTakenOnService = $true
+ } else {
+ Write-Host "$logLead : [$serviceIndex / $totalProviderCount] : Service [$($svc.Name)] already has start mode set to $($serviceParams.DesiredStartMode)"
+ }
+ }
+
+ if ($svc.State -eq $serviceParams.ActionableState) {
+ $actionTakenOnService = $true
+ Write-Host "$logLead : [$serviceIndex / $totalProviderCount] : $($serviceParams.DesiredStateActionVerb) service [$($svc.Name)]"
+
+ if (-NOT $serviceParams.EnableAction) {
+ $actionResult = Stop-AlkamiService -ServiceName $svc.Name -WhatIf:$WhatIfPreference
+ } else {
+ # We should add SupportsShouldProcess but it doesn't have CmdletBinding. Riskier change, not including
+ # So here is a poor man's WhatIf. We can clean this up later
+ # TODO
+ # PSCmdlet.ShouldProcess is still valid, because this function SupportsShouldProcess
+ if ($PSCmdlet.ShouldProcess($svc.Name, "Start-AlkamiService")) {
+
+ $actionResult = Start-AlkamiService -ServiceName $svc.Name
+
+ } else {
+
+ Write-Host "$logLead : Calling Start Alkami Service for [$($svc.Name)] (WhatIf)"
+ $actionResult = $true
+ }
+ }
+
+ if ($true -eq $actionResult) {
+ $statusChangedCount++
+ } else {
+ $serviceStateChangeFailures += $svc.Name
+ }
+ } else {
+ Write-Host "$logLead : [$serviceIndex / $totalProviderCount] : Service [$($svc.Name)] is not $($serviceParams.ActionableState) and cannot be set to $($serviceParams.DesiredState)"
+ }
+
+ if ($PrintDetailedResults.IsPresent -and $actionTakenOnService) {
+
+ $finalServiceRecord = Get-Service -Name $svc.Name
+ $detailedResults += New-Object PSObject -Property @{
+
+ ProviderName = $providerRecord.ProviderName
+ PackageName = $chocoPackageName
+ ServiceName = $svc.Name
+ OriginalServiceState = $svc.State
+ FinalServiceState = $finalServiceRecord.Status
+ OriginalServiceStartMode = $svc.StartMode
+ FinalServiceStartMode = $finalServiceRecord.StartType
+ }
+ }
+
+ $serviceIndex++
+ }
+
+ Write-Host "$logLead : Operation complete"
+
+ if ($WhatIfPreference) {
+ Write-Warning "$logLead : WhatIf Specified - No Action Taken"
+ }
+
+ Write-Host "`t[$notInstalledCount] Packages Not Installed Locally"
+ Write-Host "`t[$invalidCount] Invalid Services"
+ Write-Host "`t[$stateChangedCount] Services Set to $($serviceParams.DesiredStartMode)"
+ Write-Host "`t[$tier0StateChangedCount] Tier 0 Services Set to Automatic-Delayed Start"
+
+ if ($stateChangedCount -eq $statusChangedCount) {
+ Write-Host "`t[$statusChangedCount] Services $($serviceParams.DesiredStateVerb)"
+ } else {
+ Write-Host "`t[$statusChangedCount] Services $($serviceParams.DesiredStateVerb) (Variance Detected - Research Required)" -ForegroundColor Yellow
+ }
+
+ if (-NOT (Test-IsCollectionNullOrEmpty -Collection $serviceStateChangeFailures)) {
+ Write-Host "`n"
+ Write-Warning "The below services failed to change state:"
+ [array]$serviceStateChangeFailures | ForEach-Object { Write-Host "`t$_" }
+ }
+
+ if ($PrintDetailedResults.IsPresent) {
+ Write-Host "`nDetailed Results:"
+ foreach ($detailedResult in $detailedResults) {
+
+ Write-Host "`t$($detailedResult.ProviderName) || $($detailedResult.PackageName)"
+ Write-Host "`tService: $($detailedResult.ServiceName)"
+ Write-Host "`tService Start Mode: $($detailedResult.OriginalServiceStartMode) => $($detailedResult.FinalServiceStartMode)"
+ Write-Host "`tService State: $($detailedResult.OriginalServiceState) => $($detailedResult.FinalServiceState)`n"
+ }
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Public/Set-MicroserviceConfigurationBasedState.tests.ps1 b/Modules/Alkami.DevOps.Operations/Public/Set-MicroserviceConfigurationBasedState.tests.ps1
new file mode 100644
index 0000000..7ea8fcc
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Set-MicroserviceConfigurationBasedState.tests.ps1
@@ -0,0 +1,290 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ""
+
+Describe "Set-MicroserviceConfigurationBasedState" {
+
+ Mock -CommandName Get-LogLeadName -ModuleName $moduleForMock -MockWith { "[$sut (Pester)]" }
+ Mock -CommandName Stop-AlkamiService -ModuleName $moduleForMock -MockWith { return $true }
+ Mock -CommandName Start-AlkamiService -ModuleName $moduleForMock -MockWith { return $true }
+ Mock -CommandName Get-ServiceByChocoName -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Get-Service -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Set-Service -ModuleName $moduleForMock -MockWith {}
+
+#region $ProviderData Arragnement
+
+ $unmatchedProviderA = New-Object PSObject -Property @{
+ PackageId = "Pay2Win.Service.Host";
+ ProviderName = "Pokemon Go Provider";
+ ProviderType = "GottaCatchEmAll";
+ }
+
+ $unmatchedProviderB = New-Object PSObject -Property @{
+ PackageId = "Unbearable.Temperatures";
+ ProviderName = "HeatWaveProvider";
+ ProviderType = "Texas";
+ }
+
+ $matchedProvderA = New-Object PSObject -Property @{
+ PackageId = "SupaFakeChocoPackage";
+ ProviderName = "Spectre/Meltdown CPU Thrashing Provider";
+ ProviderType = "Malware";
+ MatchCount = 1
+ }
+
+ $matchedProviderB = New-Object PSObject -Property @{
+ PackageId = "Team.Fortress.2";
+ ProviderName = "Scout is OP Provider";
+ ProviderType = "Also Malware"
+ MatchCount = 2;
+ }
+
+ $matchTestData = @( $matchedProvderA, $matchedProviderB)
+ $unmatchTestData = @( $unmatchedProviderA, $unmatchedProviderB)
+
+#endregion $ProviderData Arragnement
+
+ Context "Enable Path" {
+
+ It "Exits Early if the Package Is Not Installed" {
+
+ Mock -CommandName Get-ChocoState -ModuleName $moduleForMock -MockWith {}
+ Set-MicroserviceConfigurationBasedState -ProviderData $matchTestData -Enable
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Set-Service -Times 0 -Exactly -Scope It
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Stop-AlkamiService -Times 0 -Exactly -Scope It
+ }
+
+ Mock -CommandName Get-ChocoState -ModuleName $moduleForMock -MockWith {
+
+ return @(
+ @{
+ Name = "SupaFakeChocoPackage";
+ Version = "1.0.0";
+ },
+ @{
+ Name = "Team.Fortress.2";
+ VErsion = "2.9.9";
+ }
+ )
+ }
+
+ It "Exits Early and Writes a Warning if the Package is Installed but Service Does Not Exist" {
+
+ Mock -CommandName Write-Warning -ModuleName $moduleForMock -MockWith { }
+
+ Set-MicroserviceConfigurationBasedState -ProviderData $matchTestData -Enable
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Set-Service -Times 0 -Exactly -Scope It
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Stop-AlkamiService -Times 0 -Exactly -Scope It
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Get-ServiceByChocoName -Times 1 -Exactly -Scope It -ParameterFilter {
+ $ChocolateyName -eq "SupaFakeChocoPackage"
+ }
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Get-ServiceByChocoName -Times 1 -Exactly -Scope It -ParameterFilter {
+ $ChocolateyName -eq "Team.Fortress.2"
+ }
+
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Write-Warning -Times 2 -Exactly -Scope It `
+ -ParameterFilter { $Message -Match "Unable to find a matching service for" }
+ }
+
+ Mock -CommandName Get-ServiceByChocoName -ModuleName $moduleForMock -ParameterFilter {$ChocolateyName -eq "SupaFakeChocoPackage"} -MockWith {
+
+ return New-Object PSObject -Property @{
+ ProcessId = 1;
+ Name = "Alkami.CPU.Thrasher.Service.Host";
+ StartMode = "Disabled";
+ State = "Stopped";
+ Status = "OK";
+ ExitCode = "999";
+ }
+ }
+
+ Mock -CommandName Get-ServiceByChocoName -ModuleName $moduleForMock -ParameterFilter {$ChocolateyName -eq "Team.Fortress.2"} -MockWith {
+
+ return New-Object PSObject -Property @{
+ ProcessId = 90;
+ Name = "Alkami.Heavy.Is.Trash.Service.Host";
+ StartMode = "Manual";
+ State = "Running";
+ Status = "OK";
+ ExitCode = "555";
+ }
+ }
+
+ It "Only Sets Service StartMode if the Service is Not Already Enabled" {
+
+ Set-MicroserviceConfigurationBasedState -ProviderData $matchTestData -Enable
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Get-ServiceByChocoName -Times 2 -Exactly -Scope It
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Set-Service -Times 1 -Exactly -Scope It
+ }
+
+ It "Only Sets Service State if the Service is Not Already Running" {
+
+ Set-MicroserviceConfigurationBasedState -ProviderData $matchTestData -Enable
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Get-ServiceByChocoName -Times 2 -Exactly -Scope It
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Start-AlkamiService -Times 1 -Exactly -Scope It
+ }
+
+ It "Writes a Warning if there is a Variance between Enabled and Started Services" {
+
+ Mock -CommandName Start-AlkamiService -ModuleName $moduleForMock -MockWith { return $false }
+ Mock -CommandName Write-Warning -ModuleName $moduleForMock -MockWith { }
+
+ Set-MicroserviceConfigurationBasedState -ProviderData $matchTestData -Enable
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Write-Warning -Times 1 -Exactly -Scope It `
+ -ParameterFilter { $Message -Match "The below services failed to change state:" }
+ }
+ }
+
+ Context "Disable Path" {
+
+ It "Exits Early if the Package Is Not Installed" {
+
+ Mock -CommandName Get-ChocoState -ModuleName $moduleForMock -MockWith {}
+ Set-MicroserviceConfigurationBasedState -ProviderData $unmatchTestData -Disable
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Set-Service -Times 0 -Exactly -Scope It
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Stop-AlkamiService -Times 0 -Exactly -Scope It
+ }
+
+ Mock -CommandName Get-ChocoState -ModuleName $moduleForMock -MockWith {
+
+ return @(
+ @{
+ Name = "Unbearable.Temperatures";
+ Version = "1.0.0";
+ },
+ @{
+ Name = "Pay2Win.Service.Host";
+ VErsion = "2.9.9";
+ }
+ )
+ }
+
+ It "Exits Early and Writes a Warning if the Package is Installed but Service Does Not Exist" {
+
+ Mock -CommandName Write-Warning -ModuleName $moduleForMock -MockWith { }
+
+ Set-MicroserviceConfigurationBasedState -ProviderData $unmatchTestData -Disable
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Set-Service -Times 0 -Exactly -Scope It
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Stop-AlkamiService -Times 0 -Exactly -Scope It
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Get-ServiceByChocoName -Times 2 -Exactly -Scope It
+
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Write-Warning -Times 2 -Exactly -Scope It `
+ -ParameterFilter { $Message -Match "Unable to find a matching service for" }
+ }
+
+ Mock -CommandName Get-ServiceByChocoName -ModuleName $moduleForMock -ParameterFilter {$ChocolateyName -eq "Pay2Win.Service.Host"} -MockWith {
+
+ return New-Object PSObject -Property @{
+ ProcessId = 9000;
+ Name = "Pokemon Go Griftery Store MicroService";
+ StartMode = "Disabled";
+ State = "Stopped";
+ Status = "OK";
+ ExitCode = "999";
+ }
+ }
+
+ Mock -CommandName Get-ServiceByChocoName -ModuleName $moduleForMock -ParameterFilter {$ChocolateyName -eq "Unbearable.Temperatures"} -MockWith {
+
+ return New-Object PSObject -Property @{
+ ProcessId = 456;
+ Name = "Break.Yo.Thermometer.Service.Host";
+ StartMode = "Manual";
+ State = "Running";
+ Status = "OK";
+ ExitCode = "555";
+ }
+ }
+
+ It "Only Sets Service StartMode if the Service is Not Already Disabled" {
+
+ Set-MicroserviceConfigurationBasedState -ProviderData $unmatchTestData -Disable
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Get-ServiceByChocoName -Times 2 -Exactly -Scope It
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Set-Service -Times 1 -Exactly -Scope It
+ }
+
+ It "Only Stops a Service if the Service is Not Already Stopped" {
+
+ Set-MicroserviceConfigurationBasedState -ProviderData $unmatchTestData -Disable
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Get-ServiceByChocoName -Times 2 -Exactly -Scope It
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Stop-AlkamiService -Times 1 -Exactly -Scope It
+ }
+
+ It "Writes a Warning if there is a Variance between Disabled and Stopped Services" {
+
+ Mock -CommandName Stop-AlkamiService -ModuleName $moduleForMock -MockWith { return $false }
+ Mock -CommandName Write-Warning -ModuleName $moduleForMock -MockWith { }
+
+ Set-MicroserviceConfigurationBasedState -ProviderData $unmatchTestData -Disable
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Write-Warning -Times 1 -Exactly -Scope It `
+ -ParameterFilter { $Message -Match "The below services failed to change state" }
+ }
+ }
+
+ Context "Tier 0 Path" {
+ $unmatchTestData = @( $unmatchedProviderA)
+
+ Mock -CommandName Get-ServicesByTier -ModuleName $moduleForMock -MockWith{
+ return @(
+ "Pay2Win.Service.Host"
+ )
+ }
+
+ Mock -CommandName Get-ChocoState -ModuleName $moduleForMock -MockWith {
+ return @(
+ @{
+ Name = "Pay2Win.Service.Host";
+ Version = "2.9.9";
+ }
+ )
+ }
+
+ Mock -CommandName Invoke-SCExe -ModuleName $moduleForMock -MockWith {
+ }
+
+ Mock -CommandName Get-ServiceByChocoName -ModuleName $moduleForMock -ParameterFilter {$ChocolateyName -eq "Pay2Win.Service.Host"} -MockWith {
+ return New-Object PSObject -Property @{
+ ProcessId = 9000;
+ Name = "Pokemon Go Griftery Store MicroService";
+ StartMode = "Manual";
+ State = "Running";
+ Status = "OK";
+ ExitCode = "999";
+ }
+ }
+
+ It "Sets Service StartMode if the Service is Not Already Disabled" {
+ Set-MicroserviceConfigurationBasedState -ProviderData $unmatchTestData -Disable
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Get-ServiceByChocoName -Times 1 -Exactly -Scope It
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Set-Service -Times 1 -Exactly -Scope It
+ }
+
+ It "Sets Service StartMode via Invoke-SCExe if the Service is Not Already Automatic" {
+ Set-MicroserviceConfigurationBasedState -ProviderData $unmatchTestData -Enable
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Get-ServiceByChocoName -Times 1 -Exactly -Scope It
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Set-Service -Times 0 -Exactly -Scope It
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Invoke-SCExe -Times 1 -Exactly -Scope It
+ }
+
+ Mock -CommandName Get-ServiceByChocoName -ModuleName $moduleForMock -ParameterFilter {$ChocolateyName -eq "Pay2Win.Service.Host"} -MockWith {
+ return New-Object PSObject -Property @{
+ ProcessId = 9000;
+ Name = "Pokemon Go Griftery Store MicroService";
+ StartMode = "Disabled";
+ State = "Stopped";
+ Status = "OK";
+ ExitCode = "999";
+ }
+ }
+
+ It "DOES NOT call Set-Service for a Tier 0 service" {
+ Set-MicroserviceConfigurationBasedState -ProviderData $unmatchTestData -Disable
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Get-ServiceByChocoName -Times 1 -Exactly -Scope It
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Set-Service -Times 0 -Exactly -Scope It
+ }
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Public/Set-NagAlerts.ps1 b/Modules/Alkami.DevOps.Operations/Public/Set-NagAlerts.ps1
new file mode 100644
index 0000000..39ec3e4
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Set-NagAlerts.ps1
@@ -0,0 +1,101 @@
+function Set-NagAlerts {
+ <#
+.SYNOPSIS
+Disables or Enables Nag Alerts by Tweaking the Job Whitelist. Restarts Nag afterwards
+
+.DESCRIPTION
+Disable or Enables Nag Alerts by Tweaking the Job Whitelist. Restarts Nag afterwards
+See also: https://confluence.alkami.com/display/SRE/Disable+NAG+Alerts
+
+.PARAMETER nagPath
+The path where Nag is installed. Defaults to (Get-OrbPath)\Nag
+
+.PARAMETER noRestart
+Skips recycling Nag
+
+.PARAMETER enable
+Enables Nag Alerts
+
+.PARAMETER disable
+Disables Nag Alerts
+
+#>
+ [CmdletBinding()]
+ param(
+ [Parameter(ParameterSetName = 'EnableAlertsParameterSet', Mandatory = $false)]
+ [Parameter(ParameterSetName = 'DisableAlertsParameterSet', Mandatory = $false)]
+ [Parameter(Mandatory = $false)]
+ [string]$nagPath,
+
+ [Parameter(ParameterSetName = 'EnableAlertsParameterSet', Mandatory = $false)]
+ [Parameter(ParameterSetName = 'DisableAlertsParameterSet', Mandatory = $false)]
+ [Parameter(Mandatory = $false)]
+ [switch]$noRestart,
+
+ [Parameter(ParameterSetName = 'EnableAlertsParameterSet', Mandatory = $true)]
+ [Parameter(Mandatory = $false)]
+ [Alias("Enabled")]
+ [switch]$enable,
+
+ [Parameter(ParameterSetName = 'DisableAlertsParameterSet', Mandatory = $true)]
+ [Parameter(Mandatory = $false)]
+ [Alias("Disabled")]
+ [switch]$disable
+ )
+
+ $logLead = (Get-LogLeadName);
+
+ if ([string]::IsNullOrEmpty($nagPath)) {
+ $nagPath = (Join-Path(Get-OrbPath) "\Nag")
+ }
+
+ Write-Verbose "$logLead : Using Nag Path $nagPath"
+
+ $nagService = Get-Service "Alkami Nag Service" -ErrorAction SilentlyContinue
+
+ if ($null -eq $nagService -or $nagService.StartMode -eq "Disabled") {
+ Write-Warning "$logLead : The Nag Service is not installed or is disabled on this machine."
+ }
+
+ $nagConfig = (Join-Path $nagPath "Alkami.App.Nag.Host.Service.exe.config")
+
+ if (!(Test-Path $nagConfig)) {
+
+ Write-Warning "$logLead : Could not find the Nag config file at $nagConfig. Execution cannot continue"
+ return
+ }
+
+ # ToDo: When dev changes the app setting key in ORB we'll remove the term WhiteList from this function
+ # Until then, we will match the development code base to avoid any confusion
+ # See: SRE-14133
+ $currentJobWhitelistValue = Get-AppSetting -key 'JobNameWhitelistRegex' -FilePath $nagConfig
+
+ if ($enable.IsPresent) {
+
+ $enabledJobWhitelistValue = ""
+ if ($currentJobWhitelistValue -eq $enabledJobWhitelistValue) {
+
+ Write-Warning "$logLead : App setting already has the correct value. Exiting function"
+ return
+ }
+
+ Set-AppSetting -key 'JobNameWhitelistRegex' -value $enabledJobWhitelistValue -filePath $nagConfig
+
+ } elseif ($disable.IsPresent) {
+
+ $disabledJobWhitelistValue = "(?i)Scheduled.*"
+
+ if ($currentJobWhitelistValue -eq $disabledJobWhitelistValue) {
+
+ Write-Warning "$logLead : App setting already has the correct value. Exiting function"
+ return
+ }
+
+ Set-AppSetting -key 'JobNameWhitelistRegex' -value $disabledJobWhitelistValue -filePath $nagConfig
+ }
+
+ if (!($noRestart.IsPresent)) {
+
+ Restart-Nag
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Public/Set-NagAlerts.tests.ps1 b/Modules/Alkami.DevOps.Operations/Public/Set-NagAlerts.tests.ps1
new file mode 100644
index 0000000..31d08b6
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Set-NagAlerts.tests.ps1
@@ -0,0 +1,126 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ""
+
+function Save-EnabledConfiguration {
+ param(
+ [string]$filePath
+ )
+ @"
+
+
+
+
+
+
+"@ | Out-File $filePath -Force
+}
+
+function Save-DisabledConfiguration {
+ param(
+ [string]$filePath
+ )
+ @"
+
+
+
+
+
+
+"@ | Out-File $filePath -Force
+}
+
+Describe "Set-NagAlerts" {
+
+ # Temp file to write content to
+ $tempFile = [System.IO.Path]::GetTempFileName()
+ $tempPath = $tempFile.Split(".") | Select-Object -First 1
+
+ New-Item -ItemType Directory $tempPath -ErrorAction SilentlyContinue | Out-Null
+ Write-Warning ("Using temp path: $tempPath for tests")
+
+ $tempConfig = (Join-Path $tempPath "Alkami.App.Nag.Host.Service.exe.config")
+
+ Context "Parameter Validation" {
+
+ Write-Warning $tempConfig
+ It "Does Not Run if -Enable or -Disable Are Not Provided" {
+
+ { Set-NagAlerts } | Should -Throw
+ }
+
+ It "Does Not Run if Both -Enable and -Disable Are Provided" {
+
+ { Set-NagAlerts -Enable -Disable } | Should -Throw
+ }
+
+ It "Does Not Restart Nag if the -noRestart Switch is Provided" {
+
+ Mock Restart-Nag -ModuleName $moduleForMock -MockWith {}
+ Mock Get-Service -ModuleName $moduleForMock -MockWith {}
+
+ Save-DisabledConfiguration $tempConfig
+ Set-NagAlerts -Enabled -nagPath $tempPath -noRestart
+ Assert-MockCalled -ModuleName $moduleForMock Restart-Nag -Times 0 -Exactly -Scope It
+ }
+ }
+
+ Context "File Manipulation" {
+
+ It "Disables Alerts When the Disable Switch is Provided and Alerts are Enabled" {
+
+ Mock Restart-Nag -ModuleName $moduleForMock -MockWith {}
+ Mock Get-Service -ModuleName $moduleForMock -MockWith {}
+
+ # Save a config with alerts enabled
+ Save-EnabledConfiguration $tempConfig
+ Set-NagAlerts -Disabled -nagPath $tempPath
+ Get-AppSetting -FilePath $tempConfig -Key "JobNameWhitelistRegex" | Should -Be "(?i)Scheduled.*"
+ Assert-MockCalled -ModuleName $moduleForMock Restart-Nag -Times 1 -Exactly -Scope It
+ }
+
+ It "Enables Alerts When the Enable Switch is Provided and Alerts are Disabled" {
+
+ Mock Restart-Nag -ModuleName $moduleForMock -MockWith {}
+ Mock Get-Service -ModuleName $moduleForMock -MockWith {}
+
+ # Save a config with alerts enabled
+ Save-DisabledConfiguration $tempConfig
+ Set-NagAlerts -Enabled -nagPath $tempPath
+ Get-AppSetting -FilePath $tempConfig -Key "JobNameWhitelistRegex" | Should -Be ""
+ Assert-MockCalled -ModuleName $moduleForMock Restart-Nag -Times 1 -Exactly -Scope It
+ }
+
+ It "Does Not Edit the File or Restart Nag When the Disable Switch is Provided and Alerts are Disabled" {
+
+ Mock Restart-Nag -ModuleName $moduleForMock -MockWith {}
+ Mock Get-Service -ModuleName $moduleForMock -MockWith {}
+ Mock Set-AppSetting -ModuleName $moduleForMock -MockWith { Write-Warning "Mock Called!" }
+
+ # Save a config with alerts enabled
+ Save-DisabledConfiguration $tempConfig
+ Set-NagAlerts -Disabled -nagPath $tempPath
+ Get-AppSetting -FilePath $tempConfig -Key "JobNameWhitelistRegex" | Should -Be "(?i)Scheduled.*"
+ Assert-MockCalled -ModuleName $moduleForMock Set-AppSetting -Times 0 -Exactly -Scope It
+ Assert-MockCalled -ModuleName $moduleForMock Restart-Nag -Times 0 -Exactly -Scope It
+ }
+
+ It "Does Not Edit the File or Restart Nag When the Enable Switch is Provided and Alerts are Enabled" {
+
+ Mock Restart-Nag -ModuleName $moduleForMock -MockWith {}
+ Mock Get-Service -ModuleName $moduleForMock -MockWith {}
+ Mock Set-AppSetting -ModuleName $moduleForMock -MockWith { Write-Warning "Mock Called!" }
+
+ # Save a config with alerts enabled
+ Save-EnabledConfiguration $tempConfig
+ Set-NagAlerts -Enabled -nagPath $tempPath
+ Get-AppSetting -FilePath $tempConfig -Key "JobNameWhitelistRegex" | Should -Be ""
+ Assert-MockCalled -ModuleName $moduleForMock Set-AppSetting -Times 0 -Exactly -Scope It
+ Assert-MockCalled -ModuleName $moduleForMock Restart-Nag -Times 0 -Exactly -Scope It
+ }
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Public/Set-NginxHostState.Tests.ps1 b/Modules/Alkami.DevOps.Operations/Public/Set-NginxHostState.Tests.ps1
new file mode 100644
index 0000000..4d49a71
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Set-NginxHostState.Tests.ps1
@@ -0,0 +1,201 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+#Write-Host "Overriding SUT: $functionPath"
+#Import-Module $functionPath -Force
+$moduleForMock = ""
+$moduleForMock = "Alkami.DevOps.Operations"
+# Handle the AWS Native Functions When AWSPowerShell Not Available
+# Import-AWSModule # SSM
+if ($null -eq (Get-Command "Get-SSMParameter" -ErrorAction SilentlyContinue)) {
+ function Get-SSMParameter {
+
+ throw "This Should Never Be Actually Called"
+ }
+
+}
+Describe "Set-NginxHostState" {
+
+ Mock Get-SSMParameter -ModuleName $moduleForMock {
+ $body = New-Object psobject -Property @{
+ Value = "foo"
+ }
+ return $body
+ }
+
+ Mock Test-IsAws -ModuleName $moduleForMock { return $true }
+
+ Context "Writes error when NGINX instances are not found." {
+
+ Mock Get-NginxIpAddresses -ModuleName $moduleForMock {
+ return $null
+ }
+
+ Mock -CommandName Write-Error -ModuleName $moduleForMock -MockWith {} -Verifiable
+
+ $result = ( Set-NginxHostState -desiredState "up" -targetEnvironment "staging" -hostname "localhost" -ErrorAction "Stop" )
+
+ It "Returns correct result" {
+ $result | Should -be "Fail"
+ }
+
+ It "Wrote an error" {
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Write-Error -Times 1 -Scope Context
+ }
+ }
+
+ Context "Writes error when NGINX is unreachable" {
+
+ Mock Get-NginxIpAddresses -ModuleName $moduleForMock {
+ # need address that will always resolve and is pingable
+ $mockedNginxInstanceIp = (Resolve-DnsName -Name "localhost" -Type A -ErrorAction SilentlyContinue).IpAddress
+ return @( $mockedNginxInstanceIp )
+ }
+
+ Mock -CommandName Write-Error -ModuleName $moduleForMock -MockWith {} -Verifiable
+
+ Mock Test-TcpConnection -ModuleName $moduleForMock {
+ return $false
+ }
+
+ $result = ( Set-NginxHostState -desiredState "up" -targetEnvironment "staging" -hostname "localhost" -ErrorAction "Stop" )
+
+ It "Returns correct result" {
+ $result | Should -be "Fail"
+ }
+
+ It "Wrote an error" {
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Write-Error -Times 1 -Scope Context
+ }
+ }
+
+
+ Context "peer is in up state" {
+
+ Mock Get-NginxIpAddresses -ModuleName $moduleForMock {
+ # need address that will always resolve and is pingable
+ #$mockedNginxInstanceIp = (Resolve-DnsName -Name "localhost" -Type A -ErrorAction SilentlyContinue).IpAddress
+ $mockedNginxInstanceIp = Get-IpAddress
+ return @( $mockedNginxInstanceIp )
+ }
+
+ Mock Test-TcpConnection -ModuleName $moduleForMock {
+ return $true
+ }
+
+ Mock Invoke-RestMethod -ModuleName $moduleForMock -ParameterFilter { $method -eq 'GET' } {
+ $fakeServerIP = Get-IpAddress #(Resolve-DnsName -Name "localhost" -Type A -ErrorAction SilentlyContinue).IpAddress
+ $body = New-Object psobject -Property @{
+ "FakeUpstream.com-ssl" = @{
+ "zone" = "FakeUpstream.com-ssl"
+ "peers" = @{
+ id = 0
+ server = "$($fakeServerIP):443"
+ name = "$($fakeServerIP):443"
+ state = "up"
+ }
+ }
+ }
+ return $body
+ }
+
+ Mock Invoke-RestMethod -ModuleName $moduleForMock -ParameterFilter { $method -eq 'PATCH' } {
+ $fakeServerIP = Get-IpAddress #(Resolve-DnsName -Name "localhost" -Type A -ErrorAction SilentlyContinue).IpAddress
+ $body = New-Object psobject -Property @{
+ id = 0
+ server = "$($fakeServerIP):443"
+ down = "true"
+ weight = 1
+ max_conns = 0
+ max_fails = 100
+ fail_timeout = "10s"
+ slow_start = "0s"
+ route = ""
+ backup = "False"
+ }
+ return $body
+ }
+
+ It "Asserts GET-SSMParameter mock is working ." {
+ $true | Should -BeTrue -Because "THIS TEST SHOULD NOT BE HERE"
+ (Get-NginxAuthHeaders).Authorization | Should -Be (@{Authorization = 'Basic ' + [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes("foo:foo")) }).Authorization
+ }
+
+ It "Asserts Server is already in the desired state and returns NoOp." {
+ Set-NginxHostState -desiredState "up" -targetEnvironment "staging" -hostname "localhost" -ErrorAction "Stop" | Should -be "NoOp"
+ }
+
+ It "Asserts Server is already in the desired state and forces to continue" {
+ Set-NginxHostState -desiredState "up" -targetEnvironment "prod" -hostname "localhost" -force | Should -Be "Success"
+ }
+
+ It "Asserts server is put down" {
+ Set-NginxHostState -desiredState "down" -targetEnvironment "staging" -hostname "localhost" -ErrorAction "Stop" | Should -be "Success"
+ }
+
+ It "Asserts Server is already in the desired state as prod targetEnvironment" {
+ Set-NginxHostState -desiredState "up" -targetEnvironment "prod" -hostname "localhost" -ErrorAction "Stop" | Should -be "NoOp"
+ }
+ }
+
+ Context "peer is in down state" {
+
+ Mock Get-NginxIpAddresses -ModuleName $moduleForMock {
+ # need address that will always resolve and is pingable
+ $mockedNginxInstanceIp = Get-IpAddress #(Resolve-DnsName -Name "localhost" -Type A -ErrorAction SilentlyContinue).IpAddress
+ return @( $mockedNginxInstanceIp )
+ }
+
+ Mock Test-TcpConnection -ModuleName $moduleForMock {
+ return $true
+ }
+
+ Mock Invoke-RestMethod -ModuleName $moduleForMock -ParameterFilter { $method -eq 'GET' } {
+ $fakeServerIP = Get-IpAddress #(Resolve-DnsName -Name "localhost" -Type A -ErrorAction SilentlyContinue).IpAddress
+ $body = New-Object psobject -Property @{
+ "FakeUpstream.com-ssl" = @{
+ "zone" = "FakeUpstream.com-ssl"
+ "peers" = @{
+ id = 0
+ server = "$($fakeServerIP):443"
+ name = "$($fakeServerIP):443"
+ state = "down"
+ }
+ }
+ }
+ return $body
+ }
+
+ Mock Invoke-RestMethod -ModuleName $moduleForMock -ParameterFilter { $method -eq 'PATCH' } {
+ $fakeServerIP = Get-IpAddress #(Resolve-DnsName -Name "localhost" -Type A -ErrorAction SilentlyContinue).IpAddress
+ $body = New-Object psobject -Property @{
+ id = 0
+ server = "$($fakeServerIP):443"
+ down = "false"
+ weight = 1
+ max_conns = 0
+ max_fails = 100
+ fail_timeout = "10s"
+ slow_start = "0s"
+ route = ""
+ backup = "False"
+ }
+ return $body
+ }
+
+ It "Asserts Server is already in the desired state and throws." {
+ Set-NginxHostState -desiredState "down" -targetEnvironment "staging" -hostname "localhost" -ErrorAction "Stop" | Should -be "NoOp"
+ }
+
+ It "Asserts Server is already in the desired state and forces to continue" {
+ Set-NginxHostState -desiredState "down" -targetEnvironment "staging" -hostname "localhost" -force -ErrorAction "Stop" | Should -Be "Success"
+ }
+ It "Asserts server is put down" {
+ Set-NginxHostState -desiredState "up" -targetEnvironment "staging" -hostname "localhost" -ErrorAction "Stop" | Should -be "Success"
+ }
+ It "Asserts Server is already in the desired state as prod targetEnvironment" {
+ Set-NginxHostState -desiredState "down" -targetEnvironment "prod" -hostname "localhost" -ErrorAction "Stop" | Should -be "NoOp"
+ }
+ }
+}
diff --git a/Modules/Alkami.DevOps.Operations/Public/Set-NginxHostState.ps1 b/Modules/Alkami.DevOps.Operations/Public/Set-NginxHostState.ps1
new file mode 100644
index 0000000..10d2ed6
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Set-NginxHostState.ps1
@@ -0,0 +1,156 @@
+function Set-NginxHostState {
+<#
+.Synopsis
+ Switch the current state of a host in a supplied set of Nginx servers.
+
+.Parameter desiredState
+ Required. Must be "down" or "up". Specifies which state to put the Host into.
+
+.Parameter force
+ Optional. If supplied, this will force the state to change to the specified desiredState. This could be problematic as it will eat Errors in the case of a host being in an unexpected state.
+
+.Parameter targetEnvironment
+ Required. Must be "staging" or "prod"
+
+.Parameter hostname
+ Requird. Hostname to toggle "webxxxxx"
+
+.PARAMETER AwsProfileName
+ [string] Specific AWS CLI Profile to use in AWS API calls.
+
+.PARAMETER AwsRegion
+ [string] Specific AWS CLI Region to use in AWS API calls.
+
+.OUTPUTS
+ Returns "Fail" on failing conditions
+#>
+ [CmdletBinding()]
+ [OutputType([System.String])]
+ Param(
+ [Parameter(Mandatory = $true)]
+ [ValidateSet("down", "up")]
+ [string]$desiredState,
+
+ [Parameter(Mandatory = $false)]
+ [string]$hostname = "localhost",
+
+ [Parameter(Mandatory = $false)]
+ [string]$targetEnvironment = $null,
+
+ [Parameter(Mandatory = $false)]
+ [switch]$force,
+
+ [Parameter(Mandatory = $false)]
+ [string]$AwsProfileName,
+
+ [Parameter(Mandatory = $false)]
+ [string]$AwsRegion
+ )
+
+ $logLead = (Get-LogLeadName)
+
+ if ($hostname -eq "localhost") {
+ $alkHostIP = Get-IpAddress
+ } else {
+ # attempt to resolve host to toggle
+ # Special ErrorAction circumstance - don't try this at kids, home - Really, don't normally do this
+ $alkHostIP = (Resolve-DnsName -Name $hostname -Type A -ErrorAction SilentlyContinue).IpAddress
+ }
+ if ($null -eq $alkHostIP ) {
+ Write-Error "$logLead : unable to resolve IP address of $hostname"
+ return "Fail"
+ }
+# }
+
+ # If the target environment isn't specified, construct it from the alk:env tag in the environment.
+ if([string]::IsNullOrWhiteSpace($targetEnvironment)) {
+ try {
+ $instances = (Get-EC2InstancesByHostname -Servers $hostname -ProfileName $awsProfileName)
+ $targetEnvironment = $instances[0].Tags | ` Where-Object {$_.Key -eq 'alk:env'} | Select-Object -ExpandProperty "Value"
+ } catch {
+ Write-Warning "$loglead : Exception occurred getting environment from AWS."
+ Write-Error $_.Exception.Message
+ return "Fail"
+ }
+ }
+
+ # Get Nginx server IP addresses
+ $nginxIpAddresses = Get-NginxIpAddresses -TargetEnvironment $targetEnvironment -AwsProfileName $AwsProfileName -AwsRegion $AwsRegion
+ if (Test-IsCollectionNullOrEmpty -collection $nginxIpAddresses) {
+ Write-Error "$logLead : no Nginx Servers found"
+ return "Fail"
+ }
+
+ Write-Verbose "nginx servers $nginxIpAddresses "
+
+ # Pre-flight the NGINX server connectivity. We don't want to proceed unless they are all available.
+ foreach ($nginxIpAddress in $nginxIpAddresses) {
+ $tcpResult = Test-TcpConnection -ipAddress $nginxIpAddress -port 8080 -msTimeout 5000
+ if ($false -eq $tcpResult) {
+ # If TCP test fails, we do not want to proceed. NGINX servers may become out of sync
+ Write-Error "$logLead : This NGINX server was unreachable $nginxIpAddress"
+ return "Fail"
+ }
+ }
+
+ foreach ($nginxIpAddress in $nginxIpAddresses) {
+
+ # gets the current state
+ $getResults = Get-NginxUpstreams -NginxServer $nginxIpAddress -targetEnvironment $targetEnvironment -ProfileName $AwsProfileName -Region $AwsRegion
+
+ # If getting the upstreams fails, but ping succeded, nginx may become out of sync
+ if (!$getResults) {
+ Write-Error "$logLead : Getting upstreams failed."
+ break
+ }
+
+ $resultsHash = $getResults | ConvertTo-Json -Depth 4 | ConvertFrom-JsonToHashtable
+
+ foreach ($serviceName in $resultsHash.Keys) {
+ Write-Verbose "$logLead : Looking for $hostname in ServiceName : $serviceName"
+ foreach ($peer in $resultsHash["$serviceName"]["peers"]) {
+ Write-Verbose "$logLead : Looking for $hostname in Peer : $($peer.name)"
+
+ Write-Verbose "$logLead : Testing if $($peer["server"]) matches '$($alkHostIP):*'"
+ #The $($varName) format is necessary to put a ":" after a variable, otherwise it thinks it's a scope variable
+ #Like $Global:varname
+ #Alternate matcher: '-match "^$($alkHostIp):"'
+ if ($peer["server"] -like "$($alkHostIp):*") {
+ $uri = "http://$($nginxIpAddress):8080/api/3/http/upstreams/$serviceName/servers/$($peer["id"])"
+
+ if (($peer["state"] -like $desiredState) -and (!$force.IsPresent)) {
+ Write-Warning "$logLead : $serviceName is not in the expected state: Not $desiredState"
+ return "NoOp"
+ } elseif (($peer["state"] -like $desiredState) -and ($force.IsPresent)) {
+ Write-Warning "$logLead : $serviceName is already in the desired state. Continuing anyway..."
+ $PatchResult = Set-NginxUpstreamHost -Uri $uri -targetEnvironment $targetEnvironment -desiredState $desiredState -ProfileName $AwsProfileName -Region $AwsRegion
+ } else {
+ $PatchResult = Set-NginxUpstreamHost -Uri $uri -targetEnvironment $targetEnvironment -desiredState $desiredState -ProfileName $AwsProfileName -Region $AwsRegion
+ }
+
+ if ($peer["state"] -notlike $PatchResult.down -and ($null -ne $PatchResult.down)) {
+ Write-Verbose "patch result"
+ $patchResult | Write-Verbose
+ Write-Verbose "$logLead : Service: $serviceName for host: $hostname state is now: down=$($patchResult.down)"
+ Write-Host "$logLead : State changed succesfully"
+ $statusToReturn = "success"
+ } elseif (($peer["state"] -like $PatchResult.down) -and ($force.IsPresent)) {
+ Write-Verbose "patch result"
+ $patchResult | Write-Verbose
+ Write-Verbose "$logLead : Service: $serviceName for host: $hostname state is still: down=$($patchResult.down)"
+ Write-Host "$logLead : State was forced and stayed the same"
+ $statusToReturn = "success"
+ } else {
+ Write-Warning "$logLead : State was not changed Succesfully, Nginx requires review"
+ return "Fail"
+ }
+ }
+ }
+ }
+ if (!$uri) {
+ Write-Error "$logLead : $($hostname) $alkhostip was not found in upstreams queried "
+ return "Fail"
+ }
+ }
+ return $statusToReturn
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Public/Set-NginxUpstreamHost.ps1 b/Modules/Alkami.DevOps.Operations/Public/Set-NginxUpstreamHost.ps1
new file mode 100644
index 0000000..4f4ca0f
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Set-NginxUpstreamHost.ps1
@@ -0,0 +1,62 @@
+function Set-NginxUpstreamHost {
+ <#
+.SYNOPSIS
+ Set the state of the host at the given URI to a specific state, up or down.
+
+.PARAMETER Uri
+ Nginx server API URI to set Upstream Host State
+
+.PARAMETER TargetEnvironment
+ Target environment of host to update, staging or prod
+
+.PARAMETER DesiredState
+ Desired end state of host to change, up or down
+
+.PARAMETER ProfileName
+ AWS ProfileName
+
+.PARAMETER Region
+ AWS Region
+
+#>
+ [CmdletBinding()]
+ param (
+ [Parameter(Mandatory = $true)]
+ [string]$Uri,
+
+ [Parameter(Mandatory = $true)]
+ [ValidateSet("staging", "prod")]
+ [string]$TargetEnvironment,
+
+ [Parameter(Mandatory = $true)]
+ [ValidateSet("down", "up")]
+ [string]$DesiredState,
+
+ [Parameter(Mandatory = $false)]
+ [string]$ProfileName,
+
+ [Parameter(Mandatory = $false)]
+ [string]$Region
+ )
+
+ $logLead = Get-LogLeadName
+
+ if ($TargetEnvironment -eq "staging") {
+ $getAuthHeaders = Get-NginxAuthHeaders -ProfileName $ProfileName -Region $Region
+ }
+
+ # We need the "down" value state as the JSON Body
+ if ($DesiredState -eq "down") {
+ $downVal = @{"down" = $true } | ConvertTo-Json
+ } else {
+ $downVal = @{"down" = $false } | ConvertTo-Json
+ }
+
+ Write-Host "$logLead : Making request against: $Uri"
+ Write-Host "$logLead : Setting to desired state: $DesiredState"
+ $results = Invoke-RestMethod -Uri $Uri -Headers $getAuthHeaders -Body $downVal -Method 'PATCH' -UseBasicParsing
+ if ($null -eq $results) {
+ Write-Error "$logLead : Unable to set $DesiredState at $Uri"
+ }
+ return $results
+}
diff --git a/Modules/Alkami.DevOps.Operations/Public/Set-Route53HostedZoneVpcAssocations.ps1 b/Modules/Alkami.DevOps.Operations/Public/Set-Route53HostedZoneVpcAssocations.ps1
new file mode 100644
index 0000000..645d6f4
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Set-Route53HostedZoneVpcAssocations.ps1
@@ -0,0 +1,150 @@
+function Set-Route53HostedZoneVpcAssocations {
+
+<#
+.SYNOPSIS
+ Establish VPC associations for all Alkami VPCs with a set of Route53 Hosted Zones.
+
+.DESCRIPTION
+ Establish VPC associations for all Alkami VPCs with a set of Route53 Hosted Zones. By default, this function
+ will process all Alkami Route53 Hosted Zones; use parameters to limit the scope of the change if desired.
+
+ Note that this function will attempt to register all Alkami VPCs with the newly created zone; to facilitate that process,
+ call this function only if you have current AWS CLI credentials in each Alkami account.
+
+ If you only want to associate a single VPC with a single Route53 Hosted Zone, prefer 'Add-Route53HostedZoneVpcAssociation'.
+
+.PARAMETER TargetProfile
+ [string] Limit Route53 Hosted Zone operations to a single AWS account. Required if the 'TargetHostedZoneId' parameter is provided.
+
+.PARAMETER TargetHostedZoneId
+ [string] Limit Route53 Hosted Zone operations to a single Route53 Hosted Zone.
+
+.EXAMPLE
+ Set-Route53HostedZoneVpcAssocations -TargetProfile 'temp-sandbox' -TargetHostedZoneId 'Z097623831BHXFFO7D74E'
+
+[Add-Route53HostedZoneVpcAssociation]: Creating VPC 'vpc-f7cc3a8c' association authorization for Route53 Hosted Zone '/hostedzone/Z097623831BHXFFO7D74E'
+
+#>
+
+ [CmdletBinding(DefaultParameterSetName='TargetProfile')]
+ param (
+ [Parameter( Mandatory = $false, ParameterSetName='TargetProfile' )]
+ [Parameter( Mandatory = $true, ParameterSetName='TargetZone' )]
+ [ValidateNotNullOrEmpty()]
+ [string] $TargetProfile,
+
+ [Parameter( Mandatory = $true, ParameterSetName='TargetZone' )]
+ [ValidateNotNullOrEmpty()]
+ [string] $TargetHostedZoneId
+ )
+
+ [string] $logLead = (Get-LogLeadName)
+ [object[]] $totalVpcList = @()
+ [string[]] $vpcRegions = @( "us-east-1", "us-west-2" )
+
+ # Loop through each profile and region and pull the VPC IDs for each.
+ Write-Verbose "$logLead : Retrieving all VPC IDs for Alkami."
+ [string[]] $vpcProfileNameList = Get-AlkamiAwsProfileList
+ foreach ( $profile in $vpcProfileNameList ) {
+
+ foreach ( $region in $VpcRegions ) {
+
+ [string[]] $curVpcList = Get-VpcIdList -ProfileName $profile -Region $region
+
+ foreach ( $vpcEntry in $curVpcList ) {
+
+ $totalVpcList += New-Object PSObject -Property @{
+ ID = $vpcEntry
+ Region = $region
+ Profile = $profile
+ }
+ }
+ }
+ }
+
+ # Sanity check that there are VPCs at Alkami.
+ if ( Test-IsCollectionNullOrEmpty $totalVpcList ) {
+
+ Write-Error "There are no VPCs in the entirety of AWS for Alkami. Something has gone horribly wrong (or your credentials are invalid)."
+ return
+ }
+
+ Write-Verbose ( "{0} : Discovered {1} Alkami VPCs." -f $logLead, $totalVpcList.Count )
+
+ $vpcAssociationScript = {
+ param(
+ [object] $vpcConfiguration,
+ [string[]] $inputArguments
+ )
+
+ $scriptResult = New-Object -TypeName PSObject -Property @{
+ HostedZoneId = $inputArguments[0]
+ HostedZoneProfile = $inputArguments[1]
+ VpcId = $vpcConfiguration.ID
+ VpcRegion = $vpcConfiguration.Region
+ VpcProfile = $vpcConfiguration.Profile
+ Success = $false
+ }
+
+ try {
+
+ $scriptResult.Success = ( Add-Route53HostedZoneVpcAssociation -ZoneId $inputArguments[0] -ZoneProfileName $inputArguments[1] `
+ -VpcId $vpcConfiguration.ID -VpcRegion $vpcConfiguration.Region `
+ -VpcProfileName $vpcConfiguration.Profile )
+
+ } catch {
+
+ Write-Warning "$logLead : $_"
+ }
+
+ return $scriptResult
+ }
+
+ # If the user provided a target profile, use it. By default, we process all profiles.
+ if ( $PSBoundParameters.ContainsKey( 'TargetProfile' ) ) {
+
+ [string[]] $targetHostedZoneProfileList = @( $TargetProfile )
+
+ } else {
+
+ [string[]] $targetHostedZoneProfileList = $vpcProfileNameList
+ }
+
+ # Loop through each profile and pull the Route 53 Hosted Zones
+ foreach ( $profile in $targetHostedZoneProfileList ) {
+
+ [string[]] $hostedZoneList = Get-Route53HostedZoneIdList -ProfileName $profile -PrivacyStatusFilter $true
+
+ foreach ( $hostedZone in $hostedZoneList ) {
+
+ # Determine if we're targeting a specific hosted zone.
+ # - If we aren't, process all zones.
+ # - If we are, is this the droid we're looking for?
+ if ( ( $false -eq $PSBoundParameters.ContainsKey( 'TargetHostedZoneId' ) ) -or ( $hostedZone -match $TargetHostedZoneId ) ) {
+
+ # Process the VPC associations for the current hosted zone.
+ Write-Verbose "$logLead : Processing Route53 Hosted Zone ID '$hostedZone' using profile '$profile'."
+ $scriptResultList = Invoke-Parallel -objects $totalVpcList -script $vpcAssociationScript -arguments @( $hostedZone, $profile )
+
+ # Process any failed results. Note that we don't fail the job since theoretically some of the associations might have worked.
+ $failedScriptResultList = $scriptResultList | Where-Object { $false -eq $_.Success }
+ foreach ( $failedResult in $failedScriptResultList ) {
+
+ Write-Warning ( "{0} : Failed to associate VPC ID '{1}' using profile '{2}' and region '{3}' with Route 53 Hosted Zone ID '{4}' using profile '{5}'" -f `
+ $logLead, $failedResult.VpcId, $failedResult.VpcProfile, $failedResult.VpcRegion, $failedResult.HostedZoneId, $failedResult.HostedZoneProfile )
+ }
+
+ # Look for any stale authorizations that may be languishing for this Route 53 hosted zone and remove them.
+ # These can be left behind if credentials expire mid-association. Technically, these don't cause any harm
+ # to leave (and you can create the association authorization even if it pre-exists), but we should try to clean
+ # up after ourselves because there IS a quota limit on the number of VPC association authorizations.
+ # Ref: https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/DNSLimitations.html
+ $staleAuthorizations = ( Get-R53VPCAssociationAuthorizationList -ProfileName $profile -HostedZoneId $hostedZone ).VPCs
+ foreach ( $auth in $staleAuthorizations ) {
+
+ Remove-R53VPCAssociationAuthorization -ProfileName $profile -HostedZoneId $hostedZone -VPC_VPCId $auth.VPCId -VPC_VPCRegion $auth.VPCRegion -Force
+ }
+ }
+ }
+ }
+}
diff --git a/Modules/Alkami.DevOps.Operations/Public/Set-Route53HostedZoneVpcAssocations.tests.ps1 b/Modules/Alkami.DevOps.Operations/Public/Set-Route53HostedZoneVpcAssocations.tests.ps1
new file mode 100644
index 0000000..f197779
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Set-Route53HostedZoneVpcAssocations.tests.ps1
@@ -0,0 +1,209 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+
+Describe "Set-Route53HostedZoneVpcAssocations" {
+
+ Mock -CommandName Get-LogLeadName -ModuleName Alkami.DevOps.Operations -MockWith { return 'Set-Route53HostedZoneVpcAssocations.tests' }
+
+ Context "Input Validation" {
+
+ It "Target Profile Name Should Not Be Null" {
+
+ { Set-Route53HostedZoneVpcAssocations -TargetProfile $null } | Should -Throw
+ }
+
+ It "Target Profile Name Should Not Be Empty" {
+
+ { Set-Route53HostedZoneVpcAssocations -TargetProfile '' } | Should -Throw
+ }
+
+ It "Target Hosted Zone ID Should Not Be Null" {
+
+ { Set-Route53HostedZoneVpcAssocations -TargetProfile 'Test' -TargetHostedZoneId $null } | Should -Throw
+ }
+
+ It "Target Hosted Zone ID Should Not Be Empty" {
+
+ { Set-Route53HostedZoneVpcAssocations -TargetProfile 'Test' -TargetHostedZoneId '' } | Should -Throw
+ }
+ }
+
+ Context "Result Validation" {
+
+ It "Should Write Error If No VPCs Exist" {
+
+ Mock -CommandName Get-AlkamiAwsProfileList -ModuleName Alkami.DevOps.Operations -MockWith { return @( 'Test' ) }
+ Mock -CommandName Get-VpcIdList -ModuleName Alkami.DevOps.Operations -MockWith { return $null }
+ Mock -CommandName Write-Error -ModuleName Alkami.DevOps.Operations -MockWith {}
+
+ Set-Route53HostedZoneVpcAssocations | Out-Null
+
+ # Call count breakdown:
+ # - Get-AlkamiAwsProfileList: Called once at the beginning of the function.
+ # - Get-VpcIdList: Called once per Alkami profile per region -- one profile, two regions.
+ # - Write-Error: Called once if there are no VPCs.
+ Assert-MockCalled -CommandName Get-AlkamiAwsProfileList -Times 1 -Exactly -Scope It -ModuleName Alkami.DevOps.Operations
+ Assert-MockCalled -CommandName Get-VpcIdList -Times 2 -Exactly -Scope It -ModuleName Alkami.DevOps.Operations
+ Assert-MockCalled -CommandName Write-Error -Times 1 -Exactly -Scope It -ModuleName Alkami.DevOps.Operations `
+ -ParameterFilter { $Message -match "There are no VPCs" }
+
+ }
+
+ It "Should Process All Alkami Profile Hosted Zones If Target Profile Not Supplied" {
+
+ Mock -CommandName Get-AlkamiAwsProfileList -ModuleName Alkami.DevOps.Operations -MockWith { return @( 'Test1', 'Test2' ) }
+ Mock -CommandName Get-VpcIdList -ModuleName Alkami.DevOps.Operations -MockWith { return @( 'Test' ) }
+ Mock -CommandName Get-Route53HostedZoneIdList -ModuleName Alkami.DevOps.Operations -MockWith { return @( 'Test1', 'Test2' ) }
+ Mock -CommandName Invoke-Parallel -ModuleName Alkami.DevOps.Operations -MockWith { return @( @{ 'Success' = $true } ) }
+ Mock -CommandName Get-R53VPCAssociationAuthorizationList -ModuleName Alkami.DevOps.Operations -MockWith { return @() }
+
+ Set-Route53HostedZoneVpcAssocations | Out-Null
+
+ # Call count breakdown:
+ # - Get-AlkamiAwsProfileList: Called once at the beginning of the function.
+ # - Get-VpcIdList: Called once per Alkami profile per region -- two profiles, two regions.
+ # - Get-Route53HostedZoneIdList: Called once per profile.
+ # - Invoke-Parallel: Called once per profile per hosted zone -- two profiles, two hosted zones.
+ # - Get-R53VPCAssociationAuthorizationList: Called once per profile per hosted zone -- two profiles, two hosted zones.
+ Assert-MockCalled -CommandName Get-AlkamiAwsProfileList -Times 1 -Exactly -Scope It -ModuleName Alkami.DevOps.Operations
+ Assert-MockCalled -CommandName Get-VpcIdList -Times 4 -Exactly -Scope It -ModuleName Alkami.DevOps.Operations
+ Assert-MockCalled -CommandName Get-Route53HostedZoneIdList -Times 2 -Exactly -Scope It -ModuleName Alkami.DevOps.Operations
+ Assert-MockCalled -CommandName Invoke-Parallel -Times 4 -Exactly -Scope It -ModuleName Alkami.DevOps.Operations
+ Assert-MockCalled -CommandName Get-R53VPCAssociationAuthorizationList -Times 4 -Exactly -Scope It -ModuleName Alkami.DevOps.Operations
+ }
+
+ It "Should Process Only Target Profile Hosted Zones If Target Profile Supplied" {
+
+ Mock -CommandName Get-AlkamiAwsProfileList -ModuleName Alkami.DevOps.Operations -MockWith { return @( 'Test1', 'Test2' ) }
+ Mock -CommandName Get-VpcIdList -ModuleName Alkami.DevOps.Operations -MockWith { return @( 'Test' ) }
+ Mock -CommandName Get-Route53HostedZoneIdList -ModuleName Alkami.DevOps.Operations -MockWith { return @( 'Test1', 'Test2' ) }
+ Mock -CommandName Invoke-Parallel -ModuleName Alkami.DevOps.Operations -MockWith { return @( @{ 'Success' = $true } ) }
+ Mock -CommandName Get-R53VPCAssociationAuthorizationList -ModuleName Alkami.DevOps.Operations -MockWith { return @() }
+
+ Set-Route53HostedZoneVpcAssocations -TargetProfile 'Test1' | Out-Null
+
+ # Call count breakdown:
+ # - Get-AlkamiAwsProfileList: Called once at the beginning of the function.
+ # - Get-VpcIdList: Called once per Alkami profile per region -- two profiles, two regions.
+ # - Get-Route53HostedZoneIdList: Called once per profile -- one profile.
+ # - Invoke-Parallel: Called once per profile per hosted zone -- one profile, two hosted zones.
+ # - Get-R53VPCAssociationAuthorizationList: Called once per profile per hosted zone -- one profile, two hosted zones.
+ Assert-MockCalled -CommandName Get-AlkamiAwsProfileList -Times 1 -Exactly -Scope It -ModuleName Alkami.DevOps.Operations
+ Assert-MockCalled -CommandName Get-VpcIdList -Times 4 -Exactly -Scope It -ModuleName Alkami.DevOps.Operations
+ Assert-MockCalled -CommandName Get-Route53HostedZoneIdList -Times 1 -Exactly -Scope It -ModuleName Alkami.DevOps.Operations
+ Assert-MockCalled -CommandName Invoke-Parallel -Times 2 -Exactly -Scope It -ModuleName Alkami.DevOps.Operations
+ Assert-MockCalled -CommandName Get-R53VPCAssociationAuthorizationList -Times 2 -Exactly -Scope It -ModuleName Alkami.DevOps.Operations
+ }
+
+ It "Should Process Only Target Hosted Zone If Target Hosted Zone Supplied" {
+
+ Mock -CommandName Get-AlkamiAwsProfileList -ModuleName Alkami.DevOps.Operations -MockWith { return @( 'Test1', 'Test2' ) }
+ Mock -CommandName Get-VpcIdList -ModuleName Alkami.DevOps.Operations -MockWith { return @( 'Test' ) }
+ Mock -CommandName Get-Route53HostedZoneIdList -ModuleName Alkami.DevOps.Operations -MockWith { return @( 'Test1', 'Test2' ) }
+ Mock -CommandName Invoke-Parallel -ModuleName Alkami.DevOps.Operations -MockWith { return @( @{ 'Success' = $true } ) }
+ Mock -CommandName Get-R53VPCAssociationAuthorizationList -ModuleName Alkami.DevOps.Operations -MockWith { return @() }
+
+ Set-Route53HostedZoneVpcAssocations -TargetProfile 'Test1' -TargetHostedZoneId 'Test1' | Out-Null
+
+ # Call count breakdown:
+ # - Get-AlkamiAwsProfileList: Called once at the beginning of the function.
+ # - Get-VpcIdList: Called once per Alkami profile per region -- two profiles, two regions.
+ # - Get-Route53HostedZoneIdList: Called once per profile -- one profile.
+ # - Invoke-Parallel: Called once per profile per hosted zone -- one profile, one hosted zone.
+ # - Get-R53VPCAssociationAuthorizationList: Called once per profile per hosted zone -- one profile, one hosted zone.
+ Assert-MockCalled -CommandName Get-AlkamiAwsProfileList -Times 1 -Exactly -Scope It -ModuleName Alkami.DevOps.Operations
+ Assert-MockCalled -CommandName Get-VpcIdList -Times 4 -Exactly -Scope It -ModuleName Alkami.DevOps.Operations
+ Assert-MockCalled -CommandName Get-Route53HostedZoneIdList -Times 1 -Exactly -Scope It -ModuleName Alkami.DevOps.Operations
+ Assert-MockCalled -CommandName Invoke-Parallel -Times 1 -Exactly -Scope It -ModuleName Alkami.DevOps.Operations
+ Assert-MockCalled -CommandName Get-R53VPCAssociationAuthorizationList -Times 1 -Exactly -Scope It -ModuleName Alkami.DevOps.Operations
+ }
+
+ It "Should Process No Hosted Zones If Supplied Target Hosted Zone Not Found" {
+
+ Mock -CommandName Get-AlkamiAwsProfileList -ModuleName Alkami.DevOps.Operations -MockWith { return @( 'Test1', 'Test2' ) }
+ Mock -CommandName Get-VpcIdList -ModuleName Alkami.DevOps.Operations -MockWith { return @( 'Test' ) }
+ Mock -CommandName Get-Route53HostedZoneIdList -ModuleName Alkami.DevOps.Operations -MockWith { return @( 'Test1', 'Test2' ) }
+ Mock -CommandName Invoke-Parallel -ModuleName Alkami.DevOps.Operations -MockWith { return @( @{ 'Success' = $true } ) }
+ Mock -CommandName Get-R53VPCAssociationAuthorizationList -ModuleName Alkami.DevOps.Operations -MockWith { return @() }
+
+ Set-Route53HostedZoneVpcAssocations -TargetProfile 'Test1' -TargetHostedZoneId 'Test3' | Out-Null
+
+ # Call count breakdown:
+ # - Get-AlkamiAwsProfileList: Called once at the beginning of the function.
+ # - Get-VpcIdList: Called once per Alkami profile per region -- two profiles, two regions.
+ # - Get-Route53HostedZoneIdList: Called once per profile -- one profile.
+ # - Invoke-Parallel: Called once per profile per hosted zone -- one profile, zero hosted zones.
+ # - Get-R53VPCAssociationAuthorizationList: Called once per profile per hosted zone -- one profile, zero hosted zones.
+ Assert-MockCalled -CommandName Get-AlkamiAwsProfileList -Times 1 -Exactly -Scope It -ModuleName Alkami.DevOps.Operations
+ Assert-MockCalled -CommandName Get-VpcIdList -Times 4 -Exactly -Scope It -ModuleName Alkami.DevOps.Operations
+ Assert-MockCalled -CommandName Get-Route53HostedZoneIdList -Times 1 -Exactly -Scope It -ModuleName Alkami.DevOps.Operations
+ Assert-MockCalled -CommandName Invoke-Parallel -Times 0 -Exactly -Scope It -ModuleName Alkami.DevOps.Operations
+ Assert-MockCalled -CommandName Get-R53VPCAssociationAuthorizationList -Times 0 -Exactly -Scope It -ModuleName Alkami.DevOps.Operations
+ }
+
+ It "Should Write Warning If VPC Associations Fail" {
+
+ Mock -CommandName Get-AlkamiAwsProfileList -ModuleName Alkami.DevOps.Operations -MockWith { return @( 'Test1', 'Test2' ) }
+ Mock -CommandName Get-VpcIdList -ModuleName Alkami.DevOps.Operations -MockWith { return @( 'Test' ) }
+ Mock -CommandName Get-Route53HostedZoneIdList -ModuleName Alkami.DevOps.Operations -MockWith { return @( 'Test1', 'Test2' ) }
+ Mock -CommandName Invoke-Parallel -ModuleName Alkami.DevOps.Operations -MockWith { return @( @{ 'Success' = $false }, @{ 'Success' = $true } ) }
+ Mock -CommandName Get-R53VPCAssociationAuthorizationList -ModuleName Alkami.DevOps.Operations -MockWith { return @() }
+ Mock -CommandName Write-Warning -ModuleName Alkami.DevOps.Operations -MockWith {}
+
+ Set-Route53HostedZoneVpcAssocations | Out-Null
+
+ # Call count breakdown:
+ # - Get-AlkamiAwsProfileList: Called once at the beginning of the function.
+ # - Get-VpcIdList: Called once per Alkami profile per region -- two profiles, two regions.
+ # - Get-Route53HostedZoneIdList: Called once per profile.
+ # - Invoke-Parallel: Called once per profile per hosted zone -- two profiles, two hosted zones.
+ # - Get-R53VPCAssociationAuthorizationList: Called once per profile per hosted zone -- two profiles, two hosted zones.
+ # - Write-Warning: Called once per profile per hosted zone per failure -- two profiles, two hosted zones, one failure.
+ Assert-MockCalled -CommandName Get-AlkamiAwsProfileList -Times 1 -Exactly -Scope It -ModuleName Alkami.DevOps.Operations
+ Assert-MockCalled -CommandName Get-VpcIdList -Times 4 -Exactly -Scope It -ModuleName Alkami.DevOps.Operations
+ Assert-MockCalled -CommandName Get-Route53HostedZoneIdList -Times 2 -Exactly -Scope It -ModuleName Alkami.DevOps.Operations
+ Assert-MockCalled -CommandName Invoke-Parallel -Times 4 -Exactly -Scope It -ModuleName Alkami.DevOps.Operations
+ Assert-MockCalled -CommandName Get-R53VPCAssociationAuthorizationList -Times 4 -Exactly -Scope It -ModuleName Alkami.DevOps.Operations
+ Assert-MockCalled -CommandName Write-Warning -Times 4 -Exactly -Scope It -ModuleName Alkami.DevOps.Operations
+ }
+
+ It "Should Remove Stale VPC Association Authorizations If Exist" {
+
+ Mock -CommandName Get-AlkamiAwsProfileList -ModuleName Alkami.DevOps.Operations -MockWith { return @( 'Test1', 'Test2' ) }
+ Mock -CommandName Get-VpcIdList -ModuleName Alkami.DevOps.Operations -MockWith { return @( 'Test' ) }
+ Mock -CommandName Get-Route53HostedZoneIdList -ModuleName Alkami.DevOps.Operations -MockWith { return @( 'Test1', 'Test2' ) }
+ Mock -CommandName Invoke-Parallel -ModuleName Alkami.DevOps.Operations -MockWith { return @( @{ 'Success' = $true } ) }
+ Mock -CommandName Remove-R53VPCAssociationAuthorization -ModuleName Alkami.DevOps.Operations -MockWith {}
+ Mock -CommandName Get-R53VPCAssociationAuthorizationList -ModuleName Alkami.DevOps.Operations -MockWith {
+ $responseObject = @{
+ VPCs = @(
+ @{
+ VPCId = 'Test1'
+ VPCRegion = 'Test'
+ },
+ @{
+ VPCId = 'Test2'
+ VPCRegion = 'Test'
+ }
+ )
+ }
+ return $responseObject
+ }
+
+ Set-Route53HostedZoneVpcAssocations | Out-Null
+
+ # Call count breakdown:
+ # - Get-AlkamiAwsProfileList: Called once at the beginning of the function.
+ # - Get-VpcIdList: Called once per Alkami profile per region -- two profiles, two regions.
+ # - Get-Route53HostedZoneIdList: Called once per profile.
+ # - Invoke-Parallel: Called once per profile per hosted zone -- two profiles, two hosted zones.
+ # - Get-R53VPCAssociationAuthorizationList: Called once per profile per hosted zone -- two profiles, two hosted zones.
+ # - Remove-R53VPCAssociationAuthorization: Called once per profile per hosted zone per authorization -- two profiles, two hosted zones, two authorizations.
+ Assert-MockCalled -CommandName Get-AlkamiAwsProfileList -Times 1 -Exactly -Scope It -ModuleName Alkami.DevOps.Operations
+ Assert-MockCalled -CommandName Get-VpcIdList -Times 4 -Exactly -Scope It -ModuleName Alkami.DevOps.Operations
+ Assert-MockCalled -CommandName Get-Route53HostedZoneIdList -Times 2 -Exactly -Scope It -ModuleName Alkami.DevOps.Operations
+ Assert-MockCalled -CommandName Invoke-Parallel -Times 4 -Exactly -Scope It -ModuleName Alkami.DevOps.Operations
+ Assert-MockCalled -CommandName Get-R53VPCAssociationAuthorizationList -Times 4 -Exactly -Scope It -ModuleName Alkami.DevOps.Operations
+ Assert-MockCalled -CommandName Remove-R53VPCAssociationAuthorization -Times 8 -Exactly -Scope It -ModuleName Alkami.DevOps.Operations
+ }
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Public/Set-TracedMessagesEnabled.ps1 b/Modules/Alkami.DevOps.Operations/Public/Set-TracedMessagesEnabled.ps1
new file mode 100644
index 0000000..bcdb43c
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Set-TracedMessagesEnabled.ps1
@@ -0,0 +1,125 @@
+function Set-TracedMessagesEnabled {
+
+ <#
+.SYNOPSIS
+ Sets TracedMessages Configuration for a Given Package to the Desired Value
+
+.DESCRIPTION
+ Sets TracedMessages Configuration for a Given Package to the Desired Value. This is only applicable for Alkami Chocolatey packages
+
+.PARAMETER DesiredLogLevel
+ The log level to set for the target namespaces
+
+.PARAMETER TargetNameSpaces
+ The namespaces which cover TracedMessages
+
+.Example
+ Set-TracedMessagesEnabled "Alkami.MicroServices.Superman2" -Enabled $false
+
+.Example
+ Set-TracedMessagesEnabled "Alkami.MicroServices.PredatoryPaydayLoans" -Enabled $true -TargetNameSpaces @("Alkami.App.Providers.AceCashExpress")
+#>
+
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory = $true, Position = 1)]
+ [ValidateSet("OFF","FATAL","ERROR","WARN","INFO","DEBUG", "TRACE")]
+ [string]$DesiredLogLevel,
+
+ [Parameter(Mandatory = $false, Position = 2)]
+ [string[]]$TargetNameSpaces = @("Alkami.MicroServices.TracedMessages", "Alkami.ExternalServices.TracedMessages")
+ )
+
+ DynamicParam {
+ # Define the Paramater Attributes
+ $ParameterName = "PackageName"
+ $AttributeCollection = New-Object System.Collections.ObjectModel.Collection[System.Attribute]
+ $RuntimeParameterDictionary = New-Object System.Management.Automation.RuntimeDefinedParameterDictionary
+ $ParameterAttribute = New-Object System.Management.Automation.ParameterAttribute
+ $ParameterAttribute.HelpMessage = "The Package to target. Should be a directory in the Chocolatey lib folder"
+ $ParameterAttribute.Mandatory = $true
+ $ParameterAttribute.Position = 0
+ $AttributeCollection.Add($ParameterAttribute)
+
+ # Generate and add the ValidateSet
+ $parentPath = Join-Path -Path (Get-ChocolateyInstallPath) -ChildPath "lib"
+ $arrSet = Get-ChildItem -Path $parentPath -Directory -Depth 0 -ErrorAction SilentlyContinue | ForEach-Object {$_.Name} | Where-Object {$_.Length -gt 0}
+ $ValidateSetAttribute = New-Object System.Management.Automation.ValidateSetAttribute($arrSet)
+ $AttributeCollection.Add($ValidateSetAttribute)
+
+ # Create the dynamic parameter
+ $RuntimeParameter = New-Object System.Management.Automation.RuntimeDefinedParameter($ParameterName, [string], $AttributeCollection)
+ $RuntimeParameterDictionary.Add($ParameterName, $RuntimeParameter)
+ return $RuntimeParameterDictionary
+ }
+
+ begin {
+ $PackageName = $PsBoundParameters[$ParameterName]
+ $logLead = Get-LogLeadName
+ }
+
+ process {
+
+ if ([String]::IsNullOrEmpty($PackageName)) {
+
+ Write-Warning "$logLead : PackageName is null. Execution cannot continue"
+ return
+ }
+
+ $libDirectory = Join-Path -Path (Get-ChocolateyInstallPath) -ChildPath "lib"
+ $targetDirectory = Join-Path -Path $libDirectory -ChildPath $PackageName
+
+ if (!(Test-Path $targetDirectory)) {
+
+ Write-Warning "$logLead : How did you manage this? The PackageName [$PackageName] doesn't exist!"
+ }
+
+ Write-Host "$logLead : Looking for log4net config in $targetDirectory"
+ $logConfig = Get-ChildItem -Path $targetDirectory -File -Recurse -Depth 2 -Filter log4net.config
+
+ # This is a little hacky but should work in all known cases
+ # The shortest path is the one we care about, SDK might have log4net in the SRC folder nested deeper
+ # While it should be more than 2 directories deep and so not found by the GCI above, who knows
+ # Not me
+ # Also works for App vs. Tools
+ if ($logConfig.Count -gt 1) {
+
+ Write-Warning "$logLead : Found $($logConfig.Count) log4net.config files in the target folder. Will use the highest level one"
+ $targetLogConfig = $logConfig | Select-Object -ExpandProperty FullName | Sort-Object -Property Length | Select-Object -First 1
+ Write-Host "$logLead : Will modify $targetLogConfig"
+
+ } else {
+
+ $targetLogConfig = $logConfig.FullName
+ }
+
+ if ([String]::IsNullOrEmpty($targetLogConfig)) {
+
+ Write-Warning "$logLead : Could not find a valid log config for package $PackageName. Check the folders and retry, or open a bug"
+ return
+ }
+
+ $configXml = Read-XmlFile -xmlPath $targetLogConfig
+ $targetLoggers = $configXml.configuration.log4net.logger | Where-Object {$TargetNameSpaces -contains $_.Name}
+
+ $saveRequired = $false
+ foreach ($appender in $targetLoggers) {
+
+ $configuredLogLevel = $appender.level.value
+ if ($configuredLogLevel -ne $DesiredLogLevel) {
+
+ Write-Host "$logLead : Updating $($appender.Name) from logLevel [$configuredLogLevel] to [$DesiredLogLevel]"
+ $appender.level.value = $DesiredLogLevel
+ $saveRequired = $true
+ }
+ }
+
+ if ($saveRequired) {
+
+ Save-XmlFile -xmlPath $targetLogConfig -xml $configXml
+ } else {
+
+ Write-Host "$logLead : No modifications were made, no save required"
+ }
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Public/Show-NetTCPConnections.ps1 b/Modules/Alkami.DevOps.Operations/Public/Show-NetTCPConnections.ps1
new file mode 100644
index 0000000..5ab1ac2
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Show-NetTCPConnections.ps1
@@ -0,0 +1,53 @@
+function Show-NetTCPConnections {
+<#
+.SYNOPSIS
+Displays Net TCP Connections with Process and User Information
+
+.DESCRIPTION
+Shows Net TCP Connections with Process Name and User Name Decoration
+Can display connections grouped by process ID or individually
+
+.PARAMETER ungroupConnections
+[switch] Shows Ungrouped Connections
+
+.INPUTS
+None
+
+.OUTPUTS
+String[]
+
+.EXAMPLE
+C:\PS> Show-NetTCPConnections
+Connections Process Name Process ID UserName
+----------- ------------ ---------- --------
+ 147 Alkami.MicroServices.Broker.Host 9316 FH\pod12.micro$
+ 41 Alkami.MicroServices.BillPayProviders.IPay.Service.Host 24280 FH\pod12.micro$
+
+.EXAMPLE
+C:\PS> Show-NetTCPConnections -ShowUngrouped
+LocalPort LocalAddress RemotePort RemoteAddress State Process Name
+--------- ------------ ---------- ------------- ----- ------------
+ 80 0.0.0.0 0 0.0.0.0 Listen System
+ 80 :: 0 :: Listen System
+ 135 :: 0 :: Listen svchost
+ 135 0.0.0.0 0 0.0.0.0 Listen svchost
+#>
+
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory=$false)]
+ [Alias("ShowUngrouped")]
+ [switch]$ungroupConnections
+ )
+
+ $connections = Get-DecoratedNetTCPConnections -ungroupConnections:$ungroupConnections
+
+ if (!($ungroupConnections.IsPresent)) {
+
+ $connections | Sort-Object -Property Count -Descending | Format-Table -Property @{Label="Connections";Expression={$_.Count}}, @{Label="Process Name";Expression={$_.ProcessName}}, @{Label="Process ID";Expression={$_.Name}}, UserName -AutoSize | Out-String
+ }
+ else {
+
+ $connections | Sort-Object -Property LocalPort | Format-Table -Property LocalPort, LocalAddress, RemotePort, RemoteAddress, State, @{Label="Process Name";Expression={$_.ProcessName}}, UserName -AutoSize | Out-String
+ }
+}
diff --git a/Modules/Alkami.DevOps.Operations/Public/Test-IsCurrentTimeInsideTenantTimeRange.Tests.ps1 b/Modules/Alkami.DevOps.Operations/Public/Test-IsCurrentTimeInsideTenantTimeRange.Tests.ps1
new file mode 100644
index 0000000..70b84d4
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Test-IsCurrentTimeInsideTenantTimeRange.Tests.ps1
@@ -0,0 +1,166 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ""
+
+Describe "Test-IsCurrentTimeInsideTenantTimeRange" {
+
+ Context "Test Single Tenant" {
+
+ # Return only one tenant just to validate the single-tenant case.
+ Mock -CommandName Get-FullTenantListFromServer -ModuleName $moduleForMock -MockWith {
+ return @(
+ @{
+ Name = "FakeTenant1"
+ ConnectionString = "FakeConnectionString1"
+ }
+ )
+ }
+
+ # Return the current time zone.
+ Mock -CommandName Get-TenantTimeZone -ModuleName $moduleForMock -MockWith {
+ return "Greenwich Standard Time"
+ }
+
+ $cases = @(
+ # Should return false before the time window.
+ @{ StartTime = 5; EndTime = 10; Time = 2; Expect = $false },
+
+ # Should return true during the time window.
+ @{ StartTime = 5; EndTime = 10; Time = 7; Expect = $true },
+
+ # Should return true at the start the time window.
+ @{ StartTime = 5; EndTime = 10; Time = 5; Expect = $true },
+
+ # Should return false at the end of the time window.
+ @{ StartTime = 5; EndTime = 10; Time = 10; Expect = $false },
+
+ # Should return false after the time window.
+ @{ StartTime = 5; EndTime = 10; Time = 13; Expect = $false }
+
+ # Should work with overnight cases (If we want 11:00am->1:00am times, [23,1])
+ @{ StartTime = 23; EndTime = 1; Time = 22; Expect = $false }
+ @{ StartTime = 23; EndTime = 1; Time = 23; Expect = $true }
+ @{ StartTime = 23; EndTime = 1; Time = 0; Expect = $true }
+ @{ StartTime = 23; EndTime = 1; Time = 1; Expect = $false }
+
+ # Test that >=24 start times wrap around to 0
+ @{ StartTime = 24; EndTime = 1; Time = 24; Expect = $true }
+ @{ StartTime = 25; EndTime = 2; Time = 25; Expect = $true }
+ @{ StartTime = 25; EndTime = 2; Time = 26; Expect = $false }
+
+ # Test that <0 times wrap around to 24
+ @{ StartTime = -1; EndTime = 1; Time = 0; Expect = $true }
+
+ # Should return false with a time-window of 0 hours.
+ @{ StartTime = 5; EndTime = 5; Time = 14; Expect = $false }
+ )
+ It "Should Return Expected Results" -TestCases $cases {
+ param($StartTime, $EndTime, $Time, $Expect)
+
+ $now = (Get-Date)
+ $timeZoneInfo = [TimeZoneInfo]::FindSystemTimeZoneById("Greenwich Standard Time")
+ $testTime = [TimeZoneInfo]::ConvertTime($now, $timeZoneInfo)
+ $global:returnTime = $testTime.Date.AddHours($Time)
+ $global:localTimeZoneInfo = $timeZoneInfo
+ Mock -CommandName Get-Date -ModuleName $moduleForMock -MockWith {
+ return $global:returnTime
+ }
+ Mock -CommandName Get-TimeZone -ModuleName $moduleForMock -MockWith {
+ return $global:localTimeZoneInfo
+ }
+
+ $arguments = @{
+ MasterConnectionString = "FakeConnectionString"
+ StartTimeHour = $StartTime
+ EndTimeHour = $EndTime
+ }
+ (Test-IsCurrentTimeInsideTenantTimeRange @arguments) | Should -Be $Expect
+ }
+ }
+
+
+ Context "Test Multi Tenant" {
+
+ # Return only one tenant just to validate the single-tenant case.
+ Mock -CommandName Get-FullTenantListFromServer -ModuleName $moduleForMock -MockWith {
+ return @(
+ @{
+ Name = "FakeTenant1"
+ ConnectionString = "FakeConnectionString1"
+ },
+ @{
+ Name = "FakeTenant2"
+ ConnectionString = "FakeConnectionString2"
+ }
+ )
+ }
+
+ # Return the current time zone.
+ Mock -CommandName Get-TenantTimeZone -ModuleName $moduleForMock -MockWith {
+ param($TenantConnectionString)
+
+ if($TenantConnectionString -eq "FakeConnectionString1") {
+ return "Greenwich Standard Time"
+ } else {
+ # Use a timezone +3 from GST
+ return "Arab Standard Time"
+ }
+ }
+
+ $cases = @(
+ # NOTE: The second tenant timezone is +3 hours offset from GST.
+
+ # Should return false before the time windows.
+ @{ StartTime = 5; EndTime = 10; Time = 2; Expect = $false },
+
+ # Should return false inside first tenant time window, but not the second tenant window.
+ # Lower threshold to enter the +3 tenant time window.
+ @{ StartTime = 5; EndTime = 10; Time = 1; Expect = $false },
+ @{ StartTime = 5; EndTime = 10; Time = 2; Expect = $false },
+ @{ StartTime = 5; EndTime = 10; Time = 3; Expect = $false },
+
+ # Should return true inside both tenant time windows.
+ # Lower threshold to enter both tenant time windows.
+ @{ StartTime = 5; EndTime = 10; Time = 4; Expect = $false }
+ @{ StartTime = 5; EndTime = 10; Time = 5; Expect = $true }
+
+ # Should return false inside the second tenant's time window, but not the first tenant window.
+ # Upper threshold to exit the +3 time window.
+ @{ StartTime = 5; EndTime = 10; Time = 6; Expect = $true },
+ @{ StartTime = 5; EndTime = 10; Time = 7; Expect = $false },
+ @{ StartTime = 5; EndTime = 10; Time = 8; Expect = $false },
+
+ # Should return false after both tenant time windows.
+ # Threshold to exit both time windows.
+ @{ StartTime = 5; EndTime = 10; Time = 12; Expect = $false }
+ @{ StartTime = 5; EndTime = 10; Time = 13; Expect = $false }
+ @{ StartTime = 5; EndTime = 10; Time = 14; Expect = $false }
+ )
+ It "Should Return Expected Results" -TestCases $cases {
+ param($StartTime, $EndTime, $Time, $Expect)
+
+ $now = (Get-Date)
+ $timeZoneInfo = [TimeZoneInfo]::FindSystemTimeZoneById("Greenwich Standard Time")
+ $testTime = [TimeZoneInfo]::ConvertTime($now, $timeZoneInfo)
+ $global:returnTime = $testTime.Date.AddHours($Time)
+ $global:localTimeZoneInfo = $timeZoneInfo
+ Mock -CommandName Get-Date -ModuleName $moduleForMock -MockWith {
+ return $global:returnTime
+ }
+ Mock -CommandName Get-TimeZone -ModuleName $moduleForMock -MockWith {
+ return $global:localTimeZoneInfo
+ }
+
+ $arguments = @{
+ MasterConnectionString = "FakeConnectionString"
+ StartTimeHour = $StartTime
+ EndTimeHour = $EndTime
+ }
+ (Test-IsCurrentTimeInsideTenantTimeRange @arguments) | Should -Be $Expect
+ }
+ }
+}
diff --git a/Modules/Alkami.DevOps.Operations/Public/Test-IsCurrentTimeInsideTenantTimeRange.ps1 b/Modules/Alkami.DevOps.Operations/Public/Test-IsCurrentTimeInsideTenantTimeRange.ps1
new file mode 100644
index 0000000..e6972ce
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Test-IsCurrentTimeInsideTenantTimeRange.ps1
@@ -0,0 +1,111 @@
+function Test-IsCurrentTimeInsideTenantTimeRange {
+ <#
+.SYNOPSIS
+ This function returns true if the current-time is within an hourly time window for all tenants in a target pod in their local-times.
+
+.PARAMETER ComputerName
+ [string] A machine from a target environment to pull the master connection string from.
+
+.PARAMETER MasterConnectionString
+ [string] The master connection string of the pod.
+
+.PARAMETER StartTimeHour
+ [string] The inclusive start-hour of the time window that you want this function to return true within. (24 hour time)
+
+.PARAMETER EndTimeHour
+ [string] The exclusive end-hour of the time window that you want this function to return true within. (24 hour time)
+
+.NOTES
+ StartTimeHour is inclusive, and EndTimeHour is exclusive. For example [0,6] would match true from 12:00am to 5:59am in the tenant timezone:
+
+ Test-IsCurrentTimeInsideTenantTimeRange -ComputerName "APP1234.some.domain" -StartTimeHour 0 -EndTimeHour 6
+ #>
+ [CmdletBinding(DefaultParameterSetName = 'ByServer')]
+ param(
+ [Parameter(ParameterSetName = 'ByServer', Mandatory=$true)]
+ [string]$ComputerName,
+ [Parameter(ParameterSetName = 'ByConnectionString', Mandatory=$true)]
+ [string]$MasterConnectionString,
+ [Parameter(ParameterSetName = 'ByServer', Mandatory=$false)]
+ [Parameter(ParameterSetName = 'ByConnectionString', Mandatory=$false)]
+ [int]$StartTimeHour = 0,
+ [Parameter(ParameterSetName = 'ByServer', Mandatory=$false)]
+ [Parameter(ParameterSetName = 'ByConnectionString', Mandatory=$false)]
+ [int]$EndTimeHour = 6
+ )
+
+ $logLead = (Get-LogLeadName)
+
+ # Wrap negative times around to 24 hours, and >24 times back to 0.
+ if($StartTimeHour -lt 0) {
+ $StartTimeHour = 24 - (-$StartTimeHour % 24)
+ } elseif($StartTimeHour -gt 24) {
+ $StartTimeHour = $StartTimeHour % 24
+ }
+ if($EndTimeHour -lt 0) {
+ $EndTimeHour = 24 - (-$EndTimeHour % 24)
+ } elseif($EndTimeHour -gt 24) {
+ $EndTimeHour = $EndTimeHour % 24
+ }
+
+ # Return false if the start is the same as the end hour.
+ if($StartTimeHour -eq $EndTimeHour) {
+ return $false
+ }
+
+ # If the user has provided a server to pull a connection string from, do so.
+ if($PSCmdlet.ParameterSetName -eq "ByServer") {
+ $MasterConnectionString = Get-ConnectionString -name "AlkamiMaster" -ComputerName $ComputerName
+ }
+
+ # Make sure the connection string is good.
+ if([string]::IsNullOrWhiteSpace($MasterConnectionString)) {
+ Write-Error "$logLead : Master Connection String cannot be null or whitespace."
+ }
+
+ # Pull tenants (and their connection strings) from the master connection string.
+ $tenants = Get-FullTenantListFromServer -connectionString $MasterConnectionString
+
+ # Iterate over each tenant and figure out if we the current time is inside the local-time time window for each FI.
+ $insideAllTimeRanges = $true
+ foreach($tenant in $tenants) {
+
+ # Query for the timezone of the FI out of the database.
+ try {
+ $timeZoneName = Get-TenantTimeZone -TenantConnectionString $tenant.ConnectionString
+ } catch {
+ throw "$logLead : Could not query time zone from tenant $($tenant.Name)"
+ }
+
+ # Convert from the local time to the FI's time zone.
+ # Don't use UTC because we want the time to convert to the tenant's local time.
+ $timeZoneInfo = [TimeZoneInfo]::FindSystemTimeZoneById($timeZoneName)
+ $currentTimeZone = (Get-TimeZone)
+ $currentTime = (Get-Date)
+ $tenantTime = [TimeZoneInfo]::ConvertTime($currentTime, $currentTimeZone, $timeZoneInfo)
+
+ Write-Verbose "Tenant: $($tenant.Name)`n Current Time: $currentTime`n Tenant Time: $tenantTime`n Tenant Time Zone: $timeZoneName"
+
+ # If startTime > endTime
+ # startTime = -(24 - startTime)
+ # endTime = endTime
+
+ # Figure out if we are inside the time range for this particular FI.
+ $fiHour = $tenantTime.Hour
+
+ # Figure out if we are inside the time range.
+ # Account for if the start time is larger than the end time, ie if someone wants 11:00pm-1:00am at [23,1]
+ if($StartTimeHour -lt $EndTimeHour) {
+ $insideTimeRange = ($fiHour -ge $StartTimeHour) -and ($fiHour -lt $EndTimeHour)
+ }
+ if($StartTimeHour -gt $EndTimeHour) {
+ $insideTimeRange = ($fiHour -ge $StartTimeHour) -or ($fiHour -lt $EndTimeHour)
+ }
+ if(!$insideTimeRange) {
+ $insideAllTimeRanges = $false
+ Write-Verbose "$logLead : Not inside the expected time range of [$StartTimeHour-$EndTimeHour] for tenant `"$($tenant.Name)`""
+ }
+ }
+
+ return $insideAllTimeRanges
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Public/Test-OpenPorts.ps1 b/Modules/Alkami.DevOps.Operations/Public/Test-OpenPorts.ps1
new file mode 100644
index 0000000..02aee0d
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Test-OpenPorts.ps1
@@ -0,0 +1,215 @@
+function Test-OpenPorts {
+<#
+.SYNOPSIS
+ Tests common ports to see if they're open. Requires the target services to be running on the other side.
+.PARAMETER vipPrefix
+
+Alias: VIPSubnet
+The first 3 segments of the VIP Subnet. For example, for POD 8.3, the parameter value should be 10.100.83
+.PARAMETER machines
+
+Alias: MachineList
+A comma separated list of web and app servers to test. Useful when the broadcasters value is not set up.
+#>
+
+ [CmdletBinding()]
+ Param(
+ [Parameter(Mandatory=$false)]
+ [Alias("VIPSubnet")]
+ [string]$vipPrefix,
+
+ [Parameter(Mandatory=$false)]
+ [Alias("MachineList")]
+ [string]$machines
+ )
+
+ $logLead = (Get-LogLeadName);
+
+ $subscriptionServicePort = 50001 # Only server(s) running the subscription service will be listening on this port
+
+ $appServerPorts = (
+ $subscriptionServicePort,
+ 12016,
+ 8001,
+ 808,
+ 80
+ )
+
+ if (!([string]::IsNullOrEmpty($vipPrefix)))
+ {
+ [string[]]$vips = @(
+ $vipPrefix.TrimEnd('.') + ".50"
+ $vipPrefix.TrimEnd('.') + ".51"
+ $vipPrefix.TrimEnd('.') + ".52"
+ $vipPrefix.TrimEnd('.') + ".53"
+ $vipPrefix.TrimEnd('.') + ".54"
+ $vipPrefix.TrimEnd('.') + ".55"
+ $vipPrefix.TrimEnd('.') + ".56"
+ $vipPrefix.TrimEnd('.') + ".57"
+ $vipPrefix.TrimEnd('.') + ".58"
+ $vipPrefix.TrimEnd('.') + ".59"
+ $vipPrefix.TrimEnd('.') + ".60"
+ $vipPrefix.TrimEnd('.') + ".61"
+ $vipPrefix.TrimEnd('.') + ".62"
+ )
+ }
+
+ # Get the first client from the master for DB checks
+ if (Test-IsAppServer) {
+ [array]$clients += Get-CatalogsFromMaster
+ $client = $clients[0]
+ }
+
+ # Load the AD Module (and install if needed)
+ Install-ActiveDirectoryModule -Verbose:$false | Out-Null
+
+ # Figure out which database server to check
+ if (Test-IsAppServer) {
+ $masterConnectionString = Get-MasterConnectionString
+ $conStrBuilder = New-Object System.Data.SqlClient.SqlConnectionStringBuilder($masterConnectionString)
+ $dbServer = $conStrBuilder.DataSource
+ } else {
+ $reportServer = (Get-AppSetting "ReportServer")
+ [System.Uri]$reportServerUri = $null
+ if (($null -eq $reportServer) -or
+ !(
+ [System.URI]::TryCreate($reportServer, [System.UriKind]::Absolute, [ref]$reportServerUri) -and
+ ($reportServerUri.Scheme -eq [System.Uri]::UriSchemeHttp -or $reportServerUri.Scheme -eq [System.Uri]::UriSchemeHttps)
+ )
+ ) {
+ Write-Verbose ("$logLead : Unable to Create URI from ReportServer appSetting")
+ $dbServer = $null
+ } else {
+ $dbServer = $reportServerUri.Host
+ }
+ }
+
+ Write-Verbose ("$logLead : DBServer Read as {0}" -f $dbServer)
+
+ if ([String]::IsNullOrEmpty($machines))
+ {
+
+ #TODO use alk Tags to get machine list
+ # Figure out all servers in the POD from the the servers in this server's OU
+ # This won't work at AWS and will only work if the OU contains all the servers for the POD
+ # It's also pretty hacky
+ $currentOU = Get-ComputerOU
+ Write-Verbose ("$logLead : Pulling computers from OU {0} for web/app server checks" -f $currentOU)
+ $broadCasters = (Get-ADComputer -Filter * -SearchBase $currentOU)
+
+ # Try to get the Broadcasters Values first
+ $bcValue = Get-AppSetting "Broadcasters" -ErrorAction SilentlyContinue
+
+ if ($null -ne $bcValue) {
+ Write-Verbose ("$logLead : Using Broadcasters value from machine.config for web/app server checks")
+
+ $webServers = $bcValue.Split(",") | Where-Object {$_ -match "VMW"}
+ $appServers = $bcValue.Split(",") | Where-Object {$_ -match "VMA"}
+
+ Write-Verbose ("$logLead : Web Servers To Test: {0}" -f ($webServers -join ", "))
+ Write-Verbose ("$logLead : App Servers To Test: {0}" -f ($appServers -join ", "))
+ } elseif ($null -ne $dcValue) {
+ Write-Verbose ("$logLead : Using Broadcasters value from app.config for web/app server checks")
+
+ $webServers = $dcValue.Split(",") | Where-Object {$_ -match "VMW"}
+ $appServers = $dcValue.Split(",") | Where-Object {$_ -match "VMA"}
+
+ Write-Verbose ("$logLead : Web Servers To Test: {0}" -f ($webServers -join ", "))
+ Write-Verbose ("$logLead : App Servers To Test: {0}" -f ($appServers -join ", "))
+ } else {
+ # Figure out all servers in the POD from the the servers in this server's OU
+ # This won't work at AWS and will only work if the OU contains all the servers for the POD
+ # It's also pretty hacky
+ $currentOU = Get-ComputerOU
+ Write-Verbose ("$logLead : Pulling computers from OU {0} for web/app server checks" -f $currentOU)
+ $broadCasters = (Get-ADComputer -Filter * -SearchBase $currentOU)
+
+ $webServers = $broadCasters | Where-Object {$_.Name -match "VMW"}
+ $appServers = $broadCasters | Where-Object {$_.Name -match "VMA"}
+
+ Write-Verbose ("$logLead : Web Servers To Test: {0}" -f ($webServers.Name -join ", "))
+ Write-Verbose ("$logLead : App Servers To Test: {0}" -f ($appServers.Name -join ", "))
+ }
+ } else {
+ [array]$machineArray = $machines.Split(",") | ForEach-Object {$_.Trim()}
+ Write-Verbose ("$logLead : Parsed {0} machines from parameter" -f $machineArray.Count)
+
+ # This won't work at AWS
+ [string[]]$webServers = $machineArray | Where-Object {$_ -match "VMW"}
+ [string[]]$appServers = $machineArray | Where-Object {$_ -match "VMA"}
+
+ Write-Verbose ("$logLead : Web Servers To Test: {0}" -f ($webServers -join ", "))
+ Write-Verbose ("$logLead : App Servers To Test: {0}" -f ($appServers -join ", "))
+ }
+
+ # Get the Entrust URL if We Need to Test It
+ if (Test-IsAppServer) {
+ Write-Verbose ("$logLead : Getting Entrust URL from {0}" -f $client.ConnectionString)
+ $entrustUrlFromDatabase = Get-EntrustAdminUrlFromClient $client
+ [System.Uri]$entrustUri = $null
+ if (!([System.URI]::TryCreate($entrustUrlFromDatabase, [System.UriKind]::Absolute, [ref]$entrustUri) -and ($entrustUri.Scheme -eq [System.Uri]::UriSchemeHttp -or $entrustUri.Scheme -eq [System.Uri]::UriSchemeHttps))) {
+ Write-Warning ("$logLead : Unable to Read Entrust Server from Client Database")
+ $entrustServer = $null
+ } else {
+ $entrustServer = $entrustUri.Host
+ }
+ } else {
+ $entrustServer = $null
+ }
+
+ Write-Verbose ("$logLead : Entrust Server To Test: {0}" -f $entrustServer)
+
+ # Get Redis Endpoints to Test
+ $rawRedisConnectionString = Get-RedisConnectionString $client
+ $redisConnectionString = $rawRedisConnectionString.Split(":") | Select-Object -First 1
+ $redisConnectionPort = $rawRedisConnectionString.Split(":") | Select-Object -Last 1 | ForEach-Object {$_.Split(",")} | Select-Object -First 1
+
+ Write-Verbose ("$logLead : RedisConnectionString to Test: {0}:{1}" -f $redisConnectionString, $redisConnectionPort)
+
+ [string[]]$redisIPs = ([System.Net.DNS]::GetHostEntry($redisConnectionString).AddressList | Select-Object -ExpandProperty IPAddressToString)
+ Write-Verbose ("$logLead : Redis IPs to Test: {0}" -f ($redisIPs -join ", "))
+
+ $domainControllers = (Get-ADGroupMember 'Domain Controllers' | Select-Object -ExpandProperty name)
+ Write-Verbose ("$logLead : Domain Controllers to Test: {0}" -f ($domainControllers -join ", "))
+
+ if (!([String]::IsNullOrEmpty($vips)))
+ {
+ Write-Verbose ("$logLead : VIPs to Test: {0}" -f ($vips -join ", "))
+ }
+
+ # Get WSUS Endpoints to Test
+ $windowsUpdatePolicy = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate'
+ $useWsusKey = Get-ItemProperty ($windowsUpdatePolicy + "\AU") -ErrorAction SilentlyContinue | Select-Object -ExpandProperty UseWUServer -ErrorAction SilentlyContinue
+
+ if ($null -ne $useWsusKey -and $useWsusKey -eq 1)
+ {
+ [System.Uri]$uri = Get-ItemProperty $windowsUpdatePolicy | Select-Object -ExpandProperty WUServer
+ Write-Verbose ("$logLead : WSUS Servers to Test: {0}" -f $uri.Host)
+ }
+ else
+ {
+ Write-Verbose ("$logLead : Server is Not Configured to use WSUS")
+ }
+
+ $portList = @()
+
+ foreach ($server in $appServers)
+ {
+ if ($null -ne $server.Name)
+ {
+ $serverName = $server.Name
+ }
+ else
+ {
+ $serverName = $server
+ }
+
+ foreach ($appServerPort in $appServerPorts)
+ {
+ Write-Verbose ("$logLead : Adding Type:App, Server:{0}, Port={1}" -f $serverName, $appServerPort)
+ $portList += @{Type = "ORB App"; Server = $serverName; Port = $appServerPort; }
+ }
+
+ # This doesn't work in Armor for reasons unknown...
+ }
+}
diff --git a/Modules/Alkami.DevOps.Operations/Public/Test-SmtpServer.ps1 b/Modules/Alkami.DevOps.Operations/Public/Test-SmtpServer.ps1
new file mode 100644
index 0000000..7cbacb0
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Test-SmtpServer.ps1
@@ -0,0 +1,146 @@
+function Test-SmtpServer {
+ <#
+.SYNOPSIS
+ Test if email is correctly set up for a given FI
+
+.PARAMETER FIname
+ Name of the FI to test
+
+.PARAMETER SendTo
+ Intended recipient of the test email
+
+#>
+ [CmdletBinding()]
+ param (
+ [Parameter(Mandatory = $true)]
+ [string]$FIname,
+ [Parameter(Mandatory = $false)]
+ [string]$SendTo
+ )
+
+ $logLead = Get-LogLeadName
+
+ try {
+ Import-Module ActiveDirectory
+ } catch {
+ Write-Error "$logLead : Error loading AD module"
+ }
+
+ # Get the current AD User's info including the extended mail property
+ $currentUser = Get-ADUser $env:username -Server "corp.alkamitech.com" -Properties mail
+
+ # If $SendTo was not provided as an argument and we can't pull the AD email, then respond with error
+ if ([string]::IsNullOrEmpty($currentUser.mail) -and [string]::IsNullOrEmpty($SendTo)) {
+ Write-Error "$logLead : Unable to dynamically pull AD email. Call this function against and provide a SendTo argument value with an email"
+ return
+ } elseif ([string]::IsNullOrEmpty($SendTo)) {
+ # Set the $SendTo value as the AD User's email address
+ $SendTo = $currentUser.mail
+ }
+
+ # Read connectionstring from machine.config for master
+ try {
+ $MasterConnectionString = Get-ConnectionString -name "AlkamiMaster"
+ } catch {
+ Write-Error "$logLead : Could not get/find a Master Connection String in machine.config - $($_.Exception.Message)"
+ return
+ }
+
+ # Open connection to AlkamiMaster to get FI DB ConnectionString
+ $Connection = New-Object System.Data.SqlClient.SqlConnection
+ $Connection.ConnectionString = $MasterConnectionString
+
+ $Command = New-Object System.Data.SQLClient.SQLCommand
+ $Command.Connection = $Connection
+ $Command.CommandText = "select connectionstring as ConnectionString
+ from dbo.tenant
+ where bankurlsignatures like '%$FIName%'"
+ $errorDuringQuery = ""
+
+ # Check Master dbo.Tenant to find FI
+ try {
+ $Connection.Open()
+ $FIDBConnectionString = $Command.ExecuteScalar()
+
+ if ([string]::IsNullOrEmpty($FIDBConnectionString)) { #no match in DB, we are done
+ $errorDuringQuery = "$logLead : No match in AlkamiMaster for $FIname."
+ }
+ } catch {
+ $errorDuringQuery = "$logLead : Error: $($_.Exception.Message)"
+ } finally {
+ $Connection.Close()
+ if (![string]::IsNullOrEmpty($errorDuringQuery)) {
+ Write-Error $errorDuringQuery
+ }
+ }
+
+
+ $Command.CommandText = "select its.Name as KeyName, its.Value as KeyValue
+ from core.itemsetting as its
+ join core.item as i
+ on i.id = its.itemid
+ where i.name like '%smtp%'
+ and its.name in ('Sender Address','SMTP Password','SMTP Port','SMTP Server','SMTP Username','Use SSL');"
+
+ $Connection.ConnectionString = $FIDBConnectionString
+
+
+ try {
+ $Connection.Open()
+ $reader = $Command.ExecuteReader()
+ Write-Host $Connection.ConnectionString
+
+ # Verify that there was a return result
+ if (!$reader.HasRows) {
+ Write-Warning "No matches for SMTP key/values in $Database for $FIname. Script ending..."
+ return
+ }
+
+ while ( $reader.Read()) {
+ switch ($reader[0]) {
+ "SMTP Port" {$SMTPPort = $reader[1];break}
+
+ "Sender Address" {$testFrom = $reader[1];break}
+
+ "SMTP Password" {$SMTPPassword = ConvertTo-SecureString $reader[1] -AsPlainText -Force;break}
+
+ "SMTP Server" {$SMTPServer = $reader[1];break}
+
+ "SMTP Username" {$SMTPUsername = $reader[1];break}
+
+ "Use SSL" {$UseSSL = $reader[1];break}
+ default {break}
+ }
+ }
+ $reader.Close()
+
+ } catch {
+ $errorDuringQuery = "$logLead : Error: $($_.Exception.Message)"
+ } finally {
+ $Connection.Close()
+ if (![string]::IsNullOrEmpty($errorDuringQuery)) {
+ Write-Error $errorDuringQuery
+ }
+ }
+
+ $testSubject = "Test email from $FIname"
+ $testBody = "Test Body from $FIname"
+
+ $SMTPMessage = New-Object System.Net.Mail.MailMessage($testFrom,$SendTo,$testSubject,$testBody)
+
+ $SMTPClient = New-Object Net.Mail.SmtpClient($SmtpServer, $SMTPPort)
+ $SMTPClient.EnableSsl = $UseSSL
+ $SMTPClient.Credentials = New-Object System.Net.NetworkCredential($SMTPUsername, $SMTPPassword);
+
+ try {
+ $SMTPClient.Send($SMTPMessage)
+ } catch {
+ $errorDuringQuery = "$logLead : Error: $($_.Exception.Message)"
+ } finally {
+ if (![string]::IsNullOrEmpty($errorDuringQuery)) {
+ Write-Error $errorDuringQuery
+ } else {
+ Write-Host "$logLead : Email successfully sent to $SendTo"
+ }
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Public/Test-SymConnectNonLoopbackExists.ps1 b/Modules/Alkami.DevOps.Operations/Public/Test-SymConnectNonLoopbackExists.ps1
new file mode 100644
index 0000000..7a14699
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Test-SymConnectNonLoopbackExists.ps1
@@ -0,0 +1,95 @@
+function Test-SymConnectNonLoopbackExists {
+ <#
+ .SYNOPSIS
+ Tests hosts file for a non loopback entry for SymconnectMultiplexer
+ .DESCRIPTION
+ Uses a regex to look for non loopback entry, returns true is non loopback found, returns false if not found.
+ .PARAMETER ComputerName
+ The computer name of the remote machine.
+ .EXAMPLE
+ $TestSymConnectNonLoopbackExists = Test-SymConnectNonLoopbackExists
+ .INPUTS
+ none
+ .OUTPUTS
+ $bool
+ .NOTES
+ Explanation of the regex
+ ^ asserts position at start of a line
+ \s matches any whitespace character
+ * matches the previous token between zero and unlimited times, as many times as possible, giving back as needed (greedy)
+ Negative Lookahead (?!127\.0{1,3}\.0{1,3}\.0{0,2}1)
+ Assert that the Regex below does not match
+ 127 matches the characters 127 literally (case sensitive)
+ \. matches the character . literally (case sensitive)
+ 0 matches the character 0 literally (case sensitive)
+ {1,3} matches the previous token between 1 and 3 times, as many times as possible, giving back as needed (greedy)
+ \. matches the character . literally (case sensitive)
+ 0 matches the character 0 literally (case sensitive)
+ {1,3} matches the previous token between 1 and 3 times, as many times as possible, giving back as needed (greedy)
+ \. matches the character . literally (case sensitive)
+ 0 matches the character 0 literally (case sensitive)
+ 1 matches the character 1 literally (case sensitive)
+ 1st Capturing Group ((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}
+ {3} matches the previous token exactly 3 times
+ A repeated capturing group will only capture the last iteration. Put a capturing group around the repeated group to capture all iterations or use a non-capturing group instead if you're not interested in the data
+ 2nd Capturing Group (25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)
+ 1st Alternative 25[0-5]
+ 25 matches the characters 25 literally (case sensitive)
+ Match a single character present in the list below [0-5]
+ 2nd Alternative 2[0-4][0-9]
+ 2 matches the character 2 literally (case sensitive)
+ Match a single character present in the list below [0-4]
+ Match a single character present in the list below [0-9]
+ 3rd Alternative [01]?[0-9][0-9]?
+ \. matches the character . literally (case sensitive)
+ 3rd Capturing Group (25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)
+ 1st Alternative 25[0-5]
+ 25 matches the characters 25 literally (case sensitive)
+ Match a single character present in the list below [0-5]
+ 0-5 matches a single character in the range between 0 (index 48) and 5 (index 53) (case sensitive)
+ 2nd Alternative 2[0-4][0-9]
+ 2 matches the character 2 literally (case sensitive)
+ Match a single character present in the list below [0-4]
+ 0-4 matches a single character in the range between 0 (index 48) and 4 (index 52) (case sensitive)
+ Match a single character present in the list below [0-9]
+ 3rd Alternative [01]?[0-9][0-9]?
+ Match a single character present in the list below [01]
+ ? matches the previous token between zero and one times, as many times as possible, giving back as needed (greedy)
+ 01 matches a single character in the list 01 (case sensitive)
+ Match a single character present in the list below [0-9]
+ Match a single character present in the list below [0-9]
+ \s matches any whitespace character (equivalent to [\r\n\t\f\v ])
+ + matches the previous token between one and unlimited times, as many times as possible, giving back as needed (greedy)
+ SymConnectMultiplexer matches the characters SymConnectMultiplexer literally (case sensitive)
+ . matches any character (except for line terminators)
+ * matches the previous token between zero and unlimited times, as many times as possible, giving back as needed (greedy)
+ $ asserts position at the end of a line
+
+ #>
+ [CmdletBinding()]
+ param (
+ [Parameter(Mandatory = $false)]
+ [string]$ComputerName = $null
+ )
+
+ $logLead = (Get-LogLeadName)
+
+ if (Test-StringIsNullOrWhitespace -Value $ComputerName) {
+ $ComputerName = $env:COMPUTERNAME
+ }
+
+ $hostsFilePath = Get-UncPath -FilePath "$env:windir\System32\Drivers\etc\hosts" -ComputerName $ComputerName -IgnoreLocalPaths
+ $hostContent = Get-HostsFileContent -hostsPath $hostsFilePath
+ Write-Host "$logLead : Checking $ComputerName host file for SymconnectMultiplexer loopback."
+ $regex2 = '^\s*(?!127\.0{1,3}\.0{1,3}\.0{0,2}1)((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\s+SymConnectMultiplexer.*$'
+
+ $selectedHostLine = $hostContent | Select-String -Pattern $regex2
+ if ($selectedHostLine.count -gt 0) {
+ Write-Host "$logLead : Found the following non loopback entries on host $env:COMPUTERNAME:"
+ Write-Host $selectedHostLine
+ return $true
+ } else {
+ Write-Host "$loglead : No non loopback entries found for SymconnectMultiplexer"
+ return $false
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Public/Test-SymConnectNonLoopbackExists.tests.ps1 b/Modules/Alkami.DevOps.Operations/Public/Test-SymConnectNonLoopbackExists.tests.ps1
new file mode 100644
index 0000000..a96b9f7
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Test-SymConnectNonLoopbackExists.tests.ps1
@@ -0,0 +1,75 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ""
+
+Describe "Test-SymConnectNonLoopbackExists" {
+
+ Mock Write-Host {}
+
+ Context "Test single line Regex accuracy" {
+ It "returns true for non loopback with comment" {
+ Mock -CommandName Get-HostsFileContent -MockWith {
+ return "123.4.5.6 SymConnectMultiplexer #this can be set back to 127.0.0.1 later"
+ }
+ Test-SymConnectNonLoopbackExists | Should -BeTrue
+ }
+ It "returns true for non loopback" {
+ Mock -CommandName Get-HostsFileContent -MockWith {
+ return "123.4.5.6 SymConnectMultiplexer"
+ }
+ Test-SymConnectNonLoopbackExists | Should -BeTrue
+ }
+ It "returns false for non loopback with not symconnect hostname" {
+ Mock -CommandName Get-HostsFileContent -MockWith {
+ return "123.4.5.6 bankservice"
+ }
+ Test-SymConnectNonLoopbackExists | Should -beFalse
+ }
+ It "returns false for loopback and not symmconnect hostname " {
+ Mock -CommandName Get-HostsFileContent -MockWith {
+ return "127.0.0.1 bankservice"
+ }
+ Test-SymConnectNonLoopbackExists | Should -BeFalse
+ }
+ It "returns false for loopback with spaces " {
+ Mock -CommandName Get-HostsFileContent -MockWith {
+ return " 127.0.0.1 bankservice "
+ }
+ Test-SymConnectNonLoopbackExists | Should -BeFalse
+ }
+ It "returns false for commented line" {
+ Mock -CommandName Get-HostsFileContent -MockWith {
+ return "# 127.0.0.1 SymConnectMultiplexer"
+ }
+ Test-SymConnectNonLoopbackExists | Should -BeFalse
+ }
+
+ }
+ Context "Test multi line Regex accuracy" {
+ It "returns true for non loopback with comment and commented loopback" {
+ Mock -CommandName Get-HostsFileContent -MockWith {
+ return @("123.4.5.6 SymConnectMultiplexer #this can be set back to 127.0.0.1 later",
+ "# 127.0.0.1 SymConnectMultiplexer")
+ }
+ Test-SymConnectNonLoopbackExists | Should -BeTrue
+ }
+ It "returns true for non loopback and commented non loopback" {
+ Mock -CommandName Get-HostsFileContent -MockWith {
+ return @("123.4.5.6 SymConnectMultiplexer",
+ "# 123.4.5.6 SymConnectMultiplexer #this can be set back to 127.0.0.1 later")
+ }
+ Test-SymConnectNonLoopbackExists | Should -BeTrue
+ }
+ It "returns false for non loopback with not symconnect hostname and commented loopback" {
+ Mock -CommandName Get-HostsFileContent -MockWith {
+ return @("123.4.5.6 bankservice",
+ "#127.0.0.1 SymConnectMultiplexer")
+ }
+ Test-SymConnectNonLoopbackExists | Should -BeFalse
+ }
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Public/Test-SymitarPorts.ps1 b/Modules/Alkami.DevOps.Operations/Public/Test-SymitarPorts.ps1
new file mode 100644
index 0000000..4100c08
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Test-SymitarPorts.ps1
@@ -0,0 +1,94 @@
+function Test-SymitarPorts {
+<#
+
+.SYNOPSIS
+ Test a Symitar IP address and list of ports for connectivity.
+
+.DESCRIPTION
+ Loops over the input list of ports and calls 'Test-TcpConnection' for each port.
+
+.PARAMETER ipAddress
+ [string] The IP address of the Symitar host to test.
+
+.PARAMETER ports
+ [uint32[]] The array of ports on the Symitar host to test.
+
+.PARAMETER msTimeout
+ [uint32] Optional - the connection timeout in milliseconds. Default is 500.
+
+.PARAMETER msDelay
+ [uint32] Optional - the delay between each port test in milliseconds. Default is 0.
+
+.EXAMPLE
+ Test-SymitarPorts -ipAddress '10.0.28.11' -ports @(12138, 12139)
+
+ Port Result
+ ---- ------
+12138 True
+12139 True
+
+
+True
+
+.EXAMPLE
+ Test-SymitarPorts -ipAddress '10.0.28.11' -ports (12138..12140)
+
+ Port Result
+ ---- ------
+12138 True
+12139 False
+12140 True
+
+
+False
+#>
+
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory = $true)]
+ [ValidateNotNullorEmpty()]
+ [string]$ipAddress,
+
+ [Parameter(Mandatory = $true)]
+ [ValidateNotNullorEmpty()]
+ [uint32[]]$ports,
+
+ [Parameter(Mandatory = $false)]
+ [ValidateRange(1, [uint32]::MaxValue)]
+ [uint32]$msTimeout = 500,
+
+ [Parameter(Mandatory = $false)]
+ [ValidateRange(0, [uint32]::MaxValue)]
+ [uint32]$msDelay = 0
+ )
+
+ $isApp = Test-IsAppServer
+ $isMic = Test-IsMicroServer
+
+ if ( ( $isApp -eq $false ) -and ( $isMic -eq $false ) ) {
+
+ Write-Error "This function is only available on an App or Mic VM."
+ return $null
+ }
+
+ $output = @()
+ $finalResult = $true
+
+ foreach ( $port in $ports ) {
+
+ $result = (Test-TcpConnection -ipAddress $ipAddress -port $port -msTimeout $msTimeout -requestMessage "`n" -expectedResponse "RSWHAT?`n")
+
+ $finalResult = ( $finalResult -and $result )
+
+ $output += New-Object PSObject -Property @{
+ 'Port' = $port
+ 'Result' = $result
+ }
+
+ Start-Sleep -Milliseconds $msDelay
+ }
+
+ $output | Format-Table -AutoSize -Property Port, Result | Out-String
+
+ return $finalResult
+}
diff --git a/Modules/Alkami.DevOps.Operations/Public/Test-SymitarPorts.tests.ps1 b/Modules/Alkami.DevOps.Operations/Public/Test-SymitarPorts.tests.ps1
new file mode 100644
index 0000000..89f8a4c
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Test-SymitarPorts.tests.ps1
@@ -0,0 +1,82 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ""
+
+Describe "Test-SymitarPorts" {
+
+ Context "Input Validation" {
+
+ It "IP Address Should Not Be Null" {
+
+ { Test-SymitarPorts -ipAddress $null } | Should Throw
+ }
+
+ It "IP Address Should Not Be Empty" {
+
+ { Test-SymitarPorts -ipAddress '' } | Should Throw
+ }
+
+ It "Ports Array Should Not Be Null" {
+
+ { Test-SymitarPorts -ipAddress '127.0.0.1' -ports $null } | Should Throw
+ }
+
+ It "Ports Array Should Not Be Empty" {
+
+ { Test-SymitarPorts -ipAddress '127.0.0.1' -ports @() } | Should Throw
+ }
+
+ It "Ports Array Should Contain Positive Integers" {
+
+ { Test-SymitarPorts -ipAddress '127.0.0.1' -ports @(-1) } | Should Throw
+ }
+
+ It "Ports Array Should Contain Unsigned 32-bit Integers" {
+
+ { Test-SymitarPorts -ipAddress '127.0.0.1' -ports @([uint32]::MaxValue + 1) } | Should Throw
+ }
+
+ It "Timeout Should Be Natural Number" {
+
+ { Test-SymitarPorts -ipAddress '127.0.0.1' -ports @(12345) -msTimeout -1 } | Should Throw
+ }
+
+ It "Timeout Should Be Unsigned 32-bit Integer" {
+
+ { Test-SymitarPorts -ipAddress '127.0.0.1' -ports @(12345) -msTimeout ([uint32]::MaxValue + 1) } | Should Throw
+ }
+
+ It "Delay Should Be Positive Integer" {
+
+ { Test-SymitarPorts -ipAddress '127.0.0.1' -ports @(12345) -msDelay -1 } | Should Throw
+ }
+
+ It "Delay Should Be Unsigned 32-bit Integer" {
+
+ { Test-SymitarPorts -ipAddress '127.0.0.1' -ports @(12345) -msDelay ([uint32]::MaxValue + 1) } | Should Throw
+ }
+ }
+
+ Context "Tier Validation" {
+
+ It "Should Be Run on App Or Microservice Machine" {
+
+ Mock -CommandName Test-IsAppServer -ModuleName $moduleForMock -MockWith { return $false }
+ Mock -CommandName Test-IsMicroServer -ModuleName $moduleForMock -MockWith { return $false }
+ Mock -CommandName Write-Error -ModuleName $moduleForMock -MockWith {}
+
+ $result = ( Test-SymitarPorts -ipAddress '127.0.0.1' -ports @(12345) )
+
+ Assert-MockCalled -CommandName Test-IsAppServer -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Test-IsMicroServer -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Write-Error -Times 1 -Exactly -Scope It `
+ -ModuleName $moduleForMock -ParameterFilter { $Message -match "This function is only available on an App or Mic VM." }
+
+ $result | Should -BeFalse
+ }
+ }
+}
diff --git a/Modules/Alkami.DevOps.Operations/Public/Test-TcpConnection.ps1 b/Modules/Alkami.DevOps.Operations/Public/Test-TcpConnection.ps1
new file mode 100644
index 0000000..3aaa70d
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Test-TcpConnection.ps1
@@ -0,0 +1,109 @@
+function Test-TcpConnection {
+<#
+
+.SYNOPSIS
+ Test an IP address and port for TCP connectivity.
+
+.DESCRIPTION
+ Establishes a TCP connection to the specified IP address and port.
+ Optionally, sends a string request to the connection and compares
+ the response to a specified expected value.
+
+.PARAMETER ipAddress
+ [string] The IP address of the host to test.
+
+.PARAMETER port
+ [uint32] The port on the host to test.
+
+.PARAMETER msTimeout
+ [uint32] The connection timeout in milliseconds.
+
+.PARAMETER requestMessage
+ [string] The request message to send on the TCP connection.
+
+.PARAMETER expectedResponse
+ [string] The expected response message from the TCP connection.
+
+.EXAMPLE
+ Test-TcpConnection -ipAddress '10.0.28.11' -port 12138 -msTimeout 5000
+
+True
+
+.EXAMPLE
+ Test-TcpConnection -ipAddress '10.0.28.11' -port 12138 -msTimeout 5000 -requestMessage "`n" -expectedResponse "RSWHAT?`n"
+
+True
+#>
+
+ [CmdletBinding(DefaultParameterSetName='Default')]
+ param(
+ [Parameter(ParameterSetName='Default', Mandatory = $true)]
+ [Parameter(ParameterSetName='ReqRes' , Mandatory = $true)]
+ [ValidateNotNullorEmpty()]
+ [string]$ipAddress,
+
+ [Parameter(ParameterSetName='Default', Mandatory = $true)]
+ [Parameter(ParameterSetName='ReqRes' , Mandatory = $true)]
+ [ValidateRange(1, [uint32]::MaxValue)]
+ [uint32]$port,
+
+ [Parameter(ParameterSetName='Default', Mandatory = $true)]
+ [Parameter(ParameterSetName='ReqRes' , Mandatory = $true)]
+ [ValidateRange(1, [uint32]::MaxValue)]
+ [uint32]$msTimeout,
+
+ [Parameter(ParameterSetName='ReqRes', Mandatory = $true)]
+ [ValidateNotNullorEmpty()]
+ [string]$requestMessage,
+
+ [Parameter(ParameterSetName='ReqRes', Mandatory = $true)]
+ [ValidateLength(1,4096)]
+ [string]$expectedResponse
+ )
+
+ $tcpStream = $null
+ $tcpObject = $null
+ $tcpConnect = $null
+ $result = $false
+
+ try {
+
+ # Attempt the connection.
+ $tcpObject = New-Object System.Net.Sockets.TcpClient
+ $tcpConnect = $tcpObject.BeginConnect( $ipAddress, $port, $null, $null )
+ $result = $tcpConnect.AsyncWaitHandle.WaitOne( $msTimeout , $false )
+
+ if ( $result -and $PSBoundParameters.ContainsKey('requestMessage') ) {
+
+ # Write the request message, converting the input to an ASCII byte array.
+ $tcpStream = $tcpObject.GetStream()
+ $message = [System.Text.Encoding]::ASCII.GetBytes($requestMessage)
+ $tcpStream.Write( $message, 0, $message.Length )
+
+ # Read the response, converting the ASCII byte array back to a string
+ # (for ease of debugging).
+ $buf = New-Object System.Byte[] 4096
+ $count = $tcpStream.Read( $buf, 0, 4096 )
+ $response = [System.Text.Encoding]::ASCII.GetString( $buf, 0, $count )
+
+ $result = ( $expectedResponse -eq $response )
+ }
+
+ } catch {
+
+ $_.Exception | Format-List -force
+ $result = $false
+
+ } finally {
+
+ if ( $null -ne $tcpStream ) {
+ $tcpStream.Close()
+ }
+
+ if ( $null -ne $tcpObject ) {
+ $tcpObject.Close()
+ }
+ }
+
+ return $result
+}
diff --git a/Modules/Alkami.DevOps.Operations/Public/Test-TcpConnection.tests.ps1 b/Modules/Alkami.DevOps.Operations/Public/Test-TcpConnection.tests.ps1
new file mode 100644
index 0000000..61e432e
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Test-TcpConnection.tests.ps1
@@ -0,0 +1,71 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ""
+
+Describe "Test-TcpConnection" {
+
+ Context "Input Validation" {
+
+ It "IP Address Should Not Be Null" {
+
+ { Test-TcpConnection -ipAddress $null } | Should Throw
+ }
+
+ It "IP Address Should Not Be Empty" {
+
+ { Test-TcpConnection -ipAddress '' } | Should Throw
+ }
+
+ It "Port Should Be Positive Integer" {
+
+ { Test-TcpConnection -ipAddress '127.0.0.1' -port -1 } | Should Throw
+ }
+
+ It "Port Should Be Unsigned 32-bit Integer" {
+
+ { Test-TcpConnection -ipAddress '127.0.0.1' -port ([uint32]::MaxValue + 1) } | Should Throw
+ }
+
+ It "Timeout Should Be Natural Number" {
+
+ { Test-TcpConnection -ipAddress '127.0.0.1' -port 12345 -msTimeout -1 } | Should Throw
+ }
+
+ It "Timeout Should Be Unsigned 32-bit Integer" {
+
+ { Test-TcpConnection -ipAddress '127.0.0.1' -port 12345 -msTimeout ([uint32]::MaxValue + 1) } | Should Throw
+ }
+
+ It "Request Message Should Not Be Null" {
+
+ { Test-TcpConnection -ipAddress '127.0.0.1' -port 12345 -msTimeout 1 -requestMessage $null } | Should Throw
+ }
+
+ It "Request Message Should Not Be Empty" {
+
+ { Test-TcpConnection -ipAddress '127.0.0.1' -port 12345 -msTimeout 1 -requestMessage '' } | Should Throw
+ }
+
+ It "Expected Response Should Not Be $null" {
+
+ { Test-TcpConnection -ipAddress '127.0.0.1' -port 12345 -msTimeout 1 -requestMessage 'Test' -expectedResponse $null } | Should Throw
+ }
+
+ It "Expected Response Should Not Be Empty" {
+
+ { Test-TcpConnection -ipAddress '127.0.0.1' -port 12345 -msTimeout 1 -requestMessage 'Test' -expectedResponse '' } | Should Throw
+ }
+
+ It "Expected Response Should Not Be Greater Than 4096 Bytes" {
+
+ $buffer = [System.Byte[]] (,0x65 * 4097)
+ $bufferStr = [System.Text.Encoding]::ASCII.GetString( $buffer, 0, 4097 )
+
+ { Test-TcpConnection -ipAddress '127.0.0.1' -port 12345 -msTimeout 1 -requestMessage 'Test' -expectedResponse $bufferStr } | Should Throw
+ }
+ }
+}
diff --git a/Modules/Alkami.DevOps.Operations/Public/Test-VpcExists.ps1 b/Modules/Alkami.DevOps.Operations/Public/Test-VpcExists.ps1
new file mode 100644
index 0000000..bc2ff12
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Test-VpcExists.ps1
@@ -0,0 +1,58 @@
+function Test-VpcExists {
+
+<#
+.SYNOPSIS
+ Determine if a VPC exists using the specified parameters.
+
+.PARAMETER Id
+ [string] The ID of the VPC.
+
+.PARAMETER Region
+ [string] The AWS region of the VPC (e.g. 'us-east-1').
+
+.PARAMETER ProfileName
+ [string] The AWS profile where the VPC is located (e.g. 'temp-prod').
+
+.OUTPUTS
+ [Boolean] True if the VPC exists; false otherwise.
+
+.EXAMPLE
+ Test-VpcExists -Id 'vpc-1234' -Region 'us-east-1' -ProfileName 'temp-prod'
+
+True
+#>
+
+ [OutputType([Boolean])]
+ [CmdletBinding()]
+ param (
+ [Parameter(Mandatory = $true)]
+ [ValidateNotNullOrEmpty()]
+ [string] $Id,
+
+ [Parameter(Mandatory = $true)]
+ [ValidateScript({$_ -in (Get-AWSRegion).region})]
+ [string] $Region,
+
+ [Parameter(Mandatory = $true)]
+ [ValidateNotNullOrEmpty()]
+ [string] $ProfileName
+ )
+
+ $result = $false
+ $logLead = (Get-LogLeadName)
+
+ Import-AWSModule
+
+ try {
+
+ # Attempt the VPC lookup using the arguments provided.
+ $vpcEntry = ( Get-EC2Vpc -VpcId $Id -ProfileName $ProfileName -Region $Region )
+ $result = ( $null -ne $vpcEntry )
+
+ } catch {
+
+ Write-Verbose "$logLead : $_"
+ }
+
+ return $result
+}
diff --git a/Modules/Alkami.DevOps.Operations/Public/Test-VpcExists.tests.ps1 b/Modules/Alkami.DevOps.Operations/Public/Test-VpcExists.tests.ps1
new file mode 100644
index 0000000..2581d02
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Test-VpcExists.tests.ps1
@@ -0,0 +1,91 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ""
+
+Describe "Test-VpcExists" {
+
+ Mock -CommandName Get-AWSRegion -ModuleName $moduleForMock -MockWith {
+ return @(
+ @{ 'Region' = 'us-east-1' },
+ @{ 'Region' = 'us-west-2' }
+ )
+ }
+
+ Mock -CommandName Import-AWSModule -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Get-LogLeadName -ModuleName $moduleForMock -MockWith { return 'Test-VpcExists.tests' }
+
+ Context "Input Validation" {
+
+ It "ID Should Not Be Null" {
+
+ { Test-VpcExists -Id $null } | Should -Throw
+ }
+
+ It "ID Should Not Be Empty" {
+
+ { Test-VpcExists -Id '' } | Should -Throw
+ }
+
+ It "Region Should Not Be Null" {
+
+ { Test-VpcExists -Id 'Test' -Region $null } | Should -Throw
+ }
+
+ It "Region Should Not Be Empty" {
+
+ { Test-VpcExists -Id 'Test' -Region '' } | Should -Throw
+ }
+
+ It "Region Should Be In Supported Regions List" {
+
+ { Test-VpcExists -Id 'Test' -Region 'Test' } | Should -Throw
+ }
+
+ It "ProfileName Should Not Be Null" {
+
+ { Test-VpcExists -Id 'Test' -Region 'us-east-1' -ProfileName $null } | Should -Throw
+ }
+
+ It "ProfileName Should Not Be Empty" {
+
+ { Test-VpcExists -Id 'Test' -Region 'us-east-1' -ProfileName '' } | Should -Throw
+ }
+ }
+
+ Context "Result Validation" {
+
+ It "Should Return False When AWS Call Throws" {
+
+ Mock -CommandName Get-EC2Vpc -ModuleName $moduleForMock -MockWith { throw "This is a test." }
+
+ $result = Test-VpcExists -Id 'Test' -Region 'us-east-1' -ProfileName 'Test'
+ $result | Should -BeFalse
+
+ Assert-MockCalled -CommandName Get-EC2Vpc -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+ }
+
+ It "Should Return False When VPC Not Found" {
+
+ Mock -CommandName Get-EC2Vpc -ModuleName $moduleForMock -MockWith { return $null }
+
+ $result = Test-VpcExists -Id 'Test' -Region 'us-east-1' -ProfileName 'Test'
+ $result | Should -BeFalse
+
+ Assert-MockCalled -CommandName Get-EC2Vpc -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+ }
+
+ It "Should Return True When VPC Found" {
+
+ Mock -CommandName Get-EC2Vpc -ModuleName $moduleForMock -MockWith { return 'Test' }
+
+ $result = Test-VpcExists -Id 'Test' -Region 'us-east-1' -ProfileName 'Test'
+ $result | Should -BeTrue
+
+ Assert-MockCalled -CommandName Get-EC2Vpc -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+ }
+ }
+}
diff --git a/Modules/Alkami.DevOps.Operations/Public/Update-AlkamiDatabase.ps1 b/Modules/Alkami.DevOps.Operations/Public/Update-AlkamiDatabase.ps1
new file mode 100644
index 0000000..8111fd9
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Update-AlkamiDatabase.ps1
@@ -0,0 +1,107 @@
+Workflow Update-AlkamiDatabase {
+<#
+.SYNOPSIS
+ Database Migration
+
+.PARAMETER MigratePath
+ Path to migration exe and dlls
+.PARAMETER WhatIf
+ Show planned execution without actually changing database
+.PARAMETER Tee
+ Log output to a file in $MigratePath or $fallbackMigratePath
+.PARAMETER MasterConnectionString
+ Connection string for Master Database
+#>
+ [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingInvokeExpression', '', Justification = 'Alkami generates this string manually, no user injection')]
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory=$false)]
+ [string]$MigratePath = "C:\Temp\deploy\DatabaseMigration\",
+ [Parameter(Mandatory=$false)]
+ [switch]$WhatIf,
+ [Parameter(Mandatory=$false)]
+ [switch]$Tee,
+ [Parameter(Mandatory=$false)]
+ [string]$MasterConnectionString
+ )
+
+ $logLead = Get-LogLeadName
+ $DateTime = Get-Date -Format "MMddhhmm"
+
+ $migrationPath = $MigratePath
+ # Old Folder name
+ $fallbackMigratePath = "C:\Temp\deploy\FluentMigrator\"
+ if (!(Test-Path -Path $migrationPath)) {
+ Write-Warning ("$logLead : Could not find path {0} -- defaulting to path {1}" -f $migrationPath, $fallbackMigratePath)
+ $migrationPath = $fallbackMigratePath
+ }
+
+ $migrateExe = Join-Path -Path $migrationPath -ChildPath "Migrate.exe"
+ $masterDll = Join-Path -Path $migrationPath -ChildPath "Alkami.Tools.MasterDatabaseMigration.dll"
+ $tenantDll = Join-Path -Path $migrationPath -ChildPath "Alkami.Tools.TenantMigration.dll"
+ $tempPath = Split-Path -Path $migrationPath -Parent
+
+ if (!(Test-Path $migrateExe)) {
+ throw ("$logLead : File not found {0}"-f $migrateExe)
+ } elseif (!(Test-Path $masterDll)) {
+ throw ("$logLead : File not found {0}"-f $masterDll)
+ } elseif (!(Test-Path $tenantDll)) {
+ throw ("$logLead : File not found {0}"-f $tenantDll)
+ }
+
+ if (!($MasterConnectionString)) {
+ $MasterConnectionString = InlineScript { Get-MasterConnectionString }
+ }
+
+ if ($null -ne $MasterConnectionString -and $MasterConnectionString -ne "REPLACEME") {
+ Write-Output "$logLead : Getting Tenants Using Connection String $MasterConnectionString"
+ $tenants = InlineScript { Get-CatalogsFromMaster $using:MasterConnectionString }
+ Write-Output ("$logLead : {0} Tenants Found" -f $tenants.Count)
+ } else {
+ throw "$logLead : Could Not Find a Valid AlkamiMaster Connection String in the machine.config and it was not passed as a parameter"
+ }
+
+ if ($WhatIf.IsPresent -eq $true) {
+ $preview = "--preview"
+ } elseif ($WhatIf.IsPresent -eq $false) {
+ $preview = $null
+ }
+
+ if ($Verbose.IsPresent -eq $true) {
+ $vString = "--verbose"
+ } elseif ($Verbose.IsPresent -eq $false) {
+ $vString = $null
+ }
+
+ if ($MasterConnectionString) {
+ if ($Tee.IsPresent -eq $true) {
+ $masterLog = join-Path -Path $tempPath -childpath "MasterMigration_$DateTime.log"
+ $masterPath = "| Tee-Object -FilePath $masterLog"
+ } elseif ($Tee.IsPresent -eq $false) {
+ $masterPath = $null
+ }
+
+ $masterMigration = "$migrateExe -c='$MasterConnectionString' -a='$masterDll' --db='sqlserver2008' --timeout=2500 '$vString' -task='migrate' $preview $masterPath"
+ Write-Information "$logLead : Executing master migration command: $masterMigration"
+ $masterResults = Invoke-Expression $masterMigration
+ Write-Output $masterResults
+ }
+ if ($tenants) {
+ foreach -parallel ($tenant in $tenants) {
+ if ($Tee.IsPresent -eq $true) {
+ $tenantLog = join-Path -Path $tempPath -childpath "$($tenant.catalog)_$DateTime.log"
+ $tenantPath = "| Tee-Object -FilePath $tenantLog"
+ } elseif ($Tee.IsPresent -eq $false) {
+ $tenantPath = $null
+ }
+ $tenantMigration = "$migrateExe -c='$($tenant.ConnectionString)' -a='$tenantDll' --db='sqlserver2008' --timeout=2500 '$vString' -task='migrate' $preview $tenantPath"
+ Write-Information "$logLead : Executing tenant migration command: $tenantMigration"
+ $tenantResults = Invoke-Expression $tenantMigration
+
+ Write-Output $tenantResults
+ }
+ }
+ if ($Tee.IsPresent -eq $true) {
+ Write-Output ("`r`n$logLead : Writing Output to {0}`r`n" -f $tempPath)
+ }
+}
diff --git a/Modules/Alkami.DevOps.Operations/Public/Update-Database.ps1 b/Modules/Alkami.DevOps.Operations/Public/Update-Database.ps1
new file mode 100644
index 0000000..95a5bed
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Update-Database.ps1
@@ -0,0 +1,57 @@
+function Update-Database {
+<#
+.SYNOPSIS
+ Update Alkami Database
+#>
+ [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingInvokeExpression', '', Justification = 'Alkami generates this string manually, no user injection')]
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory=$true)]
+ [string]$MigratePath,
+ [Parameter(Mandatory=$true)]
+ [string]$DatabaseString,
+ [Parameter(Mandatory=$true)]
+ [string]$DLLString,
+ [Parameter(Mandatory=$false)]
+ [switch]$WhatIf,
+ [Parameter(Mandatory=$false)]
+ [switch]$Tee
+
+ )
+
+ $logLead = (Get-LogLeadName);
+ $DateTime = Get-Date -Format "MMddhhmmss"
+
+ $migrateExe = Join-Path $migratePath "Migrate.exe"
+ $migrateDll = Join-Path $migratePath $DLLString
+
+ if (!(Test-Path $migrateExe)){
+ throw ("$logLead : File not found {0}"-f $migrateExe)
+ } elseif (!(Test-Path $migrateDll)){
+ throw ("$logLead : File not found {0}"-f $migrateDll)
+ }
+
+ if ($WhatIf.IsPresent -eq $true) {
+ $preview = "--preview"
+ } elseif ($WhatIf.IsPresent -eq $false) {
+ $preview = $null
+ }
+
+ if ($verbose.IsPresent -eq $true) {
+ $vString = "--verbose"
+ } elseif ($verbose.IsPresent -eq $false) {
+ $vString = $null
+ }
+
+ if ($tee.IsPresent -eq $true) {
+ $logs = join-Path -Path $migratePath -childpath "Migrate$DateTime.log"
+ $logsOutput = "| Tee-Object -FilePath $logs"
+ Write-Output ("$logLead : Logs Output '{0}'" -f $logs )
+ } elseif ($tee.IsPresent -eq $false) {
+ $logsOutput = $null
+ }
+
+ $Migrate = "$migrateExe -c='$DatabaseString' -a='$migrateDll' --db='sqlserver2012' --timeout=2500 '$vString' -task='migrate' $preview $logsOutput"
+ Invoke-Expression $Migrate
+}
+
diff --git a/Modules/Alkami.DevOps.Operations/Public/Update-S3BucketTags.ps1 b/Modules/Alkami.DevOps.Operations/Public/Update-S3BucketTags.ps1
new file mode 100644
index 0000000..4f3b2f9
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Update-S3BucketTags.ps1
@@ -0,0 +1,106 @@
+function Update-S3BucketTags {
+
+ <#
+ .SYNOPSIS
+ Updates the tags on the specified S3 Bucket. Returns True if tags update as instructed.
+
+ .DESCRIPTION
+ Updates the tags on the specified S3 Bucket. The existing Write-S3BucketTagging over-writes existing tags.
+ This function is just a wrapper to allow for additive behavior if desired.
+
+ .PARAMETER BucketName
+ [string] The name of the bucket to update.
+
+ .PARAMETER ProfileName
+ [string] The AWS profile to use, generally temp-.
+
+ .PARAMETER Region
+ [string] Specify the region of the bucket.
+
+ .PARAMETER TagSet
+ [string[]] The tags to add to the bucket, in format "Key1,Value1", "Key2,Value2" etc.
+
+ .PARAMETER OverWrite
+ [switch] Indicate if the TagSet is adding to, or replacing, existing tags.
+
+ .EXAMPLE
+ Update-S3BucketTags -BucketName bucket1-east -ProfileName temp-prod -Region us-east-1 -TagSet "TestKey,TestValue"
+
+ This example will add TestKey:TestValue to the existing tags on bucket bucket1-east
+
+ .EXAMPLE
+ Update-S3BucketTags -BucketName bucket1-east -ProfileName temp-prod -Region us-east-1 -TagSet "TestKey,TestValue", "TestKey2,TestValue2" -Overwrite
+
+ This example will replace any existing keys on bucket1-east with the supplied tags:
+ TestKey:TestValue
+ TestKey2:TestValue2
+ #>
+
+ [cmdletbinding(DefaultParameterSetName = "Add")]
+ param (
+ [Parameter(Mandatory = $true)]
+ [Parameter(ParameterSetName = "Add")]
+ [ValidateNotNullorEmpty()]
+ [string]
+ $BucketName,
+ [Parameter(Mandatory = $true)]
+ [Parameter(ParameterSetName = "Add")]
+ [ValidateNotNullorEmpty()]
+ [string]
+ $ProfileName,
+ [Parameter(Mandatory = $true)]
+ [Parameter(ParameterSetName = "Add")]
+ [ValidateNotNullorEmpty()]
+ [string]
+ $Region,
+ [Parameter(Mandatory = $true)]
+ [Parameter(ParameterSetName = "Add")]
+ [ValidateNotNullorEmpty()]
+ [string[]]
+ $TagSet,
+ [Parameter(Mandatory = $false)]
+ [switch]
+ $OverWrite
+ )
+ $logLead = (Get-LogLeadName)
+
+ Import-AWSModule
+
+ $awsTagSet = @()
+
+ if (!($OverWrite)) {
+ try {
+ $currentTags = @(Get-S3BucketTagging -BucketName $BucketName -ProfileName $ProfileName -Region $Region)
+ } catch {
+ $errorMessage = $_.Exception.Message
+ if ($errorMessage -like "*The bucket you are attempting to access must be addressed using the specified endpoint*") {
+ [string]$errorMessage = "The region specified, $Region, is incorrect for bucket $BucketName"
+ }
+ Write-Error "$logLead : $errorMessage"
+ return
+ }
+
+ if (-not (Test-IsCollectionNullOrEmpty $currentTags)) {
+ foreach ($tag in $currentTags) {
+ $awsTagSet += @{ Key = $tag.Key; Value = $tag.Value; }
+ }
+ }
+ }
+
+ foreach ($set in $TagSet) {
+ $splits = $set.Split(',')
+ $awsTagSet += @{ Key = $splits[0]; Value = $splits[1]; }
+ }
+
+ # Write new tagset to the bucket
+ try {
+ Write-S3BucketTagging -BucketName $BucketName -ProfileName $ProfileName -TagSet $awsTagSet -Region $Region
+ } catch {
+ $errorMessage = $_.Exception.Message
+ if ($errorMessage -like "*The bucket you are attempting to access must be addressed using the specified endpoint*") {
+ [string]$errorMessage = "The region specified, [$Region], is incorrect for bucket [$BucketName]"
+ }
+ Write-Error "$logLead : $errorMessage"
+ return
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Public/Update-S3BucketTags.tests.ps1 b/Modules/Alkami.DevOps.Operations/Public/Update-S3BucketTags.tests.ps1
new file mode 100644
index 0000000..9a969f3
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Update-S3BucketTags.tests.ps1
@@ -0,0 +1,99 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ""
+
+Mock -CommandName Get-LogLeadName -ModuleName $moduleForMock -MockWith { return 'Update-S3BucketTags.tests' }
+Mock -CommandName Import-AWSModule -ModuleName $moduleForMock -MockWith {}
+Mock -CommandName Write-Error -ModuleName $moduleForMock -MockWith {}
+
+Describe "Update-S3BucketTags" {
+
+ Context "When a required parameter is null" {
+ It "Throws an Exception if the bucket name is blank" {
+ { Update-S3BucketTags -BucketName '' -ProfileName aTest -Region us-east-1 -TagSet "TestTag1,TestValue1" } | Should -Throw
+ }
+
+ It "Throws an Exception if the ProfileName is blank" {
+ { Update-S3BucketTags -BucketName aBucket -ProfileName '' -Region us-east-1 -TagSet "TestTag1,TestValue1" } | Should -Throw
+ }
+
+ It "Throws an Exception if the Region is blank" {
+ { Update-S3BucketTags -BucketName aBucket -ProfileName aTest -Region '' -TagSet "TestTag1,TestValue1" } | Should -Throw
+ }
+
+ It "Throws an Exception if the TagSet is blank" {
+ { Update-S3BucketTags -BucketName aBucket -ProfileName aTest -Region us-east-1 -TagSet '' } | Should -Throw
+ }
+ }
+
+ Context "Error Handling" {
+ It "Writes an error if the region is incorrect for the given bucket" {
+ Mock -CommandName Get-S3BucketTagging -ModuleName $moduleForMock `
+ -MockWith { throw "The bucket you are attempting to access must be addressed using the specified endpoint. Please send all future requests to this endpoint."}
+
+ Update-S3BucketTags -BucketName aBucket -ProfileName aTest -Region us-east-1 -TagSet "Key1,Value1"
+
+ Assert-MockCalled -CommandName Write-Error -Times 1 -Exactly -ModuleName $moduleForMock -Scope It -ParameterFilter { $Message -like "*The region specified, *, is incorrect for bucket *" }
+
+ }
+
+ It "Writes an error if there are issues reaching the bucket to retrieve tags" {
+ Mock -CommandName Get-S3BucketTagging -ModuleName $moduleForMock -MockWith { throw "Oh no, an error." }
+
+ Update-S3BucketTags -BucketName aBucket -ProfileName aTest -Region us-east-1 -TagSet "Key1,Value1"
+
+ Assert-MockCalled -CommandName Write-Error -Times 1 -Exactly -ModuleName $moduleForMock -Scope It -ParameterFilter { $Message -match "Oh no, an error."}
+ }
+
+ It "Writes an error if there are issues writing tags to the bucket" {
+ Mock -CommandName Get-S3BucketTagging -ModuleName $moduleForMock -MockWith { return @() }
+ Mock -CommandName Test-IsCollectionNullOrEmpty -ModuleName $moduleForMock -MockWith { return $true }
+ Mock -CommandName Write-S3BucketTagging -MockWith { throw "Oh no, an error." }
+
+ Update-S3BucketTags -BucketName aBucket -ProfileName aTest -Region us-east-1 -TagSet "Key1,Value1"
+
+ Assert-MockCalled -CommandName Write-Error -Times 1 -Exactly -ModuleName $moduleForMock -Scope It -ParameterFilter { $Message -match "Oh no, an error." }
+ Assert-MockCalled -CommandName Get-S3BucketTagging -Times 1 -Exactly -ModuleName $moduleForMock -Scope It
+ Assert-MockCalled -CommandName Test-IsCollectionNullOrEmpty -Times 1 -Exactly -ModuleName $moduleForMock -Scope It
+ Assert-MockCalled -CommandName Write-S3BucketTagging -Times 1 -Exactly -ModuleName $moduleForMock -Scope It
+ }
+ }
+
+ Context "Adds tags to the bucket" {
+ It "Adds additional tags" {
+ $mockS3BucketTagging = {
+ $tag = [Amazon.S3.Model.Tag]::new()
+ $tag.Set_Key('ATestKey')
+ $tag.Set_Value('ATestValue')
+ return $tag
+ }
+
+ Mock -CommandName Get-S3BucketTagging -ModuleName $moduleForMock -MockWith $mockS3BucketTagging
+ Mock -CommandName Test-IsCollectionNullOrEmpty -ModuleName $moduleForMock -MockWith { return $false }
+ Mock -CommandName Write-S3BucketTagging -ModuleName $moduleForMock -MockWith {}
+
+ Update-S3BucketTags -BucketName aBucket -ProfileName aTest -Region us-east-1 -TagSet "Key1,Value1"
+
+ Assert-MockCalled Get-S3BucketTagging -Times 1 -Exactly -ModuleName $moduleForMock -Scope It
+ Assert-MockCalled Test-IsCollectionNullOrEmpty -Times 1 -Exactly -ModuleName $moduleForMock -Scope It
+ Assert-MockCalled Write-S3BucketTagging -Times 1 -Exactly -ModuleName $moduleForMock -Scope It
+
+ }
+
+ It "Overwrites existing tags" {
+ Mock -CommandName Get-S3BucketTagging -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Test-IsCollectionNullOrEmpty -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Write-S3BucketTagging -ModuleName $moduleForMock -MockWith {}
+
+ Update-S3BucketTags -BucketName aBucket -ProfileName aTest -Region us-east-1 -TagSet "Key1,Value1", "Key2,Value2" -OverWrite
+
+ Assert-MockCalled Get-S3BucketTagging -Times 0 -Exactly -ModuleName $moduleForMock -Scope It
+ Assert-MockCalled Test-IsCollectionNullOrEmpty -Times 0 -Exactly -ModuleName $moduleForMock -Scope It
+ Assert-MockCalled Write-S3BucketTagging -Times 1 -Exactly -ModuleName $moduleForMock -Scope It
+ }
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Public/Update-StagedConfigurationValues.ps1 b/Modules/Alkami.DevOps.Operations/Public/Update-StagedConfigurationValues.ps1
new file mode 100644
index 0000000..238f0b6
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Update-StagedConfigurationValues.ps1
@@ -0,0 +1,69 @@
+function Update-StagedConfigurationValues {
+<#
+.SYNOPSIS
+ Sets the appropriate configuration values in various staged *.config files
+
+.PARAMETER EnvironmentKey
+ Environment name used in NewRelic AppName
+
+.PARAMETER StagedFilePath
+ Path to staged deployment files
+
+.PARAMETER DeployedFilePath
+ Path to deployed files
+
+.DESCRIPTION
+ After the build is staged for copy on each server this function calls functions which either set
+ static defaults, or copies values from the prior config file in to the new one from the build. This
+ allows us to retain settings from one release to the next while still incorporating changes which come from development
+
+#>
+
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory = $true)]
+ [string]$EnviromentKey,
+
+ [Parameter(Mandatory = $false)]
+ [string]$StagedFilePath = "C:\temp\Deploy\Orb\",
+
+ [Parameter(Mandatory = $false)]
+ [string]$DeployedFilePath
+ )
+
+ $logLead = Get-LogLeadName
+
+ if (Test-StringIsNullOrEmpty -Value $DeployedFilePath) {
+ $DeployedFilePath = Get-OrbPath
+ }
+
+ $pathError = "$logLead : Path {0} Does Not Exist!"
+ @($StagedFilePath, $DeployedFilePath) | ForEach-Object {
+
+ if (!(Test-Path $_ -PathType Container)) {
+ Write-Warning ($pathError -f $_)
+ throw ($pathError -f $_)
+ }
+ }
+
+ # Get Configuration Files That Need Updates
+ Write-Host ("$logLead : Looking for Configuration Files in {0}" -f $filePath)
+ $configFiles = @()
+ $configFiles += Get-ConfigurationFiles $StagedFilePath $true
+
+ # Also get Configuration Files that are not prefaced with new.*
+ # Some are released this way, and this will also cover any new ones that get thrown in the mix
+ $configFiles += Get-ConfigurationFiles $StagedFilePath $false
+
+ # Set Standard Configurations
+ Write-Verbose "$logLead : Calling Set-StaticConfigValues"
+ Set-StaticConfigValues $configfiles $StagedFilePath $DeployedFilePath
+
+ # Update NewRelic App Names
+ Write-Verbose ("$logLead : Calling Set-NewRelicAppName With Environment Key '{0}'" -f $EnviromentKey)
+ Set-NewRelicAppName -enviroment "$EnviromentKey" -configFiles $configfiles
+
+ # Rename the Files
+ Write-Verbose "$logLead : Calling Rename-TemporaryConfigFiles"
+ Rename-TemporaryConfigFiles $configfiles
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Public/Update-TrustedSites.ps1 b/Modules/Alkami.DevOps.Operations/Public/Update-TrustedSites.ps1
new file mode 100644
index 0000000..cd7d080
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Update-TrustedSites.ps1
@@ -0,0 +1,82 @@
+function Update-TrustedSites {
+<#
+.SYNOPSIS
+ Updates IE's Trusted Sites for the Current User and Disables the IE Popup Blocker
+
+.DESCRIPTION
+ Updates IE's Trusted Sites for the Current User and Disables the IE Popup Blocker
+
+.EXAMPLE
+ Update-TrustedSites
+
+PS C:\Users\dsage> Update-TrustedSites
+[Update-TrustedSites] : Adding URL *.bethpagefcu.com to Trusted Zone
+[Update-TrustedSites] : Adding URL *.bethpagefcu.com to Escaped Domains
+[Update-TrustedSites] : Adding URL *.secumd.org to Trusted Zone
+[Update-TrustedSites] : Adding URL *.secumd.org to Escaped Domains
+[Update-TrustedSites] : Adding URL *.bellco.org to Trusted Zone
+[Update-TrustedSites] : Adding URL *.bellco.org to Escaped Domains
+#>
+ [CmdletBinding()]
+ Param()
+
+ $logLead = (Get-LogLeadName);
+
+ $trustedSitesRegistryKey = "HKCU:\Software\Microsoft\Windows\CurrentVersion\Internet Settings\ZoneMap\Domains"
+ $escDomainsRregistryKey = "HKCU:\Software\Microsoft\Windows\CurrentVersion\Internet Settings\ZoneMap\EscDomains"
+ $popupBlockerRegistryKey = "HKCU:\Software\Microsoft\Internet Explorer\New Windows"
+ $popupBlockerRegistrySetting = "PopupMgr"
+
+ $trustedDWord = 2
+
+ $mgr = New-Object Microsoft.Web.Administration.ServerManager
+ $clientSites = ($mgr.Sites | Where-Object {$_.Applications["/"].VirtualDirectories["/"].PhysicalPath -like (join-Path(Get-OrbPath) "WebClient*")})
+
+ if (Test-IsCollectionNullOrEmpty $clientSites) {
+
+ Write-Warning ("$logLead : Could not find any WebClient sites on this server")
+ return
+ }
+
+ [string[]]$addedSites = @()
+ foreach ($site in ($clientSites | Sort-Object | Get-Unique)) {
+
+ [array]$sslBindings = ($site.Bindings | Where-Object {$_.Protocol -eq "https"})
+
+ if (Test-IsCollectionNullOrEmpty $sslBindings) {
+
+ Write-Warning ("$logLead : Could not find SSL Binding for Site {0}" -f $site.Name)
+ continue
+ }
+
+ foreach ($sslBinding in $sslBindings) {
+
+ $hostSegments = $sslBinding.Host.Split(".")
+ $hostSegments[0] = "*"
+ $trustedSegment = $hostSegments -join "."
+ $registryKeyName = ($hostSegments | Select-Object -Skip 1) -join "."
+
+ if ($addedSites.Contains($registryKeyName)) {
+
+ # We've already hit this one
+ continue
+ }
+
+ $addedSites += $registryKeyName
+ Write-Host ("$logLead : Adding URL {0} to Trusted Zone" -f $trustedSegment)
+ (New-Item -Path $trustedSitesRegistryKey -ItemType File -Name "$registryKeyName" -ErrorAction SilentlyContinue) | Out-Null
+ (Set-ItemProperty -Path $trustedSitesRegistryKey\$registryKeyName -Name "https" -Value $trustedDWord -ErrorAction SilentlyContinue) | Out-Null
+
+ Write-Host ("$logLead : Adding URL {0} to Escaped Domains" -f $trustedSegment)
+ (New-Item -Path $escDomainsRregistryKey -ItemType File -Name "$registryKeyName" -ErrorAction SilentlyContinue) | Out-Null
+ (Set-ItemProperty -Path $escDomainsRregistryKey\$registryKeyName -Name "https" -Value $trustedDWord -ErrorAction SilentlyContinue) | Out-Null
+ }
+ }
+
+ # Disable Popup Blocker
+ if ((Get-ItemProperty $popupBlockerRegistryKey $popupBlockerRegistrySetting).PopupMgr -eq 1) {
+
+ Write-Host ("$logLead : Disabling IE Popup Blocker")
+ (Set-ItemProperty -Path $popupBlockerRegistryKey -Name $popupBlockerRegistrySetting -Value 0 -ErrorAction SilentlyContinue) | Out-Null
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Public/Wait-Route53ChangeStatus.ps1 b/Modules/Alkami.DevOps.Operations/Public/Wait-Route53ChangeStatus.ps1
new file mode 100644
index 0000000..d990a12
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Wait-Route53ChangeStatus.ps1
@@ -0,0 +1,87 @@
+function Wait-Route53ChangeStatus {
+
+<#
+.SYNOPSIS
+ Attempt to wait for the specified Route53 Hosted Zone change to exhibit the specified Route53 change status.
+
+.DESCRIPTION
+ Attempt to wait for the specified Route53 Hosted Zone change to exhibit the specified Route53 change status. Will write an error if a timeout occurs.
+ Refer to https://docs.aws.amazon.com/Route53/latest/APIReference/API_ChangeInfo.html
+
+.PARAMETER ChangeId
+ [string] The ID of the Route53 Hosted Zone change.
+
+.PARAMETER AwsProfileName
+ [string] The AWS profile where the Route53 Hosted Zone is located (e.g. 'temp-prod').
+
+.PARAMETER ChangeStatus
+ [string] The desired Route53 Hosted Zone change status. Default is "INSYNC".
+
+.PARAMETER DelaySeconds
+ [byte] The number of seconds to delay between each change status poll request. Range is from 5 to 30 seconds. Default is 10 seconds.
+
+.PARAMETER DelaySeconds
+ [byte] The number of seconds to wait before timing the operation out. Range is from 30 to 900 seconds. Default is 300 seconds.
+
+.EXAMPLE
+ Wait-Route53ChangeStatus -ChangeId 'CHANGE1234' -AwsProfileName 'temp-prod' -ChangeStatus 'INSYNC' -TimeoutSeconds 300 -DelaySeconds 10
+
+[Wait-Route53ChangeStatus] : Route53 Change ID 'CHANGE1234' has status 'INSYNC' after 82.0345 seconds.
+#>
+
+ [CmdletBinding()]
+ param (
+ [Parameter(Mandatory = $true)]
+ [ValidateNotNullOrEmpty()]
+ [string] $ChangeId,
+
+ [Parameter(Mandatory = $true)]
+ [ValidateNotNullOrEmpty()]
+ [string] $AwsProfileName,
+
+ [Parameter(Mandatory = $false)]
+ [ValidateNotNullOrEmpty()]
+ [string] $ChangeStatus = "INSYNC",
+
+ [Parameter(Mandatory = $false)]
+ [ValidateRange(5, 30)]
+ [byte] $DelaySeconds = 10,
+
+ [Parameter(Mandatory = $false)]
+ [ValidateRange(30, 900)]
+ [uint16] $TimeoutSeconds = 300
+ )
+
+ $logLead = (Get-LogLeadName)
+
+ Import-AWSModule
+
+ # Start the timer.
+ $stopwatch = [system.diagnostics.stopwatch]::StartNew()
+
+ # Retrieve the initial status. Loop until we get the target status or run out of time.
+ $actualStatus = ( Get-R53Change -Id $ChangeId -ProfileName $AwsProfileName ).Status
+ while ( ( $actualStatus -ne $ChangeStatus ) -and ( $stopwatch.Elapsed.TotalSeconds -lt $TimeoutSeconds )) {
+
+ Write-Verbose ( "{0} : Route53 Change ID '{1}' has status '{2}' after {3} seconds." -f $logLead, $ChangeId, $actualStatus, $stopwatch.Elapsed.TotalSeconds )
+
+ # Delay slightly to allow the change time to apply.
+ Start-Sleep -Seconds $DelaySeconds
+
+ # Poll the status again.
+ $actualStatus = ( Get-R53Change -Id $ChangeId -ProfileName $AwsProfileName ).Status
+ }
+
+ # Stop the timer.
+ $stopwatch.Stop()
+
+ # If we timed out, let the user know.
+ if ( $stopwatch.Elapsed.TotalSeconds -ge $TimeoutSeconds ) {
+
+ Write-Error ( "{0} : Timeout after {1} seconds waiting on Route53 change ID '{2}' to have status '{3}'" -f $logLead, $stopwatch.Elapsed.TotalSeconds, $ChangeId, $ChangeStatus )
+ return $null
+ }
+
+ # If we didn't time out, let the user know the final state.
+ Write-Host ( "{0} : Route53 Change ID '{1}' has status '{2}' after {3} seconds." -f $logLead, $ChangeId, $actualStatus, $stopwatch.Elapsed.TotalSeconds )
+}
diff --git a/Modules/Alkami.DevOps.Operations/Public/Wait-Route53ChangeStatus.tests.ps1 b/Modules/Alkami.DevOps.Operations/Public/Wait-Route53ChangeStatus.tests.ps1
new file mode 100644
index 0000000..1dd672c
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Wait-Route53ChangeStatus.tests.ps1
@@ -0,0 +1,103 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ""
+
+Describe "Wait-Route53ChangeStatus" {
+
+ Mock -CommandName Import-AWSModule -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Get-LogLeadName -ModuleName $moduleForMock -MockWith { return 'Wait-Route53ChangeStatus.tests' }
+
+ Context "Input Validation" {
+
+ It "Change ID Should Not Be Null" {
+
+ { Wait-Route53ChangeStatus -ChangeId $null } | Should -Throw
+ }
+
+ It "Change ID Should Not Be Empty" {
+
+ { Wait-Route53ChangeStatus -ChangeId '' } | Should -Throw
+ }
+
+ It "AWS Profile Name Should Not Be Null" {
+
+ { Wait-Route53ChangeStatus -ChangeId 'Test' -AwsProfileName $null } | Should -Throw
+ }
+
+ It "AWS Profile Name Should Not Be Empty" {
+
+ { Wait-Route53ChangeStatus -ChangeId 'Test' -AwsProfileName '' } | Should -Throw
+ }
+
+ It "Change Status Should Not Be Null" {
+
+ { Wait-Route53ChangeStatus -ChangeId 'Test' -AwsProfileName 'Test' -ChangeStatus $null } | Should -Throw
+ }
+
+ It "Change Status Should Not Be Empty" {
+
+ { Wait-Route53ChangeStatus -ChangeId 'Test' -AwsProfileName 'Test' -ChangeStatus '' } | Should -Throw
+ }
+
+ It "Delay Seconds Should Be An Integer" {
+
+ { Wait-Route53ChangeStatus -ChangeId 'Test' -AwsProfileName 'Test' -DelaySeconds 'Test' } | Should -Throw
+ }
+
+ It "Delay Seconds Should Respect Minimum" {
+
+ { Wait-Route53ChangeStatus -ChangeId 'Test' -AwsProfileName 'Test' -DelaySeconds 0 } | Should -Throw
+ }
+
+ It "Delay Seconds Should Respect Maximum" {
+
+ { Wait-Route53ChangeStatus -ChangeId 'Test' -AwsProfileName 'Test' -DelaySeconds 9001 } | Should -Throw
+ }
+
+ It "Timeout Seconds Should Be An Integer" {
+
+ { Wait-Route53ChangeStatus -ChangeId 'Test' -AwsProfileName 'Test' -TimeoutSeconds 'Test' } | Should -Throw
+ }
+
+ It "Timeout Seconds Should Respect Minimum" {
+
+ { Wait-Route53ChangeStatus -ChangeId 'Test' -AwsProfileName 'Test' -TimeoutSeconds 0 } | Should -Throw
+ }
+
+ It "Timeout Seconds Should Respect Maximum" {
+
+ { Wait-Route53ChangeStatus -ChangeId 'Test' -AwsProfileName 'Test' -TimeoutSeconds 9001 } | Should -Throw
+ }
+ }
+
+ Context "Result Validation" {
+
+ It "Should Return Without Error If Change Status Matches Desired" {
+
+ Mock -CommandName Get-R53Change -ModuleName $moduleForMock -MockWith { return @{ 'Status' = 'Test' } }
+ Mock -CommandName Write-Error -ModuleName $moduleForMock -MockWith {}
+
+ Wait-Route53ChangeStatus -ChangeId 'Test' -AwsProfileName 'Test' -ChangeStatus 'Test'
+
+ Assert-MockCalled -CommandName Get-R53Change -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Write-Error -Times 0 -Exactly -Scope It -ModuleName $moduleForMock
+ }
+
+ It "Should Return With Error If Timeout Occurs" {
+
+ Mock -CommandName Get-R53Change -ModuleName $moduleForMock -MockWith { return @{ 'Status' = 'Test' } }
+ Mock -CommandName Write-Error -ModuleName $moduleForMock -MockWith {}
+
+ Write-Host "Testing timeout of 30 seconds; be patient."
+ Wait-Route53ChangeStatus -ChangeId 'Test' -AwsProfileName 'Test' -ChangeStatus 'FailTest' -TimeoutSeconds 30 -DelaySeconds 10
+
+ Assert-MockCalled -CommandName Get-R53Change -Times 4 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Write-Error -Times 1 -Exactly -Scope It -ModuleName $moduleForMock `
+ -ParameterFilter { $Message -match "Timeout" }
+ }
+ }
+}
diff --git a/Modules/Alkami.DevOps.Operations/Public/Write-ChocoCommandsToFile.ps1 b/Modules/Alkami.DevOps.Operations/Public/Write-ChocoCommandsToFile.ps1
new file mode 100644
index 0000000..e6e313d
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Write-ChocoCommandsToFile.ps1
@@ -0,0 +1,68 @@
+function Write-ChocoCommandsToFile {
+<#
+.SYNOPSIS
+ Writes a list of chocolatey package commands out to a file. Force parameter optional.
+
+.NOTES
+ The default output path is a "known" file location intended to work with Install-MicroservicesWithMigrations
+#>
+ [CmdletBinding()]
+ Param (
+ [Parameter(Mandatory = $true)]
+ [AllowNull()]
+ [object[]]$ChocoPackages,
+
+ [Parameter(Mandatory = $false)]
+ [string]$OutputPath = "C:/temp/deploy/chocoInstallCommands.ps1",
+
+ [Parameter(Mandatory = $false)]
+ [string]$Environment,
+
+ [Parameter(Mandatory = $false)]
+ [switch]$Force,
+
+ [Parameter(Mandatory = $false)]
+ [Alias("migrate", "m")]
+ [switch]$RunMigrations,
+
+ [Parameter(Mandatory = $false)]
+ [switch]$DisableMicroservices
+ )
+
+ $forceFlag = ""
+ if ($Force.IsPresent) {
+ $forceFlag = "f"
+ }
+ $output = "`$env:MaxMigrationJobs = 15`n";
+
+ if (!$ChocoPackages) {
+ $output += "Write-Host ""No chocolatey packages to install. Done!"""
+ } else {
+ # Randomize the order of the installed chocolatey packages, to better guarantee that there is at least one microservice running at a time when several installs are running.
+ $ChocoPackages = Get-Random -InputObject $ChocoPackages -Count ([int]::MaxValue)
+
+ $counter = 1
+ $numPackages = $ChocoPackages.count
+ foreach ($package in $ChocoPackages) {
+ # Get NewRelic params
+ $paramsSwitch = Get-ChocolateyParameterString $package -env $Environment -migrate $RunMigrations.IsPresent -start $false;
+
+ $output += "Write-Host `"Installing Package ($counter/$numPackages):`";"
+ $output += "choco upgrade $($package.Name) -y$forceFlag --version $($package.Version) $paramsSwitch;`n"
+
+ # If we have just installed the notification service, guarantee that it is running after the install to deal with migrations.
+ $notificationServiceName = "Alkami.MicroServices.Notifications.Service.Host";
+ if ($package.Name -like $notificationServiceName) {
+ $output += "`$service = (Get-ServiceByChocoName $notificationServiceName);`nif((`$service -ne `$null) -and (`$service.Name -ne `$null)){ Start-AlkamiService -serviceName `$service.Name; }`n"
+ }
+
+ $counter++
+ }
+ }
+
+ if ($DisableMicroservices.IsPresent) {
+ $output += "(Disable-Microservices -Verbose);`n"
+ }
+
+ Set-Content -Path $OutputPath -Value $output -Force
+}
diff --git a/Modules/Alkami.DevOps.Operations/Public/Write-TcpSocketStreamWriter.ps1 b/Modules/Alkami.DevOps.Operations/Public/Write-TcpSocketStreamWriter.ps1
new file mode 100644
index 0000000..3575b0e
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Public/Write-TcpSocketStreamWriter.ps1
@@ -0,0 +1,17 @@
+function Write-TcpSocketStreamWriter {
+ <#
+.SYNOPSIS
+ Writes String to the Stream Writer buffer. Execustes WriteLine() on System.IO.StreamWriter.
+.EXAMPLE
+ Write-TcpSocketStreamWriter -Writer $writer -Value $metric
+.INPUTS
+ Writer
+ Value
+#>
+ [cmdletbinding()]
+ param(
+ $Writer,
+ $Value
+ )
+ $Writer.WriteLine($Value)
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Resources/PackageFiltering-min.json b/Modules/Alkami.DevOps.Operations/Resources/PackageFiltering-min.json
new file mode 100644
index 0000000..0d3601c
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Resources/PackageFiltering-min.json
@@ -0,0 +1 @@
+{"mappings":[{"PackageId":"Alkami.MicroServices.AccountBalanceSync1.Processor.Host","ProviderType":"EventProcessor","ProviderName":"Alkami Event Management Account Balance Sync 1 Processor Service"},{"PackageId":"Alkami.MicroServices.AccountBalanceSync2.Processor.Host","ProviderType":"EventProcessor","ProviderName":"Alkami Event Management Account Balance Sync 2 Processor Service"},{"PackageId":"Alkami.MicroServices.ACHStopPay.SymConnect.Service.Host","ProviderType":"ACHStopPay","ProviderName":"SymConnect ACH Stop Pay Provider"},{"PackageId":"Alkami.MicroServices.AddressValidation.SmartyStreets.Host","ProviderType":"AddressValidation","ProviderName":"Smarty Streets Address Validation Provider"},{"PackageId":"Alkami.MicroServices.AggregationProviders.Yodlee.Host","ProviderType":"Aggregation","ProviderName":"Yodlee Aggregation Provider"},{"PackageId":"Alkami.MicroServices.Alerts.Patelco.Service.Host","ProviderType":"Nag","ProviderName":"Patelco Alert Microsevice"},{"PackageId":"Alkami.MicroServices.Authentication.AD.Service.Host","ProviderType":"Authentication","ProviderName":"Active Directory"},{"PackageId":"Alkami.MicroServices.Authentication.Entrust.Service.Host","ProviderType":"Authentication","ProviderName":"Entrust Authentication Provider"},{"PackageId":"Alkami.MicroServices.Authentication.External.Service.Host","ProviderType":"Authentication","ProviderName":"External Authentication Provider"},{"PackageId":"Alkami.MicroServices.Authentication.Static.Service.Host","ProviderType":"Authentication","ProviderName":"Static Authentication Provider"},{"PackageId":"Alkami.MicroServices.Authentication.Workflow.Service.Host","ProviderType":"AuthenticationWorkflow","ProviderName":"Alkami Authentication Workflow Provider"},{"PackageId":"Alkami.MicroServices.AutoBiller.Static.Service.Host","ProviderType":"AutoBiller","ProviderName":"Static AutoBiller Provider"},{"PackageId":"Alkami.MicroServices.AutoBiller.Symitar.Service.Host","ProviderType":"AutoBiller","ProviderName":"Symitar AutoBiller Provider"},{"PackageId":"Alkami.MicroServices.BCUBenefits.Service.Host","ProviderType":"CustomBenefitsBcu","ProviderName":"BCU Benefits Provider"},{"PackageId":"Alkami.MicroServices.BillPayProviders.CheckFree.Service.Host","ProviderType":"BillPay","ProviderName":"CheckFree Bill Pay"},{"PackageId":"Alkami.MicroServices.BillPayProviders.CheckFreeSB.Service.Host","ProviderType":"BillPay","ProviderName":"CheckFree Small Business Bill Pay"},{"PackageId":"Alkami.MicroServices.BillPayProviders.FIS.MACU.Service.Host","ProviderType":"BillPay","ProviderName":"FIS MACU Bill Pay"},{"PackageId":"Alkami.MicroServices.BillPayProviders.FIS.Service.Host","ProviderType":"BillPay","ProviderName":"FIS Bill Pay"},{"PackageId":"Alkami.MicroServices.BillPayProviders.IPay.Service.Host","ProviderType":"BillPay","ProviderName":"IPay Bill Pay"},{"PackageId":"Alkami.MicroServices.BillPayProviders.Payveris.Service.Host","ProviderType":"BillPay","ProviderName":"Payveris Bill Pay"},{"PackageId":"Alkami.MicroServices.BillPayProviders.PSCU.Service.Host","ProviderType":"BillPay","ProviderName":"PSCU Bill Pay"},{"PackageId":"Alkami.MicroServices.BillPayProviders.Static.Service.Host","ProviderType":"BillPay","ProviderName":"Static Bill Pay"},{"PackageId":"Alkami.MicroServices.BusinessReport.Notifications.Host","ProviderType":"EventProcessor","ProviderName":"Alkami Event Management Business Report Notifications Processor Service"},{"PackageId":"Alkami.MicroServices.CardManagementProviders.Bcu.Host","ProviderType":"CardManagement","ProviderName":"BCU Card Management Provider"},{"PackageId":"Alkami.MicroServices.CardManagementProviders.Connex.Host","ProviderType":"CardManagement","ProviderName":"Connex Card Management Provider"},{"PackageId":"Alkami.MicroServices.CardManagementProviders.Corelation.Host","ProviderType":"CardManagement","ProviderName":"Corelation Card Management Provider"},{"PackageId":"Alkami.MicroServices.CardManagementProviders.DNA.Host","ProviderType":"CardManagement","ProviderName":"DNA Card Management Provider"},{"PackageId":"Alkami.MicroServices.CardManagementProviders.Ondot.Host","ProviderType":"CardManagement","ProviderName":"Ondot Card Management Provider"},{"PackageId":"Alkami.MicroServices.CardManagementProviders.Pscu.Host","ProviderType":"CardManagement","ProviderName":"PSCU Card Management Provider"},{"PackageId":"Alkami.MicroServices.CardManagementProviders.Spectrum.Host","ProviderType":"CardManagement","ProviderName":"Spectrum Card Management Provider"},{"PackageId":"Alkami.MicroServices.CardManagementProviders.Static.Host","ProviderType":"CardManagement","ProviderName":"Static Card Management Provider"},{"PackageId":"Alkami.MicroServices.CardManagementProviders.SymConnect.Host","ProviderType":"CardManagement","ProviderName":"SymConnect Card Management Provider"},{"PackageId":"Alkami.MicroServices.CardManagementProviders.UltraData.Host","ProviderType":"CardManagement","ProviderName":"UltraData Card Management Provider"},{"PackageId":"Alkami.MicroServices.CardManagementProviders.Universal.Host","ProviderType":"CardManagement","ProviderName":"Universal Card Management Provider"},{"PackageId":"Alkami.MicroServices.CardManagementProviders.XP.Host","ProviderType":"CardManagement","ProviderName":"XP Card Management Provider"},{"PackageId":"Alkami.MicroServices.CheckOrderProviders.Deluxe.Host","ProviderType":"CheckOrder","ProviderName":"DeluxeCheckReorderProvider"},{"PackageId":"Alkami.MicroServices.CheckOrderProviders.Static.Host","ProviderType":"CheckOrder","ProviderName":"Static Check Order Provider"},{"PackageId":"Alkami.MicroServices.CivicRewards.Service.Host","ProviderType":"CivicRewards","ProviderName":"CivicRewards"},{"PackageId":"Alkami.MicroServices.Core.AccountUpdate.CoA.Service.Host","ProviderType":"Core","ProviderName":"CoA Core Provider"},{"PackageId":"Alkami.MicroServices.Core.AccountUpdate.CoreApi.Service.Host","ProviderType":"Core","ProviderName":"CoreApi Core Provider"},{"PackageId":"Alkami.MicroServices.Core.AccountUpdate.ESB.OCCU.Service.Host","ProviderType":"Core","ProviderName":"OCCU ESB Core Provider"},{"PackageId":"Alkami.MicroServices.Core.AccountUpdate.Miser.Service.Host","ProviderType":"Miser Core Provider","ProviderName":"Core"},{"PackageId":"Alkami.MicroServices.Core.AccountUpdate.Spectrum.Service.Host","ProviderType":"Core","ProviderName":"Spectrum Core Provider"},{"PackageId":"Alkami.MicroServices.Core.AccountUpdate.SymConnect.Service.Host","ProviderType":"Core","ProviderName":"SymConnect Core Provider MS"},{"PackageId":"Alkami.MicroServices.Core.CCM.Service.Host","ProviderType":"Core","ProviderName":"CCM Core Provider"},{"PackageId":"Alkami.MicroServices.Core.CIF2020.Registration.Service.Host","ProviderType":"Core","ProviderName":"CIF2020CoreProvider"},{"PackageId":"Alkami.MicroServices.Core.CIF2020.Service.Host","ProviderType":"Core","ProviderName":"CIF2020CoreProvider"},{"PackageId":"Alkami.MicroServices.Core.CoA.CCM.Force.Service.Host","ProviderType":"Core","ProviderName":"CoA CCM Salesforce Core Provider"},{"PackageId":"Alkami.MicroServices.Core.CoA.CCM.Service.Host","ProviderType":"Core","ProviderName":"CoA CCM Core Provider"},{"PackageId":"Alkami.MicroServices.Core.CoA.Registration.Service.Host","ProviderType":"Core","ProviderName":"CoA Core Provider"},{"PackageId":"Alkami.MicroServices.Core.CoA.Service.Host","ProviderType":"Core","ProviderName":"CoA Core Provider"},{"PackageId":"Alkami.MicroServices.Core.CoreApi.Registration.Service.Host","ProviderType":"Core","ProviderName":"CoreApi Core Provider"},{"PackageId":"Alkami.MicroServices.Core.CoreApi.Service.Host","ProviderType":"Core","ProviderName":"CoreApi Core Provider"},{"PackageId":"Alkami.MicroServices.Core.Corelation.Service.Host","ProviderType":"Core","ProviderName":"Corelation Core Provider"},{"PackageId":"Alkami.MicroServices.Core.Dynamic.Service.Host","ProviderType":"Core","ProviderName":"Dynamic Core Provider"},{"PackageId":"Alkami.MicroServices.Core.ESB.CUFX.Service.Host","ProviderType":"Core","ProviderName":"CUFX ESB Core Provider"},{"PackageId":"Alkami.MicroServices.Core.ESB.OCCU.Service.Host","ProviderType":"Core","ProviderName":"OCCU ESB Core Provider"},{"PackageId":"Alkami.MicroServices.Core.LoanPayoffCalculator.Corelation.Service.Host","ProviderType":"Core","ProviderName":"Corelation Core Provider"},{"PackageId":"Alkami.MicroServices.Core.LoanPayoffCalculator.ESB.OCCU.Service.Host","ProviderType":"Core","ProviderName":"OCCU ESB Core Provider"},{"PackageId":"Alkami.MicroServices.Core.LoanPayoffCalculator.Miser.Service.Host","ProviderType":"Core","ProviderName":"Miser Core Provider"},{"PackageId":"Alkami.MicroServices.Core.Miser.Service.Host","ProviderType":"Core","ProviderName":"Miser Core Provider"},{"PackageId":"Alkami.MicroServices.Core.Registration.ESB.CUFX.Service.Host","ProviderType":"Core","ProviderName":"CUFX ESB Core Provider"},{"PackageId":"Alkami.MicroServices.Core.Registration.ESB.OCCU.Service.Host","ProviderType":"Core","ProviderName":"OCCU ESB Core Provider"},{"PackageId":"Alkami.MicroServices.Core.Registration.Miser.Service.Host","ProviderType":"Core","ProviderName":"Miser Core Provider"},{"PackageId":"Alkami.MicroServices.Core.RTA.Service.Host","ProviderType":"Core","ProviderName":"RTA Core Provider"},{"PackageId":"Alkami.MicroServices.Core.Silverlake.Registration.Service.Host","ProviderType":"Core","ProviderName":"SilverlakeCoreProvider"},{"PackageId":"Alkami.MicroServices.Core.Silverlake.Service.Host","ProviderType":"Core","ProviderName":"SilverlakeCoreProvider"},{"PackageId":"Alkami.MicroServices.Core.Spectrum.Service.Host","ProviderType":"Core","ProviderName":"Spectrum Core Provider"},{"PackageId":"Alkami.MicroServices.Core.Static.Service.Host","ProviderType":"Core","ProviderName":"Static Core Provider"},{"PackageId":"Alkami.MicroServices.CourtesyPayProviders.Corelation.Host","ProviderType":"CourtesyPay","ProviderName":"Corelation Courtesy Pay Provider"},{"PackageId":"Alkami.MicroServices.CourtesyPayProviders.Dynamic.Host","ProviderType":"CourtesyPay","ProviderName":"Dynamic Courtesy Pay Provider"},{"PackageId":"Alkami.MicroServices.CourtesyPayProviders.ESB.OTS.Service.Host","ProviderType":"CourtesyPay","ProviderName":"ESB OTS Courtesy Pay Provider"},{"PackageId":"Alkami.MicroServices.CourtesyPayProviders.Spectrum.Host","ProviderType":"CourtesyPay","ProviderName":"Spectrum Courtesy Pay Provider"},{"PackageId":"Alkami.MicroServices.CourtesyPayProviders.SymConnect.Service.Host","ProviderType":"CourtesyPay","ProviderName":"SymConnect Courtesy Pay Provider"},{"PackageId":"Alkami.MicroServices.CourtesyPayProviders.UltraData.Service.Host","ProviderType":"CourtesyPay","ProviderName":"UltraData Courtesy Pay Provider"},{"PackageId":"Alkami.MicroServices.CourtesyPayProviders.XP.Host","ProviderType":"CourtesyPay","ProviderName":"XP Courtesy Pay Provider"},{"PackageId":"Alkami.MicroServices.CustomCore.Miser.Occu.Service.Host","ProviderType":"CustomCore","ProviderName":"Custom Core Miser Occu Service"},{"PackageId":"Alkami.MicroServices.EM.BadProcessor.Host","ProviderType":"EventProcessor","ProviderName":"Alkami MicroServices Event Management BadProcessor Processor Service"},{"PackageId":"Alkami.MicroServices.EM.BlockedACH.Alert.Host","ProviderType":"EventProcessor","ProviderName":"Alkami MicroServices Event Management BlockedACHAccountAlert Processor Service"},{"PackageId":"Alkami.MicroServices.EM.BlockedACH.Host","ProviderType":"EventProcessor","ProviderName":"Alkami MicroServices Event Management BlockedACHAccountAlert Processor Service"},{"PackageId":"Alkami.MicroServices.EM.ExternalTransfer.Alert.Host","ProviderType":"EventProcessor","ProviderName":"Alkami MicroServices Event Management ETAP Service"},{"PackageId":"Alkami.MicroServices.EM.ExternalTransferCancelled.Alert.Host","ProviderType":"EventProcessor","ProviderName":"Alkami MicroServices Event Management ExternalTransferCancelled Processor Service"},{"PackageId":"Alkami.MicroServices.EM.ExtTransferCancelled.Host","ProviderType":"EventProcessor","ProviderName":"Alkami MicroServices Event Management ExternalTransferCancelled Processor Service"},{"PackageId":"Alkami.MicroServices.EM.TransferFailed.Alert.Host","ProviderType":"EventProcessor","ProviderName":"Alkami MicroServices Event Management TransferFailed Processor Service"},{"PackageId":"Alkami.MicroServices.EM.TransferFailed.Host","ProviderType":"EventProcessor","ProviderName":"Alkami MicroServices Event Management TransferFailed Processor Service"},{"PackageId":"Alkami.MicroServices.EM.TransferSucceeded.Alert.Host","ProviderType":"EventProcessor","ProviderName":"Alkami MicroServices Event Management TransferSucceeded Processor Service"},{"PackageId":"Alkami.MicroServices.EM.TransferSucceeded.Host","ProviderType":"EventProcessor","ProviderName":"Alkami MicroServices Event Management TransferSucceeded Processor Service"},{"PackageId":"Alkami.Microservices.EventManagement.Cleanup.Host","ProviderType":"EventProcessor","ProviderName":"Alkami Event Management Cleanup Service"},{"PackageId":"Alkami.MicroServices.EventManagement.MMAP.Host","ProviderType":"EventProcessor","ProviderName":"Alkami Event Management MACU MyStyle Alert Processor Service"},{"PackageId":"Alkami.MicroServices.EventManagement.OAP.Host","ProviderType":"EventProcessor","ProviderName":"Alkami Event Management Ondot Alert Processor Service"},{"PackageId":"Alkami.MicroServices.EventManagement.SAP.Host","ProviderType":"EventProcessor","ProviderName":"Alkami Event Management Security Alert Processor Service"},{"PackageId":"Alkami.MicroServices.EventManagement.VAP.Host","ProviderType":"EventProcessor","ProviderName":"Alkami Event Management Visa Authorization Processor Service"},{"PackageId":"Alkami.MicroServices.Fact.Desert.Service.Host","ProviderType":"Fact Provider","ProviderName":"Alkami.MicroServices.Fact.Desert"},{"PackageId":"Alkami.MicroServices.Fact.Miser.Occu.Service.Host","ProviderType":"Fact Provider","ProviderName":"Miser OCCU Fact Provider"},{"PackageId":"Alkami.MicroServices.Features.Beacon.Host","ProviderType":"Beacon","ProviderName":null},{"PackageId":"Alkami.MicroServices.FicoScore.Service.Host","ProviderType":"CreditScore","ProviderName":"FICO Credit Score Provider"},{"PackageId":"Alkami.MicroServices.Iav.Service.Host","ProviderType":"Iav","ProviderName":"Iav Service"},{"PackageId":"Alkami.MicroServices.Iav.Yodlee.Service.Host","ProviderType":"Iav","ProviderName":"Iav Yodlee Service"},{"PackageId":"Alkami.MicroServices.ICCUBusinessCardManagement.Service.Host","ProviderType":"ICCUBusinessCardManagement","ProviderName":"ICCUBusinessCardManagement"},{"PackageId":"Alkami.MicroServices.LinkedAccounts.Service.Host","ProviderType":"LinkedAccounts","ProviderName":"LinkedAccount Service"},{"PackageId":"Alkami.MicroServices.MacuRewards.Service.Host","ProviderType":"CustomMacuRewards","ProviderName":"Macu Rewards Provider"},{"PackageId":"Alkami.MicroServices.MessageCenter.Service.Host","ProviderType":"MessageCenterProvider","ProviderName":"AlkamiMessageCenterDynamicProvider"},{"PackageId":"Alkami.Microservices.ODPP.Corelation.Service.Host","ProviderType":"OverdraftProtection","ProviderName":"Corelation Overdraft Protection Provider"},{"PackageId":"Alkami.Microservices.ODPP.Dynamic.Service.Host","ProviderType":"OverdraftProtection","ProviderName":"Dynamic Overdraft Service"},{"PackageId":"Alkami.Microservices.ODPP.Patelco.Service.Host","ProviderType":"OverdraftProtection","ProviderName":"Patelco Overdraft Service"},{"PackageId":"Alkami.MicroServices.ODPP.SymConnect.Service.Host","ProviderType":"OverdraftProtection","ProviderName":"SymConnect Overdraft Protection Provider"},{"PackageId":"Alkami.MicroServices.ODPP.UltraData.Service.Host","ProviderType":"OverdraftProtection","ProviderName":"UltraData Overdraft Protection Provider"},{"PackageId":"Alkami.MicroServices.Payroll.SandiaLabs.Service.Host","ProviderType":"PayrollDistribution","ProviderName":"SandiaLabs Payroll Distribution Provider"},{"PackageId":"Alkami.MicroServices.Payroll.SymConnect.Service.Host","ProviderType":"PayrollDistribution","ProviderName":"SymConnect Payroll Distribution Provider"},{"PackageId":"Alkami.MicroServices.Processor.ApiPassthrough.Host","ProviderType":"EventProcessor","ProviderName":"Alkami MicroServices Event Management Api Passthrough Service"},{"PackageId":"Alkami.MicroServices.Processor.Flux.Reporting.Host","ProviderType":"EventProcessor","ProviderName":"Alkami MicroServices Event Management Flux Reporting Processor Service"},{"PackageId":"Alkami.MicroServices.Processor.RemoteDepositCompletion.Host","ProviderType":"EventProcessor","ProviderName":"Alkami MicroServices Event Management RemoteDepositCompletion Processor Service"},{"PackageId":"Alkami.MicroServices.Processor.RevokeToken.PasswordChanged.Host","ProviderType":"EventProcessor","ProviderName":"Alkami MicroServices Event Management RevokeToken.PasswordChanged Processor Service"},{"PackageId":"Alkami.MicroServices.Processor.RevokeToken.UsernameChanged.Host","ProviderType":"EventProcessor","ProviderName":"Alkami MicroServices Event Management RevokeToken.UsernameChanged Processor Service"},{"PackageId":"Alkami.MicroServices.Processor.RIMT.Host","ProviderType":"EventProcessor","ProviderName":"Alkami Event Management Resolve Inactive Message Threads Processor Service"},{"PackageId":"Alkami.MicroServices.QuickApply.CoA.Service.Host","ProviderType":"QuickApply","ProviderName":"Alkami.MicroServices.QuickApply.CoA Quick Apply Provider"},{"PackageId":"Alkami.MicroServices.QuickApply.DNA.Service.Host","ProviderType":"QuickApply","ProviderName":"Alkami.MicroServices.QuickApply.DNA Quick Apply Provider"},{"PackageId":"Alkami.MicroServices.QuickApply.Dynamic.Service.Host","ProviderType":"QuickApply","ProviderName":"Dynamic Quick Apply Provider"},{"PackageId":"Alkami.MicroServices.QuickApply.ESB.Desert.Service.Host","ProviderType":"QuickApply","ProviderName":"DSFCU Quick Apply Provider"},{"PackageId":"Alkami.Microservices.QuickApply.Esb.Occu.Service.Host","ProviderType":"QuickApply","ProviderName":"ESB OCCU Quick Apply Provider"},{"PackageId":"Alkami.MicroServices.QuickApply.MeridianLink.Service.Host","ProviderType":"QuickApply","ProviderName":"Meridian Link Quick Apply Provider"},{"PackageId":"Alkami.MicroServices.QuickApply.Miser.Service.Host","ProviderType":"QuickApply","ProviderName":"Miser Quick Apply Provider"},{"PackageId":"Alkami.MicroServices.QuickApply.Service.Host","ProviderType":"QuickApply","ProviderName":"Quick Apply Service"},{"PackageId":"Alkami.MicroServices.QuickApply.Spectrum.Service.Host","ProviderType":"QuickApply","ProviderName":"Spectrum Quick Apply Provider"},{"PackageId":"Alkami.MicroServices.QuickApply.SymConnect.Service.Host","ProviderType":"QuickApply","ProviderName":"SymConnect Quick Apply Provider"},{"PackageId":"Alkami.MicroServices.QuickApply.UltraData.Service.Host","ProviderType":"QuickApply","ProviderName":"UltraData Quick Apply Provider"},{"PackageId":"Alkami.MicroServices.QuickApply.XP.Service.Host","ProviderType":"QuickApply","ProviderName":"XP Quick Apply Provider"},{"PackageId":"Alkami.MicroServices.RDCoreDeposit.CoA.Service.Host","ProviderType":"RemoteDepositCoreDeposit","ProviderName":"RemoteDepositCoreDepositCoAProvider"},{"PackageId":"Alkami.MicroServices.RDCoreDeposit.Corelation.Service.Host","ProviderType":"RemoteDepositCoreDeposit","ProviderName":"RemoteDepositCoreDepositCorelationProvider"},{"PackageId":"Alkami.MicroServices.RDCoreDeposit.DNA.Service.Host","ProviderType":"RemoteDepositCoreDeposit","ProviderName":"RemoteDepositCoreDepositDNAProvider"},{"PackageId":"Alkami.MicroServices.RDCoreDeposit.Phoenix.Service.Host","ProviderType":"RemoteDepositCoreDeposit","ProviderName":"RemoteDepositCoreDepositPhoenixProvider"},{"PackageId":"Alkami.MicroServices.RDCoreDeposit.Spectrum.Service.Host","ProviderType":"RemoteDepositCoreDeposit","ProviderName":"RemoteDepositCoreDepositSpectrumProvider"},{"PackageId":"Alkami.MicroServices.RDCoreDeposit.Static.Service.Host","ProviderType":"RemoteDepositCoreDeposit","ProviderName":"RemoteDepositCoreDepositStaticProvider"},{"PackageId":"Alkami.MicroServices.RDCoreDeposit.Symitar.Service.Host","ProviderType":"RemoteDepositCoreDeposit","ProviderName":"RemoteDepositCoreDepositSymitarProvider"},{"PackageId":"Alkami.MicroServices.RDCoreDeposit.UltraData.Service.Host","ProviderType":"RemoteDepositCoreDeposit","ProviderName":"RemoteDepositCoreDepositUltraDataProvider"},{"PackageId":"Alkami.MicroServices.RDCoreDeposit.XP.Service.Host","ProviderType":"RemoteDepositCoreDeposit","ProviderName":"RemoteDepositCoreDepositXPProvider"},{"PackageId":"Alkami.MicroServices.Registration.Entrust.Service.Host","ProviderType":"Registration","ProviderName":"Alkami Entrust Registration Service"},{"PackageId":"Alkami.MicroServices.Registration.Service.Host","ProviderType":"Registration","ProviderName":"Alkami Registration Service"},{"PackageId":"Alkami.MicroServices.RememberedDevices.Entrust.Service.Host","ProviderType":"RememberedDevices","ProviderName":"Entrust Remembered Devices Provider"},{"PackageId":"Alkami.MicroServices.RemoteDepositProviders.Dynamic.Service.Host","ProviderType":"RemoteDeposit","ProviderName":"MicroserviceDynamicRDC"},{"PackageId":"Alkami.MicroServices.RemoteDepositProviders.Ensenta.Service.Host","ProviderType":"RemoteDeposit","ProviderName":"MicroserviceEnsentaRDC"},{"PackageId":"Alkami.MicroServices.RemoteDepositProviders.Fxd.Service.Host","ProviderType":"RemoteDeposit","ProviderName":"MicroserviceFxdRDC"},{"PackageId":"Alkami.MicroServices.RemoteDepositProviders.ProfitStars.Onus.Service.Host","ProviderType":"RemoteDeposit","ProviderName":"MicroserviceProfitStarsOnusRDC"},{"PackageId":"Alkami.MicroServices.RemoteDepositProviders.ProfitStars.Service.Host","ProviderType":"RemoteDeposit","ProviderName":"MicroserviceProfitStarsRDC"},{"PackageId":"Alkami.MicroServices.RemoteDepositProviders.Vertifi.Service.Host","ProviderType":"RemoteDeposit","ProviderName":"MicroserviceVertifiRDC"},{"PackageId":"Alkami.MicroServices.Reporting.BusinessReports.Service.Host","ProviderType":"Reporting","ProviderName":"Business Reports Reporting MicroService"},{"PackageId":"Alkami.MicroServices.Reporting.URA.Service.Host","ProviderType":"Reporting","ProviderName":"User Recent Activity Reporting MicroService"},{"PackageId":"Alkami.MicroServices.Risk.Alkami.Service.Host","ProviderType":"Risk","ProviderName":"Alkami Risk Provider"},{"PackageId":"Alkami.MicroServices.Risk.Biocatch.Service.Host","ProviderType":"Risk","ProviderName":"Biocatch Risk Provider"},{"PackageId":"Alkami.MicroServices.Risk.DetectTA.Service.Host","ProviderType":"Risk","ProviderName":"DetectTA Risk Evaluation"},{"PackageId":"Alkami.MicroServices.Risk.Entrust.Service.Host","ProviderType":"Risk","ProviderName":"Entrust Risk"},{"PackageId":"Alkami.MicroServices.SamlAuth.Service.Host","ProviderType":"SamlAuthentication","ProviderName":"Alkami SamlAuth Authentication Provider"},{"PackageId":"Alkami.MicroServices.SkipPaymentProviders.Corelation.Service.Host","ProviderType":"SkipPayment","ProviderName":"Corelation SkipPayment Provider"},{"PackageId":"Alkami.MicroServices.SkipPaymentProviders.Dynamic.Service.Host","ProviderType":"SkipPayment","ProviderName":"SkipPayment Dynamic Provider"},{"PackageId":"Alkami.MicroServices.SkipPaymentProviders.Patelco.Host","ProviderType":"SkipPayment","ProviderName":"SkipPayment Patelco Provider"},{"PackageId":"Alkami.MicroServices.SkipPaymentProviders.SymConnect.Service.Host","ProviderType":"SkipPayment","ProviderName":"SkipPayment SymConnect Provider"},{"PackageId":"Alkami.MicroServices.SkipPaymentProviders.Veridian.Host","ProviderType":"SkipPayment","ProviderName":"Veridian SkipPayment Provider"},{"PackageId":"Alkami.MicroServices.SSOProviders.ACI.VCA.Service.Host","ProviderType":"GenericSSO","ProviderName":"ACI VCA SSO Provider"},{"PackageId":"Alkami.MicroServices.SSOProviders.AutoBooks.Service.Host","ProviderType":"GenericSSO","ProviderName":"AutoBooks SSO Provider"},{"PackageId":"Alkami.MicroServices.SSOProviders.Avoka.Service.Host","ProviderType":"GenericSSO","ProviderName":"Avoka SSO Provider"},{"PackageId":"Alkami.MicroServices.SSOProviders.BALoyaltyRewards.Service.Host","ProviderType":"GenericSSO","ProviderName":"BALoyaltyRewards SSO Provider"},{"PackageId":"Alkami.MicroServices.SSOProviders.BillMatrixNext.Service.Host","ProviderType":"GenericSSO","ProviderName":"BillMatrixNext SSO Provider"},{"PackageId":"Alkami.MicroServices.SSOProviders.BlackKnightMortgage.Service.Host","ProviderType":"GenericSSO","ProviderName":"Black Knight Mortgage SSO Provider"},{"PackageId":"Alkami.MicroServices.SSOProviders.Blend.Service.Host","ProviderType":"GenericSSO","ProviderName":"Blend SSO Provider"},{"PackageId":"Alkami.MicroServices.SSOProviders.BlendMortgage.Service.Host","ProviderType":"GenericSSO","ProviderName":"Blend Mortgage SSO Provider"},{"PackageId":"Alkami.MicroServices.SSOProviders.CentrixSso.Service.Host","ProviderType":"GenericSSO","ProviderName":"Centrix SSO Provider"},{"PackageId":"Alkami.MicroServices.SSOProviders.ClickSwitch.Service.Host","ProviderType":"GenericSSO","ProviderName":"ClickSwitchService"},{"PackageId":"Alkami.MicroServices.SSOProviders.CubusSkipAPay.Service.Host","ProviderType":"GenericSSO","ProviderName":"CubusSkipAPay SSO Provider"},{"PackageId":"Alkami.MicroServices.SSOProviders.CubusSso.Service.Host","ProviderType":"GenericSSO","ProviderName":"Cubus Sso Provider"},{"PackageId":"Alkami.MicroServices.SSOProviders.CuOneLoanPay.Service.Host","ProviderType":"GenericSSO","ProviderName":"CuOneLoanPay SSO Provider"},{"PackageId":"Alkami.MicroServices.SSOProviders.Imsi.Service.Host","ProviderType":"GenericSSO","ProviderName":"ImsiSso"},{"PackageId":"Alkami.MicroServices.SSOProviders.InstantOpen.Service.Host","ProviderType":"GenericSSO","ProviderName":"InstantOpenSsoService"},{"PackageId":"Alkami.MicroServices.SSOProviders.MacuAugeoRewards.Service.Host","ProviderType":"GenericSSO","ProviderName":"Macu AugeoRewards SSO Provider"},{"PackageId":"Alkami.MicroServices.SSOProviders.MacuProPay.Service.Host","ProviderType":"GenericSSO","ProviderName":"MacuProPay SSO Provider"},{"PackageId":"Alkami.MicroServices.SSOProviders.MagicWrighter.Service.Host","ProviderType":"GenericSSO","ProviderName":"Magic Wrighter SSO Provider"},{"PackageId":"Alkami.MicroServices.SSOProviders.MeridianLink.Service.Host","ProviderType":"GenericSSO","ProviderName":"MeridianLink SSO Provider"},{"PackageId":"Alkami.MicroServices.SSOProviders.MeridianLinkSsoV2.Service.Host","ProviderType":"GenericSSO","ProviderName":"MeridianLinkSsoV2 Provider"},{"PackageId":"Alkami.MicroServices.SSOProviders.MyCardInfo.Service.Host","ProviderType":"GenericSSO","ProviderName":"MyCardInfo SSO Provider"},{"PackageId":"Alkami.MicroServices.SSOProviders.OnDot.Service.Host","ProviderType":"GenericSSO","ProviderName":"OnDotMobileGateway"},{"PackageId":"Alkami.MicroServices.SSOProviders.OrionInvestments.Service.Host","ProviderType":"GenericSSO","ProviderName":"Orion Investments SSO Provider"},{"PackageId":"Alkami.MicroServices.SSOProviders.PayTraceSso.Service.Host","ProviderType":"GenericSSO","ProviderName":"PayTrace Sso Provider"},{"PackageId":"Alkami.MicroServices.SSOProviders.PropayByTSYS.Service.Host","ProviderType":"GenericSSO","ProviderName":"PropayByTSYS SSO Provider"},{"PackageId":"Alkami.MicroServices.SSOProviders.QCash.Service.Host","ProviderType":"GenericSSO","ProviderName":"QCash"},{"PackageId":"Alkami.MicroServices.SSOProviders.QuavoDisputeSso.Service.Host","ProviderType":"GenericSSO","ProviderName":"QuavoDispute Sso Provider"},{"PackageId":"Alkami.MicroServices.SSOProviders.QuavoStatusSso.Service.Host","ProviderType":"GenericSSO","ProviderName":"QuavoStatus Sso Provider"},{"PackageId":"Alkami.MicroServices.SSOProviders.SymApp.Service.Host","ProviderType":"GenericSSO","ProviderName":"SymApp SSO Provider"},{"PackageId":"Alkami.MicroServices.SSOProviders.TCILoanOrigination.Service.Host","ProviderType":"GenericSSO","ProviderName":"TCI Loan Origination SSO Provider"},{"PackageId":"Alkami.MicroServices.SSOProviders.UChooseRewards.Service.Host","ProviderType":"GenericSSO","ProviderName":"UChooseRewards SSO Provider"},{"PackageId":"Alkami.MicroServices.SSOProviders.uOpen.Service.Host","ProviderType":"GenericSSO","ProviderName":"uOpen SSO Provider"},{"PackageId":"Alkami.MicroServices.SSOProviders.VirtualCapture.Service.Host","ProviderType":"GenericSSO","ProviderName":"VirtualCapture SSO Provider"},{"PackageId":"Alkami.MicroServices.StepUp.Alkami.Service.Host","ProviderType":null,"ProviderName":"Alkami Step Up"},{"PackageId":"Alkami.MicroServices.StepUp.Entrust.Service.Host","ProviderType":"StepUp","ProviderName":"Entrust Step Up"},{"PackageId":"Alkami.MicroServices.Transfers.CoreProxy.Service.Host","ProviderType":"Transfers","ProviderName":"Transfers Core Proxy Service"},{"PackageId":"Alkami.MicroServices.UserLookupHandlers.External.Service.Host","ProviderType":"UserLookup","ProviderName":"External User Lookup"},{"PackageId":"Alkami.MicroServices.UserLookupHandlers.Internal.Service.Host","ProviderType":"UserLookup","ProviderName":"Internal User Lookup"},{"PackageId":"Alkami.MicroServices.UUIDGen.SandiaLabs.Service.Host","ProviderType":"UUID","ProviderName":"SandiaLabs ESB UUID Provider"},{"PackageId":"Alkami.MicroServices.UUIDGen.Service.Host","ProviderType":"UUID","ProviderName":"SandiaLabs ESB UUID Provider"},{"PackageId":"Alkami.MicroServices.VisaGiveBack.Service.Host","ProviderType":"visagiveback","ProviderName":"Visa GiveBack Provider"},{"PackageId":"Alkami.MicroServices.WebAnalytics.Adobe.EP.Service.Host","ProviderType":"Web Analytics","ProviderName":"Adobe Analytics Provider"},{"PackageId":"Alkami.MicroServices.WebAnalytics.Google.Service.Host","ProviderType":"Web Analytics","ProviderName":"Google Analytics Provider"},{"PackageId":"Alkami.MicroServices.ZelleProviders.FIS.MACU.Service.Host","ProviderType":"Zelle","ProviderName":"FIS MACU Zelle"},{"PackageId":"Alkami.MicroServices.ZelleProviders.FIS.Service.Host","ProviderType":"Zelle","ProviderName":"FIS Zelle"},{"PackageId":"Alkami.MicroServices.ZelleProviders.FiServ.Service.Host","ProviderType":"Zelle","ProviderName":"FIServ Zelle"},{"PackageId":"Alkami.MicroServices.ZelleProviders.JHA.Service.Host","ProviderType":"Zelle","ProviderName":"JHA Zelle"},{"PackageId":"Alkami.MS.AccountRefresh.SubscriptionFilter.Host","ProviderType":"AccountRefreshSubscriptionFilter","ProviderName":"Alkami MS Account Refresh Subscription Filter"},{"PackageId":"Alkami.MS.ActionableAlertsProviders.Income.Service.Host","ProviderType":"ActionableAlert","ProviderName":"Income Actionable Alert"},{"PackageId":"Alkami.MS.AutomaticDepositAlert.Host","ProviderType":"AlertProcessor","ProviderName":"Alkami MS Automatic Deposit Alert"},{"PackageId":"Alkami.MS.AutomaticWithdrawalAlert.Host","ProviderType":"AlertProcessor","ProviderName":"Alkami MS Automatic Withdrawal Alert"},{"PackageId":"Alkami.MS.BACH.NeedsAuthorizationAlert.Host","ProviderType":"AlertProcessor","ProviderName":"Alkami MS BACH Needs Authorization Alert"},{"PackageId":"Alkami.MS.BACH.OptInAlerts.Host","ProviderType":"AlertProcessor","ProviderName":"Alkami MS BACH OptIn Alerts"},{"PackageId":"Alkami.MS.BACH.RejectedByFIAlert.Host","ProviderType":"AlertProcessor","ProviderName":"Alkami MS BACH RejectedByFI Alert"},{"PackageId":"Alkami.MS.BalanceAlert.Host","ProviderType":"AlertProcessor","ProviderName":"Alkami MS Balance Alert"},{"PackageId":"Alkami.MS.BillingFile.Service.Host","ProviderType":"BillingFile","ProviderName":"Billing File Provider"},{"PackageId":"Alkami.MS.CardManagementProviders.CardApi.Host","ProviderType":"CardManagement","ProviderName":"CardApi Card Management Provider"},{"PackageId":"Alkami.MS.CardManagementProviders.CardValet.Host","ProviderType":"CardManagement","ProviderName":"CardValet Card Management Provider"},{"PackageId":"Alkami.MS.CardManagementProviders.CodeConnect.Host","ProviderType":"CardManagement","ProviderName":"CodeConnect Card Management Provider"},{"PackageId":"Alkami.MS.CardManagementProviders.COOPPortal.Host","ProviderType":"CardManagement","ProviderName":"COOPPortal Card Management Provider"},{"PackageId":"Alkami.MS.CardManagementProviders.CoreApi.Host","ProviderType":"CardManagement","ProviderName":"CoreApi Card Management Provider"},{"PackageId":"Alkami.MS.CardManagementProviders.Cots.Host","ProviderType":"CardManagement","ProviderName":"COTS Card Management Provider"},{"PackageId":"Alkami.MS.CardManagementProviders.CustomControl.Host","ProviderType":"CardManagement","ProviderName":"Custom Control Card Management Provider"},{"PackageId":"Alkami.MS.CardManagementProviders.Entrust.Host","ProviderType":"CardManagement","ProviderName":"Entrust Card Management Provider"},{"PackageId":"Alkami.MS.CardManagementProviders.FIS.Host","ProviderType":"CardManagement","ProviderName":"FIS Card Management Provider"},{"PackageId":"Alkami.MS.CardManagementProviders.JXchange.Host","ProviderType":"CardManagement","ProviderName":"JXchange Card Management Provider"},{"PackageId":"Alkami.MS.CardManagementProviders.Phoenix.Host","ProviderType":"CardManagement","ProviderName":"Phoenix Card Management Provider"},{"PackageId":"Alkami.MS.CardManagementProviders.PushProvisioning.Host","ProviderType":"CardManagement","ProviderName":"Push Provisioning Card Management Provider"},{"PackageId":"Alkami.MS.CardManagementProviders.Visa.Host","ProviderType":"CardManagement","ProviderName":"Visa Card Management Provider"},{"PackageId":"Alkami.MS.CDMaturity.Spectrum.Host","ProviderType":"CDMaturity","ProviderName":"CDMaturitySpectrumProvider"},{"PackageId":"Alkami.MS.CDMaturity.SymConnect.Host","ProviderType":"CDMaturity","ProviderName":"CDMaturitySymConnectProvider"},{"PackageId":"Alkami.MS.CheckClearedAlert.Host","ProviderType":"AlertProcessor","ProviderName":"Alkami MS Check Cleared Alert"},{"PackageId":"Alkami.MS.CheckOrderProviders.DeluxeSSO.Host","ProviderType":"GenericSSO","ProviderName":"Deluxe CheckOrder SSO Provider"},{"PackageId":"Alkami.MS.CheckWithdrawal.CoreApi.Host","ProviderType":"CheckWithdrawal","ProviderName":"CoreApi CheckWithdrawal Provider"},{"PackageId":"Alkami.MS.CheckWithdrawal.Corelation.Host","ProviderType":"CheckWithdrawal","ProviderName":"Corelation CheckWithdrawal Provider"},{"PackageId":"Alkami.MS.CheckWithdrawal.iPower.Host","ProviderType":"CheckWithdrawal","ProviderName":"iPower CheckWithdrawal Provider"},{"PackageId":"Alkami.MS.CheckWithdrawal.OSI.Host","ProviderType":"CheckWithdrawal","ProviderName":"OSI CheckWithdrawal Provider"},{"PackageId":"Alkami.MS.CheckWithdrawal.Spectrum.Host","ProviderType":"CheckWithdrawal","ProviderName":"Spectrum CheckWithdrawal Provider"},{"PackageId":"Alkami.MS.CheckWithdrawal.Static.Host","ProviderType":"CheckWithdrawal","ProviderName":"Static CheckWithdrawal Provider"},{"PackageId":"Alkami.MS.CheckWithdrawal.SymConnect.Host","ProviderType":"CheckWithdrawal","ProviderName":"SymConnect CheckWithdrawal Provider"},{"PackageId":"Alkami.MS.CheckWithdrawal.UltraData.Host","ProviderType":"CheckWithdrawal","ProviderName":"UltraData CheckWithdrawal Provider"},{"PackageId":"Alkami.MS.CheckWithdrawal.XP.Host","ProviderType":"CheckWithdrawal","ProviderName":"XP CheckWithdrawal Provider"},{"PackageId":"Alkami.MS.Core.Base2000.Service.Host","ProviderType":"Core","ProviderName":"Base2000CreditCardProvider"},{"PackageId":"Alkami.MS.Core.Corelation.Registration.Service.Host","ProviderType":"Core","ProviderName":"Corelation Core Provider"},{"PackageId":"Alkami.MS.Core.Corelation.Service.Host","ProviderType":"Core","ProviderName":"Corelation Core Provider"},{"PackageId":"Alkami.MS.Core.DataExchange.Host","ProviderType":"Core","ProviderName":"PSCUDataExchangeCreditCardProvider"},{"PackageId":"Alkami.MS.Core.DMI.Service.Host","ProviderType":"Core","ProviderName":"DMI Core Provider"},{"PackageId":"Alkami.MS.Core.ESB.Salesforce.AccountUpdate.Service.Host","ProviderType":"Core","ProviderName":"Salesforce Core Provider"},{"PackageId":"Alkami.MS.Core.ESB.Salesforce.Registration.Service.Host","ProviderType":"Core","ProviderName":"Salesforce Core Provider"},{"PackageId":"Alkami.MS.Core.ESB.Salesforce.Service.Host","ProviderType":"Core","ProviderName":"Salesforce Core Provider"},{"PackageId":"Alkami.MS.Core.Legacy.Corelation.Registration.Service.Host","ProviderType":"Core","ProviderName":"Corelation Core Provider"},{"PackageId":"Alkami.MS.Core.Legacy.Corelation.Service.Host","ProviderType":"Core","ProviderName":"Corelation Core Provider"},{"PackageId":"Alkami.MS.Core.Legacy.SymConnect.Registration.Service.Host","ProviderType":"Core","ProviderName":"SymConnectCoreProvider"},{"PackageId":"Alkami.MS.Core.Legacy.SymConnect.Service.Host","ProviderType":"Core","ProviderName":"SymConnectCoreProvider"},{"PackageId":"Alkami.MS.Core.LoanPayoffCalculator.CoreAPI.Service.Host","ProviderType":"Core","ProviderName":"CoreApi Core Provider"},{"PackageId":"Alkami.MS.Core.LoanPayoffCalculator.XP.Service.Host","ProviderType":"Core","ProviderName":"XP Core Provider"},{"PackageId":"Alkami.MS.Core.LPC.Corelation.Service.Host","ProviderType":"Core","ProviderName":"Corelation Core Provider"},{"PackageId":"Alkami.MS.Core.Orion.Force.Service.Host","ProviderType":"Core","ProviderName":"Orion Force Core Provider"},{"PackageId":"Alkami.MS.Core.Orion.Service.Host","ProviderType":"Core","ProviderName":"Orion Core Provider"},{"PackageId":"Alkami.MS.Core.Phoenix.AccountUpdate.Host","ProviderType":"Core","ProviderName":"HarlandPhoenixCore"},{"PackageId":"Alkami.MS.Core.Phoenix.Host","ProviderType":"Core","ProviderName":"HarlandPhoenixCore"},{"PackageId":"Alkami.MS.Core.Phoenix.Registration.Host","ProviderType":"Core","ProviderName":"HarlandPhoenixCore"},{"PackageId":"Alkami.MS.Core.SymConnect.Registration.Service.Host","ProviderType":"Core","ProviderName":"SymConnect Core Provider MS"},{"PackageId":"Alkami.MS.Core.SymConnect.Service.Host","ProviderType":"Core","ProviderName":"SymConnect Core Provider MS"},{"PackageId":"Alkami.MS.Core.TMG.FirstTech.Service.Host","ProviderType":"Core","ProviderName":"First Tech TMG Credit Card Provider"},{"PackageId":"Alkami.MS.Core.TMG.Host","ProviderType":"Core","ProviderName":"TMGCreditCardProvider"},{"PackageId":"Alkami.MS.CorePasswordUpdate.Corelation.Service.Host","ProviderType":"CorePasswordUpdate","ProviderName":"Corelation Core Password Update"},{"PackageId":"Alkami.MS.CourtesyPayProviders.CoreApi.Host","ProviderType":"CourtesyPay","ProviderName":"CoreApi Courtesy Pay Provider"},{"PackageId":"Alkami.MS.CourtesyPayProviders.DirectFCU.Host","ProviderType":"CourtesyPay","ProviderName":"DirectFCU Courtesy Pay Provider"},{"PackageId":"Alkami.MS.CourtesyPayProviders.Patelco.Host","ProviderType":"CourtesyPay","ProviderName":"Patelco Courtesy Pay Provider"},{"PackageId":"Alkami.MS.CourtesyPayProviders.Static.Host","ProviderType":"CourtesyPay","ProviderName":"Static Courtesy Pay Provider"},{"PackageId":"Alkami.MS.CreditScoreInfoProviders.Savvy.Host","ProviderType":"CreditScoreInfo","ProviderName":"SavvyMoneyCreditScoreInfoProvider"},{"PackageId":"Alkami.MS.Crypto.Dynamic.Service.Host","ProviderType":"Crypto","ProviderName":"Alkami Crypto Provider"},{"PackageId":"Alkami.MS.Crypto.Nydig.Service.Host","ProviderType":"Crypto","ProviderName":"Nydig Crypto Provider"},{"PackageId":"Alkami.MS.DebitCardPurchaseAlert.Host","ProviderType":"AlertProcessor","ProviderName":"Alkami MS Debit Card Purchase Alert"},{"PackageId":"Alkami.MS.DirectDepositAlert.Host","ProviderType":"AlertProcessor","ProviderName":"Alkami MS Direct Deposit Alert"},{"PackageId":"Alkami.MS.EM.ACHRiskProcessor.Host","ProviderType":"EventProcessor","ProviderName":"AlkamiACHRiskProcessor"},{"PackageId":"Alkami.MS.EM.BusinessPayee.Alert.Host","ProviderType":"EventProcessor","ProviderName":"Alkami MicroServices Event Management Business Payee Alert Processor Service"},{"PackageId":"Alkami.MS.EM.BusinessReportsBackfill.Processor.Host","ProviderType":"EventProcessor","ProviderName":"Alkami Event Management Business Reports Backfill Processor Service"},{"PackageId":"Alkami.MS.EM.CardValet.Host","ProviderType":"EventProcessor","ProviderName":"Alkami MS EM CardValet Processor"},{"PackageId":"Alkami.MS.EM.CryptoReceiptProcessor.Host","ProviderType":"EventProcessor","ProviderName":"Alkami MicroServices Event Management Crypto Receipt Processor Service"},{"PackageId":"Alkami.MS.EM.CryptoReconProcessor.Host","ProviderType":"EventProcessor","ProviderName":"Alkami MicroServices Event Management Crypto Reconciliation Processor Service"},{"PackageId":"Alkami.MS.EM.CryptoTransaction.Alert.Host","ProviderType":"EventProcessor","ProviderName":"Alkami MicroServices Event Management Crypto Transaction Processor Service"},{"PackageId":"Alkami.MS.EM.Processor.ExternalBilling.Host","ProviderType":"EventProcessor","ProviderName":"ExternalBillingReportProvider"},{"PackageId":"Alkami.MS.EM.Processor.ScheduledACH.Host","ProviderType":"EventProcessor","ProviderName":"AlkamiScheduledACH"},{"PackageId":"Alkami.MS.EM.Processor.ScheduledWire.Host","ProviderType":"EventProcessor","ProviderName":"AlkamiScheduledWire"},{"PackageId":"Alkami.MS.EM.Scheduler.Host","ProviderType":"EventProcessor","ProviderName":"Alkami Event Management Scheduler Processor Service"},{"PackageId":"Alkami.MS.EM.SubscriptionBasedBilling.Host","ProviderType":"EventProcessor","ProviderName":"SubscriptionBasedBillingProvider"},{"PackageId":"Alkami.MS.EM.UserNotificationProcessor.Host","ProviderType":"EventProcessor","ProviderName":"Alkami MS EM User Notification Processor"},{"PackageId":"Alkami.MS.Fact.CoreAPI.Host","ProviderType":"Fact Provider","ProviderName":"Alkami.MS.Fact.CoreAPI"},{"PackageId":"Alkami.MS.Fact.Symitar.Service.Host","ProviderType":"Fact Provider","ProviderName":"Alkami.MS.Fact.Symitar"},{"PackageId":"Alkami.MS.GenericProxy.Service.Host","ProviderType":"GenericProxy","ProviderName":"Alkami.MS.GenericProxy"},{"PackageId":"Alkami.MS.InsufficientFundsAlert.Host","ProviderType":"AlertProcessor","ProviderName":"Alkami MS Insufficient Funds Alert"},{"PackageId":"Alkami.MS.InvalidPasswordAlert.Host","ProviderType":"AlertProcessor","ProviderName":"Alkami MS Invalid Password Alert"},{"PackageId":"Alkami.MS.Investment.FirstTech.Host","ProviderType":"Investment","ProviderName":"First Tech Investments Provider"},{"PackageId":"Alkami.MS.Investment.Orcas.Host","ProviderType":"Investment","ProviderName":"ORCAS Investments Provider"},{"PackageId":"Alkami.MS.LoanPaymentDueAlert.Host","ProviderType":"AlertProcessor","ProviderName":"Alkami MS Loan Payment Due Alert"},{"PackageId":"Alkami.MS.LocationProviders.COOP.Host","ProviderType":"Location","ProviderName":"AlkamiCOOPLocationProvider"},{"PackageId":"Alkami.MS.LocationProviders.COOP.ProximitySearch.Host","ProviderType":"Location","ProviderName":"AlkamiProximitySearchLocationProvider"},{"PackageId":"Alkami.MS.LocationProviders.Dynamic.Host","ProviderType":"Location","ProviderName":"AlkamiDynamicLocationProvider"},{"PackageId":"Alkami.MS.LocationProviders.LocatorSearch.Host","ProviderType":"Location","ProviderName":"AlkamiLocatorSearchLocationProvider"},{"PackageId":"Alkami.MS.LocationProviders.MoneyPass.Host","ProviderType":"Location","ProviderName":"AlkamiMoneyPassLocationProvider"},{"PackageId":"Alkami.MS.LocationProviders.Patelco.Host","ProviderType":"Location","ProviderName":"AlkamiLocationPatelcoProvider"},{"PackageId":"Alkami.MS.MM.ExternalAccount.Alert.Host","ProviderType":"AlertProcessor","ProviderName":"Alkami MS MM External Account Alert"},{"PackageId":"Alkami.MS.MM.MultiAccount.Alert.Host","ProviderType":"AlertProcessor","ProviderName":"Alkami MS MM Multi Account Alert"},{"PackageId":"Alkami.MS.Notifications.SMS.Clickatell.Service.Host","ProviderType":"SMS","ProviderName":"Alkami MS Notifications SMS Clickatell"},{"PackageId":"Alkami.MS.Notifications.SMS.Telesign.Service.Host","ProviderType":"SMS","ProviderName":"Alkami MicroServices Notifications SMS Telesign"},{"PackageId":"Alkami.MS.Notifications.SMS.Twilio.Service.Host","ProviderType":"SMS","ProviderName":"Alkami MS Notifications SMS Twilio"},{"PackageId":"Alkami.MS.NotificationWidget.Examples.Host","ProviderType":"AlertProcessor","ProviderName":"Alkami MS Notification Widget Example Alert"},{"PackageId":"Alkami.MS.ODPP.CoreApi.Host","ProviderType":"OverdraftProtection","ProviderName":"CoreApi Overdraft Protection Provider"},{"PackageId":"Alkami.MS.ODPP.Static.Host","ProviderType":"OverdraftProtection","ProviderName":"Static Overdraft Service"},{"PackageId":"Alkami.MS.ODPP.XP.Host","ProviderType":"OverdraftProtection","ProviderName":"XP Overdraft Protection Provider"},{"PackageId":"Alkami.MS.Processor.MessageCenter.SLA.Host","ProviderType":"EventProcessor","ProviderName":"Alkami Event Management SLA Notification Processor Service"},{"PackageId":"Alkami.MS.Processor.MessageCenter.SLA.Service.Host","ProviderType":"EventProcessor","ProviderName":"Alkami Event Management SLA Notification Processor Service"},{"PackageId":"Alkami.MS.Push.UrbanAirship.Service.Host","ProviderType":"Push","ProviderName":"Alkami MS Push UrbanAirship"},{"PackageId":"Alkami.MS.QuickApply.Corelation.Service.Host","ProviderType":"QuickApply","ProviderName":"Alkami.MS.QuickApply.Corelation Quick Apply Provider"},{"PackageId":"Alkami.MS.QuickApply.Silverlake.Service.Host","ProviderType":"QuickApply","ProviderName":"Alkami.MS.QuickApply.Silverlake Quick Apply Provider"},{"PackageId":"Alkami.MS.RDC.Dynamic.Service.Host","ProviderType":"RemoteDeposit","ProviderName":"MicroserviceDynamicRDC"},{"PackageId":"Alkami.MS.RDC.Ensenta.Service.Host","ProviderType":"RemoteDeposit","ProviderName":"MicroserviceEnsentaRDC"},{"PackageId":"Alkami.MS.RDC.Fxd.Service.Host","ProviderType":"RemoteDeposit","ProviderName":"MicroserviceFxdRDC"},{"PackageId":"Alkami.MS.RDC.ProfitStars.Onus.Service.Host","ProviderType":"RemoteDeposit","ProviderName":"MicroserviceProfitStarsOnusRDC"},{"PackageId":"Alkami.MS.RDC.ProfitStars.Service.Host","ProviderType":"RemoteDeposit","ProviderName":"MicroserviceProfitStarsRDC"},{"PackageId":"Alkami.MS.RDC.Vertifi.Service.Host","ProviderType":"RemoteDeposit","ProviderName":"MicroserviceVertifiRDC"},{"PackageId":"Alkami.MS.ReturnedCheckAlert.Host","ProviderType":"AlertProcessor","ProviderName":"Alkami MS Returned Check Alert"},{"PackageId":"Alkami.MS.SkipPaymentProviders.CoreApi.Service.Host","ProviderType":"SkipPayment","ProviderName":"CoreApi SkipPayment Provider"},{"PackageId":"Alkami.MS.SkipPaymentProviders.Spectrum.Host","ProviderType":"SkipPayment","ProviderName":"SkipPayment Spectrum Provider"},{"PackageId":"Alkami.MS.SkipPaymentProviders.XP.Host","ProviderType":"SkipPayment","ProviderName":"XP SkipPayment Provider"},{"PackageId":"Alkami.MS.SSOProviders.AugeoRewards.Host","ProviderType":"GenericSSO","ProviderName":"Augeo Rewards Generic SSO Provider"},{"PackageId":"Alkami.MS.SSOProviders.AvtexPureConnect.Service.Host","ProviderType":"GenericSSO","ProviderName":"Avtex Pure Connect SSO"},{"PackageId":"Alkami.MS.SSOProviders.CUDirectSSO.Service.Host","ProviderType":"GenericSSO","ProviderName":"CU Direct SSO Provider"},{"PackageId":"Alkami.MS.SSOProviders.CUDL.Service.Host","ProviderType":"GenericSSO","ProviderName":"CUDL SSO Provider"},{"PackageId":"Alkami.MS.SSOProviders.LoanRateReset.Service.Host","ProviderType":"GenericSSO","ProviderName":"LoanRateReset"},{"PackageId":"Alkami.MS.SSOProviders.VirtualStrongBox.Service.Host","ProviderType":"GenericSSO","ProviderName":"VirtualStrongBox SSO Provider"},{"PackageId":"Alkami.MS.StopPayment.CoA.Host","ProviderType":"StopPayParser","ProviderName":"CoA StopPay Provider"},{"PackageId":"Alkami.MS.StopPayment.CoreApi.Host","ProviderType":"StopPayParser","ProviderName":"CoreApi Stop Payment Provider"},{"PackageId":"Alkami.MS.StopPayment.Corelation.Host","ProviderType":"StopPayParser","ProviderName":"Corelation Stop Payment Provider"},{"PackageId":"Alkami.MS.StopPayment.Dynamic.Host","ProviderType":"StopPayParser","ProviderName":"DynamicStopPayInputProvider"},{"PackageId":"Alkami.MS.StopPayment.iPower.Host","ProviderType":"StopPayParser","ProviderName":"iPower StopPay Provider"},{"PackageId":"Alkami.MS.StopPayment.Miser.Host","ProviderType":"StopPayParser","ProviderName":"MiserStopPayInputProvider"},{"PackageId":"Alkami.MS.StopPayment.OSI.Host","ProviderType":"StopPayParser","ProviderName":"OSIStopPayInputProvider"},{"PackageId":"Alkami.MS.StopPayment.Phoenix.Host","ProviderType":"StopPayParser","ProviderName":"HarlandPhoenixStopPayProvider"},{"PackageId":"Alkami.MS.StopPayment.Silverlake.Host","ProviderType":"StopPayParser","ProviderName":"Silverlake StopPay Provider"},{"PackageId":"Alkami.MS.StopPayment.Spectrum.Host","ProviderType":"StopPayParser","ProviderName":"Spectrum Stop Payment Provider"},{"PackageId":"Alkami.MS.StopPayment.SymConnect.Host","ProviderType":"StopPayParser","ProviderName":"SymConnect Stop Pay Input Provider"},{"PackageId":"Alkami.MS.StopPayment.UltraData.Host","ProviderType":"StopPayParser","ProviderName":"UltraData StopPay Provider"},{"PackageId":"Alkami.MS.StopPayment.XP.Host","ProviderType":"StopPayParser","ProviderName":"XP StopPay Provider"},{"PackageId":"Alkami.MS.SyncCoordinator.AccountSync.Host","ProviderType":"Syncing","ProviderName":"Alkami MS SyncCoordinator AccountSync"},{"PackageId":"Alkami.MS.SyncCoordinator.Host","ProviderType":"Syncing","ProviderName":"Alkami MS Sync Coordinator"},{"PackageId":"Alkami.MS.TransactionAlert.Host","ProviderType":"AlertProcessor","ProviderName":"Alkami MS Transaction Alert"},{"PackageId":"Alkami.MS.TransactionDescriptionAlert.Host","ProviderType":"AlertProcessor","ProviderName":"Alkami MS Transaction Description Alert"},{"PackageId":"Alkami.MS.TransactionExportProviders.BAI.Host","ProviderType":"TransactionExports","ProviderName":"AlkamiBAIExportProvider"},{"PackageId":"Alkami.MS.TransactionExportProviders.CSV.Host","ProviderType":"TransactionExports","ProviderName":"AlkamiCSVExportProvider"},{"PackageId":"Alkami.MS.TransactionExportProviders.OFX.Host","ProviderType":"TransactionExports","ProviderName":"AlkamiOFXExportProvider"},{"PackageId":"Alkami.MS.TransactionExportProviders.QBO.Host","ProviderType":"TransactionExports","ProviderName":"AlkamiQBOExportProvider"},{"PackageId":"Alkami.MS.TransactionExportProviders.QFX.Host","ProviderType":"TransactionExports","ProviderName":"AlkamiQFXExportProvider"},{"PackageId":"Alkami.MS.TransactionImage.Catalyst.Service.Host","ProviderType":"TransactionImage","ProviderName":"Alkami MS TransactionImage Catalyst"},{"PackageId":"Alkami.MS.TransactionImage.CorporateAmerica.Service.Host","ProviderType":"TransactionImage","ProviderName":"Alkami MS TransactionImage CorporateAmerica"},{"PackageId":"Alkami.MS.TransactionImage.FedImage.Service.Host","ProviderType":"TransactionImage","ProviderName":"Alkami MS TransactionImage Fed Image"},{"PackageId":"Alkami.MS.TransactionImage.FinastraActiveView.Service.Host","ProviderType":"TransactionImage","ProviderName":"Finastra ActiveView TransactionImage Provider MS"},{"PackageId":"Alkami.MS.TransactionImage.FiservDirector.Service.Host","ProviderType":"TransactionImage","ProviderName":"Fiserv Director TransactionImage Provider MS"},{"PackageId":"Alkami.MS.TransactionImage.JHA4sight.Service.Host","ProviderType":"TransactionImage","ProviderName":"JHA4sight TransactionImage Provider MS"},{"PackageId":"Alkami.MS.TransactionImage.MidAtlantic.Service.Host","ProviderType":"TransactionImage","ProviderName":"Alkami.MS.TransactionImage.MidAtlantic"},{"PackageId":"Alkami.MS.TransactionImage.MVI.Service.Host","ProviderType":"TransactionImage","ProviderName":"MVI TransactionImage ProviderMS"},{"PackageId":"Alkami.MS.TransactionImage.SFSolutions.Service.Host","ProviderType":"TransactionImage","ProviderName":"SFSolutions TransactionImage ProviderMS"},{"PackageId":"Alkami.MS.TransactionImage.StandardSSO.Service.Host","ProviderType":"TransactionImage","ProviderName":"Standard SSO Transaction Image Provider MS"},{"PackageId":"Alkami.MS.TransactionImage.Static.Service.Host","ProviderType":"TransactionImage","ProviderName":"Alkami.MS.TransactionImage.Static"},{"PackageId":"Alkami.MS.TransactionImage.Synergy.Service.Host","ProviderType":"TransactionImage","ProviderName":"Alkami.MS.TransactionImage.Synergy"},{"PackageId":"Alkami.MS.WireProcessor.DirectLine.Service.Host","ProviderType":"WireProcessor","ProviderName":"DirectLine"},{"PackageId":"Alkami.MS.WireProcessor.Static.Service.Host","ProviderType":"WireProcessor","ProviderName":"Static"},{"PackageId":"Alkami.MS.WireProcessor.WireXChange.Service.Host","ProviderType":"WireProcessor","ProviderName":"WireXChange"},{"PackageId":"fake.Alkami.MicroServices.SymConnectMultiplexer.Service.Host","ProviderType":"Core","ProviderName":"SymConnectCoreProvider"}]}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Retired/Copy-AlkamiReleaseManifest.Tests.retired.ps1 b/Modules/Alkami.DevOps.Operations/Retired/Copy-AlkamiReleaseManifest.Tests.retired.ps1
new file mode 100644
index 0000000..a2c82d4
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Retired/Copy-AlkamiReleaseManifest.Tests.retired.ps1
@@ -0,0 +1,129 @@
+# Load the Dependant Modules From Disk
+if (Get-Module -Name Alkami.DevOps.Common) {
+ Remove-Module -Name Alkami.DevOps.Common
+}
+
+$commonModulePath = Get-ChildItem -Filter 'Alkami.DevOps.Common.psd1' -Recurse -ErrorAction Continue
+Write-Host ("Forcing load of Alkami.DevOps.Common from {0}" -f $commonModulePath.FullName)
+Import-Module $commonModulePath.FullName -Force
+
+## TODO: There is no more Alkami.DevOps.Operations module ...
+# Load the Module Under Test From Disk
+$deployModulePath = Get-ChildItem -Filter 'Alkami.DevOps.Operations.psd1' -Recurse -ErrorAction Continue
+Write-Host ("Forcing load of Alkami.DevOps.Operations from {0}" -f $deployModulePath.FullName)
+if (Get-Module -Name Alkami.DevOps.Operations) {
+ Remove-Module -Name Alkami.DevOps.Operations -Force}
+
+Import-Module $deployModulePath.FullName -Force
+
+
+InModuleScope Alkami.DevOps.Operations {
+ Describe "Copy-AlkamiReleaseManifest" {
+
+ # Temp folder to do tests in.
+ $tempFile = [System.IO.Path]::GetTempFileName()
+ $tempPath = $tempFile.Split(".") | Select-Object -First 1
+ New-Item -ItemType Directory $tempPath -ErrorAction SilentlyContinue | Out-Null
+ Write-Host "Writing test files to $tempPath"
+
+ # Make Directories/Files for Tests
+
+ $testFiles = @{
+ # Current Deploy
+ "Current\test.txt" = "test"
+ "Current\DirA\a.txt" = "a"
+ "Current\DirA\b.txt" = "b"
+ "Current\DirA\c.txt" = "oldValue"
+
+ "NewRelease\DirA\a.txt" = "a"
+ "NewRelease\DirA\b.txt" = "b"
+ "NewRelease\DirA\c.txt" = "newValue"
+ "NewRelease\DirB\d.txt" = "d"
+ "NewRelease\DirB\e.txt" = "e"
+
+ "NewerRelease\DirA\a.txt" = "a"
+ "NewerRelease\DirA\b.txt" = "1"
+ "NewerRelease\DirA\c.txt" = "c"
+ "NewerRelease\DirB\d.txt" = "2"
+ }
+
+ foreach($key in $testFiles.keys)
+ {
+ $file = (Join-Path $tempPath $key)
+ $content = $testFiles[$key]
+
+ New-Item -ItemType File -Path $file -Force | Out-Null
+ Set-Content -Force -Path $file -Value $content
+ }
+
+ # Do the first copy from "NewRelease" into "Current"
+ $src = (Join-Path $tempPath "NewRelease")
+ $dst = (Join-Path $tempPath "Current")
+ Copy-AlkamiReleaseManifest $src $dst -DiffMove -Verbose
+
+ It "Deletes a file that was not in the newest release." {
+ $deletedFile = (Join-Path $dst "test.txt")
+ Write-Host "Checking that $deletedFile does not exist.."
+ Test-Path $deletedFile | Should Be $false
+ }
+
+ It "Creates new folder and files in the release." {
+ $newFile1 = (Join-Path $dst "DirB\d.txt")
+ $newFile2 = (Join-Path $dst "DirB\e.txt")
+
+ Test-Path $newFile1 | Should Be $true
+ Get-Content $newFile1 | Should Match "d"
+ Test-Path $newFile2 | Should Be $true
+ Get-Content $newFile2 | Should Match "e"
+ }
+
+ It "Updated a changed file." {
+ $changedFile = (Join-Path $dst "DirA\c.txt")
+ Test-Path $changedFile | Should Be $true
+ Get-Content $changedFile | Should Match "newValue"
+ }
+
+ It "Created manifest files." {
+ $manifest = (Join-Path $dst "manifest.txt")
+ $manifestLeftovers = (Join-Path $dst "manifestLeftovers.txt")
+
+ Test-Path $manifest | Should Be $true
+ Test-Path $manifestLeftovers | Should Be $true
+ }
+
+ # Copy in an extra file to make sure it gets preserved through the next copy.
+ $extraFile = (Join-Path $dst "DirB\extraFile.txt")
+ New-Item -ItemType File -Path $extraFile -Force | Out-Null
+ Set-Content -Force -Path $extraFile -Value "things and stuff!"
+
+ # Do the next copy from "NewerRelease" into "Current"
+ $src = (Join-Path $tempPath "NewerRelease")
+ $dst = (Join-Path $tempPath "Current")
+ Copy-AlkamiReleaseManifest $src $dst -DiffMove -Verbose
+
+ It "Does not delete the extra file from outside the release." {
+ Test-Path $extraFile | Should Be $true
+ Get-Content $extraFile | Should Match "things and stuff!"
+ }
+
+ It "Updated the further-changed files." {
+
+ foreach($key in $testFiles.keys)
+ {
+ if($key -like "NewerRelease")
+ {
+ $file = (Join-Path $dst $key)
+ $content = $testFiles[$key]
+
+ Get-Content -Path $file | Should Match $content
+ }
+ }
+ }
+
+ It "Deleted file removed from release." {
+
+ $deletedFile = (Join-Path $dst "DirB\e.txt")
+ Test-Path $deletedFile | Should Be $false
+ }
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/Retired/Copy-AlkamiReleaseManifest.ps1 b/Modules/Alkami.DevOps.Operations/Retired/Copy-AlkamiReleaseManifest.ps1
new file mode 100644
index 0000000..a208f12
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Retired/Copy-AlkamiReleaseManifest.ps1
@@ -0,0 +1,214 @@
+function Copy-AlkamiReleaseManifest {
+<#
+.SYNOPSIS
+ Copies Orb with a file manifest to track files between releases.
+ Files that are not tracked by the manifest are left alone.
+ DiffMove flag makes it so that unchanged files are not copied.
+#>
+ [CmdletBinding()]
+ Param (
+ [Parameter(Mandatory=$false)]
+ [Alias("TempOrbPath")]
+ [string]$srcPath = "C:\temp\deploy\orb",
+
+ [Parameter(Mandatory=$false)]
+ [Alias("OrbPath")]
+ [string]$dstPath,
+
+ [Parameter(Mandatory=$false)]
+ [switch] $diffMove
+ )
+
+ $logLead = (Get-LogLeadName);
+
+ if([string]::IsNullOrEmpty($dstPath)) {
+ $dstPath = (Get-OrbPath)
+ }
+
+ if($dstPath -eq $srcPath)
+ {
+ Write-Warning "$logLead Cannot copy Alkami release from/to the same folder. $srcPath"
+ return
+ }
+
+ $timer = [System.Diagnostics.Stopwatch]::StartNew()
+
+ $manifestPath = Join-Path $dstPath "manifest.txt"
+ $manifestLeftoverPath = Join-Path $dstPath "manifestLeftovers.txt"
+
+ $exclude = "*log4net.config"
+
+ Write-Host "$logLead Discovering files to copy:"
+ Write-Host "$logLead Note: log4net.config files are ignored!"
+ Write-Host "$loglead Discovering files in $srcPath"
+ $srcFiles = (Get-RelativeFileList $srcPath -exclude $exclude)
+
+ $dstFiles = @();
+ if(Test-Path $manifestPath)
+ {
+ Write-Host "$loglead Loading file paths from manifest file: $manifestPath"
+ $dstFiles = (Get-Content $manifestPath)
+ }
+ else
+ {
+ Write-Host "$logLead Release manifest not detected at $manifestPath"
+ Write-Host "$logLead Discovering files in $dstPath"
+ $dstFiles = (Get-RelativeFileList $dstPath -exclude $exclude)
+ }
+
+ Write-Host "`n$logLead Copying Alkami release from $srcPath to $dstPath"
+ Write-Host "$logLead Determining files to add."
+ $filesToAdd = (Get-SetDifference $srcFiles $dstFiles)
+ Write-Host "$logLead Determining files to delete."
+ $filesToDelete = (Get-SetDifference $dstFiles $srcFiles)
+ Write-Host "$logLead Determining files to move."
+ $filesToMove = @();
+ $filesToMove = (Get-SetDifference $srcFiles $filesToAdd);
+ $filesToMove = (Get-SetDifference $filesToMove $filesToDelete)
+
+ if($diffMove.isPresent)
+ {
+ Write-Host "$logLead DiffMove flag is present. Finding files that have not changed to avoid copies."
+
+ # Only move the files that have changed by hash comparison.
+ $newFilesToMove = @();
+ foreach($file in $filesToMove)
+ {
+ $srcFile = Join-Path $srcPath $file
+ $dstFile = Join-Path $dstPath $file
+ if((Get-FileHash -path $srcFile).hash -ne (Get-FileHash -path $dstFile).hash)
+ {
+ Write-Verbose "$logLead $srcFile hash is different than $dstFile hash."
+ $newFilesToMove += $file
+ }
+ }
+
+ # Reassign $filesToMove to a list of fewer files to copy.
+ $count = $filesToMove.Count
+ $filesToMove = $newFilesToMove
+ $count = $count - $filesToMove.Count
+ Write-Host "$logLead $count files don't need to be updated!"
+ }
+
+ $addCount = $filesToAdd.count;
+ $delCount = $filesToDelete.count;
+ $movCount = $filesToMove.count;
+
+ Write-Host "`n$logLead Peforming the following actions:"
+ Write-Host "$logLead Adding $addCount new files."
+ Write-Host "$logLead Copying $movCount files."
+ Write-Host "$logLead Deleting $delCount existing files.`n"
+
+ $filesToCopy = $filesToAdd + $filesToMove
+ if($filesToCopy.Count)
+ {
+ Write-Host "$logLead Starting file copies."
+
+ $filesPerJob = 3000;
+ $maxJobs = 8;
+ $numFiles = $filesToCopy.Count
+ $numJobs = [Math]::Ceiling($numFiles / $filesPerJob);
+ Write-Host "NumJobs $numJobs"
+
+ $scriptBlock = {
+
+ param ($srcPath, $dstPath, $copyFiles)
+
+ $VerbosePreference = 'Continue'
+
+ foreach($file in $copyFiles)
+ {
+ $srcFile = Join-Path $srcPath $file
+ $dstFile = Join-Path $dstPath $file
+
+ if(!(Test-Path $dstFile))
+ {
+ Write-Verbose "$logLead File $dstFile doesn't exist. Creating."
+ New-Item -ItemType File -Path $dstFile -Force | Out-Null
+ }
+
+ Write-Verbose "$logLead Copying $file"
+ Copy-Item $srcFile $dstFile -Force
+ }
+ }
+
+ $jobs = @()
+ for($i = 0; $i -lt $numJobs; $i++)
+ {
+ $start = $i * $filesPerJob
+ $end = ($i + 1) * $filesPerJob
+
+ if($start -ge $numFiles)
+ {
+ break;
+ }
+ elseif($end -gt ($numFiles - 1))
+ {
+ $end = ($numFiles - 1)
+ }
+
+ $copyFiles = $filesToCopy[$start..$end]
+ $jobs += Start-Job -ScriptBlock $scriptBlock -ArgumentList $srcPath, $dstPath, $copyFiles
+
+ $running = @($jobs | Where-Object {$_.State -in ('Running','NotStarted')})
+ while ($running.Count -ge $maxJobs -and $running.Count -ne 0)
+ {
+ $finished = Wait-Job -Job $jobs -Any
+ $running = @($jobs | Where-Object {$_.State -in ('Running','NotStarted')})
+ }
+ }
+
+ # Wait for all jobs in this session to complete.
+ Get-Job | Wait-Job > $null
+
+ # Receive-Job to output the logs.
+ $jobs | ForEach-Object { $_ | Receive-Job }
+ }
+ else
+ {
+ Write-Host "$logLead No files to copy."
+ }
+
+ if($delCount)
+ {
+ Write-Host "$logLead Starting file deletes."
+ foreach($file in $filesToDelete)
+ {
+ $dstFile = Join-Path $dstPath $file
+ if(test-path $dstFile)
+ {
+ Write-Verbose "$logLead Deleting $dstFile"
+ Remove-Item -Path $dstFile -Force
+ }
+ }
+ }
+ else
+ {
+ Write-Host "$logLead No files to delete."
+ }
+
+ Write-Host "`n$logLead Writing file manifest to $manifestPath"
+ Set-Content -Force -Path $manifestPath -Value $srcFiles
+
+ # Write out leftover files that survived the release.
+ Write-Host "`n$logLead Now determining leftover files that were untouched by the release copy in $dstPath"
+ $dstFiles = (Get-RelativeFileList $dstPath)
+ $leftoverFiles = Get-SetDifference $dstFiles $srcFiles
+
+ Write-Host "$logLead Writing leftover files to $manifestLeftoverPath"
+ Set-Content -Force -Path $manifestLeftoverPath -Value $leftoverFiles
+
+ $symlinkPath = (Join-Path $dstPath "createSymlinks.ps1")
+ if (Test-Path $symlinkPath)
+ {
+ Write-Output "$logLead Running Symlink script."
+ & $symlinkPath
+ }
+ else
+ {
+ Write-Warning ("$logLead Symlink script not found at `"$symlinkPath`"")
+ }
+
+ $timer.Stop();
+ Write-Host ("`n$logLead Alkami Manifest Release Copy finished in [{0}]" -f $timer.Elapsed.ToString())
+}
diff --git a/Modules/Alkami.DevOps.Operations/Retired/Get-RelativeFileList.ps1 b/Modules/Alkami.DevOps.Operations/Retired/Get-RelativeFileList.ps1
new file mode 100644
index 0000000..e29b467
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/Retired/Get-RelativeFileList.ps1
@@ -0,0 +1,29 @@
+function Get-RelativeFileList {
+<#
+.SYNOPSIS
+ Returns a list of file names minus the base path.
+#>
+ [CmdletBinding()]
+ Param (
+ [Parameter(Mandatory=$false)]
+ [string]$path = ".\",
+ [Parameter(Mandatory=$false)]
+ [string]$exclude = ""
+ )
+
+ $files = (Get-FilesNoSymlink $path)
+
+ # Trim the base path off.
+ $results = @()
+ $basePath = (Get-Item -path $path).FullName
+ $basePathSubstringLength = ($basePath.length + 1) # +1 gets leading slash.
+ $files | ForEach-Object {
+ if($_ -notlike $exclude)
+ {
+ $results += $_.substring($basePathSubstringLength)
+ }
+ }
+
+ return $results
+}
+
diff --git a/Modules/Alkami.DevOps.Operations/tools/chocolateyInstall.ps1 b/Modules/Alkami.DevOps.Operations/tools/chocolateyInstall.ps1
new file mode 100644
index 0000000..e915b5b
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/tools/chocolateyInstall.ps1
@@ -0,0 +1,43 @@
+[CmdletBinding()]
+Param()
+process {
+ $myCurrentPath = $PSScriptRoot;
+ Write-Verbose "Installing the Module from $myCurrentPath";
+
+ $parentPath = (Split-Path $myCurrentPath);
+ $systemModulePath = "C:\Program Files\WindowsPowerShell\Modules\";
+ $myModulePath = (Join-Path $parentPath "module");
+
+ $metadata = ([Xml](Get-Content (Join-Path $parentPath "*.nuspec"))).package.metadata;
+
+ $id = $metadata.id;
+
+ $version = $metadata.version -replace '-pre.+','';
+
+ $targetModulePath = (Join-Path $systemModulePath $id);
+ $targetModuleVersionPath = (Join-Path $targetModulePath $version);
+
+ if (Test-Path $targetModulePath) {
+ ## If the target folder already existed, remove it, because we are re-installing this package, obviously
+ if (Test-Path $targetModuleVersionPath) {
+ Write-Warning "Found an already existing module at [$targetModuleVersionPath]!!"
+ Remove-Item $targetModuleVersionPath -Recurse -Force;
+ }
+
+ ## Clear previous children for name conflicts
+ (Get-ChildItem $targetModulePath) | ForEach-Object {
+ Write-Information "Removing module located at [$_]";
+ Remove-Item $_.FullName -Recurse -Force;
+ }
+ }
+
+ Write-Host "Copying module $id to [$targetModuleVersionPath]";
+ Copy-Item $myModulePath -Destination $targetModuleVersionPath -Recurse -Force;
+
+ $resourcesFolder = (Join-Path -Path $parentPath -ChildPath "Resources")
+ if (Test-Path -Path $resourcesFolder) {
+
+ Write-Host "Copying resources folder for module $id to [$targetModuleVersionPath]"
+ Copy-Item -Path $resourcesFolder -Destination $targetModuleVersionPath -Recurse -Force
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Operations/tools/chocolateyUninstall.ps1 b/Modules/Alkami.DevOps.Operations/tools/chocolateyUninstall.ps1
new file mode 100644
index 0000000..7c36766
--- /dev/null
+++ b/Modules/Alkami.DevOps.Operations/tools/chocolateyUninstall.ps1
@@ -0,0 +1,25 @@
+[CmdletBinding()]
+Param()
+process {
+ $myCurrentPath = $PSScriptRoot;
+ Write-Verbose "Uninstalling the Module from $myCurrentPath";
+
+ $parentPath = (Split-Path $myCurrentPath);
+ $systemModulePath = "C:\Program Files\WindowsPowerShell\Modules\";
+ $myModulePath = (Join-Path $parentPath "module");
+
+ $metadata = ([Xml](Get-Content (Join-Path $parentPath "*.nuspec"))).package.metadata;
+
+ $id = $metadata.id;
+
+ $version = $metadata.version -replace '-pre.+','';
+
+ $targetModulePath = (Join-Path $systemModulePath $id);
+ $targetModuleVersionPath = (Join-Path $targetModulePath $version);
+
+ if (Test-Path $targetModuleVersionPath) {
+ Write-Information "Removing module at [$targetModuleVersionPath]!!"
+ Remove-Item $targetModuleVersionPath -Recurse -Force;
+ }
+}
+
diff --git a/Modules/Alkami.DevOps.SqlReports/Alkami.DevOps.SqlReports.nuspec b/Modules/Alkami.DevOps.SqlReports/Alkami.DevOps.SqlReports.nuspec
new file mode 100644
index 0000000..f7cafef
--- /dev/null
+++ b/Modules/Alkami.DevOps.SqlReports/Alkami.DevOps.SqlReports.nuspec
@@ -0,0 +1,31 @@
+
+
+
+ Alkami.DevOps.SqlReports
+ $version$
+ Alkami Platform Modules - DevOps - SqlReports
+ Alkami Technologies
+ Alkami Technologies
+ https://extranet.alkamitech.com/display/ORB/Alkami.DevOps.SqlReports
+ https://www.alkami.com/files/alkamilogo75x75.png
+ http://alkami.com/files/orblicense.html
+ false
+ Installs the DevOps SqlReports module for use with PowerShell.
+
+ PowerShell
+ Copyright (c) 2018 Alkami Technologies
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Modules/Alkami.DevOps.SqlReports/Alkami.DevOps.SqlReports.psd1 b/Modules/Alkami.DevOps.SqlReports/Alkami.DevOps.SqlReports.psd1
new file mode 100644
index 0000000..c7669b8
--- /dev/null
+++ b/Modules/Alkami.DevOps.SqlReports/Alkami.DevOps.SqlReports.psd1
@@ -0,0 +1,18 @@
+@{
+ RootModule = 'Alkami.DevOps.SqlReports.psm1'
+ ModuleVersion = '3.20.6'
+ GUID = '889e054f-4f72-4a9a-8140-b3150bbe6e1c'
+ Author = 'SRE, cbrand'
+ CompanyName = 'Alkami Technologies, Inc.'
+ Copyright = '(c) 2018 Alkami Technologies, Inc.. All rights reserved.'
+ Description = 'Cmdlets used to manage reports for the ORB report server'
+ RequiredModules = 'Alkami.PowerShell.Common', 'Alkami.PowerShell.Configuration', 'Alkami.DevOps.Common'
+ FunctionsToExport = 'Add-ORBReportDataSources','Get-PublishedDataSources','Get-PublishedSSRSReports','Get-ReportUserCredentialsFromSecretServer','Get-SSRSDatabaseServer','Get-SSRSReportHashes','Grant-RightsToSSRSFolder','New-ExecutionProxy','New-ReportServerDataSource','New-ReportServerFolder','New-SSRSProxy','New-SSRSReportsFolder','Publish-SqlReports','Publish-SSRSChangedReports','Publish-SSRSReport','Publish-SSRSReportsDirectory','Remove-Datasources','Set-ReportDataSource','Set-ReportParameters'
+ PrivateData = @{
+ PSData = @{
+ Tags = @('powershell', 'module', 'ssrs', 'reports')
+ ProjectUri = 'https://extranet.alkamitech.com/display/SRE/Alkami.DevOps.Operations+Module'
+ IconUri = 'https://www.alkami.com/files/alkamilogo75x75.png'
+ }
+ }
+}
diff --git a/Modules/Alkami.DevOps.SqlReports/Alkami.DevOps.SqlReports.pssproj b/Modules/Alkami.DevOps.SqlReports/Alkami.DevOps.SqlReports.pssproj
new file mode 100644
index 0000000..7799a64
--- /dev/null
+++ b/Modules/Alkami.DevOps.SqlReports/Alkami.DevOps.SqlReports.pssproj
@@ -0,0 +1,69 @@
+
+
+
+ Debug
+ 2.0
+ {0f8e0fc4-8e30-4e2f-ac91-7377fe38879f}
+ Exe
+ MyApplication
+ MyApplication
+ Alkami.DevOps.SqlReports
+ Invoke-Pester;
+ ..\build-project.ps1 (Join-Path $(SolutionDir) "Alkami.DevOps.SqlReports")
+
+
+ true
+ full
+ false
+ bin\Debug\
+ DEBUG;TRACE
+ prompt
+ 4
+
+
+ pdbonly
+ true
+ bin\Release\
+ TRACE
+ prompt
+ 4
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.SqlReports/AlkamiManifest.xml b/Modules/Alkami.DevOps.SqlReports/AlkamiManifest.xml
new file mode 100644
index 0000000..f89a7a0
--- /dev/null
+++ b/Modules/Alkami.DevOps.SqlReports/AlkamiManifest.xml
@@ -0,0 +1,12 @@
+
+
+ 1.0
+
+ Alkami
+ Alkami.DevOps.SqlReports
+ SREModule
+
+
+ Production
+
+
diff --git a/Modules/Alkami.DevOps.SqlReports/Private/VariableDeclarations.ps1 b/Modules/Alkami.DevOps.SqlReports/Private/VariableDeclarations.ps1
new file mode 100644
index 0000000..d503b77
--- /dev/null
+++ b/Modules/Alkami.DevOps.SqlReports/Private/VariableDeclarations.ps1
@@ -0,0 +1,6 @@
+[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')]
+param()
+
+$Global:SSRSProxy = $null;
+$Global:SSRSExecutionProxy = $null;
+$DisposeSessions = $true;
diff --git a/Modules/Alkami.DevOps.SqlReports/Public/Add-ORBReportDataSources.ps1 b/Modules/Alkami.DevOps.SqlReports/Public/Add-ORBReportDataSources.ps1
new file mode 100644
index 0000000..f213614
--- /dev/null
+++ b/Modules/Alkami.DevOps.SqlReports/Public/Add-ORBReportDataSources.ps1
@@ -0,0 +1,168 @@
+function Add-ORBReportDataSources {
+
+<#
+.SYNOPSIS
+ Creates the Necessary ORB DataSources
+#>
+
+ [CmdletBinding()]
+ Param(
+ [Parameter(Mandatory=$true)]
+ [Alias("ConnectionString")]
+ [string]$masterConnectionString,
+
+ [Parameter(Mandatory=$false)]
+ [Alias("ReportServerUrl")]
+ [string]$reportServerEndpoint,
+
+ [Parameter(Mandatory=$false)]
+ [Alias("FolderName")]
+ [string]$parentFolder,
+
+ [Parameter(Mandatory=$false)]
+ [Alias("ReportServerUserName")]
+ [string]$reportsUser,
+
+ [Parameter(Mandatory=$false)]
+ [Alias("ReportServerPassword")]
+ [string]$reportsPassword,
+
+ [Parameter(Mandatory=$false)]
+ [Alias("Force")]
+ [switch]$forceOverwriteDataSources,
+
+ [Parameter(Mandatory=$false)]
+ [string]$environmentType
+ )
+
+ $logLead = (Get-LogLeadName);
+
+ if (!(Test-IsWebServer) -and [String]::IsNullOrEmpty($reportServerEndpoint))
+ {
+ Write-Warning "$logLead : This function can only be automatically executed on a web tier server. To run, call the function with the appropriate parameters."
+ return
+ }
+
+ try
+ {
+ [xml]$config = Get-ReportServerConfiguration -WarningAction SilentlyContinue
+
+ if ($null -ne $config)
+ {
+ $reportServerEndpointNode = $config.appSettings.SelectSingleNode("//add[@key=""ReportServer""]/@value")
+ $reportFolderNode = $config.appSettings.SelectSingleNode("//add[@key=""ReportServerPath""]/@value")
+ $reportUserNode = $config.appSettings.SelectSingleNode("//add[@key=""ReportServerUserName""]/@value")
+ $reportPasswordNode = $config.appSettings.SelectSingleNode("//add[@key=""ReportServerPassword""]/@value")
+ $reportEnvironmentType = $config.appSettings.SelectSingleNode("//add[@key=""Environment.Type""]/@value")
+ }
+
+ if ((($null -eq $reportServerEndpointNode) -or ([String]::IsNullOrEmpty($reportServerEndpointNode.Value))) -and [String]::IsNullOrEmpty($reportServerEndpoint))
+ {
+ Write-Warning "$logLead : Could not read the value for the ""ReportServer"" appSetting from the machine.config and no report server URL was provided as a parameter. Execution cannot continue."
+ return;
+ }
+
+ if ((($null -eq $reportFolderNode) -or ([String]::IsNullOrEmpty($reportFolderNode.Value))) -and [String]::IsNullOrEmpty($parentFolder))
+ {
+ Write-Warning "$logLead : Could not read the value for the ""ReportServerPath"" appSetting from the machine.config and no parent folder name was provided as a parameter. Execution cannot continue."
+ return;
+ }
+
+ if ((($null -eq $reportUserNode) -or ([String]::IsNullOrEmpty($reportUserNode.Value))) -and [String]::IsNullOrEmpty($reportsUser))
+ {
+ Write-Warning "$logLead : Could not read the value for the ""ReportServerUserName"" appSetting from the machine.config and no user name was provided as a parameter. Execution cannot continue."
+ return;
+ }
+
+ if ((($null -eq $reportPasswordNode) -or ([String]::IsNullOrEmpty($reportPasswordNode.Value))) -and [String]::IsNullOrEmpty($reportsPassword))
+ {
+ Write-Warning "$logLead : Could not read the value for the ""ReportServerPassword"" appSetting from the machine.config and no password was provided as a parameter. Execution cannot continue."
+ return;
+ }
+
+ if ((($null -eq $reportEnvironmentType) -or ([String]::IsNullOrEmpty($reportEnvironmentType.Value))) -and [String]::IsNullOrEmpty($environmentType))
+ {
+ Write-Warning "$logLead : Could not read the value for the ""Environment.Type"" appSetting from the machine.config and value was provided as a parameter. Execution cannot continue."
+ return;
+ }
+
+ $proxyUrlToUse = IsNull $reportServerEndpoint $reportServerEndpointNode.Value
+ $proxy = New-SSRSProxy $proxyUrlToUse
+
+ $parentFolderToUse = IsNull $parentFolder $reportFolderNode.Value
+ $usernameToUse = IsNull $reportsUser $reportUserNode.Value
+ $passwordToUse = IsNull $reportsPassword $reportPasswordNode.Value
+
+ $normalizedParentFolder = ("/" + $parentFolderToUse.TrimStart("/").TrimEnd("/"))
+
+ # Create the AlkamiMaster Data Source, points to the master database
+ $adminDatasourceCheck = $proxy.GetItemType($normalizedParentFolder + "/AlkamiMaster")
+ if ($null -eq $adminDatasourceCheck -or $adminDatasourceCheck -eq "Unknown" -or $forceOverwriteDataSources.IsPresent)
+ {
+ Write-Host ("$logLead : Creating AlkamiMaster DataSource")
+ (New-ReportServerDataSource $proxy "AlkamiMaster" $masterConnectionString $usernameToUse $passwordToUse $normalizedParentFolder -forceOverwriteDataSources:$forceOverwriteDataSources) | Out-Null
+ }
+ else
+ {
+ Write-Warning ("$logLead : The AlkamiMaster DataSource Already Exists in Folder {0}. To overwrite pass the -Force parameter" -f $normalizedParentFolder)
+ }
+
+ # Create the Alkami Data Source, the connection string is set by the application
+ $clientDatasourceCheck = $proxy.GetItemType($normalizedParentFolder + "/Alkami")
+ if ($null -eq $clientDatasourceCheck -or $clientDatasourceCheck -eq "Unknown" -or $forceOverwriteDataSources.IsPresent)
+ {
+ Write-Host ("$logLead : Creating Alkami DataSource")
+ (New-ReportServerDataSource $proxy "Alkami" "PlaceHolder" $usernameToUse $passwordToUse $normalizedParentFolder -forceOverwriteDataSources:$forceOverwriteDataSources) | Out-Null
+ }
+ else
+ {
+ Write-Warning ("$logLead : The Alkami DataSource Already Exists in Folder {0}. To overwrite pass the -Force parameter" -f $normalizedParentFolder)
+ }
+
+ # Create the Alkami_EDW Data Source, points to Flux.
+ $fluxDatasourceCheck = $proxy.GetItemType($normalizedParentFolder + "/Alkami_EDW")
+ if ($null -eq $fluxDatasourceCheck -or $fluxDatasourceCheck -eq "Unknown" -or $forceOverwriteDataSources.IsPresent)
+ {
+ Write-Host ("$logLead : Creating Alkami_EDW DataSource")
+
+ #If $reportEnvironmentType wasn't found in machine.config(or this is an agent), use the param
+ $reportEnvironmentType = IsNull $reportEnvironmentType.Value $environmentType
+
+ # Determine the Flux connection string.
+ $fluxConnectionStrings = @{
+ Production = "Data source=FLUXEDW-1;Initial Catalog=Alkami_EDW";
+ Staging = "Data Source=RC-EDW1;Initial Catalog=Alkami_EDW";
+ QA = "Data Source=QA-FLUXDB1;Initial Catalog=Alkami_EDW";
+ Development = "Data Source=DEV-FLUXDB1;Initial Catalog=Alkami_EDW";
+ }
+
+ $fluxConnectionString = $fluxConnectionStrings[$reportEnvironmentType]
+
+ #Create the datasource
+ if (!([String]::IsNullOrEmpty($fluxConnectionString))) {
+ (New-ReportServerDataSource $proxy "Alkami_EDW" $fluxConnectionString $usernameToUse $passwordToUse $normalizedParentFolder -forceOverwriteDataSources:$forceOverwriteDataSources) | Out-Null
+ } else {
+ Write-Warning("$logLead : Flux EDW connection string could not be determined based on Environment.Type: {$reportEnvironmentType} and was not configured.")
+ }
+ }
+ else
+ {
+ Write-Warning ("$logLead : The Alkami_EDW DataSource Already Exists in Folder {0}. To overwrite pass the -Force parameter" -f $normalizedParentFolder)
+ }
+ }
+ finally
+ {
+ if ($DisposeSessions)
+ {
+ if ($null -ne $SSRSProxy)
+ {
+ $SSRSProxy.Dispose()
+ }
+
+ if ($null -ne $SSRSExecutionProxy)
+ {
+ $SSRSExecutionProxy.Dispose()
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.SqlReports/Public/Get-PublishedDataSources.ps1 b/Modules/Alkami.DevOps.SqlReports/Public/Get-PublishedDataSources.ps1
new file mode 100644
index 0000000..f3bf265
--- /dev/null
+++ b/Modules/Alkami.DevOps.SqlReports/Public/Get-PublishedDataSources.ps1
@@ -0,0 +1,35 @@
+function Get-PublishedDataSources {
+ <#
+.SYNOPSIS
+ Get and return the data sources from a SOAP webservice, a SQL Reports Server
+
+.PARAMETER Proxy
+ A SOAP proxy for the SQL Reports Server
+
+.PARAMETER Folder
+ The reports folder to look for Data Sources in
+
+#>
+ [CmdletBinding()]
+ Param(
+ [Parameter(Position = 0, Mandatory = $true)]
+ [System.Web.Services.Protocols.SoapHttpClientProtocol]$Proxy,
+
+ [Parameter(Position = 1, Mandatory = $true)]
+ [Alias("reportfolder")]
+ [string]$Folder
+ )
+ $logLead = Get-LogLeadName
+
+ if ($folder[0] -ne "/") {
+ Write-Verbose ("$logLead : Transform {0} => /{0}" -f $folder)
+ $folder = "/" + $folder
+ }
+
+ Write-Verbose ("$logLead : Querying children of {0}" -f $folder)
+ $datasources = $proxy.ListChildren($folder, $false) | Where-Object {$_.TypeName -eq "DataSource"}
+
+ Write-Verbose ("$logLead : {0} datasource objects located." -f $datasources.Count)
+ return $datasources
+}
+
diff --git a/Modules/Alkami.DevOps.SqlReports/Public/Get-PublishedSSRSReports.ps1 b/Modules/Alkami.DevOps.SqlReports/Public/Get-PublishedSSRSReports.ps1
new file mode 100644
index 0000000..558af40
--- /dev/null
+++ b/Modules/Alkami.DevOps.SqlReports/Public/Get-PublishedSSRSReports.ps1
@@ -0,0 +1,35 @@
+function Get-PublishedSSRSReports {
+ <#
+.SYNOPSIS
+ Get the list of Published SSRS reports from a SQL Reports Server
+
+.PARAMETER Proxy
+ The SOAP client proxy used to query the SQL Reports Server
+
+.PARAMETER Folder
+ The Reports Folder on the SQL Reports Server to query for published reports
+#>
+
+ [CmdletBinding()]
+ Param(
+ [Parameter(Position = 0, Mandatory = $true)]
+ [System.Web.Services.Protocols.SoapHttpClientProtocol]$Proxy,
+
+ [Parameter(Position = 1, Mandatory = $true)]
+ [Alias("reportfolder")]
+ [string]$Folder
+ )
+ $logLead = Get-LogLeadName
+
+ if ($Folder[0] -ne "/") {
+ Write-Verbose ("$logLead : Transform {0} => /{0}" -f $Folder)
+ $Folder = "/" + $Folder
+ }
+
+ Write-Verbose ("$logLead : Querying children of {0}" -f $Folder)
+ $reports = $Proxy.ListChildren($Folder, $false) | Where-Object { $_.TypeName -eq "Report" }
+
+ Write-Verbose ("$logLead : {0} report objects located." -f $reports.Count)
+ return $reports
+}
+
diff --git a/Modules/Alkami.DevOps.SqlReports/Public/Get-ReportUserCredentialsFromSecretServer.ps1 b/Modules/Alkami.DevOps.SqlReports/Public/Get-ReportUserCredentialsFromSecretServer.ps1
new file mode 100644
index 0000000..33ddc47
--- /dev/null
+++ b/Modules/Alkami.DevOps.SqlReports/Public/Get-ReportUserCredentialsFromSecretServer.ps1
@@ -0,0 +1,75 @@
+function Get-ReportUserCredentialsFromSecretServer () {
+<#
+.SYNOPSIS
+ Gets the username and password of a report user secret from Secret Server.
+
+.PARAMETER secretUserName
+ Username of the user to authenticate with on Secret Server.
+
+.PARAMETER secretPassword
+ Password of the user to authenticate with on Secret Server.
+
+.PARAMETER environmentName
+ The environment name of the report user to retrieve (e.g. "12")
+
+.PARAMETER environmentType
+ The environment type of the report user to retrieve (e.g. "Production")
+
+.OUTPUTS
+ Either an object containing the username and password of the reports user or null.
+
+.EXAMPLE
+ Get-ReportUserCredentialsFromSecretServer -secretUserName "BobBarker" -secretPassword "PIR123!" -environmentName "12" -environmentType "Production"
+
+Password Username
+-------- --------
+ExamplePwd ExampleUser
+#>
+ [CmdletBinding()]
+ Param(
+ [Parameter(Mandatory=$true)]
+ [String]$secretUserName,
+ [Parameter(Mandatory=$true)]
+ [String]$secretPassword,
+ [Parameter(Mandatory=$true)]
+ [String]$environmentName,
+ [Parameter(Mandatory=$true)]
+ [String]$environmentType
+ )
+
+ $loglead = (Get-LogLeadName)
+
+ # Note: If the bootstrap scripts are modified to pass in a credential object, this won't be necessary.
+ $secretCredential = New-Object System.Management.Automation.PSCredential ( $secretUserName , (Get-SecureString $secretPassword))
+
+ $folderName = "ReportUsers"
+ $result = $null
+
+ # Determine the name of the secret based on environment type.
+ # Only production (for now) has separate secrets per-environment.
+ $secretName = $null
+ if($environmentType -eq "Production") {
+ # Extract the major pod from the name.
+ $dotSearch = $environmentName.IndexOf(".")
+ if($dotSearch -ge 0) {
+ $environmentName = $environmentName.Substring(0, $dotSearch)
+ }
+ $secretName = "$environmentType-$environmentName-ReportUser"
+ } else {
+ $secretName = "$environmentType-ReportUser"
+ }
+
+ Write-Verbose "$loglead : Searching for secret '$secretName' in folder '$folderName'"
+ $resultCredential = ( Get-UserCredentialsFromSecretServer $secretCredential $folderName $secretName )
+
+ # Note: If the bootstrap scripts are modified to accept credential results, this won't be necessary.
+ if ( $null -ne $resultCredential ) {
+
+ $result = New-Object PSObject -Property @{
+ 'Username' = $resultCredential.UserName
+ 'Password' = (Get-PasswordFromCredential $resultCredential)
+ }
+ }
+
+ return $result
+}
diff --git a/Modules/Alkami.DevOps.SqlReports/Public/Get-SSRSDatabaseServer.ps1 b/Modules/Alkami.DevOps.SqlReports/Public/Get-SSRSDatabaseServer.ps1
new file mode 100644
index 0000000..f49ab8f
--- /dev/null
+++ b/Modules/Alkami.DevOps.SqlReports/Public/Get-SSRSDatabaseServer.ps1
@@ -0,0 +1,31 @@
+function Get-SSRSDatabaseServer {
+ <#
+ .SYNOPSIS
+ Retrieves the configured ReportServer name from an SSRS Server.
+
+ .DESCRIPTION
+ Generally, this is the same name as the server that is queried, but this adds an additional layer of checking.
+
+ .PARAMETER ReportServer
+ [string] The server name to be queried. Required.
+
+ .EXAMPLE
+ Get-SSRSDatabaseServer "sc00rs01.fh.local"
+
+ .EXAMPLE
+ Get-SSRSDatabaseServer -ReportServer "sc00rs01.fh.local"
+
+ .NOTES
+ This requires the calling user to have sysadmin rights to the SSRS server
+#>
+ [CmdletBinding()]
+ param (
+ [Parameter(Mandatory = $true)]
+ [Alias("ReportServer")]
+ [string]$server
+ )
+ $proxy = New-SSRSProxy "http://$server/ReportServer"
+ $configInfo = [xml]$proxy.GetReportServerConfigInfo($false)
+
+ return $configInfo.ServerConfigInfo.Server.MachineName
+}
diff --git a/Modules/Alkami.DevOps.SqlReports/Public/Get-SSRSReportHashes.ps1 b/Modules/Alkami.DevOps.SqlReports/Public/Get-SSRSReportHashes.ps1
new file mode 100644
index 0000000..2b5a316
--- /dev/null
+++ b/Modules/Alkami.DevOps.SqlReports/Public/Get-SSRSReportHashes.ps1
@@ -0,0 +1,85 @@
+function Get-SSRSReportHashes {
+<#
+.SYNOPSIS
+ Retrieves a hashtable containing the Name of a report and a hashed value genreated from the Report's content.
+
+.DESCRIPTION
+ Use this command to generate a hashtable of report hashes for use in determining if a report has changed.
+
+.PARAMETER SqlReportServer
+ [string] The machine name of the Reports SQL Server. Required.
+
+.PARAMETER ReportPath
+ [string] The case-sensitive ReportPath used in SQL Server (i.e. /AWS Staging QAstg). Required.
+
+.EXAMPLE
+ Get-SSRSReportHashes "sc00rs01.fh.local" "/AWS Staging QAstg"
+
+.EXAMPLE
+ Get-SSRSReportHashes -SqlReportServer "sc00rs01.fh.local" -ReportPath "/AWS Staging QAstg"
+#>
+ [CmdletBinding()]
+ [OutputType([System.Collections.Hashtable])]
+ param (
+ [Parameter(Mandatory = $true)]
+ [Alias("SqlReportServer")]
+ [string]$reportServer,
+
+ [Parameter(Mandatory = $true)]
+ [Alias("ReportPath")]
+ [string]$path
+ )
+
+ Write-Verbose "$logLead : Filtering data using report path $path"
+ $connectionString = "data source=$($reportServer);Integrated Security=SSPI;Database=ReportServer;MultiSubnetFailover=true"
+ Write-Host "$logLead : Connecting to ReportServer Database with connection string: $connectionString"
+
+ $query = @"
+SELECT Name, CONVERT(varchar(max), CONVERT(varbinary(max), Content)) as TextContent
+FROM dbo.Catalog c
+WHERE Content IS NOT NULL
+AND PATH like '%$path/%'
+ORDER BY Name
+"@
+
+ try {
+ $conStrBuilder = New-Object System.Data.SqlClient.SqlConnectionStringBuilder($connectionString)
+ } catch {
+ Write-Error "$logLead : Exception occurred building the ConnectionString: $($_.Exception.Message.ToString())"
+ throw $_
+ }
+
+ $conn = New-Object System.Data.SqlClient.SqlConnection $conStrBuilder.ToString()
+ $command = New-Object System.Data.SqlClient.SqlCommand($query, $conn)
+ $dbReportHashes = @{ }
+
+ try {
+ if ($conn.State -ne [System.Data.ConnectionState]::Open) {
+ $conn.Open()
+ }
+ $results = $command.ExecuteReader()
+
+ while ($results.Read()) {
+ $name = $results.Item(0)
+ $content = $results.Item(1)
+
+ $hashedContent = Get-UTF8ContentHash $content
+ Write-Verbose "$logLead : Name: $($name) Hash: $($hashedContent)"
+
+ $dbReportHashes.Add($name, $hashedContent)
+ }
+ } catch {
+ Write-Error "$logLead : Exception occurred reading database results: $($_.Exception.Message.ToString())"
+ throw $_
+ } finally {
+ if ($null -ne $results) {
+ $results.Dispose()
+ }
+
+ if ($null -ne $results) {
+ $conn.Dispose()
+ }
+ }
+
+ return $dbReportHashes
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.SqlReports/Public/Grant-RightsToSSRSFolder.ps1 b/Modules/Alkami.DevOps.SqlReports/Public/Grant-RightsToSSRSFolder.ps1
new file mode 100644
index 0000000..9e0dd1c
--- /dev/null
+++ b/Modules/Alkami.DevOps.SqlReports/Public/Grant-RightsToSSRSFolder.ps1
@@ -0,0 +1,149 @@
+function Grant-RightsToSSRSFolder {
+<#
+.SYNOPSIS
+ Grants Rights to a Folder on the SSRS Server
+#>
+
+ [CmdletBinding()]
+ Param(
+ [Parameter(Position=0,Mandatory=$true)]
+ [Alias("Roles")]
+ [string[]]$roleNames,
+
+ [Parameter(Position=1,Mandatory=$false)]
+ [Alias("User")]
+ [string]$userName,
+
+ [Parameter(Position=2,Mandatory=$false)]
+ [Alias("Folder")]
+ [string]$folderName,
+
+ [Parameter(Position=3,Mandatory=$false)]
+ [Alias("ReportServerUrl")]
+ [string]$reportServerEndpoint
+ )
+
+ $logLead = (Get-LogLeadName);
+
+ try
+ {
+ if (!(Test-IsWebServer) -and [String]::IsNullOrEmpty($reportServerEndpoint))
+ {
+ Write-Warning "$logLead : This function can only be automatically executed on a web tier server. To run, call the function with the appropriate parameters."
+ return
+ }
+
+ [xml]$config = Get-ReportServerConfiguration -WarningAction SilentlyContinue
+
+ if ($null -ne $config)
+ {
+ $reportServerEndpointNode = $config.appSettings.SelectSingleNode("//add[@key=""ReportServer""]/@value")
+ $reportFolderNode = $config.appSettings.SelectSingleNode("//add[@key=""ReportServerPath""]/@value")
+ $reportUserNode = $config.appSettings.SelectSingleNode("//add[@key=""ReportServerUserName""]/@value")
+ }
+
+ if ((($null -eq $reportServerEndpointNode) -or ([String]::IsNullOrEmpty($reportServerEndpointNode.Value))) -and [String]::IsNullOrEmpty($reportServerEndpoint))
+ {
+ Write-Warning "$logLead : Could not read the value for the ""ReportServer"" appSetting from the machine.config and no report server URL was provided as a parameter. Execution cannot continue."
+ return;
+ }
+
+ if ((($null -eq $reportFolderNode) -or ([String]::IsNullOrEmpty($reportFolderNode.Value))) -and [String]::IsNullOrEmpty($folderName))
+ {
+ Write-Warning "$logLead : Could not read the value for the ""ReportServerPath"" appSetting from the machine.config and no folder name was provided as a parameter. Execution cannot continue."
+ return;
+ }
+
+ if ((($null -eq $reportUserNode) -or ([String]::IsNullOrEmpty($reportUserNode.Value))) -and [String]::IsNullOrEmpty($userName))
+ {
+ Write-Warning "$logLead : Could not read the value for the ""ReportServerUserName"" appSetting from the machine.config and no user name was provided as a parameter. Execution cannot continue."
+ return;
+ }
+
+ $proxyUrlToUse = IsNull $reportServerEndpoint $reportServerEndpointNode.Value
+ $proxy = New-SSRSProxy $proxyUrlToUse
+ $proxyNameSpace = $proxy.GetType().Namespace
+
+ $folderToUse = IsNull $folderName $reportFolderNode.Value
+ $userToUse = IsNull $userName $reportUserNode.Value
+
+ $normalizedFolder = "/" + $folderToUse.TrimStart("/")
+
+ # Make sure the folder exists
+ try
+ {
+ $folderType = $proxy.GetItemType($normalizedFolder)
+ }
+ catch
+ {
+ Write-Warning ("$logLead : The folder {0} does not exist. Execution cannot continue" -f $normalizedFolder)
+ return
+ }
+
+ ## TODO: cbrand ~ candidate for [string]::IsNullOrEmpty() ?
+ if (($null -eq $folderType) -or ($folderType -eq "Unknown"))
+ {
+ Write-Warning ("$logLead : The folder {0} does not exist. Execution cannot continue" -f $normalizedFolder)
+ return
+ }
+
+ $folderPolicies = $proxy.GetPolicies($normalizedFolder, [ref]$false)
+ $userPolicies = $folderPolicies | Where-Object {$_.GroupUserName -eq $userToUse}
+
+ if ($null -eq $userPolicies)
+ {
+ # Add a Policy for the User
+ Write-Output ("$logLead : Creating User Policy")
+
+ $policy = New-Object "${proxyNameSpace}.Policy"
+ $policy.GroupUserName = $userToUse
+ $folderPolicies += $policy
+
+ [array]$userPolicies += $policy
+ }
+
+ # Add the Role to the User Policy
+ Write-Output ("$logLead : Creating User Role")
+
+ $rolesDirty = $false;
+ foreach ($roleToAdd in $roleNames)
+ {
+ if ($userPolicies | Where-Object {$_.Roles.Name -eq $roleToAdd})
+ {
+ Write-Output ("$logLead : User {0} already has role {1} on folder {2}" -f $userToUse, $roleToAdd, $folderToUse)
+ continue;
+ }
+
+ $role = New-Object "${proxyNameSpace}.Role"
+ $role.Name = $roleToAdd
+ ($userPolicies | Select-Object -First 1).Roles += $role
+ Write-Output ("$logLead : User {0} granted role {1} on folder {2}" -f $userToUse, $roleToAdd, $folderToUse)
+ $rolesDirty = $true
+ }
+
+ if ($rolesDirty)
+ {
+ $proxy.SetPolicies($normalizedFolder, $folderPolicies)
+ Write-Output "$logLead : Role Changes Committed"
+ }
+ else
+ {
+ Write-Output "$logLead : No Role Changes Required"
+ }
+ }
+ finally
+ {
+ if ($null -ne $SSRSProxy)
+ {
+ $SSRSProxy.Dispose()
+ }
+
+ if ($null -ne $SSRSExecutionProxy)
+ {
+ $SSRSExecutionProxy.Dispose()
+ }
+ }
+}
+
+#region Private Functions
+
diff --git a/Modules/Alkami.DevOps.SqlReports/Public/New-ExecutionProxy.ps1 b/Modules/Alkami.DevOps.SqlReports/Public/New-ExecutionProxy.ps1
new file mode 100644
index 0000000..f6b2a01
--- /dev/null
+++ b/Modules/Alkami.DevOps.SqlReports/Public/New-ExecutionProxy.ps1
@@ -0,0 +1,40 @@
+function New-ExecutionProxy {
+ <#
+.SYNOPSIS
+ Create a SSRS(ReportExecution2005.asmx) Execution Proxy. If a $Global:SSRSExecutionProxy already exists, return that. Otherwise, create it, set it, and return it.
+
+.PARAMETER WebServiceUrl
+ Url of the SSRS webservice
+#>
+
+ [CmdletBinding()]
+ Param(
+ [Parameter(Position = 0, Mandatory = $true)]
+ [Alias("url")]
+ [string]$WebServiceUrl
+ )
+
+ $logLead = Get-LogLeadName
+
+ if ($null -ne $Global:SSRSExecutionProxy) {
+ Write-Host "$logLead : Using Existing Global Execution Proxy"
+ return $Global:SSRSExecutionProxy
+ }
+
+ # Add trailing /
+ if ($WebServiceUrl -notcontains "ASMX") {
+ if (!($WebServiceUrl.EndsWith("/"))) {
+ Write-Verbose ("$logLead : Transform {0} => {0}/" -f $WebServiceUrl)
+ $WebServiceUrl = $WebServiceUrl + "/"
+ }
+
+ Write-Verbose ("$logLead : Transform {0} => {0}ReportExecution2010.asmx" -f $WebServiceUrl)
+ $WebServiceUrl = $WebServiceUrl + "ReportExecution2005.asmx"
+ }
+
+ Write-Host ("$logLead : Creating new Execution Web Service Proxy for Url {0}" -f $WebServiceUrl)
+ $Global:ExecutionProxy = New-WebServiceProxy -Uri $WebServiceUrl -UseDefaultCredential -ErrorAction 0
+
+ return $Global:ExecutionProxy
+}
+
diff --git a/Modules/Alkami.DevOps.SqlReports/Public/New-ReportServerDataSource.ps1 b/Modules/Alkami.DevOps.SqlReports/Public/New-ReportServerDataSource.ps1
new file mode 100644
index 0000000..8769b45
--- /dev/null
+++ b/Modules/Alkami.DevOps.SqlReports/Public/New-ReportServerDataSource.ps1
@@ -0,0 +1,62 @@
+function New-ReportServerDataSource {
+<#
+.SYNOPSIS
+ Creates a new DataSource Using the Supplied Parameters
+#>
+ [CmdletBinding()]
+ param (
+ [Parameter(Position=0,Mandatory=$true)]
+ [System.Web.Services.Protocols.SoapHttpClientProtocol]$proxy,
+
+ [Parameter(Position=1,Mandatory=$true)]
+ [string]$DataSourceName,
+
+ [Parameter(Position=2,Mandatory=$true)]
+ [string]$ConnectionString,
+
+ [Parameter(Position=3,Mandatory=$true)]
+ [string]$UserName,
+
+ [Parameter(Position=4,Mandatory=$true)]
+ [string]$Password,
+
+ [Parameter(Position=5,Mandatory=$true)]
+ [string]$ParentFolder,
+
+ [Parameter(Mandatory=$false)]
+ [Alias("Force")]
+ [switch]$ForceOverwriteDataSources
+ )
+
+ $proxyNameSpace = $proxy.GetType().Namespace
+ $dataSource = New-Object("$proxyNameSpace.DataSourceDefinition")
+
+ $dataSource.ConnectString = $ConnectionString
+ $dataSource.Extension = "SQL"
+ $dataSource.Enabled = $true
+
+ $dataSource.CredentialRetrieval = [SSRS.CredentialRetrievalEnum]::Store
+ $dataSource.ImpersonateUser = $false
+ $dataSource.ImpersonateUserSpecified = $true
+ $dataSource.WindowsCredentials = $true
+ $dataSource.UserName = $UserName
+ $dataSource.Password = $Password
+
+ $propertyHash = @{
+ 'ConnectString' = $ConnectionString;
+ 'UserName' = $UserName;
+ 'Password' = $Password;
+ 'WindowsCredentials' = $true;
+ 'Enabled' = $true;
+ 'Extension' = "SQL";
+ 'ImpersonateUser' = $false;
+ 'ImpersonateUserSpecified' = $true;
+ 'CredentialRetrieval' = [SSRS.CredentialRetrievalEnum]::Store;
+ }
+
+ $propertyCollection = $propertyHash.Keys.foreach{ @{ Name = $_; Value = $propertyHash[$_] } -as "${proxyNameSpace}.property" }
+ $newDataSource = $proxy.CreateDataSource($DataSourceName, $ParentFolder, $ForceOverwriteDataSources.IsPresent, $dataSource, $propertyCollection)
+
+ return $newDataSource
+}
+
diff --git a/Modules/Alkami.DevOps.SqlReports/Public/New-ReportServerFolder.ps1 b/Modules/Alkami.DevOps.SqlReports/Public/New-ReportServerFolder.ps1
new file mode 100644
index 0000000..32d222c
--- /dev/null
+++ b/Modules/Alkami.DevOps.SqlReports/Public/New-ReportServerFolder.ps1
@@ -0,0 +1,55 @@
+function New-ReportServerFolder {
+ <#
+.SYNOPSIS
+ Create a new report folder on the SSRS server
+
+.PARAMETER Proxy
+ SSRS Proxy
+
+.PARAMETER ReportFolder
+ Folder to create
+
+.PARAMETER ReportPath
+ Path in which to create ReportFolder
+#>
+
+ [CmdletBinding()]
+ Param(
+ [Parameter(Position = 0, Mandatory = $true)]
+ [System.Web.Services.Protocols.SoapHttpClientProtocol]$Proxy,
+
+ [Parameter(Position = 1, Mandatory = $true)]
+ [string]$ReportFolder,
+
+ [Parameter(Position = 2, Mandatory = $false)]
+ [string]$ReportPath = "/"
+ )
+
+ $logLead = Get-LogLeadName
+ Write-Verbose ("$logLead : Checking for existing folder {0}." -f $ReportFolder)
+ # Check if folder exists, create if not found
+
+ try {
+ $folderType = $Proxy.GetItemType("/" + $ReportFolder.TrimStart("/"))
+ } catch {
+ Write-Output ("$logLead : Folder {0} does not exist, it will be created" -f $ReportFolder)
+ }
+
+ if ($null -eq $folderType -or $folderType -eq "Unknown") {
+ try {
+ $Proxy.CreateFolder($ReportFolder, $ReportPath, $null) | Out-Null
+ Write-Output ("$logLead : Created new folder {0}/{1}" -f $ReportPath.TrimEnd("/"), $ReportFolder)
+ } catch [System.Web.Services.Protocols.SoapException] {
+ if ($_.Exception.Detail.InnerText -like "*rsItemAlreadyExists*") {
+ Write-Verbose ("$logLead : Folder {0}/{1} already exists." -f $ReportPath, $ReportFolder)
+ } else {
+ $msg = ("$logLead : Error creating folder: {0}/{1}. Msg: '{2}'" -f $ReportPath, $ReportFolder, $_.Exception.Detail.InnerText)
+ Write-Error $msg
+ }
+ } catch {
+ Write-Warning ("$logLead : An Unknown Error Occurred -- {0}" -f $_.Exception.Message)
+ }
+ } else {
+ Write-Output ("$logLead : Folder {0}/{1} already exists" -f $ReportPath.TrimEnd("/"), $ReportFolder)
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.SqlReports/Public/New-SSRSProxy.ps1 b/Modules/Alkami.DevOps.SqlReports/Public/New-SSRSProxy.ps1
new file mode 100644
index 0000000..9678918
--- /dev/null
+++ b/Modules/Alkami.DevOps.SqlReports/Public/New-SSRSProxy.ps1
@@ -0,0 +1,39 @@
+function New-SSRSProxy {
+ <#
+.SYNOPSIS
+ Create a SSRS(ReportService2010.asmx) Service Proxy. If a $Global:SSRSProxy already exists, return that. Otherwise, create it, set it, and return it.
+
+.PARAMETER WebServiceUrl
+ Url of the SSRS webservice
+#>
+ [CmdletBinding()]
+ Param(
+ [Parameter(Position = 0, Mandatory = $true)]
+ [Alias("url")]
+ [string]$WebServiceUrl
+ )
+
+ $logLead = Get-LogLeadName
+
+ if ($null -ne $Global:SSRSProxy) {
+ Write-Host "$logLead : Using Existing Global Proxy"
+ return $Global:SSRSProxy
+ }
+
+ # Add trailing /
+ if ($WebServiceUrl -notcontains "ASMX") {
+ if (!($WebServiceUrl.EndsWith("/"))) {
+ Write-Verbose ("$logLead : Transform {0} => {0}/" -f $WebServiceUrl)
+ $WebServiceUrl = $WebServiceUrl + "/"
+ }
+
+ Write-Verbose ("$logLead : Transform {0} => {0}ReportService2010.asmx" -f $WebServiceUrl)
+ $WebServiceUrl = $WebServiceUrl + "ReportService2010.asmx"
+ }
+
+ Write-Host ("$logLead : Creating new Web Service Proxy for Url {0}" -f $WebServiceUrl)
+ $Global:SSRSProxy = New-WebServiceProxy -Uri $WebServiceUrl -UseDefaultCredential -ErrorAction 0 -Namespace SSRS
+
+ return $Global:SSRSProxy
+}
+
diff --git a/Modules/Alkami.DevOps.SqlReports/Public/New-SSRSReportsFolder.ps1 b/Modules/Alkami.DevOps.SqlReports/Public/New-SSRSReportsFolder.ps1
new file mode 100644
index 0000000..3f8f984
--- /dev/null
+++ b/Modules/Alkami.DevOps.SqlReports/Public/New-SSRSReportsFolder.ps1
@@ -0,0 +1,98 @@
+function New-SSRSReportsFolder {
+<#
+.SYNOPSIS
+Creates a New Top Level Folder in SSRS
+
+.DESCRIPTION
+Creates a folder with the specified name in the root directory of the specified SSRS server. If no parameters are provided, and
+the function is being run on a Web server, the function attempts to read the configured ReportServer and ReportServerPath settings
+from the machine.config
+
+.PARAMETER folderName
+[string] The name of the folder to create
+
+.PARAMETER reportServerEndpoint
+[string] The URL to the SSRS Report Server
+
+.INPUTS
+None
+
+.OUTPUTS
+None
+
+.EXAMPLE
+C:\Users\dsage> New-SSRSReportsFolder
+[New-ReportServerFolder] : Created new folder /Firehost Production 999
+
+.EXAMPLE
+PS C:\Users\dsage> New-SSRSReportsFolder -FolderName "Firehost Production 999" -ReportServerUrl "http://ALKA02VMR001/ReportServer"
+[New-ReportServerFolder] : Created new folder /Firehost Production 999
+#>
+
+ [CmdletBinding()]
+ Param(
+ [Parameter(Position=0,Mandatory=$false)]
+ [Alias("Name")]
+ [string]$folderName,
+
+ [Parameter(Position=1,Mandatory=$false)]
+ [Alias("ReportServerUrl")]
+ [string]$reportServerEndpoint
+ )
+
+ $logLead = (Get-LogLeadName);
+
+ try
+ {
+ if (!(Test-IsWebServer) -and [String]::IsNullOrEmpty($reportServerEndpoint))
+ {
+ Write-Warning "$logLead : This function can only be automatically executed on a web tier server. To run, call the function with the appropriate parameters."
+ return
+ }
+
+ [xml]$config = Get-ReportServerConfiguration -WarningAction SilentlyContinue
+
+ if ($null -ne $config)
+ {
+ $reportServerEndpointNode = $config.appSettings.SelectSingleNode("//add[@key=""ReportServer""]/@value")
+ $reportFolderNode = $config.appSettings.SelectSingleNode("//add[@key=""ReportServerPath""]/@value")
+ }
+
+ if ((($null -eq $reportServerEndpointNode) -or ([String]::IsNullOrEmpty($reportServerEndpointNode.Value))) -and [String]::IsNullOrEmpty($reportServerEndpoint))
+ {
+ Write-Warning "$logLead : Could not read the value for the ""ReportServer"" appSetting from the machine.config and no report server URL was provided as a parameter. Execution cannot continue."
+ return;
+ }
+
+ if ((($null -eq $reportFolderNode) -or ([String]::IsNullOrEmpty($reportFolderNode.Value))) -and [String]::IsNullOrEmpty($folderName))
+ {
+ Write-Warning "$logLead : Could not read the value for the ""ReportServerPath"" appSetting from the machine.config and no folder name was provided as a parameter. Execution cannot continue."
+ return;
+ }
+
+ $folderToUse = IsNull $folderName $reportFolderNode.Value.TrimStart("/")
+ Write-Verbose ("$logLead : Using Report Folder $folderToUse")
+
+ $proxyUrlToUse = IsNull $reportServerEndpoint $reportServerEndpointNode.Value
+ Write-Verbose ("$logLead : Using Proxy Endpoint $proxyUrlToUse")
+
+ $proxy = New-SSRSProxy $proxyUrlToUse
+ New-ReportServerFolder $proxy $folderToUse
+ }
+ finally
+ {
+ if ($DisposeSessions)
+ {
+ if ($null -ne $SSRSProxy)
+ {
+ $SSRSProxy.Dispose()
+ }
+
+ if ($null -ne $SSRSExecutionProxy)
+ {
+ $SSRSExecutionProxy.Dispose()
+ }
+ }
+ }
+}
+
diff --git a/Modules/Alkami.DevOps.SqlReports/Public/Publish-SSRSChangedReports.ps1 b/Modules/Alkami.DevOps.SqlReports/Public/Publish-SSRSChangedReports.ps1
new file mode 100644
index 0000000..87e98b1
--- /dev/null
+++ b/Modules/Alkami.DevOps.SqlReports/Public/Publish-SSRSChangedReports.ps1
@@ -0,0 +1,173 @@
+function Publish-SSRSChangedReports {
+ <#
+ .SYNOPSIS
+ Publishes any changed SSRS report files to a report server
+
+ .DESCRIPTION
+ Loads and compares all SSRS report files in the provided folder against the reports stored on the report database server.
+
+ .PARAMETER ReportServiceUrl
+ [string] The url of the report server endpoint. Required.
+
+ .PARAMETER ReportFolder
+ [string] The case-sensitive ReportPath used in SQL Server (i.e. /AWS Staging QAstg). Required.
+
+ .PARAMETER ReportsDirectory
+ [string] The full folder location containing all report .rdl files to be compared. Required.
+
+ .PARAMETER ServerUserName
+ [string] The username used to publish to the reports server. Required.
+
+ .PARAMETER ServerPassword
+ [string] The password used to publish to the reports server. Required.
+
+ .PARAMETER MaximumJobs
+ [int] The maximum number of job threads to be run in parallel. Not Required. Default: 5.
+
+ .PARAMETER WhatIf
+ [switch] If the WhatIf switch is provided, no actual changes will be made against the SQL server. Use this for performing test runs.
+
+ .EXAMPLE
+ Publish-SSRSChangedReports "http://SC00RS01.fh.local/ReportServer" "/AWS Staging QAstg" "C:\Temp\Deploy\AdminReports" "reportuser" "PASSWORD" -WhatIf
+
+ .EXAMPLE
+ Publish-SSRSChangedReports -ReportServiceUrl "http://SC00RS01.fh.local/ReportServer" -ReportFolder "/AWS Staging QAstg" -ReportsDirectory "D:\Temp\Deploy\AdminReports" -WhatIf -Verbose
+
+ #>
+ [CmdletBinding(SupportsShouldProcess)]
+ param (
+ [Parameter(Mandatory = $true)]
+ [Alias("ReportServiceUrl")]
+ [string]$serviceUrl,
+
+ [Parameter(Mandatory = $true)]
+ [Alias("ReportFolder")]
+ [string]$folder,
+
+ [Parameter(Mandatory = $true)]
+ [Alias("ReportsDirectory")]
+ [string]$directory,
+
+ [Parameter(Mandatory = $true)]
+ [Alias("ServerUserName")]
+ [string]$reportServerUserName,
+
+ [Parameter(Mandatory = $true)]
+ [Alias("ServerPassword")]
+ [string]$reportServerPassword,
+
+ [Parameter(Mandatory = $false)]
+ [Alias("MaximumJobs")]
+ [int]$maxJobs = 5
+ )
+
+ $logLead = (Get-LogLeadName)
+
+ if (-not (Test-Path -Path $directory)) {
+ Write-Host "$logLead : Folder not found at [$directory], nothing to do here"
+ return
+ }
+
+ $functionStopWatch = [System.Diagnostics.Stopwatch]::StartNew()
+
+ #Extract the report server name from the service URL.
+ $reportServer = ([uri]$serviceUrl).Host
+
+ #Get the Name and Hash of all the Enviroment's reports in one call
+ Write-Verbose ("$logLead : Getting Reports Content from '$reportServer' for '$folder'")
+ $dbReportHashes = Get-SSRSReportHashes $reportServer $folder
+
+ Write-Verbose ("$logLead : {0} reports located in DB." -f $dbReportHashes.Count)
+
+ #Get and loop through the files
+ Write-Verbose ("$logLead : Getting *.rdl from {0}" -f $directory)
+ $reportFiles = Get-ChildItem $directory -Recurse -Include *.rdl
+ Write-Verbose ("$logLead : {0} report definition files located." -f $reportFiles.Count)
+
+ if(Test-IsCollectionNullOrEmpty $reportFiles)
+ {
+ Write-Host "$logLead : No report files found. Exiting early."
+ return
+ }
+
+ $updateCount = 0
+ $jobs = @()
+
+ $scriptBlock = {
+ param ($reportFile, $webServiceUrl, $reportFolder, $reportServerUserName, $reportServerPassword)
+
+ Write-Verbose ("$logLead : Preparing RDL {0}" -f $reportFile.FullName)
+
+ [xml]$reportXml = Get-Content $reportFile
+ $description = $reportXml.Report.Description
+
+ if ([String]::IsNullOrEmpty($description)) {
+ $description = [String]::Empty
+ } elseif ($description -match "-") {
+ $description = $description.Split("-")[0]
+ }
+
+ Write-Verbose ("$logLead : Report description set to {0}" -f $description)
+
+ (Publish-SSRSReport -webServiceUrl $webServiceUrl `
+ -rdlPath $reportFile.FullName `
+ -reportFolder $reportFolder `
+ -avoidDoubleHop $True `
+ -reportServerUserName $reportServerUserName `
+ -reportServerPassword $reportServerPassword) `
+ | Out-Null
+}
+
+foreach ($reportFile in $reportFiles) {
+ Write-Verbose ("$logLead : Beginning hash comparison for $($reportFile.Name)")
+ $reportFileKey = $reportFile.Name.Split(".")[0]
+ $reportFileHash = Get-FileContentHash $reportFile.FullName
+
+ #Lookup the DB Hash and do work accordingly
+ $foundDbHash = $dbReportHashes[$reportFileKey]
+
+ if (($null -ne $foundDbHash) -and ($foundDbHash -eq $reportFileHash)) {
+ Write-Host ("$logLead : Hash hasn't changed for report {0}. Not re-publishing." -f $reportFile.Name)
+ } else {
+ Write-Host ("$logLead : Starting Load Job for {0}." -f $reportFile.Name)
+ Write-Verbose ("$logLead : File and Db hashes to not match. `nFile: $reportFileHash `nDB: $foundDbHash")
+
+ if ($PSCmdlet.ShouldProcess($reportFile.Name, "Publish SSRS Report File")) {
+
+ Write-Verbose ("$logLead : Adding Job for Publish-SSRSReport")
+ Write-Verbose ("$logLead : -webServiceUrl $serviceUrl")
+ Write-Verbose ("$logLead : -rdlPath $($reportFile.FullName)")
+ Write-Verbose ("$logLead : -reportFolder $folder")
+ Write-Verbose ("$logLead : -reportServerUserName $reportServerUserName")
+
+ $jobs += Start-Job -ScriptBlock $scriptBlock -ArgumentList $reportFile, $serviceUrl, $folder, $reportServerUserName, $reportServerPassword
+
+ $running = @($jobs | Where-Object { $_.State -in ('Running', 'NotStarted') })
+
+ while ($running.Count -ge $maxJobs -and $running.Count -ne 0) {
+ Wait-Job -Job $jobs -Any | Out-Null
+ $running = @($jobs | Where-Object { $_.State -in ('Running', 'NotStarted') })
+ }
+ }
+
+ $updateCount++
+ $foundDbHash = $Null
+ }
+}
+
+try {
+ if ($jobs) {
+ # Receive-Job to output the logs
+ Receive-Job -Job $jobs -Wait -AutoRemoveJob
+ }
+}
+catch {
+ Write-Host "$logLead : Exception occurred while receiving jobs. Carry on."
+ Write-Host "$logLead : Exception was: $_"
+}
+
+$functionStopWatch.Stop()
+Write-Host ("$logLead : Total SSRS Report Files Analyzed: $($reportFiles.Count)")
+Write-Host ("$logLead : Total SSRS Reports Updated: $updateCount")
+Write-Host ("$logLead : Total Execution Time: {0}" -f $functionStopWatch.Elapsed.ToString())
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.SqlReports/Public/Publish-SSRSReport.ps1 b/Modules/Alkami.DevOps.SqlReports/Public/Publish-SSRSReport.ps1
new file mode 100644
index 0000000..6beb621
--- /dev/null
+++ b/Modules/Alkami.DevOps.SqlReports/Public/Publish-SSRSReport.ps1
@@ -0,0 +1,100 @@
+function Publish-SSRSReport {
+<#
+.SYNOPSIS
+ Publish SSRS report to SSRS server
+
+.PARAMETER WebServiceUrl
+ Url of SSRS webservice
+
+.PARAMETER RdlPath
+ Path to report file
+
+.PARAMETER ReportFolder
+ Folder on SSRS server to publish report to
+
+.PARAMETER AvoidDoubleHop
+ Parameter to pass to Set-ReportDataSource to avoid DoubleHob issue
+
+.PARAMETER ReportServerUserName
+ Username for SSRS server
+
+.PARAMETER ReportServerPassword
+ Password for SSRS server
+
+#>
+ [CmdletBinding()]
+ Param(
+ [Parameter(Position=0, Mandatory=$true)]
+ [Alias("url")]
+ [string]$WebServiceUrl,
+
+ [ValidateScript({Test-Path $_})]
+ [Parameter(Position=1, Mandatory=$true)]
+ [Alias("rdl")]
+ [string]$RdlPath,
+
+ [Parameter(Position=2, Mandatory=$true)]
+ [Alias("folder")]
+ [string]$ReportFolder,
+
+ [Parameter(Position=3, Mandatory=$true)]
+ [Alias("ApplyDoubleHopFix")]
+ [bool]$AvoidDoubleHop,
+
+ [Parameter(Position=4, Mandatory=$false)]
+ [Alias("Username")]
+ [string]$ReportServerUserName,
+
+ [Parameter(Position=5, Mandatory=$false)]
+ [Alias("Password")]
+ [string]$ReportServerPassword
+ )
+
+ $logLead = Get-LogLeadName
+
+ # Create Proxy
+ Write-Verbose "$logLead : Calling New-SSRSProxy"
+ $proxy = New-SSRSProxy $WebServiceUrl
+ New-ExecutionProxy -WebServiceUrl $WebServiceUrl | Out-Null
+
+ # Set reportname if blank, default will be the filename without extension
+ $reportName = [System.IO.Path]::GetFileNameWithoutExtension($RdlPath)
+ Write-Verbose ("$logLead : Report name set to: {0}" -f $reportName)
+
+ #Get Report content in bytes
+ Write-Host ("$logLead : Publishing report file {0}" -f $reportName)
+ Write-Verbose "$logLead : Getting file content (byte[]) of : $RdlPath"
+ $byteArray = Get-Content $RdlPath -encoding byte
+ Write-Verbose ("$logLead : Total length: {0}" -f $byteArray.Length)
+
+ if (!($ReportFolder.StartsWith("/"))) {
+ Write-Verbose ("$logLead : Transform {0} => /{0}" -f $ReportFolder)
+ $ReportFolder = "/" + $ReportFolder
+ }
+
+ Write-Verbose ("$logLead : Uploading to: {0}" -f $ReportFolder)
+
+ #Call Proxy to upload report
+ Write-Host "$logLead : --- Uploading RDL"
+ [Ref]$UploadWarnings = $null
+ $proxy.CreateCatalogItem("Report", $reportName, $ReportFolder, $true, $byteArray, $null, $UploadWarnings) | Out-Null
+
+ Write-Verbose "$logLead : Calling Get-PublishedSSRSReports"
+ $allItems = Get-PublishedSSRSReports $proxy $ReportFolder
+ $publishedReport = $allItems | Where-Object {($_.Name -eq $reportName)} | Sort-Object ModifiedDate -Descending | Select-Object -first 1
+
+ if ($null -ne $publishedReport) {
+ Write-Host "$logLead : --- Setting DataSources"
+ Write-Verbose "$logLead : Calling Set-ReportDataSource"
+ Write-Verbose ("Username: $ReportServerUserName")
+ Set-ReportDataSource $proxy $publishedReport.Path $RdlPath $AvoidDoubleHop -Username $ReportServerUserName -Password $ReportServerPassword
+
+ Write-Host "$logLead : --- Setting Parameters"
+ Write-Verbose "$logLead : Calling Set-ReportParameters"
+ Set-ReportParameters $proxy $executionProxy $publishedReport.Path $RdlPath
+ } else {
+ Write-Warning ("$logLead : Could not locate published report {0}. Verify the upload and datasources manually." -f $reportName)
+ }
+
+ Write-Host "$logLead : --- Done"
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.SqlReports/Public/Publish-SSRSReportsDirectory.Tests.ps1 b/Modules/Alkami.DevOps.SqlReports/Public/Publish-SSRSReportsDirectory.Tests.ps1
new file mode 100644
index 0000000..6f50437
--- /dev/null
+++ b/Modules/Alkami.DevOps.SqlReports/Public/Publish-SSRSReportsDirectory.Tests.ps1
@@ -0,0 +1,117 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ""
+
+describe 'Publish-SSRSReportsDirectory' {
+
+ $reportDirectory = $env:TEMP
+ $reportFolder = "foo report"
+ $reportHashLocation = "someHashLocation"
+
+ $webServiceUrl = "http:\\foo.com"
+
+ mock -ModuleName $moduleForMock Get-ChildItem -MockWith {
+ [PSCustomObject] `
+ @{ Name = '.\someReport.rdl' }
+ }
+
+ mock -ModuleName $moduleForMock Get-Content -MockWith {
+ [xml]
+ "
+
+
+ SQL
+ Data Source=JunkSource;Initial Catalog=JunkCatalog
+ true
+
+ 00000000-0000-0000-0000-000000000000
+
+ Fizz-Buzz
+
+ "
+ }
+
+ mock -ModuleName $moduleForMock Import-Csv -MockWith {
+ [PSCustomObject] `
+ @{
+ Algorithm = "SHA256"
+ Hash = "I'm a matching hash"
+ Path = ".\someReport.rdl"
+ }
+ }
+
+ mock -ModuleName $moduleForMock Publish-SSRSReport -MockWith {
+ 123
+ }
+
+ mock -ModuleName $moduleForMock Start-Job -MockWith {
+
+ Invoke-Command -ScriptBlock {'Do Nothing'} -ComputerName localhost -AsJob
+ }
+
+ mock -ModuleName $moduleForMock Wait-Job -MockWith {$null}
+
+ it "should not throw when reportHashLocation is null" {
+
+ mock -ModuleName $moduleForMock Get-FileHash -MockWith {
+ [PSCustomObject] `
+ @{
+ Algorithm = "SHA256"
+ Hash = "I'm a matching hash"
+ Path = ".\someReport.rdl"
+ }
+ }
+
+ { Publish-SSRSReportsDirectory $reportDirectory $webServiceUrl $reportFolder -avoidDoubleHop $true -Verbose } `
+ | should -Not -Throw
+ }
+
+ it "should call Start-Job when a provided reportHashLocation is null" {
+
+ mock -ModuleName $moduleForMock Get-FileHash -MockWith {
+ [PSCustomObject] `
+ @{
+ Algorithm = "SHA256"
+ Hash = "I'm a matching hash"
+ Path = ".\someReport.rdl"
+ }
+ }
+
+ Publish-SSRSReportsDirectory $reportDirectory $webServiceUrl $reportFolder -avoidDoubleHop $true -Verbose
+ Assert-MockCalled -ModuleName $moduleForMock Start-Job -Scope It
+ }
+
+ it "should call start-job when a provided hash doesn't match" {
+
+ mock -ModuleName $moduleForMock Get-FileHash -mockwith {
+ [PSCustomObject] `
+ @{
+ Algorithm = "SHA256"
+ Hash = "I'm a mismatched hash"
+ Path = ".\someReport.rdl"
+ }
+ }
+
+ Publish-SSRSReportsDirectory $reportdirectory $webserviceurl $reportfolder -avoiddoublehop $true -filehashlocation $reportHashLocation -verbose
+ Assert-MockCalled -ModuleName $moduleForMock Start-Job -scope it
+ }
+
+ it "should not call start-job when a provided hash matches" {
+
+ mock -ModuleName $moduleForMock Get-FileHash -mockwith {
+ [PSCustomObject] `
+ @{
+ Algorithm = "SHA256"
+ Hash = "I'm a matching hash"
+ Path = ".\someReport.rdl"
+ }
+ }
+
+ Publish-SSRSReportsDirectory $reportdirectory $webserviceurl $reportfolder -avoiddoublehop $true -filehashlocation $reportHashLocation -verbose
+ Assert-MockCalled -ModuleName $moduleForMock Start-Job -exactly 0 -scope it
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.SqlReports/Public/Publish-SSRSReportsDirectory.ps1 b/Modules/Alkami.DevOps.SqlReports/Public/Publish-SSRSReportsDirectory.ps1
new file mode 100644
index 0000000..483ec07
--- /dev/null
+++ b/Modules/Alkami.DevOps.SqlReports/Public/Publish-SSRSReportsDirectory.ps1
@@ -0,0 +1,121 @@
+function Publish-SSRSReportsDirectory {
+ <#
+.SYNOPSIS
+
+.PARAMETER ReportDirectory
+.PARAMETER WebServiceUrl
+.PARAMETER ReportFolder
+.PARAMETER AvoidDoubleHop
+.PARAMETER ReportServerUserName
+.PARAMETER ReportServerPassword
+.PARAMETER ReportHashLocation
+
+#>
+
+ [CmdletBinding()]
+ Param(
+ [ValidateScript( { Test-Path $_ })]
+ [Parameter(Position = 0, Mandatory = $true)]
+ [string]$ReportDirectory,
+
+ [Parameter(Position = 1, Mandatory = $true)]
+ [Alias("url")]
+ [string]$WebServiceUrl,
+
+ [Parameter(Position = 2, Mandatory = $true)]
+ [string]$ReportFolder,
+
+ [Parameter(Position = 3, Mandatory = $true)]
+ [Alias("ApplyDoubleHopFix")]
+ [bool]$AvoidDoubleHop,
+
+ [Parameter(Position = 4, Mandatory = $false)]
+ [Alias("Username")]
+ [string]$ReportServerUserName,
+
+ [Parameter(Position = 5, Mandatory = $false)]
+ [Alias("Password")]
+ [string]$ReportServerPassword,
+
+ [Parameter(Position = 6, Mandatory = $false)]
+ [Alias("FileHashLocation")]
+ $ReportHashLocation = $null
+ )
+
+ $logLead = Get-LogLeadName
+
+ if (-not (Test-Path -Path $ReportDirectory)) {
+ Write-Host "$logLead : Folder not found at [$ReportDirectory], nothing to do here"
+ return
+ }
+
+ $functionStopWatch = [System.Diagnostics.Stopwatch]::StartNew()
+
+ Write-Verbose ("$logLead : Getting *.rdl from {0}" -f $ReportDirectory)
+ $reportFiles = Get-ChildItem $ReportDirectory -Recurse -Include *.rdl
+ Write-Verbose ("$logLead : {0} report definition files located." -f $reportFiles.Count)
+
+ $maxJobs = 5
+ $jobs = @()
+
+ $scriptBlock = {
+
+ param ($reportFile, $WebServiceUrl, $ReportFolder, $AvoidDoubleHop, $ReportServerUserName, $ReportServerPassword)
+
+ Write-Verbose ("$logLead : Preparing RDL {0}" -f $reportFile.FullName)
+ [xml]$reportXml = Get-Content $reportFile
+ $description = $reportXml.Report.Description
+
+ if ([String]::IsNullOrEmpty($description)) {
+
+ $description = [String]::Empty
+ } elseif ($description -match "-") {
+
+ $description = $description.Split("-")[0]
+ }
+
+ Write-Verbose ("$logLead : Report description set to {0}" -f $description)
+
+ Write-Verbose "$logLead : Calling Publish-SSRSReport"
+ (Publish-SSRSReport -webServiceUrl $WebServiceUrl `
+ -rdlPath $reportFile.FullName `
+ -reportFolder $ReportFolder `
+ -avoidDoubleHop $AvoidDoubleHop `
+ -reportServerUserName $ReportServerUserName `
+ -reportServerPassword $ReportServerPassword) | Out-Null
+ }
+
+ if ( $null -ne $ReportHashLocation ) {
+ $reportHashes = Import-Csv $ReportHashLocation
+ } else {
+ $reportHashes = $null
+ }
+
+ foreach ($reportFile in $reportFiles) {
+ Write-Verbose ("$logLead : Beginning hash comparison for $($reportFile.Name)")
+ $reportHash = Get-FileHash $reportFile
+
+ if (($null -ne $reportHashes) -and ($null -ne ($reportHashes | Where-Object { $_.Hash -eq $reportHash.Hash } | Select-Object -First 1))) {
+ Write-Host ("$logLead : Hash hasn't changed for report {0}. Not republishing." -f $reportFile.Name)
+ } else {
+ Write-Host ("$logLead : Starting Load Job for {0}." -f $reportFile.Name)
+ $jobs += Start-Job -ScriptBlock $scriptBlock -ArgumentList $reportFile, $WebServiceUrl, $ReportFolder, $AvoidDoubleHop, $ReportServerUserName, $ReportServerPassword
+
+ $running = @($jobs | Where-Object {$_.State -in ('Running', 'NotStarted')})
+
+ while ($running.Count -ge $maxJobs -and $running.Count -ne 0) {
+ Wait-Job -Job $jobs -Any | Out-Null
+ $running = @($jobs | Where-Object {$_.State -in ('Running', 'NotStarted')})
+ }
+ }
+ }
+
+ if ($jobs) {
+ Wait-Job -Job $jobs | Out-Null
+ }
+ # Receive-Job to output the logs
+ $jobs | ForEach-Object { Receive-Job -Job $_ }
+
+ $functionStopWatch.Stop()
+ Write-Host ("$logLead : Total Execution Time: {0}" -f $functionStopWatch.Elapsed.ToString())
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.SqlReports/Public/Publish-SqlReports.Tests.ps1 b/Modules/Alkami.DevOps.SqlReports/Public/Publish-SqlReports.Tests.ps1
new file mode 100644
index 0000000..454a1b1
--- /dev/null
+++ b/Modules/Alkami.DevOps.SqlReports/Public/Publish-SqlReports.Tests.ps1
@@ -0,0 +1,80 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ""
+
+describe -Tag 'Unit' 'Publish-SqlReports' {
+
+ it "should not call publish-ssrsreportsdirectory if test-iswebserver == false." {
+ mock -ModuleName $moduleForMock publish-ssrsreportsdirectory -mockwith { }
+
+ mock -ModuleName $moduleForMock test-path -verifiable { return $true }
+ mock -ModuleName $moduleForMock test-iswebserver -mockwith { return $false }
+
+ Publish-SqlReports -reportFolder "aws test 6"
+ Assert-MockCalled -ModuleName $moduleForMock Publish-SSRSReportsDirectory 0
+ }
+
+ it "should not call publish-ssrsreportsdirectory if the reportfoldernode is null or empty" {
+
+ # report server path is null
+ mock -ModuleName $moduleForMock Get-AppSetting -MockWith {
+ return "JunkUrl"
+ } -ParameterFilter { $appSettingKey -and $appSettingKey -eq "ReportServer" }
+
+ mock -ModuleName $moduleForMock Get-AppSetting -MockWith {
+ return $null
+ } -ParameterFilter { $appSettingKey -and $appSettingKey -eq "ReportServerPath" }
+
+ mock -ModuleName $moduleForMock publish-ssrsreportsdirectory -mockwith {}
+
+ mock -ModuleName $moduleForMock test-iswebserver -mockwith { return $true }
+ mock -ModuleName $moduleForMock Test-Path -Verifiable { return $true }
+
+ Publish-SqlReports -reportFolder "aws test 6"
+ assert-mockcalled -ModuleName $moduleForMock publish-ssrsreportsdirectory 0
+ }
+
+ it "should not call publish-ssrsreportsdirectory if the reportserverendpointnode is null or empty" {
+ # report server url is null
+ mock -ModuleName $moduleForMock Get-AppSetting -MockWith {
+ return $null
+ } -ParameterFilter { $appSettingKey -and $appSettingKey -eq "ReportServer" }
+
+ mock -ModuleName $moduleForMock Get-AppSetting -MockWith {
+ return "c:\junkPath"
+ } -ParameterFilter { $appSettingKey -and $appSettingKey -eq "ReportServerPath" }
+
+ mock -ModuleName $moduleForMock publish-ssrsreportsdirectory -mockwith {}
+
+ mock -ModuleName $moduleForMock test-iswebserver -mockwith { return $true }
+
+ Publish-SqlReports -reportFolder "aws test 6"
+ assert-mockcalled -ModuleName $moduleForMock publish-ssrsreportsdirectory 0
+ }
+
+ it "should call Publish-SSRSReportsDirectory" {
+ # Full config
+ mock -ModuleName $moduleForMock Get-AppSetting -MockWith {
+ return "JunkUrl"
+ } -ParameterFilter { $appSettingKey -and $appSettingKey -eq "ReportServer" }
+
+ mock -ModuleName $moduleForMock Get-AppSetting -MockWith {
+ return "c:\junkPath"
+ } -ParameterFilter { $appSettingKey -and $appSettingKey -eq "ReportServerPath" }
+
+
+ mock -ModuleName $moduleForMock Publish-SSRSReportsDirectory -MockWith {}
+
+ mock -ModuleName $moduleForMock Test-IsWebserver -MockWith { return $true }
+
+ mock -ModuleName $moduleForMock Test-Path -Verifiable { return $true }
+
+ Publish-SqlReports -ReportFolder "AWS Test 6"
+
+ Assert-MockCalled -ModuleName $moduleForMock Publish-SSRSReportsDirectory
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.SqlReports/Public/Publish-SqlReports.ps1 b/Modules/Alkami.DevOps.SqlReports/Public/Publish-SqlReports.ps1
new file mode 100644
index 0000000..3698ba6
--- /dev/null
+++ b/Modules/Alkami.DevOps.SqlReports/Public/Publish-SqlReports.ps1
@@ -0,0 +1,74 @@
+function Publish-SqlReports {
+<#
+.SYNOPSIS
+ Publishes All Sql Reports in a Folder
+
+.PARAMETER folder
+
+Alias: ReportFolder
+The path which contains the RDL files
+.PARAMETER avoidDoubleHop
+
+Alias: ApplyDoubleHopFix
+Embeds the report username and password in the individual RDL files, necessary when SSRS does not run on the SQL Server
+#>
+
+ [CmdletBinding()]
+ Param(
+ [Parameter(Position = 1, Mandatory = $true)]
+ [Alias("ReportFolder")]
+ [ValidateScript( {if (Test-Path $_ -PathType 'Container') {
+ $true
+ } else {
+ throw ("Unable to locate the folder {0}" -f $_)
+ }})]
+ [string]$folder,
+
+ [Parameter(Mandatory = $false)]
+ [Alias("ApplyDoubleHopFix")]
+ [switch]$avoidDoubleHop
+ )
+
+ $logLead = (Get-LogLeadName);
+
+ [System.Reflection.Assembly]::LoadWithPartialName("System.IO") | Out-Null
+ [System.Reflection.Assembly]::LoadWithPartialName("System.Data") | Out-Null
+
+ try {
+ if (Test-IsWebServer) {
+
+ $reportServerEndpoint = Get-AppSetting "ReportServer"
+ $reportFolder = Get-AppSetting "ReportServerPath"
+
+ if (($null -eq $reportFolder) -or ([String]::IsNullOrEmpty($reportFolder))) {
+ Write-Warning "$logLead : Could not read the value for the ""ReportServerPath"" appSetting from the machine.config.`n`nAutomatic execution cannot continue, but you may still run the script manually by passing the parameters to Publish-SSRSReportsDirectory"
+ return;
+ }
+ if (($null -eq $reportServerEndpoint) -or ([String]::IsNullOrEmpty($reportServerEndpoint))) {
+ Write-Warning "$logLead : Could not read the value for the ""ReportServer"" appSetting from the machine.config.`n`nAutomatic execution cannot continue, but you may still run the script manually by passing the parameters to Publish-SSRSReportsDirectory"
+ return;
+ }
+
+ Write-Host "`n$logLead : Automatically calling Publish-SSRSReportsDirectory with parameters:"
+ Write-Host ("$logLead : ReportDirectory : {0}" -f $folder)
+ Write-Host ("$logLead : WebServiceUrl : {0}" -f $reportServerEndpoint)
+ Write-Host ("$logLead : ReportFolder : {0}`n" -f $reportFolder)
+
+ Publish-SSRSReportsDirectory $folder $reportServerEndpoint $reportFolder -avoidDoubleHop:$AvoidDoubleHop.IsPresent
+ } else {
+ Write-Warning "$logLead : This script can only be automatically executed on a web tier server"
+ Write-Host "$logLead : You may still run the script manually by passing the parameters to Publish-SSRSReportsDirectory"
+ }
+ } finally {
+ if ($DisposeSessions) {
+ if ($null -ne $SSRSProxy) {
+ $SSRSProxy.Dispose()
+ }
+
+ if ($null -ne $SSRSExecutionProxy) {
+ $SSRSExecutionProxy.Dispose()
+ }
+ }
+ }
+}
+
diff --git a/Modules/Alkami.DevOps.SqlReports/Public/Remove-Datasources.ps1 b/Modules/Alkami.DevOps.SqlReports/Public/Remove-Datasources.ps1
new file mode 100644
index 0000000..4a352ca
--- /dev/null
+++ b/Modules/Alkami.DevOps.SqlReports/Public/Remove-Datasources.ps1
@@ -0,0 +1,72 @@
+function Remove-Datasources {
+ <#
+.SYNOPSIS
+ Remove DataSource nodes from SSRS Report XML data
+
+.PARAMETER ReportXml
+ XML data to remove DataSource nodes from
+
+.PARAMETER SkipParameterBasedConnections
+ Whether to keep paramter based DataSources
+#>
+
+ [CmdletBinding()]
+ Param(
+ [Parameter(Position = 0, Mandatory = $true)]
+ [XML]$ReportXml,
+
+ [Parameter(Position = 1, Mandatory = $false)]
+ [bool]$SkipParameterBasedConnections = $false
+ )
+
+ $logLead = Get-LogLeadName
+
+ $stream = New-Object System.IO.MemoryStream
+
+ try {
+ Write-Verbose "$logLead : Getting DataSourceReference Elements"
+ $dataSourceNodes = $ReportXml.GetElementsByTagName("DataSourceReference")
+
+ if (!($SkipParameterBasedConnections)) {
+ Write-Verbose "$logLead : Adding Parameter Based Datasources to List to Remove"
+ $dataSourceNodes += $dataSourceNodes = $reportXml.GetElementsByTagName("ConnectionProperties")
+ }
+
+ $nodesToRemove = @()
+
+ Write-Verbose "$logLead : Rebuilding nodes with bogus information"
+ foreach ($dataSourceNode in $dataSourceNodes) {
+ Write-Verbose ("$logLead : Rebuilding Datasource {0}" -f $dataSourceNode.ParentNode.Name)
+ $connectionProperties = $ReportXml.CreateNode($dataSourceNode.NodeType, "ConnectionProperties", $null)
+
+ $dataProvider = $ReportXml.CreateNode($dataSourceNode.NodeType, "DataProvider", $null)
+ $dataProvider.InnerText = "SQL"
+
+ $conStr = $ReportXml.CreateNode($DataSourceNode.NodeType, "ConnectString", $null)
+ $conStr.InnerText = "Data Source=Server Name Here;Initial Catalog=database name here"
+
+ $dataSourceNode.ParentNode.AppendChild($connectionProperties) | Out-Null
+
+ $connectionProperties.AppendChild($dataProvider) | Out-Null
+ $connectionProperties.AppendChild($conStr) | Out-Null
+
+ $nodesToRemove += $dataSourceNode
+ }
+
+ Write-Verbose "$logLead : Removing all nodes"
+ foreach ($node in $nodesToRemove) {
+ $node.ParentNode.RemoveChild($node) | Out-Null
+ }
+
+ Write-Verbose "$logLead : Cleaning Namespaces"
+ $ReportXml.InnerXml = $ReportXml.InnerXml.Replace("xmlns=`"`"", "")
+
+ Write-Verbose "$logLead : Saving ReportData to Stream"
+ $ReportXml.Save($stream)
+ return $stream.ToArray()
+ } finally {
+ $stream.Close()
+ $stream.Dispose()
+ }
+}
+
diff --git a/Modules/Alkami.DevOps.SqlReports/Public/Set-ReportDataSource.ps1 b/Modules/Alkami.DevOps.SqlReports/Public/Set-ReportDataSource.ps1
new file mode 100644
index 0000000..38ce05e
--- /dev/null
+++ b/Modules/Alkami.DevOps.SqlReports/Public/Set-ReportDataSource.ps1
@@ -0,0 +1,147 @@
+function Set-ReportDataSource {
+<#
+.SYNOPSIS
+ Set the DataSource for a SSRS report
+
+.PARAMETER Proxy
+ SSRS Proxy
+
+.PARAMETER ReportPath
+ Path to report on SSRS server
+
+.PARAMETER RdlPath
+ Local filepath to RDL report file
+
+.PARAMETER AvoidDoubleHop
+ Whether to use special measures to avoid the DoubleHop issue
+
+.PARAMETER ReportServerUserName
+ Username for SSRS server
+
+.PARAMETER ReportServerPassword
+ Password for SSRS server
+#>
+
+ [CmdletBinding()]
+ Param(
+ [Parameter(Position=0,Mandatory=$true)]
+ [System.Web.Services.Protocols.SoapHttpClientProtocol]$Proxy,
+
+ [Parameter(Position=1, Mandatory=$true)]
+ [string]$ReportPath,
+
+ [Parameter(Position=2, Mandatory=$true)]
+ [string]$RdlPath,
+
+ [Parameter(Position=3, Mandatory=$true)]
+ [Alias("ApplyDoubleHopFix")]
+ [bool]$AvoidDoubleHop,
+
+ [Parameter(Position=4, Mandatory=$false)]
+ [Alias("Username")]
+ [string]$ReportServerUserName,
+
+ [Parameter(Position=5, Mandatory=$false)]
+ [Alias("Password")]
+ [string]$ReportServerPassword
+ )
+
+ $logLead = Get-LogLeadName
+
+ Write-Verbose ("$logLead : Getting XML Datasources of {0}" -f $RdlPath)
+ [xml]$reportContent = Get-Content $RdlPath
+ $rdlDataSources = $reportContent.Report.DataSources
+
+ Write-Verbose ("$logLead : Getting Datasource References of published report {0} " -f $ReportPath)
+ $reportDataSources = $Proxy.GetItemDataSources($ReportPath)
+
+ Write-Verbose "$logLead : Calling Get-PublishedDataSources"
+ $publishedDataSources = Get-PublishedDataSources $Proxy ($ReportPath.Split("/") | Where-Object {$_ -ne [String]::Empty} | Select-Object -First 1)
+
+ $dirty = $false
+
+ foreach ($rdlDataSource in $rdlDataSources.DataSource) {
+ Write-Verbose ("$logLead : Checking Datasource {0}" -f $rdlDataSource.Name)
+ $targetDataSource = $reportDataSources | Where-Object {$_.Name -eq $rdlDataSource.Name}
+
+ if ($null -eq $targetDataSource) {
+ Write-Verbose ("$logLead : Unable to find data source in published report with name {0}" -f $rdlDataSource.Name)
+ continue
+ }
+
+ $ProxyNamespace = $targetDataSource.GetType().Namespace
+
+ if ($null -eq $rdlDataSource.DataSourceReference) {
+ Write-Verbose "$logLead : Setting DataProvider Reference"
+
+ $ref = New-Object $targetDataSource.GetType().Assembly.GetType("$Proxynamespace.DataSourceDefinition")
+ $ref.ConnectString = $rdlDataSource.ConnectionProperties.ConnectString
+ $ref.Extension = $rdlDataSource.ConnectionProperties.DataProvider
+ $ref.OriginalConnectStringExpressionBased = ($rdlDataSource.ConnectionProperties.ConnectString -match "^=").ToString()
+ $ref.Enabled = $true
+ $ref.EnabledSpecified = $true
+ $ref.UseOriginalConnectString = $true
+ $ref.ImpersonateUserSpecified = $true
+
+ if ($rdlDataSource.ConnectionProperties.IntegratedSecurity -match "true") {
+ if ($AvoidDoubleHop) {
+ # Set the credentials on this data source
+ if ((Test-IsWebServer) -and ([String]::IsNullOrEmpty($ReportServerUserName) -or [String]::IsNullOrEmpty($ReportServerPassword))) {
+ # Read the User Credentials from the machine.config
+ Write-Verbose ("$logLead : Reading Credential Information from the machine.config")
+ [xml]$config = Get-ReportServerConfiguration
+ $reportServerUserNode = $config.appSettings.SelectSingleNode("//add[@key=""ReportServerUserName""]/@value")
+ $ReportServerPasswordNode = $config.appSettings.SelectSingleNode("//add[@key=""ReportServerPassword""]/@value")
+
+ if (($null -eq $reportServerUserNode) -or ([String]::IsNullOrEmpty($reportServerUserNode.Value))) {
+ Write-Warning "$logLead : Could not read the value for the ""ReportServerUserName"" appSetting from the machine.config. Correct this report manually"
+ return;
+ }
+
+ if (($null -eq $ReportServerPasswordNode) -or ([String]::IsNullOrEmpty($ReportServerPasswordNode.Value))) {
+ Write-Warning "$logLead : Could not read the value for the ""ReportServerPassword"" appSetting from the machine.config. Correct this report manually"
+ return;
+ } else {
+ Write-Verbose ("$logLead : Using Credentials for $ReportServerUserName Passed via Parameter")
+ }
+
+ $ReportServerUserName = $reportServerUserNode.Value
+ $ReportServerPassword = $ReportServerPasswordNode.Value
+ }
+
+ Write-Verbose "$logLead : Setting DataSource to Use Username $ReportServerUserName"
+ $ref.UserName = $ReportServerUserName
+ $ref.Password = $ReportServerPassword
+ $ref.WindowsCredentials = $true
+ }
+
+ $ref.CredentialRetrieval = [SSRS.CredentialRetrievalEnum]::Store
+ }
+
+ Write-Host ("$logLead : Setting datasource {0} for report {1} " -f $targetDataSource.Name, $RdlPath)
+ $targetDataSource.Item = $ref
+ $dirty = $true
+ } else {
+ Write-Verbose "$logLead : Setting DataSource Reference"
+
+ $ref = New-Object $targetDataSource.GetType().Assembly.GetType("$Proxynamespace.DataSourceReference")
+ $ref.Reference = $publishedDataSources | Where-Object {$_.Name -eq $rdlDataSource.Name} | Select-Object -First 1 -ExpandProperty "Path"
+
+ if ($ref.Reference.Length -gt 0) {
+ Write-Verbose ("Setting datasource {0} for report {1} " -f $targetDataSource.Name, $RdlPath)
+ $targetDataSource.Item = $ref
+ $dirty = $true
+ }
+ }
+ }
+
+ if ($dirty) {
+ Write-Verbose "$logLead : Saving updated datasources"
+
+ try {
+ $Proxy.SetItemDataSources($ReportPath, $reportDataSources)
+ } catch [Exception] {
+ Write-Warning ("$logLead : An invalid parameter was specified for one or more datasources. Correct this report manually. Exception: {0}" -f $_.Exception.Message)
+ }
+ }
+}
diff --git a/Modules/Alkami.DevOps.SqlReports/Public/Set-ReportParameters.ps1 b/Modules/Alkami.DevOps.SqlReports/Public/Set-ReportParameters.ps1
new file mode 100644
index 0000000..ef1f604
--- /dev/null
+++ b/Modules/Alkami.DevOps.SqlReports/Public/Set-ReportParameters.ps1
@@ -0,0 +1,62 @@
+function Set-ReportParameters {
+ <#
+.SYNOPSIS
+ Set SSRS report parameters from local RDL file on remote SSRS report
+
+#>
+ [CmdletBinding()]
+ Param(
+ [Parameter(Position = 0, Mandatory = $true)]
+ [System.Web.Services.Protocols.SoapHttpClientProtocol]$Proxy,
+
+ [CmdletBinding()]
+ [Parameter(Position = 1, Mandatory = $true)]
+ [System.Web.Services.Protocols.SoapHttpClientProtocol]$SSRSExecutionProxy,
+
+ [Parameter(Position = 2, Mandatory = $true)]
+ [string]$ReportPath,
+
+ [Parameter(Position = 3, Mandatory = $true)]
+ [string]$RdlPath
+ )
+
+ $logLead = Get-LogLeadName
+
+ Write-Verbose ("$logLead : Getting RDL Content from {0}" -f $RdlPath)
+ [xml]$reportContent = Get-Content $RdlPath
+
+ $reportParameters = @()
+ $namespace = $Proxy.GetType().Namespace
+
+ Write-Verbose "$logLead : Calling Remove-DataSources"
+ $reportData = Remove-Datasources $reportContent
+
+ Write-Verbose "$logLead : Loading Report Execution Definition"
+ $warnings = $null
+
+ try {
+ $reportInfo = $SSRSExecutionProxy.LoadReportDefinition($reportData, [ref]$warnings)
+ } catch {
+ Write-Warning "$logLead : --- An error occurred reading the report parameters. Let the reports developer know and manually validate the report parameters."
+ return
+ }
+
+ Write-Verbose "$logLead : Cloning Report Parameters"
+ foreach ($parameter in $reportInfo.Parameters) {
+ $itemParameter = New-Object $Proxy.GetType().Assembly.GetType("$namespace.ItemParameter")
+ $itemParameter = Copy-ObjectProperties $parameter $itemParameter @("ValidValues")
+
+ foreach ($value in $parameter.ValidValues) {
+ $validValue = New-Object $Proxy.GetType().Assembly.GetType("$namespace.ValidValue")
+ Copy-ObjectProperties $value $validValue
+
+ $itemParameter.ValidValues += $validValue
+ }
+
+ $reportParameters += $itemParameter
+ }
+
+ Write-Verbose "$logLead : Using Proxy to Set Report Parameters"
+ $Proxy.SetItemParameters($ReportPath, $reportParameters)
+}
+
diff --git a/Modules/Alkami.DevOps.SqlReports/tools/chocolateyInstall.ps1 b/Modules/Alkami.DevOps.SqlReports/tools/chocolateyInstall.ps1
new file mode 100644
index 0000000..b01306e
--- /dev/null
+++ b/Modules/Alkami.DevOps.SqlReports/tools/chocolateyInstall.ps1
@@ -0,0 +1,37 @@
+[CmdletBinding()]
+Param()
+process {
+ $myCurrentPath = $PSScriptRoot;
+ Write-Verbose "Installing the Module from $myCurrentPath";
+
+ $parentPath = (Split-Path $myCurrentPath);
+ $systemModulePath = "C:\Program Files\WindowsPowerShell\Modules\";
+ $myModulePath = (Join-Path $parentPath "module");
+
+ $metadata = ([Xml](Get-Content (Join-Path $parentPath "*.nuspec"))).package.metadata;
+
+ $id = $metadata.id;
+
+ $version = $metadata.version -replace '-pre.+','';
+
+ $targetModulePath = (Join-Path $systemModulePath $id);
+ $targetModuleVersionPath = (Join-Path $targetModulePath $version);
+
+ if (Test-Path $targetModulePath) {
+ ## If the target folder already existed, remove it, because we are re-installing this package, obviously
+ if (Test-Path $targetModuleVersionPath) {
+ Write-Warning "Found an already existing module at [$targetModuleVersionPath]!!"
+ Remove-Item $targetModuleVersionPath -Recurse -Force;
+ }
+
+ ## Clear previous children for name conflicts
+ (Get-ChildItem $targetModulePath) | ForEach-Object {
+ Write-Information "Removing module located at [$_]";
+ Remove-Item $_.FullName -Recurse -Force;
+ }
+ }
+
+ Write-Host "Copying module $id to [$targetModuleVersionPath]";
+ Copy-Item $myModulePath -Destination $targetModuleVersionPath -Recurse -Force;
+
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.SqlReports/tools/chocolateyUninstall.ps1 b/Modules/Alkami.DevOps.SqlReports/tools/chocolateyUninstall.ps1
new file mode 100644
index 0000000..7c36766
--- /dev/null
+++ b/Modules/Alkami.DevOps.SqlReports/tools/chocolateyUninstall.ps1
@@ -0,0 +1,25 @@
+[CmdletBinding()]
+Param()
+process {
+ $myCurrentPath = $PSScriptRoot;
+ Write-Verbose "Uninstalling the Module from $myCurrentPath";
+
+ $parentPath = (Split-Path $myCurrentPath);
+ $systemModulePath = "C:\Program Files\WindowsPowerShell\Modules\";
+ $myModulePath = (Join-Path $parentPath "module");
+
+ $metadata = ([Xml](Get-Content (Join-Path $parentPath "*.nuspec"))).package.metadata;
+
+ $id = $metadata.id;
+
+ $version = $metadata.version -replace '-pre.+','';
+
+ $targetModulePath = (Join-Path $systemModulePath $id);
+ $targetModuleVersionPath = (Join-Path $targetModulePath $version);
+
+ if (Test-Path $targetModuleVersionPath) {
+ Write-Information "Removing module at [$targetModuleVersionPath]!!"
+ Remove-Item $targetModuleVersionPath -Recurse -Force;
+ }
+}
+
diff --git a/Modules/Alkami.DevOps.SystemEngineering/Alkami.DevOps.SystemEngineering.nuspec b/Modules/Alkami.DevOps.SystemEngineering/Alkami.DevOps.SystemEngineering.nuspec
new file mode 100644
index 0000000..da8ebb8
--- /dev/null
+++ b/Modules/Alkami.DevOps.SystemEngineering/Alkami.DevOps.SystemEngineering.nuspec
@@ -0,0 +1,33 @@
+
+
+
+ Alkami.DevOps.SystemEngineering
+ $version$
+ Alkami Platform Modules - SystemEngineering - Functional
+ Alkami Technology
+ Alkami Technology
+ https://extranet.alkamitech.com/display/ORB/Alkami.DevOps.SystemEngineering
+ https://www.alkami.com/files/alkamilogo75x75.png
+ http://alkami.com/files/orblicense.html
+ false
+ Installs the SRE SysEng module for use with PowerShell.
+
+ PowerShell
+ Copyright (c) 2020 Alkami Technology
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Modules/Alkami.DevOps.SystemEngineering/Alkami.DevOps.SystemEngineering.psd1 b/Modules/Alkami.DevOps.SystemEngineering/Alkami.DevOps.SystemEngineering.psd1
new file mode 100644
index 0000000..d7bcdbc
--- /dev/null
+++ b/Modules/Alkami.DevOps.SystemEngineering/Alkami.DevOps.SystemEngineering.psd1
@@ -0,0 +1,20 @@
+@{
+ RootModule = 'Alkami.DevOps.SystemEngineering.psm1'
+ ModuleVersion = '1.4.0'
+ GUID = '5151a169-c763-489e-8213-b437b7a294b1'
+ Author = 'Alkami'
+ CompanyName = 'Alkami Technology, Inc.'
+ Copyright = '(c) 2020 Alkami Technology, Inc.. All rights reserved.'
+ Description = 'A set of common functions typically used by SystemEngineering'
+ RequiredModules = 'Alkami.PowerShell.Common','Alkami.DevOps.Common','Alkami.PowerShell.AD'
+ FunctionsToExport = 'Disable-ActiveDirectoryAccount','Disable-AlkamiDomainAccounts','Export-ACMCertificatesByName','Get-ACMCertificateBindingList','Get-ACMCertificateDetailsListByName','Get-ActiveDirectoryAccount','Get-BitLockerRecoveryKeys','Get-DnsByIP','Get-DomainNameDistinguishedName','Get-SecurityGroupsForUser','Get-TerminatedComputersReport','Get-WorkspaceBundleList','Move-AccountToDisabledOU','New-GMSAStack','New-SecurePassword','New-ServerlessServiceAccountPair','New-SftpPasswordHash','New-SftpUser','Test-IsUserDomainAdmin','Update-AWSProfile','Update-SftpPassword','Write-AlkamiSecretResourcePolicy'
+ AliasesToExport = ''
+ PrivateData = @{
+ PSData = @{
+ Tags = @('powershell', 'module', 'syseng')
+ ProjectUri = 'Https://extranet.alkamitech.com/display/SRE/Alkami.DevOps.SystemEngineering+Module'
+ IconUri = 'https://www.alkami.com/files/alkamilogo75x75.png'
+ }
+ }
+ HelpInfoURI = 'https://extranet.alkamitech.com/display/SRE/Alkami.DevOps.SystemEngineering+Module'
+}
diff --git a/Modules/Alkami.DevOps.SystemEngineering/AlkamiManifest.xml b/Modules/Alkami.DevOps.SystemEngineering/AlkamiManifest.xml
new file mode 100644
index 0000000..0df0101
--- /dev/null
+++ b/Modules/Alkami.DevOps.SystemEngineering/AlkamiManifest.xml
@@ -0,0 +1,12 @@
+
+
+ 1.0
+
+ Alkami
+ Alkami.DevOps.SystemEngineering
+ SREModule
+
+
+ Production
+
+
diff --git a/Modules/Alkami.DevOps.SystemEngineering/Private/Get-AlkamiIamAssumeRolePolicyString.ps1 b/Modules/Alkami.DevOps.SystemEngineering/Private/Get-AlkamiIamAssumeRolePolicyString.ps1
new file mode 100644
index 0000000..578818b
--- /dev/null
+++ b/Modules/Alkami.DevOps.SystemEngineering/Private/Get-AlkamiIamAssumeRolePolicyString.ps1
@@ -0,0 +1,42 @@
+function Get-AlkamiIamAssumeRolePolicyString {
+<#
+.SYNOPSIS
+ Returns the string for an AWS IAM assume role policy.
+
+.PARAMETER ServiceName
+ [string] The AWS service name to grant sts:AssumeRole to in the policy (e.g. 'ec2', 'ecs-task').
+
+.EXAMPLE
+ Get-AlkamiIamAssumeRolePolicyString -ServiceName 'ec2'
+
+{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Sid":"AllowEcsAssumeRole","Principal":{"Service":"ec2.amazonaws.com"},"Action":"sts:AssumeRole"}]}
+#>
+ [CmdletBinding()]
+ [OutputType([string])]
+ param(
+ [Parameter(Mandatory = $true)]
+ [ValidateNotNullOrEmpty()]
+ [string] $ServiceName
+ )
+
+ $actualServiceName = $ServiceName
+ if ( $false -eq $actualServiceName.EndsWith('.amazonaws.com') ) {
+ $actualServiceName += '.amazonaws.com'
+ }
+
+ $policyObj = @{
+ Version = "2012-10-17"
+ Statement = @(
+ @{
+ Sid = "AllowAwsServiceAssumeRole"
+ Action = "sts:AssumeRole"
+ Effect = "Allow"
+ Principal = @{
+ Service = $actualServiceName
+ }
+ }
+ )
+ }
+
+ return (ConvertTo-Json -InputObject $policyObj -Compress -Depth 10)
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.SystemEngineering/Private/Get-AlkamiIamAssumeRolePolicyString.tests.ps1 b/Modules/Alkami.DevOps.SystemEngineering/Private/Get-AlkamiIamAssumeRolePolicyString.tests.ps1
new file mode 100644
index 0000000..7f303dc
--- /dev/null
+++ b/Modules/Alkami.DevOps.SystemEngineering/Private/Get-AlkamiIamAssumeRolePolicyString.tests.ps1
@@ -0,0 +1,48 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$global:functionPath = Join-Path -Path $here -ChildPath $sut
+
+InModuleScope -ModuleName Alkami.DevOps.SystemEngineering -ScriptBlock {
+ Write-Host "InModuleScope - Overriding SUT: $global:functionPath"
+ Import-Module $global:functionPath -Force
+
+ Describe "Get-AlkamiIamAssumeRolePolicyString" {
+
+ Context "Parameter Validation" {
+
+ It "Throws if ServiceName is Null" {
+ { Get-AlkamiIamAssumeRolePolicyString -ServiceName $null } | Should -Throw
+ }
+
+ It "Throws if ServiceName is Empty" {
+ { Get-AlkamiIamAssumeRolePolicyString -ServiceName '' } | Should -Throw
+ }
+ }
+
+ Context "Logic" {
+
+ It "Returns a String" {
+
+ (Get-Command Get-AlkamiIamAssumeRolePolicyString).OutputType.Type.ToString() | Should -BeExactly "System.String"
+ }
+
+ It "Returns a String With a Valid JSON Conversion" {
+
+ { ConvertFrom-Json (Get-AlkamiIamAssumeRolePolicyString -ServiceName 'test') } | Should -Not -Throw
+ }
+
+ It "Appends '.amazonaws.com' If Not Provided" {
+
+ $result = ConvertFrom-Json (Get-AlkamiIamAssumeRolePolicyString -ServiceName 'test')
+ $result.Statement[0].Principal.Service | Should -BeExactly 'test.amazonaws.com'
+ }
+
+ It "Does Not Append '.amazonaws.com' If Provided" {
+
+ $result = ConvertFrom-Json (Get-AlkamiIamAssumeRolePolicyString -ServiceName 'test.amazonaws.com')
+ $result.Statement[0].Principal.Service | Should -BeExactly 'test.amazonaws.com'
+ }
+ }
+ }
+}
diff --git a/Modules/Alkami.DevOps.SystemEngineering/Private/Get-AlkamiSecretResourcePolicyString.ps1 b/Modules/Alkami.DevOps.SystemEngineering/Private/Get-AlkamiSecretResourcePolicyString.ps1
new file mode 100644
index 0000000..6c694a7
--- /dev/null
+++ b/Modules/Alkami.DevOps.SystemEngineering/Private/Get-AlkamiSecretResourcePolicyString.ps1
@@ -0,0 +1,69 @@
+function Get-AlkamiSecretResourcePolicyString {
+<#
+.SYNOPSIS
+ Returns the string for an AWS Secret resource policy that allows access to admins and SysEng (by default).
+
+.PARAMETER ProfileName
+ [string] The AWS profile to use during when creating the resource policy.
+
+.PARAMETER SecretAccessExtraArns
+ [string[]] An array of AWS ARNs allowed to access the secret in addition to the defaults.
+
+.EXAMPLE
+ Get-AlkamiSecretResourcePolicyString -ProfileName 'temp-dev'
+
+{"Version":"2012-10-17","Statement":[{"Action":"secretsmanager:*","Condition":{"ArnNotEquals":{"aws:PrincipalArn":["arn:aws:iam::327695573722:role/CLI-SRE-Admin","arn:aws:iam::327695573722:role/DAG-AWS-Admins","arn:aws:iam::327695573722:root"]}},"Principal":{"AWS":"*"},"Resource":"*","Effect":"Deny","Sid":"DenyAllUnlessExplicitlyAllowed"}]}
+
+.EXAMPLE
+ Get-AlkamiSecretResourcePolicyString -ProfileName 'temp-dev' -SecretAccessExtraArns @( 'ExampleArn1', 'ExampleArn2' )
+
+{"Version":"2012-10-17","Statement":[{"Action":"secretsmanager:*","Condition":{"ArnNotEquals":{"aws:PrincipalArn":["arn:aws:iam::327695573722:role/CLI-SRE-Admin","arn:aws:iam::327695573722:role/DAG-AWS-Admins","arn:aws:iam::327695573722:root","ExampleArn1","ExampleArn2"]}},"Principal":{"AWS":"*"},"Resource":"*","Effect":"Deny","Sid":"DenyAllUnlessExplicitlyAllowed"}]}
+#>
+ [CmdletBinding()]
+ [OutputType([string])]
+ param(
+ [Parameter(Mandatory = $true)]
+ [ValidateNotNullOrEmpty()]
+ [string] $ProfileName,
+
+ [Parameter(Mandatory = $false)]
+ [string[]] $SecretAccessExtraArns = $null
+ )
+
+ Import-AWSModule
+
+ $accountNumber = (Get-STSCallerIdentity -ProfileName $ProfileName).Account
+
+ $policyObj = @{
+ Version = "2012-10-17"
+ Statement = @(
+ @{
+ Sid = "DenyAllUnlessExplicitlyAllowed"
+ Action = "secretsmanager:*"
+ Effect = "Deny"
+ Resource = "*"
+ Principal = @{
+ AWS = "*"
+ }
+ Condition = @{
+ ArnNotEquals = @{
+ "aws:PrincipalArn" = @(
+ "arn:aws:iam::${accountNumber}:role/CLI-SRE-Admin",
+ "arn:aws:iam::${accountNumber}:role/DAG-AWS-Admins",
+ "arn:aws:iam::${accountNumber}:root"
+ )
+ }
+ }
+ }
+ )
+ }
+
+ # Add any extra ARNs that need access to the secret.
+ foreach ( $extraArn in $SecretAccessExtraArns ) {
+ if ( $false -eq [string]::IsNullOrWhitespace($extraArn)) {
+ $policyObj.Statement.Condition.ArnNotEquals.'aws:PrincipalArn' += $extraArn
+ }
+ }
+
+ return (ConvertTo-Json -InputObject $policyObj -Compress -Depth 10)
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.SystemEngineering/Private/Get-AlkamiSecretResourcePolicyString.tests.ps1 b/Modules/Alkami.DevOps.SystemEngineering/Private/Get-AlkamiSecretResourcePolicyString.tests.ps1
new file mode 100644
index 0000000..ce0d81f
--- /dev/null
+++ b/Modules/Alkami.DevOps.SystemEngineering/Private/Get-AlkamiSecretResourcePolicyString.tests.ps1
@@ -0,0 +1,81 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$global:functionPath = Join-Path -Path $here -ChildPath $sut
+
+InModuleScope -ModuleName Alkami.DevOps.SystemEngineering -ScriptBlock {
+ Write-Host "InModuleScope - Overriding SUT: $global:functionPath"
+ Import-Module $global:functionPath -Force
+ $inScopeModule = "Alkami.DevOps.SystemEngineering"
+
+ Describe "Get-AlkamiSecretResourcePolicyString" {
+
+ Mock -CommandName Import-AWSModule -ModuleName $inScopeModule -MockWith {}
+ Mock -CommandName Get-STSCallerIdentity -ModuleName $inScopeModule -MockWith {
+ return @{ Account = '123456' }
+ }
+
+ Context "Parameter Validation" {
+
+ It "Throws if ProfileName is Null" {
+ { Get-AlkamiSecretResourcePolicyString -ProfileName $null } | Should -Throw
+ }
+
+ It "Throws if ProfileName is Empty" {
+ { Get-AlkamiSecretResourcePolicyString -ProfileName '' } | Should -Throw
+ }
+ }
+
+ Context "Logic" {
+
+ It "Returns a String" {
+
+ (Get-Command Get-AlkamiSecretResourcePolicyString).OutputType.Type.ToString() | Should -BeExactly "System.String"
+ }
+
+ It "Returns a String With a Valid JSON Conversion" {
+
+ { ConvertFrom-Json (Get-AlkamiSecretResourcePolicyString -ProfileName 'test') } | Should -Not -Throw
+ }
+
+ It "Includes CLI-SRE-Admin Role By Default" {
+
+ $searchTerm = 'role/CLI-SRE-Admin'
+ $jsonResult = ConvertFrom-Json (Get-AlkamiSecretResourcePolicyString -ProfileName 'test')
+ $searchResult = $jsonResult.Statement.Condition.ArnNotEquals.'aws:PrincipalArn' | Where-Object { $_.EndsWith($searchTerm) }
+ $searchResult | Should -Not -BeNull
+ }
+
+ It "Includes DAG-AWS-Admins Role By Default" {
+
+ $searchTerm = 'role/DAG-AWS-Admins'
+ $jsonResult = ConvertFrom-Json (Get-AlkamiSecretResourcePolicyString -ProfileName 'test')
+ $searchResult = $jsonResult.Statement.Condition.ArnNotEquals.'aws:PrincipalArn' | Where-Object { $_.EndsWith($searchTerm) }
+ $searchResult | Should -Not -BeNull
+ }
+
+ It "Includes Account Root User By Default" {
+
+ $searchTerm = 'root'
+ $jsonResult = ConvertFrom-Json (Get-AlkamiSecretResourcePolicyString -ProfileName 'test')
+ $searchResult = $jsonResult.Statement.Condition.ArnNotEquals.'aws:PrincipalArn' | Where-Object { $_.EndsWith($searchTerm) }
+ $searchResult | Should -Not -BeNull
+ }
+
+ It "Includes No Other AWS Entites By Default" {
+
+ $exclusionTerms = 'CLI-SRE-Admin|CLI-SRE-SysAdministrator|DAG-AWS-Admins|DAG-AWS-SRE-Infrastructure|root'
+ $jsonResult = ConvertFrom-Json (Get-AlkamiSecretResourcePolicyString -ProfileName 'test')
+ $searchResult = $jsonResult.Statement.Condition.ArnNotEquals.'aws:PrincipalArn' | Where-Object { $_ -notmatch $exclusionTerms }
+ $searchResult | Should -BeNull
+ }
+
+ It "Includes Extra Parameter Values If Provided" {
+
+ $searchTerm = "TestArn"
+ $jsonResult = ConvertFrom-Json (Get-AlkamiSecretResourcePolicyString -ProfileName 'test' -SecretAccessExtraArns @($searchTerm))
+ $jsonResult.Statement.Condition.ArnNotEquals.'aws:PrincipalArn' | Should -Contain $searchTerm
+ }
+ }
+ }
+}
diff --git a/Modules/Alkami.DevOps.SystemEngineering/Private/Get-ServerlessServiceAccountIamPolicyString.ps1 b/Modules/Alkami.DevOps.SystemEngineering/Private/Get-ServerlessServiceAccountIamPolicyString.ps1
new file mode 100644
index 0000000..96317d0
--- /dev/null
+++ b/Modules/Alkami.DevOps.SystemEngineering/Private/Get-ServerlessServiceAccountIamPolicyString.ps1
@@ -0,0 +1,38 @@
+function Get-ServerlessServiceAccountIamPolicyString {
+<#
+.SYNOPSIS
+ Returns the string for an AWS IAM policy for serverless service accounts.
+
+.PARAMETER SecretArns
+ [string[]] The AWS ARNs for the secrets associated with the serverless service account.
+
+.EXAMPLE
+ Get-ServerlessServiceAccountIamPolicyString -SecretArns @( 'example' )
+
+{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Sid":"AllowSecretsManagerAccess","Resource":["example"],"Action":["secretsmanager:DescribeSecret","secretsmanager:GetSecretValue"]}]}
+#>
+ [CmdletBinding()]
+ [OutputType([string])]
+ param(
+ [Parameter(Mandatory = $true)]
+ [ValidateNotNullOrEmpty()]
+ [string[]] $SecretArns
+ )
+
+ $policyObj = @{
+ Version = "2012-10-17"
+ Statement = @(
+ @{
+ Sid = "AllowSecretsManagerAccess"
+ Effect = "Allow"
+ Action = @(
+ "secretsmanager:DescribeSecret",
+ "secretsmanager:GetSecretValue"
+ )
+ Resource = $SecretArns
+ }
+ )
+ }
+
+ return (ConvertTo-Json -InputObject $policyObj -Compress -Depth 10)
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.SystemEngineering/Private/Get-ServerlessServiceAccountIamPolicyString.tests.ps1 b/Modules/Alkami.DevOps.SystemEngineering/Private/Get-ServerlessServiceAccountIamPolicyString.tests.ps1
new file mode 100644
index 0000000..e156b3a
--- /dev/null
+++ b/Modules/Alkami.DevOps.SystemEngineering/Private/Get-ServerlessServiceAccountIamPolicyString.tests.ps1
@@ -0,0 +1,56 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$global:functionPath = Join-Path -Path $here -ChildPath $sut
+
+InModuleScope -ModuleName Alkami.DevOps.SystemEngineering -ScriptBlock {
+ Write-Host "InModuleScope - Overriding SUT: $global:functionPath"
+ Import-Module $global:functionPath -Force
+
+ Describe "Get-ServerlessServiceAccountIamPolicyString" {
+
+ Context "Parameter Validation" {
+
+ It "Throws if SecretArns is Null" {
+ { Get-ServerlessServiceAccountIamPolicyString -SecretArns $null } | Should -Throw
+ }
+
+ It "Throws if SecretArns is Empty" {
+ { Get-ServerlessServiceAccountIamPolicyString -SecretArns @() } | Should -Throw
+ }
+ }
+
+ Context "Logic" {
+
+ It "Returns a String" {
+
+ (Get-Command Get-ServerlessServiceAccountIamPolicyString).OutputType.Type.ToString() | Should -BeExactly "System.String"
+ }
+
+ It "Returns a String With a Valid JSON Conversion" {
+
+ { ConvertFrom-Json (Get-ServerlessServiceAccountIamPolicyString -SecretArns @('test')) } | Should -Not -Throw
+ }
+
+ It "Allows DescribeSecret Action" {
+
+ $result = ConvertFrom-Json (Get-ServerlessServiceAccountIamPolicyString -SecretArns @('test'))
+ $result.Statement[0].Action | Should -Contain 'secretsmanager:DescribeSecret'
+ }
+
+ It "Allows GetSecretValue Action" {
+
+ $result = ConvertFrom-Json (Get-ServerlessServiceAccountIamPolicyString -SecretArns @('test'))
+ $result.Statement[0].Action | Should -Contain 'secretsmanager:GetSecretValue'
+ }
+
+ It "Grants Access To Specified Resource(s)" {
+
+ $result = ConvertFrom-Json (Get-ServerlessServiceAccountIamPolicyString -SecretArns @('test1', 'test2'))
+ $result.Statement[0].Resource | Should -HaveCount 2
+ $result.Statement[0].Resource | Should -Contain 'test1'
+ $result.Statement[0].Resource | Should -Contain 'test2'
+ }
+ }
+ }
+}
diff --git a/Modules/Alkami.DevOps.SystemEngineering/Private/Get-SftpUserDefaultSecretString.ps1 b/Modules/Alkami.DevOps.SystemEngineering/Private/Get-SftpUserDefaultSecretString.ps1
new file mode 100644
index 0000000..794f8c1
--- /dev/null
+++ b/Modules/Alkami.DevOps.SystemEngineering/Private/Get-SftpUserDefaultSecretString.ps1
@@ -0,0 +1,124 @@
+function Get-SftpUserDefaultSecretString {
+ <#
+.SYNOPSIS
+ Returns the default string for an SFTP user AWS Secret.
+
+.DESCRIPTION
+ Returns the default string for an SFTP user AWS Secret. This structure must match exactly the expectations of the SFTP Authentication Lambda.
+
+.PARAMETER BucketName
+ [string] The target SFTP S3 Bucket name for the environment.
+
+.PARAMETER HomeDirSuffix
+ [string] The relative path in the target SFTP S3 bucket to jail the user's home directory.
+
+.PARAMETER KmsArn
+ [string] The ARN of the KMS key used for SFTP S3 bucket object encryption for the environment.
+
+.PARAMETER RoleArn
+ [string] The ARN of the IAM role used by the SFTP Transfer Server for the environment.
+
+.PARAMETER PasswordHash
+ [string] The hashed password for the SFTP user.
+#>
+
+ [CmdletBinding()]
+ [OutputType([string])]
+ param(
+ [Parameter(Mandatory = $true)]
+ [ValidateNotNullOrEmpty()]
+ [string] $BucketName,
+
+ [Parameter(Mandatory = $true)]
+ [ValidateNotNullOrEmpty()]
+ [string] $HomeDirSuffix,
+
+ [Parameter(Mandatory = $true)]
+ [ValidateNotNullOrEmpty()]
+ [string] $KmsArn,
+
+ [Parameter(Mandatory = $true)]
+ [ValidateNotNullOrEmpty()]
+ [string] $RoleArn,
+
+ [Parameter(Mandatory = $true)]
+ [ValidateNotNullOrEmpty()]
+ [string] $PasswordHash
+ )
+
+ $policyObj = @{
+ Version = '2012-10-17'
+ Statement = @(
+ @{
+ Sid = 'KMSAccess'
+ Action = @(
+ 'kms:Decrypt',
+ 'kms:Encrypt',
+ 'kms:GenerateDataKey'
+ )
+ Effect = 'Allow'
+ Resource = $KmsArn
+ },
+ @{
+ Sid = 'AllowListingOfUserFolder'
+ Action = @(
+ 's3:ListBucket'
+ )
+ Effect = 'Allow'
+ Resource = @(
+ "arn:aws:s3:::$BucketName"
+ )
+ Condition = @{
+ StringLike = @{
+ 's3:prefix' = @(
+ "$HomeDirSuffix/*",
+ "$HomeDirSuffix"
+ )
+ }
+ }
+ },
+ @{
+ Sid = 'AWSTransferRequirements'
+ Effect = 'Allow'
+ Action = @(
+ 's3:ListAllMyBuckets',
+ 's3:GetBucketLocation'
+ )
+ Resource = '*'
+ },
+ @{
+ Sid = 'HomeDirObjectAccess'
+ Effect = 'Allow'
+ Action = @(
+ 's3:PutObject',
+ 's3:GetObject',
+ 's3:DeleteObjectVersion',
+ 's3:DeleteObject',
+ 's3:GetObjectVersion'
+ )
+ Resource = @(
+ "arn:aws:s3:::$BucketName/$HomeDirSuffix/*"
+ )
+ }
+ )
+ }
+
+ $homeDirObj = @(
+ @{
+ Entry = '/'
+ Target = "/$BucketName/$HomeDirSuffix"
+ }
+ )
+
+ $policyStr = (ConvertTo-Json -InputObject $policyObj -Compress -Depth 10)
+ $homeDirStr = (ConvertTo-Json -InputObject $homeDirObj -Compress -Depth 10)
+
+ $object = @{
+ Password = $PasswordHash
+ Role = $RoleArn
+ Policy = $policyStr
+ HomeDirectoryDetails = $homeDirStr
+ }
+
+ return (ConvertTo-Json -InputObject $object)
+}
diff --git a/Modules/Alkami.DevOps.SystemEngineering/Private/Get-SftpUserDefaultSecretString.tests.ps1 b/Modules/Alkami.DevOps.SystemEngineering/Private/Get-SftpUserDefaultSecretString.tests.ps1
new file mode 100644
index 0000000..5a8ac51
--- /dev/null
+++ b/Modules/Alkami.DevOps.SystemEngineering/Private/Get-SftpUserDefaultSecretString.tests.ps1
@@ -0,0 +1,95 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ''
+
+InModuleScope 'Alkami.DevOps.SystemEngineering' {
+ Describe 'Get-SftpUserDefaultSecretString' {
+
+ Context 'Parameter Validation' {
+
+ It 'Throws if BucketName is Null' {
+ { Get-SftpUserDefaultSecretString -BucketName $null } | Should -Throw
+ }
+
+ It 'Throws if BucketName is Empty' {
+ { Get-SftpUserDefaultSecretString -BucketName '' } | Should -Throw
+ }
+
+ It 'Throws if HomeDirSuffix is Null' {
+ { Get-SftpUserDefaultSecretString -BucketName 'TestPrefix' -HomeDirSuffix $null } | Should -Throw
+ }
+
+ It 'Throws if HomeDirSuffix is Empty' {
+ { Get-SftpUserDefaultSecretString -BucketName 'TestPrefix' -HomeDirSuffix '' } | Should -Throw
+ }
+
+ It 'Throws if KmsArn is Null' {
+ { Get-SftpUserDefaultSecretString -BucketName 'TestPrefix' -HomeDirSuffix 'TestSuffix' -KmsArn $null } | Should -Throw
+ }
+
+ It 'Throws if KmsArn is Empty' {
+ { Get-SftpUserDefaultSecretString -BucketName 'TestPrefix' -HomeDirSuffix 'TestSuffix' -KmsArn '' } | Should -Throw
+ }
+
+ It 'Throws if RoleArn is Null' {
+ { Get-SftpUserDefaultSecretString -BucketName 'TestPrefix' -HomeDirSuffix 'TestSuffix' -KmsArn 'TestKmsArn' -RoleArn $null } | Should -Throw
+ }
+
+ It 'Throws if RoleArn is Empty' {
+ { Get-SftpUserDefaultSecretString -BucketName 'TestPrefix' -HomeDirSuffix 'TestSuffix' -KmsArn 'TestKmsArn' -RoleArn '' } | Should -Throw
+ }
+
+ It 'Throws if PasswordHash is Null' {
+ { Get-SftpUserDefaultSecretString -BucketName 'TestPrefix' -HomeDirSuffix 'TestSuffix' -KmsArn 'TestKmsArn' -RoleArn 'TestRoleArn' -PasswordHash $null } | Should -Throw
+ }
+
+ It 'Throws if PasswordHash is Empty' {
+ { Get-SftpUserDefaultSecretString -BucketName 'TestPrefix' -HomeDirSuffix 'TestSuffix' -KmsArn 'TestKmsArn' -RoleArn 'TestRoleArn' -PasswordHash '' } | Should -Throw
+ }
+ }
+
+ Context 'Logic' {
+
+ It 'Uses Provided Value for PasswordHash' {
+ $result = Get-SftpUserDefaultSecretString -BucketName 'TestPrefix' -HomeDirSuffix 'TestSuffix' -KmsArn 'TestKmsArn' -RoleArn 'TestRoleArn' -PasswordHash 'TestPassword'
+
+ $resultObj = ConvertFrom-Json $result
+ $resultObj.Password | Should -BeExactly 'TestPassword'
+ }
+
+ It 'Uses Provided Value for RoleArn' {
+ $result = Get-SftpUserDefaultSecretString -BucketName 'TestPrefix' -HomeDirSuffix 'TestSuffix' -KmsArn 'TestKmsArn' -RoleArn 'TestRoleArn' -PasswordHash 'TestPassword'
+
+ $resultObj = ConvertFrom-Json $result
+ $resultObj.Role | Should -BeExactly 'TestRoleArn'
+ }
+
+ It 'Uses Provided Value for KmsArn' {
+ $result = Get-SftpUserDefaultSecretString -BucketName 'TestPrefix' -HomeDirSuffix 'TestSuffix' -KmsArn 'TestKmsArn' -RoleArn 'TestRoleArn' -PasswordHash 'TestPassword'
+
+ $resultObj = ConvertFrom-Json $result
+ $resultObj.Policy | Should -Match 'TestKmsArn'
+ }
+
+ It 'Uses Provided Value for BucketName' {
+ $result = Get-SftpUserDefaultSecretString -BucketName 'TestPrefix' -HomeDirSuffix 'TestSuffix' -KmsArn 'TestKmsArn' -RoleArn 'TestRoleArn' -PasswordHash 'TestPassword'
+
+ $resultObj = ConvertFrom-Json $result
+ $resultObj.Policy | Should -Match 'TestPrefix'
+ $resultObj.HomeDirectoryDetails | Should -Match 'TestPrefix'
+ }
+
+ It 'Uses Provided Value for HomeDirSuffix' {
+ $result = Get-SftpUserDefaultSecretString -BucketName 'TestPrefix' -HomeDirSuffix 'TestSuffix' -KmsArn 'TestKmsArn' -RoleArn 'TestRoleArn' -PasswordHash 'TestPassword'
+
+ $resultObj = ConvertFrom-Json $result
+ $resultObj.Policy | Should -Match 'TestSuffix'
+ $resultObj.HomeDirectoryDetails | Should -Match 'TestSuffix'
+ }
+ }
+ }
+}
diff --git a/Modules/Alkami.DevOps.SystemEngineering/Private/Get-YeatsLambdaIamRoleArn.ps1 b/Modules/Alkami.DevOps.SystemEngineering/Private/Get-YeatsLambdaIamRoleArn.ps1
new file mode 100644
index 0000000..d1dc4a1
--- /dev/null
+++ b/Modules/Alkami.DevOps.SystemEngineering/Private/Get-YeatsLambdaIamRoleArn.ps1
@@ -0,0 +1,52 @@
+function Get-YeatsLambdaIamRoleArn {
+<#
+.SYNOPSIS
+ Retrieves the environment-specific ARN for the Yeats rotation Lambda IAM role.
+
+.PARAMETER EnvironmentTag
+ [string] The 'alk:env' value for the Yeats Lambda IAM role name.
+
+.PARAMETER ProfileName
+ [string] The AWS profile to use during search operation.
+
+.EXAMPLE
+ Get-YeatsLambdaIamRoleArn -EnvironmentTag 'qashared' -ProfileName 'temp-qa'
+
+arn:aws:iam::668894625708:role/atlantis-generated-alk-qashared-services-yeats-lambdarole
+#>
+
+ [CmdletBinding()]
+ [OutputType([string])]
+ param (
+ [Parameter(Mandatory = $true)]
+ [ValidateNotNullOrEmpty()]
+ [string] $EnvironmentTag,
+
+ [Parameter(Mandatory = $true)]
+ [ValidateNotNullOrEmpty()]
+ [string] $ProfileName
+ )
+
+ $logLead = Get-LogLeadName
+ $result = $null
+
+ Import-AWSModule
+
+ # Build the role name. All of this is constant except for the environment designator.
+ $roleName = "atlantis-generated-alk-${EnvironmentTag}-services-yeats-lambdarole"
+ Write-Verbose "$logLead : Calculated Yeats Lambda IAM role name as '$roleName'"
+
+ try {
+
+ $role = Get-IamRole -RoleName $roleName -ProfileName $ProfileName
+ $result = $role.Arn
+
+ Write-Verbose "$logLead : Yeats Lambda IAM role ARN = '$result'"
+
+ } catch {
+
+ Write-Warning "$logLead : Unable to find Yeats Lambda IAM role named '$roleName' with provided input.`nError encountered was: '$PSItem'"
+ }
+
+ return $result
+}
diff --git a/Modules/Alkami.DevOps.SystemEngineering/Private/Get-YeatsLambdaIamRoleArn.tests.ps1 b/Modules/Alkami.DevOps.SystemEngineering/Private/Get-YeatsLambdaIamRoleArn.tests.ps1
new file mode 100644
index 0000000..6b5903c
--- /dev/null
+++ b/Modules/Alkami.DevOps.SystemEngineering/Private/Get-YeatsLambdaIamRoleArn.tests.ps1
@@ -0,0 +1,80 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$global:functionPath = Join-Path -Path $here -ChildPath $sut
+
+InModuleScope -ModuleName Alkami.DevOps.SystemEngineering -ScriptBlock {
+ Write-Host "InModuleScope - Overriding SUT: $global:functionPath"
+ Import-Module $global:functionPath -Force
+ $inScopeModule = "Alkami.DevOps.SystemEngineering"
+
+ Describe "Get-YeatsLambdaIamRoleArn" {
+
+ Mock -CommandName Get-LogLeadName -ModuleName $inScopeModule -MockWith { return 'Get-YeatsLambdaIamRoleArn.tests' }
+ Mock -CommandName Import-AWSModule -ModuleName $inScopeModule -MockWith {}
+ Mock -CommandName Write-Warning -ModuleName $inScopeModule -MockWith {}
+
+ Context "Parameter Validation" {
+
+ It "Throws if EnvironmentTag is Null" {
+ { Get-YeatsLambdaIamRoleArn -EnvironmentTag $null } | Should -Throw
+ }
+
+ It "Throws if EnvironmentName is Empty" {
+ { Get-YeatsLambdaIamRoleArn -EnvironmentTag '' } | Should -Throw
+ }
+
+ It "Throws if ProfileName is Null" {
+ { Get-YeatsLambdaIamRoleArn -EnvironmentTag "test" -ProfileName $null } | Should -Throw
+ }
+
+ It "Throws if ProfileName is Empty" {
+ { Get-YeatsLambdaIamRoleArn -EnvironmentTag "test" -ProfileName '' } | Should -Throw
+ }
+ }
+
+ Context "Logic" {
+
+ It "Returns a String" {
+
+ (Get-Command Get-YeatsLambdaIamRoleArn).OutputType.Type.ToString() | Should -BeExactly "System.String"
+ }
+
+ It "Does not Throw on AWS Exception" {
+
+ Mock -CommandName Get-IamRole -ModuleName $inScopeModule -MockWith { throw "This is a test" }
+
+ { Get-YeatsLambdaIamRoleArn -EnvironmentTag "test" -ProfileName 'temp-test' } | Should -Not -Throw
+ }
+
+ It "Writes Warning on AWS Exception" {
+
+ Mock -CommandName Get-IamRole -ModuleName $inScopeModule -MockWith { throw "This is a test" }
+
+ Get-YeatsLambdaIamRoleArn -EnvironmentTag "test" -ProfileName 'temp-test' | Out-Null
+
+ Assert-MockCalled -ModuleName $inScopeModule -CommandName Write-Warning -Times 1 -Exactly -Scope It `
+ -ParameterFilter { $Message -match 'Unable to find Yeats Lambda IAM role' }
+ }
+
+ It "Returns Null on AWS Exception" {
+
+ Mock -CommandName Get-IamRole -ModuleName $inScopeModule -MockWith { throw "This is a test" }
+
+ $result = Get-YeatsLambdaIamRoleArn -EnvironmentTag "test" -ProfileName 'temp-test'
+
+ $result | Should -BeNull
+ }
+
+ It "Returns ARN from AWS Results" {
+
+ $testString = "ThisIsAnArn"
+ Mock -CommandName Get-IamRole -ModuleName $inScopeModule -MockWith { return @{'Arn' = $testString} }
+
+ $result = Get-YeatsLambdaIamRoleArn -EnvironmentTag "test" -ProfileName 'temp-test'
+
+ $result | Should -BeExactly $testString
+ }
+ }
+ }
+}
diff --git a/Modules/Alkami.DevOps.SystemEngineering/Private/Get-YeatsRotationLambdaArn.ps1 b/Modules/Alkami.DevOps.SystemEngineering/Private/Get-YeatsRotationLambdaArn.ps1
new file mode 100644
index 0000000..85e8426
--- /dev/null
+++ b/Modules/Alkami.DevOps.SystemEngineering/Private/Get-YeatsRotationLambdaArn.ps1
@@ -0,0 +1,59 @@
+function Get-YeatsRotationLambdaArn {
+<#
+.SYNOPSIS
+ Retrieves the environment-specific ARN for the Yeats rotation Lambda.
+
+.PARAMETER EnvironmentTag
+ [string] The 'alk:env' value for the Yeats rotation Lambda name.
+
+.PARAMETER ProfileName
+ [string] The AWS profile to use during search operation.
+
+.PARAMETER Region
+ [string] The AWS region to use during search operation.
+
+.EXAMPLE
+ Get-YeatsRotationLambdaArn -EnvironmentTag 'devshared' -ProfileName 'temp-dev'
+
+arn:aws:lambda:us-east-1:327695573722:function:alk-devshared-yeats-process-rotation-event
+#>
+
+ [CmdletBinding()]
+ [OutputType([string])]
+ param (
+ [Parameter(Mandatory = $true)]
+ [ValidateNotNullOrEmpty()]
+ [string] $EnvironmentTag,
+
+ [Parameter(Mandatory = $true)]
+ [ValidateNotNullOrEmpty()]
+ [string] $ProfileName,
+
+ [Parameter(Mandatory = $false)]
+ [ValidateScript({$_ -in (Get-AWSRegion).region})]
+ [string] $Region = 'us-east-1'
+ )
+
+ $logLead = Get-LogLeadName
+ $result = $null
+
+ Import-AWSModule
+
+ # Build the Lambda name. All of this is constant except for the environment designator.
+ $lambdaName = "alk-${EnvironmentTag}-yeats-process-rotation-event"
+ Write-Verbose "$logLead : Calculated Yeats rotation Lambda name as '$lambdaName'"
+
+ try {
+
+ $lambda = Get-LMFunctionConfiguration -FunctionName $lambdaName -ProfileName $ProfileName -Region $Region
+ $result = $lambda.FunctionArn
+
+ Write-Verbose "$logLead : Yeats rotation Lambda ARN = '$result'"
+
+ } catch {
+
+ Write-Warning "$logLead : Unable to find Yeats rotation Lambda named '$lambdaName' with provided input.`nError encountered was: '$PSItem'"
+ }
+
+ return $result
+}
diff --git a/Modules/Alkami.DevOps.SystemEngineering/Private/Get-YeatsRotationLambdaArn.tests.ps1 b/Modules/Alkami.DevOps.SystemEngineering/Private/Get-YeatsRotationLambdaArn.tests.ps1
new file mode 100644
index 0000000..3e639bf
--- /dev/null
+++ b/Modules/Alkami.DevOps.SystemEngineering/Private/Get-YeatsRotationLambdaArn.tests.ps1
@@ -0,0 +1,85 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$global:functionPath = Join-Path -Path $here -ChildPath $sut
+
+InModuleScope -ModuleName Alkami.DevOps.SystemEngineering -ScriptBlock {
+ Write-Host "InModuleScope - Overriding SUT: $global:functionPath"
+ Import-Module $global:functionPath -Force
+ $inScopeModule = "Alkami.DevOps.SystemEngineering"
+
+ Describe "Get-YeatsRotationLambdaArn" {
+
+ Mock -CommandName Get-LogLeadName -ModuleName $inScopeModule -MockWith { return 'Get-YeatsRotationLambdaArn.tests' }
+ Mock -CommandName Get-AWSRegion -ModuleName $inScopeModule -MockWith { return @{region = 'us-east-1'} }
+ Mock -CommandName Import-AWSModule -ModuleName $inScopeModule -MockWith {}
+ Mock -CommandName Write-Warning -ModuleName $inScopeModule -MockWith {}
+
+ Context "Parameter Validation" {
+
+ It "Throws if EnvironmentTag is Null" {
+ { Get-YeatsRotationLambdaArn -EnvironmentTag $null } | Should -Throw
+ }
+
+ It "Throws if EnvironmentName is Empty" {
+ { Get-YeatsRotationLambdaArn -EnvironmentTag '' } | Should -Throw
+ }
+
+ It "Throws if ProfileName is Null" {
+ { Get-YeatsRotationLambdaArn -EnvironmentTag "test" -ProfileName $null } | Should -Throw
+ }
+
+ It "Throws if ProfileName is Empty" {
+ { Get-YeatsRotationLambdaArn -EnvironmentTag "test" -ProfileName '' } | Should -Throw
+ }
+
+ It "Throws if Region is Not In Validation List" {
+ { Get-YeatsRotationLambdaArn -EnvironmentTag "test" -ProfileName 'test' -Region 'us-west-2' } | Should -Throw
+ }
+ }
+
+ Context "Logic" {
+
+ It "Returns a String" {
+
+ (Get-Command Get-YeatsRotationLambdaArn).OutputType.Type.ToString() | Should -BeExactly "System.String"
+ }
+
+ It "Does not Throw on AWS Exception" {
+
+ Mock -CommandName Get-LMFunctionConfiguration -ModuleName $inScopeModule -MockWith { throw "This is a test" }
+
+ { Get-YeatsRotationLambdaArn -EnvironmentTag "test" -ProfileName 'temp-test' } | Should -Not -Throw
+ }
+
+ It "Writes Warning on AWS Exception" {
+
+ Mock -CommandName Get-LMFunctionConfiguration -ModuleName $inScopeModule -MockWith { throw "This is a test" }
+
+ Get-YeatsRotationLambdaArn -EnvironmentTag "test" -ProfileName 'temp-test' | Out-Null
+
+ Assert-MockCalled -ModuleName $inScopeModule -CommandName Write-Warning -Times 1 -Exactly -Scope It `
+ -ParameterFilter { $Message -match 'Unable to find Yeats rotation Lambda' }
+ }
+
+ It "Returns Null on AWS Exception" {
+
+ Mock -CommandName Get-LMFunctionConfiguration -ModuleName $inScopeModule -MockWith { throw "This is a test" }
+
+ $result = Get-YeatsRotationLambdaArn -EnvironmentTag "test" -ProfileName 'temp-test'
+
+ $result | Should -BeNull
+ }
+
+ It "Returns ARN from AWS Results" {
+
+ $testString = "ThisIsAnArn"
+ Mock -CommandName Get-LMFunctionConfiguration -ModuleName $inScopeModule -MockWith { return @{'FunctionArn' = $testString} }
+
+ $result = Get-YeatsRotationLambdaArn -EnvironmentTag "test" -ProfileName 'temp-test'
+
+ $result | Should -BeExactly $testString
+ }
+ }
+ }
+}
diff --git a/Modules/Alkami.DevOps.SystemEngineering/Private/New-ServerlessServiceAccountActiveDirectoryUserPair.ps1 b/Modules/Alkami.DevOps.SystemEngineering/Private/New-ServerlessServiceAccountActiveDirectoryUserPair.ps1
new file mode 100644
index 0000000..e9c3d93
--- /dev/null
+++ b/Modules/Alkami.DevOps.SystemEngineering/Private/New-ServerlessServiceAccountActiveDirectoryUserPair.ps1
@@ -0,0 +1,131 @@
+function New-ServerlessServiceAccountActiveDirectoryUserPair {
+<#
+.SYNOPSIS
+ Create a pair of serverless service accounts in Active Directory.
+
+.DESCRIPTION
+ Creates a pair of serverless service accounts in Active Directory and adds the newly
+ created accounts to the SQL access security group for the appropriate environment.
+
+.PARAMETER Cred
+ [PSCredential] A credential object for the FH user that has permissions to create new accounts
+ on the domain.
+
+.PARAMETER UserDataList
+ [PSCredential[]] An array with exactly two credential objects containing the usernames and passwords to use
+ during account creation.
+
+.PARAMETER UserOuPathCommon
+ [string] The OU Path on the domain to create the users within. Formatted as a filesystem path
+ by the calling module to synchronize the AD implementation with the AWS Secrets Manager implementation.
+
+.PARAMETER Environment
+ [string] The target environment for the user accounts.
+
+.PARAMETER TicketNumber
+ [string] The Jira ticket identifier requesting the new accounts.
+
+.EXAMPLE
+ New-ServerlessServiceAccountActiveDirectoryUserPair -Cred (Get-AlkamiCredential) `
+ -UserDataList @(( Get-AlkamiCredential -UserName 'ExampleA' -Password 'ExampleA!1Pass'), ( Get-AlkamiCredential -UserName 'ExampleB' -Password 'ExampleB!1Pass')) `
+ -UserOuPathCommon '/ServiceAccounts/Dev' `
+ -Environment 'Dev' `
+ -TicketNumber 'SYSENG-1234'
+#>
+ [CmdletBinding()]
+ param (
+ [Parameter(Mandatory = $true)]
+ [PSCredential] $Cred,
+
+ [Parameter(Mandatory = $true)]
+ [ValidateCount(2, 2)]
+ [PSCredential[]] $UserDataList,
+
+ [Parameter(Mandatory = $true)]
+ [ValidateNotNullOrEmpty()]
+ [string] $UserOuPathCommon,
+
+ [Parameter(Mandatory = $true)]
+ [ValidateSet('Dev', 'Qa', 'LoadTest', 'Staging', 'Prod', IgnoreCase = $false)]
+ [string] $Environment,
+
+ [Parameter(Mandatory = $true)]
+ [ValidatePattern("^[a-z]+-\d+$")]
+ [string]$TicketNumber
+ )
+
+ $logLead = (Get-LogLeadName)
+ $domainName = "fh.local"
+ $domainNameDn = Get-DomainNameDistinguishedName $domainName
+ $serviceAccountGroupOuPath = "OU=ServiceAccounts,OU=${Environment},OU=SecurityGroups,${domainNameDn}"
+
+ # Convert the user OU Path into DistinguisedName format
+ $userOuDnParts = $UserOuPathCommon.Split('/', [System.StringSplitOptions]::RemoveEmptyEntries) | ForEach-Object { "OU=$_" }
+ [array]::Reverse($userOuDnParts)
+ $userOuDn = ( "{0},{1}" -f ($userOuDnParts -join ','), $domainNameDn)
+ Write-Verbose "$logLead : Calculated user OU DistinguishedName as '$userOuDn'"
+
+ # Get the required security group for the user or die trying.
+ $sqlGroupName = "SQL-$Environment-ServerlessApplicationServices"
+ $sqlGroup = Get-ADGroup -filter { GroupCategory -eq "Security" -and Name -eq $sqlGroupName } `
+ -SearchBase $serviceAccountGroupOuPath `
+ -Server $domainName `
+ -Credential $Cred
+ if ( $null -eq $sqlGroup ) {
+
+ $errorMsg = "$logLead : Unable to find Active Directory group named '$sqlGroupName'; verify AD configuration."
+ Write-Error $errorMsg
+ throw $errorMsg
+ }
+
+ # Pre-flight that the users do not exist; die early if they do.
+ foreach ( $userAcct in $UserDataList ) {
+
+ $userName = $userAcct.UserName
+ $userSearchResults = Get-ADUser -Filter { Name -eq $userName } -Credential $Cred -Server $domainName -SearchBase $userOuDn
+ if ( $null -ne $userSearchResults ) {
+
+ $errorMsg = "$logLead : Found pre-existing user named '$userName' at '$userOuDn'; aborting."
+ Write-Error $errorMsg
+ throw $errorMsg
+ }
+ }
+
+ Write-Host "$logLead : Creating accounts in security group $sqlGroupName"
+ foreach ($userAcct in $UserDataList) {
+
+ $userName = $userAcct.UserName
+
+ try {
+
+ Write-Verbose "$logLead : Creating User '$userName'"
+ New-ADUser -AccountPassword $userAcct.Password `
+ -CannotChangePassword $false `
+ -ChangePasswordAtLogon $false `
+ -Credential $Cred `
+ -Description $TicketNumber `
+ -Enabled $true `
+ -Name $userName `
+ -PasswordNeverExpires $false `
+ -PasswordNotRequired $false `
+ -Path $userOuDn `
+ -SamAccountName $userName `
+ -Server $domainName `
+ -UserPrincipalName "$userName@$domainName"
+
+ # Add the new account to the SQL group.
+ Add-ADGroupMember -Identity $sqlGroup -Members $userName
+
+ # Add the new account to the Disable Interactive Logon group.
+ Add-ADGroupMember -Identity "Disable Interactive Logon" -Members $userName
+
+ Write-Host "$logLead : Created user '$userName'"
+
+ } catch {
+
+ $errorMsg = "$logLead : Creation of user '$userName' failed. Error encountered was: [$PSItem]"
+ Write-Error $errorMsg
+ throw $errorMsg
+ }
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.SystemEngineering/Private/New-ServerlessServiceAccountActiveDirectoryUserPair.tests.ps1 b/Modules/Alkami.DevOps.SystemEngineering/Private/New-ServerlessServiceAccountActiveDirectoryUserPair.tests.ps1
new file mode 100644
index 0000000..72215f0
--- /dev/null
+++ b/Modules/Alkami.DevOps.SystemEngineering/Private/New-ServerlessServiceAccountActiveDirectoryUserPair.tests.ps1
@@ -0,0 +1,125 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$global:functionPath = Join-Path -Path $here -ChildPath $sut
+
+InModuleScope -ModuleName Alkami.DevOps.SystemEngineering -ScriptBlock {
+ Write-Host "InModuleScope - Overriding SUT: $global:functionPath"
+ Import-Module $global:functionPath -Force
+ $inScopeModule = "Alkami.DevOps.SystemEngineering"
+
+ Describe "New-ServerlessServiceAccountActiveDirectoryUserPair" {
+
+ Mock -CommandName Get-LogLeadName -ModuleName $inScopeModule -MockWith { return 'New-ServerlessServiceAccountActiveDirectoryUserPair.tests' }
+ Mock -CommandName Get-DomainNameDistinguishedName -ModuleName $inScopeModule -MockWith { return 'DC=fh,DC=local' }
+ Mock -CommandName Write-Error -ModuleName $inScopeModule -MockWith {}
+ Mock -CommandName Add-ADGroupMember -ModuleName $inScopeModule -MockWith {}
+
+ $testCredential = New-Object 'Management.Automation.PsCredential' 'Test', ( ConvertTo-SecureString -AsPlainText -Force -String 'Test' )
+
+ $testList = @()
+ $testList += ( New-Object 'Management.Automation.PsCredential' 'test1', ( ConvertTo-SecureString -AsPlainText -Force -String 'test1' ))
+ $testList += ( New-Object 'Management.Automation.PsCredential' 'test2', ( ConvertTo-SecureString -AsPlainText -Force -String 'test2' ))
+
+ Context "Parameter Validation" {
+
+ It "Throws if UserDataList has too few elements" {
+ { New-ServerlessServiceAccountActiveDirectoryUserPair -Cred $testCredential -UserDataList @() } | Should -Throw
+ }
+
+ It "Throws if UserDataList has too many elements" {
+ $badTestList = @()
+ $badTestList += ( New-Object 'Management.Automation.PsCredential' 'test1', ( ConvertTo-SecureString -AsPlainText -Force -String 'test1' ))
+ $badTestList += ( New-Object 'Management.Automation.PsCredential' 'test2', ( ConvertTo-SecureString -AsPlainText -Force -String 'test2' ))
+ $badTestList += ( New-Object 'Management.Automation.PsCredential' 'test3', ( ConvertTo-SecureString -AsPlainText -Force -String 'test3' ))
+
+ { New-ServerlessServiceAccountActiveDirectoryUserPair -Cred $testCredential -UserDataList $badTestList } | Should -Throw
+ }
+
+ It "Throws if UserOuPathCommon Is Null" {
+ { New-ServerlessServiceAccountActiveDirectoryUserPair -Cred $testCredential -UserDataList $testList -UserOuPathCommon $null } | Should -Throw
+ }
+
+ It "Throws if UserOuPathCommon Is Empty" {
+ { New-ServerlessServiceAccountActiveDirectoryUserPair -Cred $testCredential -UserDataList $testList -UserOuPathCommon '' } | Should -Throw
+ }
+
+ It "Throws if Environment Is Not In Approved List" {
+ { New-ServerlessServiceAccountActiveDirectoryUserPair -Cred $testCredential -UserDataList $testList -UserOuPathCommon 'Test' -Environment 'Test' } | Should -Throw
+ }
+
+ It "Throws if TicketNumber Does Not Match Regex" {
+ { New-ServerlessServiceAccountActiveDirectoryUserPair -Cred $testCredential -UserDataList $testList -UserOuPathCommon 'Test' -Environment 'Dev' -TicketNumber 'Test!' } | Should -Throw
+ }
+ }
+
+ Context "Logic" {
+
+ It "Writes Error and Throws if SQL Group Not Found" {
+
+ Mock -CommandName Get-ADGroup -ModuleName $inScopeModule -MockWith { return $null }
+ Mock -CommandName Get-ADUser -ModuleName $inScopeModule -MockWith { return $null }
+ Mock -CommandName New-ADUser -ModuleName $inScopeModule -MockWith {}
+
+ { New-ServerlessServiceAccountActiveDirectoryUserPair -Cred $testCredential -UserDataList $testList -UserOuPathCommon 'Test' `
+ -Environment 'Dev' -TicketNumber 'Test-123' } | Should -Throw "Unable to find Active Directory group"
+
+ Assert-MockCalled -ModuleName $inScopeModule -CommandName Write-Error -Times 1 -Exactly -Scope It `
+ -ParameterFilter { $Message -match 'Unable to find Active Directory group named' }
+ Assert-MockCalled -ModuleName $inScopeModule -CommandName Get-ADGroup -Times 1 -Exactly -Scope It
+ Assert-MockCalled -ModuleName $inScopeModule -CommandName Get-ADUser -Times 0 -Exactly -Scope It
+ Assert-MockCalled -ModuleName $inScopeModule -CommandName New-ADUser -Times 0 -Exactly -Scope It
+ Assert-MockCalled -ModuleName $inScopeModule -CommandName Add-ADGroupMember -Times 0 -Exactly -Scope It
+ }
+
+ It "Writes Error and Throws if User Already Exists" {
+
+ Mock -CommandName Get-ADGroup -ModuleName $inScopeModule -MockWith { return $true }
+ Mock -CommandName Get-ADUser -ModuleName $inScopeModule -MockWith { return $true }
+ Mock -CommandName New-ADUser -ModuleName $inScopeModule -MockWith {}
+
+ { New-ServerlessServiceAccountActiveDirectoryUserPair -Cred $testCredential -UserDataList $testList -UserOuPathCommon 'Test' `
+ -Environment 'Dev' -TicketNumber 'Test-123' } | Should -Throw "Found pre-existing user"
+
+ Assert-MockCalled -ModuleName $inScopeModule -CommandName Write-Error -Times 1 -Exactly -Scope It `
+ -ParameterFilter { $Message -match 'Found pre-existing user named' }
+ Assert-MockCalled -ModuleName $inScopeModule -CommandName Get-ADGroup -Times 1 -Exactly -Scope It
+ Assert-MockCalled -ModuleName $inScopeModule -CommandName Get-ADUser -Times 1 -Exactly -Scope It
+ Assert-MockCalled -ModuleName $inScopeModule -CommandName New-ADUser -Times 0 -Exactly -Scope It
+ Assert-MockCalled -ModuleName $inScopeModule -CommandName Add-ADGroupMember -Times 0 -Exactly -Scope It
+ }
+
+ It "Writes Error and Throws if User Creation Fails" {
+
+ Mock -CommandName Get-ADGroup -ModuleName $inScopeModule -MockWith { return $true }
+ Mock -CommandName Get-ADUser -ModuleName $inScopeModule -MockWith { return $null }
+ Mock -CommandName New-ADUser -ModuleName $inScopeModule -MockWith { throw "Test" }
+
+ { New-ServerlessServiceAccountActiveDirectoryUserPair -Cred $testCredential -UserDataList $testList -UserOuPathCommon 'Test' `
+ -Environment 'Dev' -TicketNumber 'Test-123' } | Should -Throw "Creation of user 'test1' failed"
+
+ Assert-MockCalled -ModuleName $inScopeModule -CommandName Write-Error -Times 1 -Exactly -Scope It `
+ -ParameterFilter { $Message -match "Creation of user 'test1' failed" }
+ Assert-MockCalled -ModuleName $inScopeModule -CommandName Get-ADGroup -Times 1 -Exactly -Scope It
+ Assert-MockCalled -ModuleName $inScopeModule -CommandName Get-ADUser -Times 2 -Exactly -Scope It
+ Assert-MockCalled -ModuleName $inScopeModule -CommandName New-ADUser -Times 1 -Exactly -Scope It
+ Assert-MockCalled -ModuleName $inScopeModule -CommandName Add-ADGroupMember -Times 0 -Exactly -Scope It
+ }
+
+ It "Creates Users and Adds Users to Group" {
+
+ Mock -CommandName Get-ADGroup -ModuleName $inScopeModule -MockWith { return New-Object Microsoft.ActiveDirectory.Management.ADPrincipal }
+ Mock -CommandName Get-ADUser -ModuleName $inScopeModule -MockWith { return $null }
+ Mock -CommandName New-ADUser -ModuleName $inScopeModule -MockWith {}
+
+ New-ServerlessServiceAccountActiveDirectoryUserPair -Cred $testCredential -UserDataList $testList -UserOuPathCommon 'Test' -Environment 'Dev' -TicketNumber 'Test-123'
+
+ Assert-MockCalled -ModuleName $inScopeModule -CommandName Write-Error -Times 0 -Exactly -Scope It
+ Assert-MockCalled -ModuleName $inScopeModule -CommandName Get-ADGroup -Times 1 -Exactly -Scope It
+ Assert-MockCalled -ModuleName $inScopeModule -CommandName Get-ADUser -Times 2 -Exactly -Scope It
+ Assert-MockCalled -ModuleName $inScopeModule -CommandName New-ADUser -Times 2 -Exactly -Scope It
+ Assert-MockCalled -ModuleName $inScopeModule -CommandName Add-ADGroupMember -Times 4 -Exactly -Scope It
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.SystemEngineering/Private/New-ServerlessServiceAccountIamPolicy.ps1 b/Modules/Alkami.DevOps.SystemEngineering/Private/New-ServerlessServiceAccountIamPolicy.ps1
new file mode 100644
index 0000000..b9c000b
--- /dev/null
+++ b/Modules/Alkami.DevOps.SystemEngineering/Private/New-ServerlessServiceAccountIamPolicy.ps1
@@ -0,0 +1,52 @@
+function New-ServerlessServiceAccountIamPolicy {
+<#
+.SYNOPSIS
+ Creates and configures an AWS IAM inline policy for an IAM role
+ that grants read access to the specified secrets.
+
+.PARAMETER RoleArn
+ [string] The pre-existing IAM role ARN.
+
+.PARAMETER ProfileName
+ [string] The AWS profile to use during policy creation.
+
+.PARAMETER Region
+ [string] The AWS region to use during policy creation.
+
+.PARAMETER SecretArns
+ [string[]] An array of AWS Secrets Manager secret ARNs to grant access to in the IAM policy.
+
+.EXAMPLE
+#>
+ [CmdletBinding()]
+ param (
+ [Parameter(Mandatory = $true)]
+ [ValidateNotNullOrEmpty()]
+ [string] $RoleArn,
+
+ [Parameter(Mandatory = $true)]
+ [ValidateNotNullOrEmpty()]
+ [string] $ProfileName,
+
+ [Parameter(Mandatory = $true)]
+ [ValidateScript({$_ -in (Get-AWSRegion).region})]
+ [string] $Region,
+
+ [Parameter(Mandatory = $true)]
+ [ValidateNotNullOrEmpty()]
+ [string[]] $SecretArns
+ )
+
+ Import-AWSModule
+
+ $inlinePolicy = Get-ServerlessServiceAccountIamPolicyString -SecretArns $SecretArns
+
+ # AWS PowerShell expects the role name, not ARN.
+ $roleName = $RoleArn.Split("/")[-1]
+
+ Write-IAMRolePolicy -RoleName $roleName `
+ -PolicyName "account-secret-access-inline-policy" `
+ -PolicyDocument $inlinePolicy `
+ -ProfileName $ProfileName `
+ -Region $Region
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.SystemEngineering/Private/New-ServerlessServiceAccountIamPolicy.tests.ps1 b/Modules/Alkami.DevOps.SystemEngineering/Private/New-ServerlessServiceAccountIamPolicy.tests.ps1
new file mode 100644
index 0000000..76d9b73
--- /dev/null
+++ b/Modules/Alkami.DevOps.SystemEngineering/Private/New-ServerlessServiceAccountIamPolicy.tests.ps1
@@ -0,0 +1,74 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$global:functionPath = Join-Path -Path $here -ChildPath $sut
+
+InModuleScope -ModuleName Alkami.DevOps.SystemEngineering -ScriptBlock {
+ Write-Host "InModuleScope - Overriding SUT: $global:functionPath"
+ Import-Module $global:functionPath -Force
+ $inScopeModule = "Alkami.DevOps.SystemEngineering"
+
+ Describe "New-ServerlessServiceAccountIamPolicy" {
+
+ Mock -CommandName Get-AWSRegion -ModuleName $inScopeModule -MockWith { return @( @{ 'Region' = 'us-east-1' } ) }
+ Mock -CommandName Import-AWSModule -ModuleName $inScopeModule -MockWith {}
+ Mock -CommandName Get-ServerlessServiceAccountIamPolicyString -ModuleName $inScopeModule -MockWith { return 'testInlinePolicy' }
+ Mock -CommandName Write-IAMRolePolicy -ModuleName $inScopeModule -MockWith {}
+
+ Context "Parameter Validation" {
+
+ It "Throws if RoleArn Is Null" {
+ { New-ServerlessServiceAccountIamPolicy -RoleArn $null } | Should -Throw
+ }
+
+ It "Throws if RoleArn Is Empty" {
+ { New-ServerlessServiceAccountIamPolicy -RoleArn '' } | Should -Throw
+ }
+
+ It "Throws if ProfileName Is Null" {
+ { New-ServerlessServiceAccountIamPolicy -RoleArn 'TestRole' -ProfileName $null } | Should -Throw
+ }
+
+ It "Throws if ProfileName Is Empty" {
+ { New-ServerlessServiceAccountIamPolicy -RoleArn 'TestRole' -ProfileName '' } | Should -Throw
+ }
+
+ It "Throws if Region Is Not In Allowable List" {
+ { New-ServerlessServiceAccountIamPolicy -RoleArn 'TestRole' -ProfileName 'TestProfile' -Region 'Test' } | Should -Throw
+ }
+
+ It "Throws if SecretArns Is Null" {
+ { New-ServerlessServiceAccountIamPolicy -RoleArn 'TestRole' -ProfileName 'TestProfile' -Region 'us-east-1' `
+ -SecretArns $null } | Should -Throw
+ }
+
+ It "Throws if SecretArns Is Empty" {
+ { New-ServerlessServiceAccountIamPolicy -RoleArn 'TestRole' -ProfileName 'TestProfile' -Region 'us-east-1' `
+ -SecretArns @() } | Should -Throw
+ }
+ }
+
+ Context "Logic" {
+
+ It "Proxies Supplied Secret Arns to Handling Function" {
+
+ $testArns = @( 'TestArn1', 'TestArn2')
+
+ New-ServerlessServiceAccountIamPolicy -RoleArn 'arn:aws::iam/thisisanarn/TestName' -ProfileName 'TestProfile' -Region 'us-east-1' `
+ -SecretArns $testArns
+
+ Assert-MockCalled -ModuleName $inScopeModule -CommandName Get-ServerlessServiceAccountIamPolicyString -Times 1 -Exactly -Scope It `
+ -ParameterFilter { ($null -eq (Compare-Object $SecretArns $testArns)) }
+ }
+
+ It "Applies Inline Policy to the Supplied Role" {
+
+ New-ServerlessServiceAccountIamPolicy -RoleArn 'arn:aws::iam/thisisanarn/TestName' -ProfileName 'TestProfile' -Region 'us-east-1' `
+ -SecretArns @( 'TestArn' )
+
+ Assert-MockCalled -ModuleName $inScopeModule -CommandName Write-IAMRolePolicy -Times 1 -Exactly -Scope It `
+ -ParameterFilter { $RoleName -ceq 'TestName' }
+ }
+ }
+ }
+}
diff --git a/Modules/Alkami.DevOps.SystemEngineering/Private/New-ServerlessServiceAccountSecret.ps1 b/Modules/Alkami.DevOps.SystemEngineering/Private/New-ServerlessServiceAccountSecret.ps1
new file mode 100644
index 0000000..99771b0
--- /dev/null
+++ b/Modules/Alkami.DevOps.SystemEngineering/Private/New-ServerlessServiceAccountSecret.ps1
@@ -0,0 +1,118 @@
+function New-ServerlessServiceAccountSecret {
+<#
+.SYNOPSIS
+ Creates and configures an AWS Secrets Manager secret for serverless service accounts.
+
+.DESCRIPTION
+ Creates and configures an AWS Secrets Manager secret for serverless service accounts.
+ The first account will be in secret version 'AWSPREVIOUS' and the second account will be in
+ secret version 'AWSCURRENT'.
+
+ Configures replication on the created secret if the replication region is non-null, non-empty.
+
+ Does not apply a resource policy to the created secret; this will need to be applied after creation.
+
+.PARAMETER SecretName
+ [string] The name of the secret to create.
+
+.PARAMETER UserDataList
+ [PSCredential[]] An array with exactly two credential objects containing the usernames and passwords to use
+ during account creation.
+
+.PARAMETER EnvironmentTag
+ [string] The 'alk:env' tag value to apply to the created secret.
+
+.PARAMETER ProfileName
+ [string] The AWS profile to use during secret creation.
+
+.PARAMETER Region
+ [string] The AWS region to use during user creation.
+
+.PARAMETER ReplicationRegion
+ [string] The target AWS region for replicated AWS Secrets Manager secrets.
+ To disable replication, set this value to null or empty.
+
+.PARAMETER Description
+ [string] The description of the created secret.
+
+.EXAMPLE
+ New-ServerlessServiceAccountSecret -SecretName 'Example' `
+ -UserDataList @(( Get-AlkamiCredential -UserName 'ExampleA' -Password 'ExampleA!1Pass'), ( Get-AlkamiCredential -UserName 'ExampleB' -Password 'ExampleB!1Pass')) `
+ -EnvironmentTag 'prodshared' `
+ -ProfileName 'temp-prod' `
+ -Region 'us-east-1' `
+ -ReplicationRegion 'us-west-2' `
+ -Description 'Example secret'
+#>
+ [OutputType([string[]])]
+ [CmdletBinding()]
+ param (
+ [Parameter(Mandatory = $true)]
+ [ValidateNotNullOrEmpty()]
+ [string] $SecretName,
+
+ [Parameter(Mandatory = $true)]
+ [ValidateCount(2, 2)]
+ [PSCredential[]] $UserDataList,
+
+ [Parameter(Mandatory = $true)]
+ [ValidateNotNullOrEmpty()]
+ [string] $EnvironmentTag,
+
+ [Parameter(Mandatory = $true)]
+ [ValidateNotNullOrEmpty()]
+ [string] $ProfileName,
+
+ [Parameter(Mandatory = $true)]
+ [ValidateScript({$_ -in (Get-AWSRegion).region})]
+ [string] $Region,
+
+ [Parameter(Mandatory = $true)]
+ [AllowNull()]
+ [AllowEmptyString()]
+ [ValidateScript({([String]::IsNullOrEmpty($_) -or ($_ -in (Get-AWSRegion).region))})]
+ [string] $ReplicationRegion,
+
+ [Parameter(Mandatory = $true)]
+ [ValidateNotNullOrEmpty()]
+ [string] $Description
+ )
+
+ Import-AWSModule
+
+ $logLead = (Get-LogLeadName)
+ $result = @()
+
+ # Create the Secrets Manager object in AWS with the first user's data.
+ Write-Verbose ( "{0} : Creating secret named '{1}' with {2} data" -f $logLead, $SecretName, $UserDataList[0].UserName )
+ $secretString = (ConvertTo-Json -InputObject @{ 'username' = $UserDataList[0].UserName ; 'password' = (Get-PasswordFromCredential $UserDataList[0]) } -Compress -Depth 10)
+ $tag1 = New-Object -TypeName PSObject -Property @{ Key="alk:project" ; Value="orb" }
+ $tag2 = New-Object -TypeName PSObject -Property @{ Key="alk:service" ; Value="serverless" }
+ $tag3 = New-Object -TypeName PSObject -Property @{ Key="alk:env" ; Value="$EnvironmentTag" }
+ $response = New-SECSecret -SecretString $secretString -Name $SecretName -Description $Description -Tag @($tag1,$tag2, $tag3) `
+ -ProfileName $ProfileName -Region $Region
+
+ $result += $response.ARN
+
+ # Write the second user's data to the existing secret.
+ # AWS will manage the AWSPREVIOUS and AWSCURRENT tags with this call, setting
+ # us up for future secret rotation efforts.
+ Write-Verbose ( "{0} : Updating secret value for '{1}' with {2} data" -f $logLead, $SecretName, $UserDataList[1].UserName )
+ $secretString = (ConvertTo-Json -InputObject @{ 'username' = $UserDataList[1].UserName ; 'password' = (Get-PasswordFromCredential $UserDataList[1]) } -Compress -Depth 10)
+ Write-SECSecretValue -SecretString $secretString -SecretId $SecretName -ProfileName $ProfileName -Region $Region | Out-Null
+
+ # Enable replication on the secret (if the operator didn't turn off that feature).
+ if ( $false -eq [string]::IsNullOrWhitespace( $ReplicationRegion )) {
+
+ $replicaConfig = New-Object Amazon.SecretsManager.Model.ReplicaRegionType
+ $replicaConfig.Region = $ReplicationRegion
+
+ Write-Verbose "$logLead : Replicating newly created secret to $ReplicationRegion."
+ $response = Add-SECSecretToRegion -SecretId $SecretName -AddReplicaRegion @($replicaConfig) -ProfileName $ProfileName -Region $Region
+
+ # AWS returns the original secret's ARN, not the replicated secret's ARN. Luckily, the only thing different about the ARN is the region.
+ $result += ( $response.ARN -replace $Region, $ReplicationRegion )
+ }
+
+ return $result
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.SystemEngineering/Private/New-ServerlessServiceAccountSecret.tests.ps1 b/Modules/Alkami.DevOps.SystemEngineering/Private/New-ServerlessServiceAccountSecret.tests.ps1
new file mode 100644
index 0000000..ce399a6
--- /dev/null
+++ b/Modules/Alkami.DevOps.SystemEngineering/Private/New-ServerlessServiceAccountSecret.tests.ps1
@@ -0,0 +1,169 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$global:functionPath = Join-Path -Path $here -ChildPath $sut
+
+InModuleScope -ModuleName Alkami.DevOps.SystemEngineering -ScriptBlock {
+ Write-Host "InModuleScope - Overriding SUT: $global:functionPath"
+ Import-Module $global:functionPath -Force
+ $inScopeModule = "Alkami.DevOps.SystemEngineering"
+
+ Describe "New-ServerlessServiceAccountSecret" {
+
+ Mock -CommandName Get-LogLeadName -ModuleName $inScopeModule -MockWith { return 'New-ServerlessServiceAccountSecret.tests' }
+ Mock -CommandName Get-AWSRegion -ModuleName $inScopeModule -MockWith { return @( @{ 'Region' = 'us-east-1' } ) }
+ Mock -CommandName Import-AWSModule -ModuleName $inScopeModule -MockWith {}
+ Mock -CommandName New-SECSecret -ModuleName $inScopeModule -MockWith { return @{ ARN = 'TestSecretArn' } }
+ Mock -CommandName Write-SECSecretValue -ModuleName $inScopeModule -MockWith {}
+ Mock -CommandName Add-SECSecretToRegion -ModuleName $inScopeModule -MockWith { return @{ ARN = 'TestSecretArn' } }
+
+ $testList = @()
+ $testList += ( New-Object 'Management.Automation.PsCredential' 'test1', ( ConvertTo-SecureString -AsPlainText -Force -String 'test1' ))
+ $testList += ( New-Object 'Management.Automation.PsCredential' 'test2', ( ConvertTo-SecureString -AsPlainText -Force -String 'test2' ))
+
+ Context "Parameter Validation" {
+
+ It "Throws if SecretName Is Null" {
+ { New-ServerlessServiceAccountSecret -SecretName $Null } | Should -Throw
+ }
+
+ It "Throws if SecretName Is Empty" {
+ { New-ServerlessServiceAccountSecret -SecretName '' } | Should -Throw
+ }
+
+ It "Throws if UserDataList has too few elements" {
+ { New-ServerlessServiceAccountSecret -SecretName 'Test' -UserDataList @() } | Should -Throw
+ }
+
+ It "Throws if UserDataList has too many elements" {
+ $badTestList = @()
+ $badTestList += ( New-Object 'Management.Automation.PsCredential' 'test1', ( ConvertTo-SecureString -AsPlainText -Force -String 'test1' ))
+ $badTestList += ( New-Object 'Management.Automation.PsCredential' 'test2', ( ConvertTo-SecureString -AsPlainText -Force -String 'test2' ))
+ $badTestList += ( New-Object 'Management.Automation.PsCredential' 'test3', ( ConvertTo-SecureString -AsPlainText -Force -String 'test3' ))
+
+ { New-ServerlessServiceAccountSecret -SecretName 'Test' -UserDataList $badTestList } | Should -Throw
+ }
+
+ It "Throws if EnvironmentTag Is Null" {
+ { New-ServerlessServiceAccountSecret -SecretName 'Test' -UserDataList $testList -EnvironmentTag $null } | Should -Throw
+ }
+
+ It "Throws if EnvironmentTag Is Empty" {
+ { New-ServerlessServiceAccountSecret -SecretName 'Test' -UserDataList $testList -EnvironmentTag '' } | Should -Throw
+ }
+
+ It "Throws if ProfileName Is Null" {
+ { New-ServerlessServiceAccountSecret -SecretName 'Test' -UserDataList $testList -EnvironmentTag 'test' -ProfileName $null } | Should -Throw
+ }
+
+ It "Throws if ProfileName Is Empty" {
+ { New-ServerlessServiceAccountSecret -SecretName 'Test' -UserDataList $testList -EnvironmentTag 'test' -ProfileName '' } | Should -Throw
+ }
+
+ It "Throws if Region Is Not In Allowable List" {
+ { New-ServerlessServiceAccountSecret -SecretName 'Test' -UserDataList $testList -EnvironmentTag 'test' -ProfileName 'temp-test' -Region 'Test' } | Should -Throw
+ }
+
+ It "Throws if ReplicationRegion Is Not In Allowable List" {
+ { New-ServerlessServiceAccountSecret -SecretName 'Test' -UserDataList $testList -EnvironmentTag 'test' -ProfileName 'temp-test' -Region 'us-east-1' `
+ -ReplicationRegion 'Test' } | Should -Throw
+ }
+
+ It "Throws if Description Is Null" {
+ { New-ServerlessServiceAccountSecret -SecretName 'Test' -UserDataList $testList -EnvironmentTag 'test' -ProfileName 'temp-test' -Region 'us-east-1' `
+ -ReplicationRegion 'us-east-1' -Description $null } | Should -Throw
+ }
+
+ It "Throws if Description Is Empty" {
+ { New-ServerlessServiceAccountSecret -SecretName 'Test' -UserDataList $testList -EnvironmentTag 'test' -ProfileName 'temp-test' -Region 'us-east-1' `
+ -ReplicationRegion 'us-east-1' -Description '' } | Should -Throw
+ }
+ }
+
+ Context "Logic" {
+
+ It "Returns an Array of Strings" {
+
+ (Get-Command New-ServerlessServiceAccountSecret).OutputType.Type.ToString() | Should -BeExactly "System.String[]"
+ }
+
+ It "Creates Secret Using Supplied Arguments" {
+
+ New-ServerlessServiceAccountSecret -SecretName 'TestName' -UserDataList $testList -EnvironmentTag 'test' -ProfileName 'temp-test' -Region 'us-east-1' `
+ -ReplicationRegion '' -Description 'TestDescription' | Out-Null
+
+ Assert-MockCalled -ModuleName $inScopeModule -CommandName New-SECSecret -Times 1 -Exactly -Scope It `
+ -ParameterFilter { (($Name -ceq 'TestName') -and ($Description -ceq 'TestDescription') -and `
+ ($ProfileName -ceq 'temp-test') -and ($Region -ceq 'us-east-1')) }
+ }
+
+ It "Applies Tags to the Created Secret" {
+
+ New-ServerlessServiceAccountSecret -SecretName 'TestName' -UserDataList $testList -EnvironmentTag 'test' -ProfileName 'temp-test' -Region 'us-east-1' `
+ -ReplicationRegion '' -Description 'TestDescription' | Out-Null
+
+ Assert-MockCalled -ModuleName $inScopeModule -CommandName New-SECSecret -Times 1 -Exactly -Scope It `
+ -ParameterFilter { $Tag.Count -gt 0 }
+ }
+
+ It "Creates Secret Using First User's Data" {
+
+ New-ServerlessServiceAccountSecret -SecretName 'Test' -UserDataList $testList -EnvironmentTag 'test' -ProfileName 'temp-test' -Region 'us-east-1' `
+ -ReplicationRegion '' -Description 'Test' | Out-Null
+
+ Assert-MockCalled -ModuleName $inScopeModule -CommandName New-SECSecret -Times 1 -Exactly -Scope It `
+ -ParameterFilter { $SecretString -match "test1" }
+ }
+
+ It "Updates Secret Value Using Second User's Data" {
+
+ New-ServerlessServiceAccountSecret -SecretName 'Test' -UserDataList $testList -EnvironmentTag 'test' -ProfileName 'temp-test' -Region 'us-east-1' `
+ -ReplicationRegion '' -Description 'Test' | Out-Null
+
+ Assert-MockCalled -ModuleName $inScopeModule -CommandName Write-SECSecretValue -Times 1 -Exactly -Scope It `
+ -ParameterFilter { $SecretString -match "test2" }
+ }
+
+ It "Applies Replication Policy If Replication Region Supplied" {
+
+ New-ServerlessServiceAccountSecret -SecretName 'Test' -UserDataList $testList -EnvironmentTag 'test' -ProfileName 'temp-test' -Region 'us-east-1' `
+ -ReplicationRegion 'us-east-1' -Description 'Test' | Out-Null
+
+ Assert-MockCalled -ModuleName $inScopeModule -CommandName Add-SECSecretToRegion -Times 1 -Exactly -Scope It `
+ -ParameterFilter { $AddReplicaRegion[0].Region -ceq 'us-east-1' }
+ }
+
+ It "Returns Two ARNs If Replication Region Supplied" {
+
+ $result = New-ServerlessServiceAccountSecret -SecretName 'Test' -UserDataList $testList -EnvironmentTag 'test' -ProfileName 'temp-test' -Region 'us-east-1' `
+ -ReplicationRegion 'us-east-1' -Description 'Test'
+
+ $result | Should -HaveCount 2
+ }
+
+ It "Does Not Apply Replication Policy If Replication Region Is Null" {
+
+ New-ServerlessServiceAccountSecret -SecretName 'Test' -UserDataList $testList -EnvironmentTag 'test' -ProfileName 'temp-test' -Region 'us-east-1' `
+ -ReplicationRegion $null -Description 'Test' | Out-Null
+
+ Assert-MockCalled -ModuleName $inScopeModule -CommandName Add-SECSecretToRegion -Times 0 -Exactly -Scope It
+ }
+
+ It "Does Not Apply Replication Policy If Replication Region Is Empty" {
+
+ New-ServerlessServiceAccountSecret -SecretName 'Test' -UserDataList $testList -EnvironmentTag 'test' -ProfileName 'temp-test' -Region 'us-east-1' `
+ -ReplicationRegion '' -Description 'Test' | Out-Null
+
+ Assert-MockCalled -ModuleName $inScopeModule -CommandName Add-SECSecretToRegion -Times 0 -Exactly -Scope It
+ }
+
+ It "Returns One ARN If Replication Region Not Supplied" {
+
+ $result = New-ServerlessServiceAccountSecret -SecretName 'Test' -UserDataList $testList -EnvironmentTag 'test' -ProfileName 'temp-test' -Region 'us-east-1' `
+ -ReplicationRegion '' -Description 'Test'
+
+ $result | Should -HaveCount 1
+ }
+ }
+ }
+}
diff --git a/Modules/Alkami.DevOps.SystemEngineering/Private/VariableDeclarations.ps1 b/Modules/Alkami.DevOps.SystemEngineering/Private/VariableDeclarations.ps1
new file mode 100644
index 0000000..e69de29
diff --git a/Modules/Alkami.DevOps.SystemEngineering/Public/Disable-ActiveDirectoryAccount.ps1 b/Modules/Alkami.DevOps.SystemEngineering/Public/Disable-ActiveDirectoryAccount.ps1
new file mode 100644
index 0000000..1950ea5
--- /dev/null
+++ b/Modules/Alkami.DevOps.SystemEngineering/Public/Disable-ActiveDirectoryAccount.ps1
@@ -0,0 +1,51 @@
+function Disable-ActiveDirectoryAccount {
+
+ <#
+ .SYNOPSIS
+ Disables a user, MSA, or gMSA account
+
+ .DESCRIPTION
+ Disables a user, MSA, or gMSA account
+
+ .PARAMETER Accounts
+ [Microsoft.ActiveDirectory.Management.ADAccount] An ADAccount base object
+
+ .EXAMPLE
+ Disable-ActiveDirectoryAccount "fake.serviceaccount"
+ #>
+
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory = $true)]
+ [Microsoft.ActiveDirectory.Management.ADAccount]$Account
+ )
+
+ $logLead = Get-LogLeadName
+
+ if (!(Test-IsUserDomainAdmin)) {
+
+ Write-Warning "$logLead : You must have domain administrative privileges to run this command"
+ return $nulls
+ }
+
+ $accountName = $Account.Name
+
+ if ($true -eq $Account.Enabled) {
+
+ Write-Host "$logLead : Disabling account [$accountName]"
+
+ if ($Account.DistinguishedName -match "Managed Service Accounts") {
+
+ Write-Verbose "$logLead : MSA/gMSA detected"
+ Set-ADServiceAccount -Identity $Account.DistinguishedName -Enabled:$false
+ } else {
+
+ Write-Verbose "$logLead : Standard account detected"
+ Set-ADUser -Identity $Account.DistinguishedName -Enabled:$false
+ }
+
+ } else {
+
+ Write-Warning "$logLead : Account [$accountName] already disabled."
+ }
+}
diff --git a/Modules/Alkami.DevOps.SystemEngineering/Public/Disable-ActiveDirectoryAccount.tests.ps1 b/Modules/Alkami.DevOps.SystemEngineering/Public/Disable-ActiveDirectoryAccount.tests.ps1
new file mode 100644
index 0000000..cbfd491
--- /dev/null
+++ b/Modules/Alkami.DevOps.SystemEngineering/Public/Disable-ActiveDirectoryAccount.tests.ps1
@@ -0,0 +1,93 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ""
+
+Import-Module ActiveDirectory
+
+Describe "Disable-ActiveDirectoryAccount" {
+
+ $fakeAccountName = "FakeyMcFakeAccount"
+
+ function Get-CleanTestUser {
+
+ $fakeAccountName = "FakeyMcFakeAccount"
+ $testUser = New-Object Microsoft.ActiveDirectory.Management.ADUser
+ $testUser.DistinguishedName = "CN=$fakeAccountName,CN=Managed Service Accounts,DC=foo,DC=bar"
+ $testUser.Enabled = $false
+ $testUser.ObjectClass = "msDS-GroupManagedServiceAccount"
+ $testUser.ObjectGUID = "deadbeef-dead-beef-dead-beef00000075"
+ $testUser.SamAccountName = "fake.mcfakeuser$"
+ $testUser.SID = "S-1-2-34-5678901234-5678901234-5678901234-56789"
+ $testUser.UserPrincipalName = ""
+
+ # This property is 'read-only'
+ $testUser.Item('Name').Value = $fakeAccountName
+ return $testUser
+ }
+
+ Mock -CommandName Get-LogLeadName -ModuleName $moduleForMock -MockWith { return 'Disable-ActiveDirectoryAccount.tests' }
+ Mock -CommandName Set-ADUser -ModuleName $moduleForMock -MockWith { }
+ Mock -CommandName Set-ADServiceAccount -ModuleName $moduleForMock -MockWith { }
+ Mock -CommandName Write-Warning -ModuleName $moduleForMock -MockWith { }
+
+ Context "User Permissions" {
+
+ It "Writes a Warning and Exits Early if the User Does Not Have Domain Admin Rights" {
+
+ Mock Test-IsUserDomainAdmin -ModuleName $moduleForMock -MockWith { return $false }
+
+ $testUser = Get-CleanTestUser
+ Disable-ActiveDirectoryAccount $testUser
+
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Write-Warning `
+ -ParameterFilter { $Message -match "You must have domain administrative privileges" } -Times 1 -Exactly -Scope It
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Set-ADServiceAccount -Times 0 -Exactly -Scope It
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Set-ADUser -Times 0 -Exactly -Scope It
+ }
+ }
+
+ Context "Logic" {
+
+ Mock Test-IsUserDomainAdmin -ModuleName $moduleForMock -MockWith { return $true }
+
+ It "Writes a Warning and Does Not Disable the User if it is Already Disabled" {
+
+ $testUser = Get-CleanTestUser
+ Disable-ActiveDirectoryAccount $testUser
+
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Write-Warning `
+ -ParameterFilter { $Message -match "already disabled" } -Times 1 -Exactly -Scope It
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Set-ADServiceAccount -Times 0 -Exactly -Scope It
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Set-ADUser -Times 0 -Exactly -Scope It
+ }
+
+ It "Disables the Service Account User if they are Enabled" {
+
+ $testUser = Get-CleanTestUser
+ $testUser.Enabled = $true
+ Disable-ActiveDirectoryAccount $testUser
+
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Set-ADServiceAccount -Times 1 -Exactly -Scope It `
+ -ParameterFilter { ($Identity -match "$fakeAccountName") -and ($Enabled -eq $false) }
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Write-Warning -Times 0 -Exactly -Scope It
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Set-ADUser -Times 0 -Exactly -Scope It
+ }
+
+ It "Disables the Standard Account User if they are Enabled" {
+
+ $testUser = Get-CleanTestUser
+ $testUser.Enabled = $true
+ $testUser.DistinguishedName = "CN=$fakeAccountName,CN=Users,DC=foo,DC=bar"
+ Disable-ActiveDirectoryAccount $testUser
+
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Set-ADUser -Times 1 -Exactly -Scope It `
+ -ParameterFilter { ($Identity -match "$fakeAccountName") -and ($Enabled -eq $false) }
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Write-Warning -Times 0 -Exactly -Scope It
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Set-ADServiceAccount -Times 0 -Exactly -Scope It
+ }
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.SystemEngineering/Public/Disable-AlkamiDomainAccounts.ps1 b/Modules/Alkami.DevOps.SystemEngineering/Public/Disable-AlkamiDomainAccounts.ps1
new file mode 100644
index 0000000..7681299
--- /dev/null
+++ b/Modules/Alkami.DevOps.SystemEngineering/Public/Disable-AlkamiDomainAccounts.ps1
@@ -0,0 +1,64 @@
+function Disable-AlkamiDomainAccounts {
+
+ <#
+ .SYNOPSIS
+ Disables active directory accounts and moves them to the disabled accounts OU
+
+ .DESCRIPTION
+ Disables active directory and moves them to the disabled accounts OU. Accounts can be standard accounts or service accounts.
+
+ .PARAMETER Accounts
+ [string[]] An array of user SAMAccountNames to act upon
+
+ .PARAMETER DisabledAccountOU
+ [string] The OU name for disabled accounts. Defaults to "Disabled Accounts"
+
+ .PARAMETER DomainName
+ [string] The domain name to act upon. Defaults to "fh.local"
+
+ .EXAMPLE
+ Disable-AlkamiServiceAccounts @("fakeuser1", "fakeuser2")
+
+ .EXAMPLE
+ Disable-AlkamiServiceAccounts @("fakeuser1", "fakeuser2") -DisabledAccountOU "Trash Can" -Domain "corp.alkamitech.com"
+ #>
+
+ [CmdletBinding()]
+ param (
+ [Parameter(Mandatory = $true)]
+ [ValidateNotNullOrEmpty()]
+ [string[]]$Accounts,
+
+ [Parameter(Mandatory = $false)]
+ [string]$DisabledAccountOU = "Disabled Accounts",
+
+ [Parameter(Mandatory = $false)]
+ [string]$DomainName = "fh.local"
+ )
+
+ $logLead = Get-LogLeadName
+
+ if (!(Test-IsUserDomainAdmin)) {
+
+ Write-Warning "$logLead : You must have domain administrative privileges to run this command"
+ return $null
+ }
+
+ foreach ($account in $Accounts) {
+
+ Write-Host "$logLead : Processing account [$account]"
+ $curAccount = Get-ActiveDirectoryAccount -Identity $account
+
+ if ($null -eq $curAccount) {
+
+ Write-Warning "$logLead : Account named [$account] not found; skipping."
+ continue
+ }
+
+ # Disable the Account
+ Disable-ActiveDirectoryAccount -Account $curAccount
+
+ # Move the account to the disabled account OU
+ Move-AccountToDisabledOU -AccountDistinguishedName $curAccount.DistinguishedName -DisabledAccountOU $DisabledAccountOU -DomainName $DomainName
+ }
+}
diff --git a/Modules/Alkami.DevOps.SystemEngineering/Public/Disable-AlkamiDomainAccounts.tests.ps1 b/Modules/Alkami.DevOps.SystemEngineering/Public/Disable-AlkamiDomainAccounts.tests.ps1
new file mode 100644
index 0000000..028dacc
--- /dev/null
+++ b/Modules/Alkami.DevOps.SystemEngineering/Public/Disable-AlkamiDomainAccounts.tests.ps1
@@ -0,0 +1,48 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ""
+
+Describe "Disable-AlkamiDomainAccounts" {
+
+ Mock -CommandName Get-LogLeadName -ModuleName $moduleForMock -MockWith { return 'Disable-AlkamiDomainAccounts.tests' }
+ Mock -CommandName Disable-ActiveDirectoryAccount -ModuleName $moduleForMock -MockWith { }
+ Mock -CommandName Move-AccountToDisabledOU -ModuleName $moduleForMock -MockWith { }
+ Mock -CommandName Write-Warning -ModuleName $moduleForMock -MockWith { }
+
+ $fakeAccountName = "FakeyMcFakeAccount"
+
+ Context "User Permissions" {
+
+ It "Writes a Warning and Exits Early if the User Does Not Have Domain Admin Rights" {
+
+ Mock -CommandName Test-IsUserDomainAdmin -ModuleName $moduleForMock -MockWith { return $false }
+ Mock -CommandName Get-ActiveDirectoryAccount -ModuleName $moduleForMock -MockWith { }
+
+ Disable-AlkamiDomainAccounts @($fakeAccountName)
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Write-Warning `
+ -ParameterFilter { $Message -match "You must have domain administrative privileges" } -Times 1 -Exactly -Scope It
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Get-ActiveDirectoryAccount -Times 0 -Exactly -Scope It
+ }
+ }
+
+ Context "Parameter Validation and Manipulation" {
+
+ Mock Test-IsUserDomainAdmin -ModuleName $moduleForMock -MockWith { return $true }
+ Mock Get-ActiveDirectoryAccount -ModuleName $moduleForMock -MockWith { return $null }
+
+ It "Writes a Warning and Exits if the User Is Not Found" {
+
+ Mock Write-Warning -ModuleName $moduleForMock -MockWith { }
+
+ Disable-AlkamiDomainAccounts @($fakeAccountName)
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Write-Warning `
+ -ParameterFilter { $Message -match "Account named \[$fakeAccountName\] not found" } -Times 1 -Exactly -Scope It
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Disable-ActiveDirectoryAccount -Times 0 -Exactly -Scope It
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Move-AccountToDisabledOU -Times 0 -Exactly -Scope It
+ }
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.SystemEngineering/Public/Export-ACMCertificatesByName.ps1 b/Modules/Alkami.DevOps.SystemEngineering/Public/Export-ACMCertificatesByName.ps1
new file mode 100644
index 0000000..b0d3575
--- /dev/null
+++ b/Modules/Alkami.DevOps.SystemEngineering/Public/Export-ACMCertificatesByName.ps1
@@ -0,0 +1,137 @@
+function Export-ACMCertificatesByName {
+
+<#
+.SYNOPSIS
+ Exports ACM certificates by domain name to the local filesystem.
+
+.DESCRIPTION
+ Exports ACM certificates by domain name to the local filesystem. Unfortunately, AWS did not accomodate
+ this use case when they wrote 'Export-ACMCertificate'.
+
+ Note that this function may generate more than one set of files because domain name uniqueness is not enforced in ACM.
+
+.PARAMETER DomainName
+ [string] The domain name of the ACM certificates to retrieve.
+
+.PARAMETER ProfileName
+ [string] The AWS profile to use during ACM queries.
+
+.PARAMETER Region
+ [string] The AWS region to use during ACM queries.
+
+.PARAMETER Passphrase
+ [string] The passphrase to associate with the encrypted exported private key. If not provided, a generated passphrase will be used.
+
+.PARAMETER GeneratePfx
+ [switch] Flag indicating whether or not the function should generate a PFX file. Note that this functionality requires OpenSSL
+ to be installed on your machine; if not installed, this flag will result in an error.
+
+.EXAMPLE
+ Export-ACMCertificatesByName -DomainName '*.sandbox.alkami.net' -Passphrase "Secret" -ProfileName 'temp-prod' -Region 'us-east-1'
+
+.EXAMPLE
+ Export-ACMCertificatesByName -DomainName '*.sandbox.alkami.net' -ProfileName 'temp-prod' -Region 'us-east-1' -GeneratePfx
+
+.LINK
+ https://confluence.alkami.com/x/5ILiBg
+#>
+
+ [CmdletBinding()]
+ [OutputType([PSObject[]])]
+ param (
+ [Parameter(Mandatory = $true)]
+ [ValidateNotNullOrEmpty()]
+ [string] $DomainName,
+
+ [Parameter(Mandatory = $true)]
+ [ValidateNotNullOrEmpty()]
+ [string] $ProfileName,
+
+ [Parameter(Mandatory = $true)]
+ [ValidateScript({$_ -in (Get-AWSRegion).region})]
+ [string] $Region,
+
+ [Parameter(Mandatory = $false)]
+ [ValidateNotNullOrEmpty()]
+ [string] $Passphrase,
+
+ [Parameter(Mandatory = $false)]
+ [switch] $GeneratePfx
+ )
+
+ $logLead = (Get-LogLeadName)
+
+ if ( $GeneratePfx ) {
+
+ # Either find OpenSSL or die trying.
+ $openSslPath = ( Get-Command -Name 'openssl' -CommandType Application -ErrorAction SilentlyContinue -TotalCount 1 ).Source
+ if ( $null -eq $openSslPath ) {
+
+ Write-Error "$logLead : GeneratePfx flag was provided but OpenSSL command was not found on your system. Please install OpenSSL and retry."
+ return
+ }
+ }
+
+ # Create the top-level temporary directory.
+ $tempDir = ( Join-Path ([System.IO.Path]::GetTempPath()) 'CertificateExport' )
+ New-Item -Path $tempDir -ItemType Directory -Force | Out-Null
+
+ # Sanitize the domain name (if necessary).
+ $pfxDomainName = $DomainName -replace '\*', '_'
+
+ $certificateDetailsList = Get-ACMCertificateDetailsListByName -DomainName $DomainName -ProfileName $ProfileName -Region $Region
+ foreach ( $cert in $certificateDetailsList ) {
+
+ Write-Host "$logLead : Processing certificate ARN [$($cert.CertificateArn)]."
+
+ if ( $false -eq $PSBoundParameters.ContainsKey( 'Passphrase' ) ) {
+
+ $actualPassphrase = New-SecurePassword -PasswordLength 15 -ProfileName $ProfileName -Region $Region
+ Write-Host "$logLead : Generated passphrase for certificate: $actualPassphrase"
+
+ } else {
+
+ $actualPassphrase = $Passphrase
+ }
+
+ try {
+
+ # Ref: https://docs.aws.amazon.com/powershell/latest/reference/items/Export-ACMCertificate.html
+ $exportedCert = ( Export-ACMCertificate -CertificateArn $cert.CertificateArn -Passphrase $actualPassphrase.ToCharArray() -ProfileName $ProfileName -Region $Region )
+ if ( $null -ne $exportedCert ) {
+
+ $certDirName = ( $cert.CertificateArn -split '/' )[-1]
+ $certDir = ( Join-Path $tempDir $certDirName )
+ $certPath = ( Join-Path $certDir "certificate.pem")
+ $chainPath = ( Join-Path $certDir "certificate_chain.pem")
+ $keyPath = ( Join-Path $certDir "private.key")
+
+ # Create the temporary directory for this certificate
+ New-Item -Path $certDir -ItemType Directory -Force | Out-Null
+
+ Set-Content -Path $certPath -Value $exportedCert.Certificate -Encoding ASCII
+ Write-Verbose "$logLead : Generated [$certPath]."
+
+ Set-Content -Path $chainPath -Value $exportedCert.CertificateChain -Encoding ASCII
+ Write-Verbose "$logLead : Generated [$chainPath]."
+
+ Set-Content -Path $keyPath -Value $exportedCert.PrivateKey -Encoding ASCII
+ Write-Verbose "$logLead : Generated [$keyPath]."
+
+ if ( $GeneratePfx ) {
+
+ $pfxPath = ( Join-Path $certDir "$pfxDomainName.pfx")
+
+ Start-Process -FilePath $openSslPath -Wait -argumentlist "pkcs12 -export -out $pfxPath -in $certPath -inkey $keyPath -certfile $chainPath -passin `"pass:$actualPassphrase`" -passout `"pass:$actualPassphrase`""
+ Write-Verbose "$logLead : Generated [$pfxPath]."
+ }
+
+ Write-Host "$logLead : Files generated at [$certDir]."
+ }
+
+ } catch {
+
+ Write-Warning "$logLead : Unable to export ACM certificate ARN [$($cert.CertificateArn)] : $($_.Exception.Message)"
+ }
+ }
+}
diff --git a/Modules/Alkami.DevOps.SystemEngineering/Public/Export-ACMCertificatesByName.tests.ps1 b/Modules/Alkami.DevOps.SystemEngineering/Public/Export-ACMCertificatesByName.tests.ps1
new file mode 100644
index 0000000..ee40bcf
--- /dev/null
+++ b/Modules/Alkami.DevOps.SystemEngineering/Public/Export-ACMCertificatesByName.tests.ps1
@@ -0,0 +1,130 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ""
+
+Describe "Export-ACMCertificatesByName" {
+
+ Mock -CommandName Get-AWSRegion -ModuleName $moduleForMock -MockWith { return @( @{ 'Region' = 'us-east-1' } ) }
+ Mock -CommandName Get-LogLeadName -ModuleName $moduleForMock -MockWith { return 'Export-ACMCertificatesByName.tests' }
+ Mock -CommandName Write-Warning -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Write-Error -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Join-Path -ModuleName $moduleForMock -MockWith { return "C:\Test" }
+ Mock -CommandName New-Item -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Set-Content -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Get-ACMCertificateDetailsListByName -ModuleName $moduleForMock -MockWith {return @(@{CertificateArn = 'TestArn'; Serial = 'TestSerial'})}
+ Mock -CommandName New-SecurePassword -ModuleName $moduleForMock -MockWith { return "GeneratedPW" }
+ Mock -CommandName Start-Process -ModuleName $moduleForMock -MockWith {}
+
+ Context "Logic" {
+
+ It "Writes Warning If AWS ACM Certificate Export Throws" {
+
+ Mock -CommandName Export-ACMCertificate -ModuleName $moduleForMock -MockWith { throw "Test1" }
+
+ Export-ACMCertificatesByName -DomainName "Test" -Passphrase "TestPW" -ProfileName 'test' -Region 'us-east-1' | Out-Null
+
+ Assert-MockCalled -CommandName Write-Warning `
+ -ParameterFilter { $Message -match "Unable to export ACM certificate ARN \[TestArn\] : Test1" } -Times 1 -Exactly -Scope It
+ Assert-MockCalled -CommandName Export-ACMCertificate -Times 1 -Exactly -Scope It
+ Assert-MockCalled -CommandName Get-ACMCertificateDetailsListByName -Times 1 -Exactly -Scope It
+ Assert-MockCalled -CommandName New-Item -Times 1 -Exactly -Scope It
+ Assert-MockCalled -CommandName Set-Content -Times 0 -Exactly -Scope It
+ }
+
+ It "Generates Three Files On Success Without PFX Flag" {
+
+ Mock -CommandName Export-ACMCertificate -ModuleName $moduleForMock -MockWith { return @{ Certificate = "TestCert"; CertificateChain = "TestChain"; PrivateKey = "TestKey"} }
+
+ Export-ACMCertificatesByName -DomainName "Test" -Passphrase "TestPW" -ProfileName 'test' -Region 'us-east-1' | Out-Null
+
+ Assert-MockCalled -CommandName Write-Warning -Times 0 -Exactly -Scope It
+ Assert-MockCalled -CommandName Get-ACMCertificateDetailsListByName -Times 1 -Exactly -Scope It
+ Assert-MockCalled -CommandName Export-ACMCertificate -Times 1 -Exactly -Scope It
+ Assert-MockCalled -CommandName New-Item -Times 2 -Exactly -Scope It
+ Assert-MockCalled -CommandName Set-Content -Times 3 -Exactly -Scope It
+ }
+
+ It "Writes Error and Aborts If PFX Flag is Present But OpenSSL Is Not Detected" {
+
+ Mock -CommandName Export-ACMCertificate -ModuleName $moduleForMock -MockWith { throw "Test1" }
+ Mock -CommandName Get-Command -ModuleName $moduleForMock -MockWith { return $null }
+
+ Export-ACMCertificatesByName -DomainName "Test" -Passphrase "TestPW" -ProfileName 'test' -Region 'us-east-1' -GeneratePfx | Out-Null
+
+ Assert-MockCalled -CommandName Write-Error `
+ -ParameterFilter { $Message -match "GeneratePfx flag was provided but OpenSSL command was not found on your system" } -Times 1 -Exactly -Scope It
+ Assert-MockCalled -CommandName Get-Command -Times 1 -Exactly -Scope It
+ Assert-MockCalled -CommandName Export-ACMCertificate -Times 0 -Exactly -Scope It
+ Assert-MockCalled -CommandName Get-ACMCertificateDetailsListByName -Times 0 -Exactly -Scope It
+ Assert-MockCalled -CommandName New-Item -Times 0 -Exactly -Scope It
+ Assert-MockCalled -CommandName Set-Content -Times 0 -Exactly -Scope It
+ }
+
+ It "Generates PFX File On Success If PFX Flag is Present and OpenSSL Is Detected" {
+
+ Mock -CommandName Export-ACMCertificate -ModuleName $moduleForMock -MockWith { return @{ Certificate = "TestCert"; CertificateChain = "TestChain"; PrivateKey = "TestKey" } }
+ Mock -CommandName Get-Command -ModuleName $moduleForMock -MockWith { return @{ Source = "C:\OpenSSL.exe" } }
+
+ Export-ACMCertificatesByName -DomainName "Test" -Passphrase "TestPW" -ProfileName 'test' -Region 'us-east-1' -GeneratePfx | Out-Null
+
+ Assert-MockCalled -CommandName Write-Error -Times 0 -Exactly -Scope It
+ Assert-MockCalled -CommandName Write-Warning -Times 0 -Exactly -Scope It
+ Assert-MockCalled -CommandName Get-Command -Times 1 -Exactly -Scope It
+ Assert-MockCalled -CommandName Get-ACMCertificateDetailsListByName -Times 1 -Exactly -Scope It
+ Assert-MockCalled -CommandName Export-ACMCertificate -Times 1 -Exactly -Scope It
+ Assert-MockCalled -CommandName New-Item -Times 2 -Exactly -Scope It
+ Assert-MockCalled -CommandName Set-Content -Times 3 -Exactly -Scope It
+ Assert-MockCalled -CommandName Start-Process `
+ -ParameterFilter { $FilePath -eq "C:\OpenSSL.exe" } -Times 1 -Exactly -Scope It
+ }
+
+ It "Sanitizes Domain Name for PFX File" {
+
+ $testDomain = "*.test.com"
+ $sanitizedDomain = "_.test.com"
+
+ Mock -CommandName Export-ACMCertificate -ModuleName $moduleForMock -MockWith { return @{ Certificate = "TestCert"; CertificateChain = "TestChain"; PrivateKey = "TestKey" } }
+ Mock -CommandName Get-Command -ModuleName $moduleForMock -MockWith { return @{ Source = "C:\OpenSSL.exe" } }
+
+ Export-ACMCertificatesByName -DomainName $testDomain -Passphrase "TestPW" -ProfileName 'test' -Region 'us-east-1' -GeneratePfx | Out-Null
+
+ Assert-MockCalled -CommandName Write-Error -Times 0 -Exactly -Scope It
+ Assert-MockCalled -CommandName Write-Warning -Times 0 -Exactly -Scope It
+ Assert-MockCalled -CommandName Get-Command -Times 1 -Exactly -Scope It
+ Assert-MockCalled -CommandName Get-ACMCertificateDetailsListByName -Times 1 -Exactly -Scope It
+ Assert-MockCalled -CommandName Export-ACMCertificate -Times 1 -Exactly -Scope It
+ Assert-MockCalled -CommandName New-Item -Times 2 -Exactly -Scope It
+ Assert-MockCalled -CommandName Set-Content -Times 3 -Exactly -Scope It
+ Assert-MockCalled -CommandName Start-Process -Times 1 -Exactly -Scope It
+ Assert-MockCalled -CommandName Join-Path `
+ -ParameterFilter { $ChildPath -eq "$sanitizedDomain.pfx" } -Times 1 -Exactly -Scope It
+ }
+ }
+
+ Context "Inputs" {
+
+ Mock -CommandName Export-ACMCertificate -ModuleName $moduleForMock -MockWith { return @{ Certificate = "TestCert"; CertificateChain = "TestChain"; PrivateKey = "TestKey"} }
+
+ It "Uses Passphrase if Provided" {
+
+ Export-ACMCertificatesByName -DomainName "Test" -Passphrase "TestPW" -ProfileName 'test' -Region 'us-east-1'
+
+ Assert-MockCalled -CommandName Export-ACMCertificate `
+ -ParameterFilter { [System.Text.Encoding]::ASCII.GetString($Passphrase) -match 'TestPW' } -Times 1 -Exactly -Scope It
+ Assert-MockCalled -CommandName New-SecurePassword -Times 0 -Exactly -Scope It
+ }
+
+ It "Uses Generated Passphrase if Not Provided" {
+
+ Export-ACMCertificatesByName -DomainName "Test" -ProfileName 'test' -Region 'us-east-1'
+
+ Assert-MockCalled -CommandName Export-ACMCertificate `
+ -ParameterFilter { [System.Text.Encoding]::ASCII.GetString($Passphrase) -match 'GeneratedPW' } -Times 1 -Exactly -Scope It
+ Assert-MockCalled -CommandName New-SecurePassword -Times 1 -Exactly -Scope It
+ }
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.SystemEngineering/Public/Get-ACMCertificateBindingList.ps1 b/Modules/Alkami.DevOps.SystemEngineering/Public/Get-ACMCertificateBindingList.ps1
new file mode 100644
index 0000000..64506ed
--- /dev/null
+++ b/Modules/Alkami.DevOps.SystemEngineering/Public/Get-ACMCertificateBindingList.ps1
@@ -0,0 +1,161 @@
+function Get-ACMCertificateBindingList {
+
+ <#
+.SYNOPSIS
+ Retrieves the Alkami AWS ACM certificate bindings for all AWS accounts.
+
+.DESCRIPTION
+ Retrieves ACM certificate bindings for all AWS accounts and includes the ELB listeners. The script will search all AWS accounts
+ and each region Alkami has resources in. Alternatively, the example below demonstrates how to limit the search by profile and region.
+
+.PARAMETER DomainName
+ The Alkami domain name of the AWS ACM certificates to retrieve.
+
+.PARAMETER ProfileName
+ The Alkami AWS profile name to query for ACM certificates.
+
+.PARAMETER Region
+ The supported Alkami AWS region in which to query for ACM certificates.
+
+.EXAMPLE
+ Get-ACMCertificateBindingList -DomainName '*.dev.alkamitech.com'' -ProfileName 'temp-dev' -Region 'us-east-1'
+#>
+
+ [CmdletBinding()]
+ [OutputType([PSObject[]])]
+ param (
+ [Parameter(Mandatory = $true)]
+ [ValidateNotNullOrEmpty()]
+ [string] $DomainName,
+
+ [Parameter(Mandatory = $false) ]
+ [ValidateScript( { $_ -in (Get-AlkamiAwsProfileList) })]
+ [string] $ProfileName = $null,
+
+ [Parameter(Mandatory = $false)]
+ [ValidateScript( { $_ -in (Get-SupportedAwsRegions) })]
+ [string] $Region = $null
+ )
+
+ $output = @()
+
+ $logLead = Get-LogLeadName
+
+ Import-AWSModule
+
+ if ( $PSBoundParameters.ContainsKey('ProfileName')) {
+
+ $profileList = @($ProfileName)
+
+ } else {
+
+ Write-Host "$logLead : User did not provide a ProfileName, using all standard Alkami AWS ProfileNames"
+ $profileList = Get-AlkamiAwsProfileList
+ }
+
+ if ($PSBoundParameters.ContainsKey('Region')) {
+
+ $regionList = @($Region)
+
+ } else {
+
+ Write-Host "$logLead : User did not provide a Region, using all supported Alkami AWS Regions"
+ $regionList = Get-SupportedAwsRegions
+ }
+
+ foreach ( $curProfile in $profileList ) {
+
+ Write-Host "$logLead : Processing $curProfile"
+
+ foreach ( $curRegion in $regionList ) {
+
+ Write-Host "$logLead : Processing $curRegion"
+
+ try {
+
+ $certList = Get-ACMCertificateDetailsListByName -DomainName $DomainName -ProfileName $curProfile -Region $curRegion
+
+ } catch {
+
+ Write-Warning "$logLead : Unable to retrieve ACM certificate details by name : $($_.Exception.Message)"
+ Continue
+ }
+
+ if (Test-IsCollectionNullOrEmpty $certList) {
+
+ Write-Warning "$logLead : No certificates found with a domain name of [$($DomainName)]"
+ Continue
+ }
+
+ $apiGatewayDomains = Get-AG2DomainNameList -ProfileName $curProfile -Region $curRegion
+
+ foreach ( $curCert in $certList ) {
+
+ Write-Host "$logLead : Processing $($curCert.CertificateArn) : expires on $($curCert.NotAfter.Date)"
+
+ $tempObject = [pscustomobject]@{
+ 'DomainName' = $curCert.DomainName
+ 'Profile' = $curProfile
+ 'Region' = $curRegion
+ 'ARN' = $curCert.CertificateArn
+ 'NotAfter' = $curCert.NotAfter.Date
+ 'RenewalEligibility' = $curCert.RenewalEligibility.Value
+ 'InUseBy' = @()
+ }
+
+ foreach ( $curUser in $curCert.InUseBy ) {
+
+ Write-Host "$logLead : Cert is in use by ARN: $curUser"
+
+ if ( $curUser -match 'loadbalancer' ) {
+
+ try {
+
+ $elbListeners = Get-ELB2Listener -LoadBalancerArn $curUser -ProfileName $curProfile -Region $curRegion
+
+ } catch {
+
+ Write-Warning "$logLead : Encountered an error retrieving ELB Listener details for $curUser : $($_.Exception.Message)"
+ $tempObject.InUseBy += $curUser
+ }
+
+ foreach ( $elbListener in $elbListeners ) {
+
+ try {
+
+ $elbListenerCertList = (Get-ELB2ListenerCertificate -ListenerArn $elbListener.ListenerArn -ProfileName $curProfile -Region $curRegion).CertificateArn
+
+ } catch {
+
+ Write-Warning "$logLead : Error encountered while retrieving ELB Listener certificate list for $($elbListener.ListenerArn): $($_.Exception.Message)"
+ }
+
+ if ( $elbListenerCertList -contains $curCert.CertificateArn ) {
+
+ Write-Verbose "$logLead : Cert is in use by ELB Listener $($elbListener.ListenerArn)"
+ $tempObject.InUseBy += $elbListener.ListenerArn
+ }
+ }
+
+ } else {
+
+ $tempObject.InUseBy += $curUser
+ }
+ }
+
+ $filteredAGDomains = $apiGatewayDomains | Where-Object { $_.DomainNameConfigurations.CertificateArn -eq $curCert.CertificateArn }
+ foreach ( $agDomain in $filteredAGDomains ) {
+
+ $agMaps = Get-AG2ApiMappingList -DomainName $agDomain.Name -ProfileName $curProfile -Region $curRegion
+ foreach ( $agMap in $agMaps ) {
+ $tempObject.InUseBy += "arn:aws:apigateway:$curRegion::/restapis/$($agMap.ApiId)/stages/$($agMap.Stage)"
+ }
+ }
+
+ $output += $tempObject
+ }
+ }
+ }
+
+ return $output
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.SystemEngineering/Public/Get-ACMCertificateBindingList.tests.ps1 b/Modules/Alkami.DevOps.SystemEngineering/Public/Get-ACMCertificateBindingList.tests.ps1
new file mode 100644
index 0000000..69b3853
--- /dev/null
+++ b/Modules/Alkami.DevOps.SystemEngineering/Public/Get-ACMCertificateBindingList.tests.ps1
@@ -0,0 +1,383 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$global:functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "InModuleScope - Overriding SUT: $global:functionPath"
+Import-Module $global:functionPath -Force
+$moduleForMock = ''
+
+Describe 'Get-ACMCertificateBindingList' {
+
+ Mock -CommandName Get-AlkamiAwsProfileList -ModuleName $moduleForMock -MockWith { return @( 'temp-test1', 'temp-test2' ) }
+ Mock -CommandName Get-SupportedAwsRegions -ModuleName $moduleForMock -MockWith { return @( 'us-fake-1', 'us-fake-2' ) }
+ Mock -CommandName Get-AWSRegion -ModuleName $moduleForMock -MockWith { return @( @{ 'Region' = 'us-fake-1' }, @{ 'Region' = 'us-fake-2' } ) }
+ Mock -CommandName Get-LogLeadName -ModuleName $moduleForMock -MockWith { return 'Get-ACMCertificateBindingList.tests' }
+ Mock -CommandName Import-AWSModule -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Write-Warning -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Test-IsCollectionNullOrEmpty -ModuleName $moduleForMock -MockWith { return $false }
+
+ Mock -CommandName Get-ELB2ListenerCertificate -ModuleName $moduleForMock -MockWith {
+ $testObject = @{
+ CertificateArn = 'TestCertificateArn'
+ }
+
+ return @($testObject)
+ }
+
+ Mock -CommandName Get-ACMCertificateDetailsListByName -ModuleName $moduleForMock -MockWith {
+ $testObject = @{
+ DomainName = 'TestDomainName'
+ CertificateArn = 'TestCertificateArn'
+ InUseBy = @(
+ 'TestCertificateUser',
+ 'TestCertificateUser-loadbalancer'
+ )
+ NotAfter = @{
+ Date = 'TestDate'
+ }
+ RenewalEligibility = @{
+ Value = 'TestRenewalEligibility'
+ }
+ }
+
+ return @($testObject)
+ }
+
+ Mock -CommandName Get-AG2DomainNameList -ModuleName $moduleForMock -MockWith {
+ $testObject = @{
+ Name = 'TestApi'
+ DomainNameConfigurations = @{
+ CertificateArn = 'TestCertificateArn'
+ }
+ }
+
+ return @($testObject)
+ }
+
+ Mock -CommandName Get-ELB2Listener -ModuleName $moduleForMock -MockWith {
+
+ $testObject = @{
+ ListenerArn = 'TestListenerArn'
+ }
+
+ return @($testObject)
+ }
+
+ Mock -CommandName Get-AG2ApiMappingList -ModuleName $moduleForMock -MockWith {
+ $testObject = @{
+ ApiId = 'TestApiId'
+ Stage = 'TestStage'
+ }
+
+ return @($testObject)
+ }
+
+ Context 'Parameter Validation' {
+
+ It 'Throws if DomainName is Null' {
+ { Get-ACMCertificateBindingList -DomainName $null } | Should -Throw
+ }
+
+ It 'Throws if DomainName is Empty' {
+ { Get-ACMCertificateBindingList -DomainName '' } | Should -Throw
+ }
+
+ It 'Throws if Profile is Not In Approved List' {
+ { Get-ACMCertificateBindingList -DomainName 'TestDomainName' -ProfileName 'temp-localtest' } | Should -Throw
+ }
+
+ It 'Throws if Region is Not In Approved List' {
+ { Get-ACMCertificateBindingList -DomainName 'TestDomainName' -ProfileName 'temp-test1' -Region 'us-test-2' } | Should -Throw
+ }
+ }
+
+ Context 'Logic Validation' {
+
+ It 'Uses ProfileName Parameter if Provided' {
+
+ Get-ACMCertificateBindingList -DomainName 'TestDomainName' -ProfileName 'temp-test1' -Region 'us-fake-1' | Out-Null
+
+ Assert-MockCalled -CommandName Get-ACMCertificateDetailsListByName -Times 1 -Exactly -Scope It `
+ -ParameterFilter { $ProfileName -ceq 'temp-test1' }
+ }
+
+ It 'Uses All Supported Profiles if ProfileName Parameter is Not Provided' {
+
+ $validProfiles = Get-AlkamiAwsProfileList
+ Get-ACMCertificateBindingList -DomainName 'TestDomainName' -Region 'us-fake-1' -Verbose | Out-Null
+
+ Assert-MockCalled -CommandName Get-ACMCertificateDetailsListByName -Times $validProfiles.Length -Exactly -Scope It `
+ -ParameterFilter { $ProfileName -in $validProfiles }
+ }
+
+ It 'Uses Region Parameter if Provided' {
+
+ Get-ACMCertificateBindingList -DomainName 'TestDomainName' -ProfileName 'temp-test1' -Region 'us-fake-1' | Out-Null
+
+ Assert-MockCalled -CommandName Get-ACMCertificateDetailsListByName -Times 1 -Exactly -Scope It `
+ -ParameterFilter { $Region -ceq 'us-fake-1' }
+ }
+
+ It 'Uses All Supported Regions if Region Parameter is Not Provided' {
+
+ $validRegions = Get-SupportedAwsRegions
+ Get-ACMCertificateBindingList -DomainName 'TestDomainName' -ProfileName 'temp-test1' -Verbose | Out-Null
+
+ Assert-MockCalled -CommandName Get-ACMCertificateDetailsListByName -Times $validRegions.Length -Exactly -Scope It `
+ -ParameterFilter { $Region -in $validRegions }
+ }
+
+ It 'Aborts Processing in Current Region if Get-ACMCertificateDetailsListByName Throws' {
+
+ Mock -CommandName Get-ACMCertificateDetailsListByName -ModuleName $moduleForMock -MockWith { throw 'This is an exception.' }
+
+ Get-ACMCertificateBindingList -DomainName 'TestDomainName' -ProfileName 'temp-test1' -Region 'us-fake-1' -Verbose | Out-Null
+
+ Assert-MockCalled -CommandName Get-ACMCertificateDetailsListByName -Times 1 -Exactly -Scope It
+ Assert-MockCalled -CommandName Test-IsCollectionNullOrEmpty -Times 0 -Exactly -Scope It
+
+ Assert-MockCalled -CommandName Write-Warning -Times 1 -Exactly -Scope It `
+ -ParameterFilter { $Message -match 'Unable to retrieve ACM certificate details by name' }
+
+ Mock -CommandName Get-ACMCertificateDetailsListByName -ModuleName $moduleForMock -MockWith {
+ $testObject = @{
+ DomainName = 'TestDomainName'
+ CertificateArn = 'TestCertificateArn'
+ InUseBy = @(
+ 'TestCertificateUser',
+ 'TestCertificateUser-loadbalancer'
+ )
+ NotAfter = @{
+ Date = 'TestDate'
+ }
+ RenewalEligibility = @{
+ Value = 'TestRenewalEligibility'
+ }
+ }
+
+ return @($testObject)
+ }
+ }
+
+ It 'Aborts Processing in Current Region if Get-ACMCertificateDetailsListByName Throws' {
+
+ Mock -CommandName Test-IsCollectionNullOrEmpty -ModuleName $moduleForMock -MockWith { return $true }
+
+ Get-ACMCertificateBindingList -DomainName 'TestDomainName' -ProfileName 'temp-test1' -Region 'us-fake-1' -Verbose | Out-Null
+
+ Assert-MockCalled -CommandName Test-IsCollectionNullOrEmpty -Times 1 -Exactly -Scope It
+ Assert-MockCalled -CommandName Get-AG2DomainNameList -Times 0 -Exactly -Scope It
+
+ Assert-MockCalled -CommandName Write-Warning -Times 1 -Exactly -Scope It `
+ -ParameterFilter { $Message -match 'No certificates found with a domain name of' }
+
+ Mock -CommandName Test-IsCollectionNullOrEmpty -ModuleName $moduleForMock -MockWith { return $false }
+ }
+
+ It 'Prints Warning if Get-ELB2Listener Throws' {
+
+ Mock -CommandName Get-ELB2Listener -ModuleName $moduleForMock -MockWith { throw 'This is an exception.' }
+
+ Get-ACMCertificateBindingList -DomainName 'TestDomainName' -ProfileName 'temp-test1' -Region 'us-fake-1' -Verbose | Out-Null
+
+ Assert-MockCalled -CommandName Get-ELB2Listener -Times 1 -Exactly -Scope It
+ Assert-MockCalled -CommandName Get-ELB2ListenerCertificate -Times 0 -Exactly -Scope It
+ Assert-MockCalled -CommandName Write-Warning -Times 1 -Exactly -Scope It `
+ -ParameterFilter { $Message -match 'Encountered an error retrieving ELB Listener details for' }
+
+ Mock -CommandName Get-ELB2Listener -ModuleName $moduleForMock -MockWith {
+
+ $testObject = @{
+ ListenerArn = 'TestListenerArn'
+ }
+
+ return @($testObject)
+ }
+ }
+
+ It 'Prints Warning if Get-ELB2ListenerCertificate Throws' {
+
+ Mock -CommandName Get-ELB2ListenerCertificate -ModuleName $moduleForMock -MockWith { throw 'This is an exception.' }
+
+ Get-ACMCertificateBindingList -DomainName 'TestDomainName' -ProfileName 'temp-test1' -Region 'us-fake-1' -Verbose | Out-Null
+
+ Assert-MockCalled -CommandName Get-ELB2ListenerCertificate -Times 1 -Exactly -Scope It
+ Assert-MockCalled -CommandName Write-Warning -Times 1 -Exactly -Scope It `
+ -ParameterFilter { $Message -match 'Error encountered while retrieving ELB Listener certificate list' }
+
+ Mock -CommandName Get-ELB2ListenerCertificate -ModuleName $moduleForMock -MockWith {
+ $testObject = @{
+ CertificateArn = 'TestCertificateArn'
+ }
+
+ return @($testObject)
+ }
+ }
+
+ It 'Skips API Gateway Domain Stage Mapping if Certificate Does Not Match' {
+
+ Mock -CommandName Get-AG2DomainNameList -ModuleName $moduleForMock -MockWith {
+ $testObject = @{
+ Name = 'TestApi'
+ DomainNameConfigurations = @{
+ CertificateArn = 'TestCertificateArnNotMatch'
+ }
+ }
+
+ return @($testObject)
+ }
+
+ Get-ACMCertificateBindingList -DomainName 'TestDomainName' -ProfileName 'temp-test1' -Region 'us-fake-1' -Verbose | Out-Null
+
+ Assert-MockCalled -CommandName Get-AG2DomainNameList -Times 1 -Exactly -Scope It
+ Assert-MockCalled -CommandName Get-AG2ApiMappingList -Times 0 -Exactly -Scope It
+
+ Mock -CommandName Get-AG2DomainNameList -ModuleName $moduleForMock -MockWith {
+ $testObject = @{
+ Name = 'TestApi'
+ DomainNameConfigurations = @{
+ CertificateArn = 'TestCertificateArn'
+ }
+ }
+
+ return @($testObject)
+ }
+ }
+
+ It 'Processes all InUseBy Entries for a Certificate' {
+
+ Get-ACMCertificateBindingList -DomainName 'TestDomainName' -ProfileName 'temp-test1' -Region 'us-fake-1' -Verbose | Out-Null
+
+ Assert-MockCalled -CommandName Get-AG2DomainNameList -Times 1 -Exactly -Scope It
+ Assert-MockCalled -CommandName Get-ELB2Listener -Times 1 -Exactly -Scope It
+ Assert-MockCalled -CommandName Get-ELB2ListenerCertificate -Times 1 -Exactly -Scope It
+ Assert-MockCalled -CommandName Get-AG2ApiMappingList -Times 1 -Exactly -Scope It
+ Assert-MockCalled -CommandName Write-Warning -Times 0 -Exactly -Scope It
+ }
+ }
+
+ Context 'Output Validation' {
+
+ It 'Returns a Single Entry When A Single Certificate Is Found' {
+
+ $result = Get-ACMCertificateBindingList -DomainName 'TestDomainName' -ProfileName 'temp-test1' -Region 'us-fake-1' -Verbose
+ $result | Should -HaveCount 1
+ }
+
+ It 'Returns an Array of InUseBy For Each Certificate User' {
+
+ $result = Get-ACMCertificateBindingList -DomainName 'TestDomainName' -ProfileName 'temp-test1' -Region 'us-fake-1' -Verbose
+ $result[0].InUseBy | Should -HaveCount 3
+ }
+
+ It 'Returned InUseBy Array Contains ELB ARN if Listener Query Throws' {
+
+ Mock -CommandName Get-ELB2Listener -ModuleName $moduleForMock -MockWith { throw 'This is a test.' }
+
+ $result = Get-ACMCertificateBindingList -DomainName 'TestDomainName' -ProfileName 'temp-test1' -Region 'us-fake-1' -Verbose
+
+ Assert-MockCalled -CommandName Get-ELB2ListenerCertificate -Times 0 -Exactly -Scope It
+
+ $result | Should -Not -BeNullOrEmpty
+ $result[0].InUseBy | Should -HaveCount 3
+ $result[0].InUseBy | Should -Contain 'TestCertificateUser-loadbalancer'
+
+ Mock -CommandName Get-ELB2Listener -ModuleName $moduleForMock -MockWith {
+
+ $testObject = @{
+ ListenerArn = 'TestListenerArn'
+ }
+
+ return @($testObject)
+ }
+ }
+
+ It 'Returns Multiple Entries When Multiple Certificates Are Found' {
+
+ Mock -CommandName Get-ACMCertificateDetailsListByName -ModuleName $moduleForMock -MockWith {
+ $testObject1 = @{
+ DomainName = 'TestDomainName'
+ CertificateArn = 'TestCertificateArn'
+ InUseBy = @(
+ 'TestCertificateUser',
+ 'TestCertificateUser-loadbalancer'
+ )
+ NotAfter = @{
+ Date = 'TestDate'
+ }
+ RenewalEligibility = @{
+ Value = 'TestRenewalEligibility'
+ }
+ }
+
+ $testObject2 = @{
+ DomainName = 'TestDomainName'
+ CertificateArn = 'TestCertificateArn2'
+ InUseBy = @(
+ 'TestCertificateUser2',
+ 'TestCertificateUser2-loadbalancer'
+ )
+ NotAfter = @{
+ Date = 'TestDate'
+ }
+ RenewalEligibility = @{
+ Value = 'TestRenewalEligibility'
+ }
+ }
+
+ return @($testObject, $testObject2)
+ }
+
+ $result = Get-ACMCertificateBindingList -DomainName 'TestDomainName' -ProfileName 'temp-test1' -Region 'us-fake-1' -Verbose
+ $result | Should -HaveCount 2
+
+ Mock -CommandName Get-ACMCertificateDetailsListByName -ModuleName $moduleForMock -MockWith {
+ $testObject = @{
+ DomainName = 'TestDomainName'
+ CertificateArn = 'TestCertificateArn'
+ InUseBy = @(
+ 'TestCertificateUser',
+ 'TestCertificateUser-loadbalancer'
+ )
+ NotAfter = @{
+ Date = 'TestDate'
+ }
+ RenewalEligibility = @{
+ Value = 'TestRenewalEligibility'
+ }
+ }
+
+ return @($testObject)
+ }
+ }
+
+ It 'Returns An Empty Array When No Certificates Are Found' {
+
+ Mock -CommandName Get-ACMCertificateDetailsListByName -ModuleName $moduleForMock -MockWith {
+ return @()
+ }
+
+ $result = Get-ACMCertificateBindingList -DomainName 'TestDomainName' -ProfileName 'temp-test1' -Region 'us-fake-1' -Verbose
+ $result | Should -HaveCount 0
+
+ Mock -CommandName Get-ACMCertificateDetailsListByName -ModuleName $moduleForMock -MockWith {
+ $testObject = @{
+ DomainName = 'TestDomainName'
+ CertificateArn = 'TestCertificateArn'
+ InUseBy = @(
+ 'TestCertificateUser',
+ 'TestCertificateUser-loadbalancer'
+ )
+ NotAfter = @{
+ Date = 'TestDate'
+ }
+ RenewalEligibility = @{
+ Value = 'TestRenewalEligibility'
+ }
+ }
+
+ return @($testObject)
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.SystemEngineering/Public/Get-ACMCertificateDetailsListByName.ps1 b/Modules/Alkami.DevOps.SystemEngineering/Public/Get-ACMCertificateDetailsListByName.ps1
new file mode 100644
index 0000000..7cbcc77
--- /dev/null
+++ b/Modules/Alkami.DevOps.SystemEngineering/Public/Get-ACMCertificateDetailsListByName.ps1
@@ -0,0 +1,81 @@
+function Get-ACMCertificateDetailsListByName {
+
+<#
+.SYNOPSIS
+ Retrieves a list of ACM certificate details by domain name.
+
+.DESCRIPTION
+ Retrieves a list of ACM certificate details by domain name. Unfortunately, AWS did not accomodate
+ this use case when they wrote 'Get-ACMCertificateDetail' or 'Get-ACMCertificateList', and
+ 'Get-ACMCertificateList' returns minimal information about the certificates -- just enough to know the
+ cert exists, but not enough to know anything useful about the certificate.
+
+ Note that this function returns an array because domain name uniqueness is not enforced in ACM.
+
+.PARAMETER DomainName
+ [string] The domain name of the ACM certificates to retrieve.
+
+.PARAMETER ProfileName
+ [string] The AWS profile to use during ACM queries.
+
+.PARAMETER Region
+ [string] The AWS region to use during ACM queries.
+
+.EXAMPLE
+ Get-ACMCertificateDetailsListByName -DomainName '*.sandbox.alkami.net' -ProfileName 'temp-prod' -Region 'us-east-1'
+#>
+
+ [CmdletBinding()]
+ [OutputType([PSObject[]])]
+ param (
+ [Parameter(Mandatory = $true)]
+ [ValidateNotNullOrEmpty()]
+ [string] $DomainName,
+
+ [Parameter(Mandatory = $true)]
+ [ValidateNotNullOrEmpty()]
+ [string] $ProfileName,
+
+ [Parameter(Mandatory = $true)]
+ [ValidateScript({$_ -in (Get-AWSRegion).region})]
+ [string] $Region
+ )
+
+ $logLead = (Get-LogLeadName)
+
+ Import-AWSModule
+
+ try {
+
+ # Ref: https://docs.aws.amazon.com/powershell/latest/reference/items/Get-ACMCertificateList.html
+ $certList = ( Get-ACMCertificateList -ProfileName $ProfileName -Region $Region )
+
+ } catch {
+
+ Write-Error "$logLead : Unable to retrieve ACM certificate list from AWS : $($_.Exception.Message)"
+ return $null
+ }
+
+ $result = @()
+ $filteredCertList = $certList | Where-Object { $_.DomainName -eq $DomainName }
+ foreach ( $cert in $filteredCertList ) {
+
+ try {
+
+ # Ref: https://docs.aws.amazon.com/powershell/latest/reference/items/Get-ACMCertificateDetail.html
+ $result += ( Get-ACMCertificateDetail -CertificateArn $cert.CertificateArn -ProfileName $ProfileName -Region $Region )
+
+ } catch {
+
+ Write-Warning "$logLead : Unable to retrieve ACM certificate details for ARN [$($cert.CertificateArn)] : $($_.Exception.Message)"
+ }
+ }
+
+ if ( Test-IsCollectionNullOrEmpty -Collection $result ) {
+
+ Write-Warning "$logLead : No certificates found with a domain name of [$DomainName]."
+ }
+
+
+ return [PSObject[]]$result
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.SystemEngineering/Public/Get-ACMCertificateDetailsListByName.tests.ps1 b/Modules/Alkami.DevOps.SystemEngineering/Public/Get-ACMCertificateDetailsListByName.tests.ps1
new file mode 100644
index 0000000..f5924e1
--- /dev/null
+++ b/Modules/Alkami.DevOps.SystemEngineering/Public/Get-ACMCertificateDetailsListByName.tests.ps1
@@ -0,0 +1,88 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ""
+
+Describe "Get-ACMCertificateDetailsListByName" {
+
+ Mock -CommandName Get-AWSRegion -ModuleName $moduleForMock -MockWith { return @( @{ 'Region' = 'us-east-1' } ) }
+ Mock -CommandName Get-LogLeadName -ModuleName $moduleForMock -MockWith { return 'Get-ACMCertificateDetailsListByName.tests' }
+ Mock -CommandName Import-AWSModule -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Write-Error -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Write-Warning -ModuleName $moduleForMock -MockWith {}
+
+
+ Context "Error Handling" {
+
+ It "Outputs an object of type PSObject[]" {
+
+ (Get-Command Get-ACMCertificateDetailsListByName).OutputType.Type.ToString() | Should -BeExactly "System.Management.Automation.PSObject[]"
+
+ }
+
+ It "Writes Error and Returns Null If ACM Certificate List Throws" {
+
+ Mock -CommandName Get-ACMCertificateList -ModuleName $moduleForMock -MockWith { throw "Test1" }
+ Mock -CommandName Test-IsCollectionNullOrEmpty -ModuleName $moduleForMock -MockWith { return $true }
+ Mock -CommandName Get-ACMCertificateDetail -ModuleName $moduleForMock -MockWith { throw "Test2" }
+
+ Get-ACMCertificateDetailsListByName -DomainName "Test" -ProfileName 'test' -Region 'us-east-1' | Should -BeNull
+
+ Assert-MockCalled -CommandName Write-Error `
+ -ParameterFilter { $Message -match "Unable to retrieve ACM certificate list from AWS .* Test1" } -Times 1 -Exactly -Scope It
+ Assert-MockCalled -CommandName Get-ACMCertificateList -Times 1 -Exactly -Scope It
+ Assert-MockCalled -CommandName Test-IsCollectionNullOrEmpty -Times 0 -Exactly -Scope It
+ }
+
+ It "Writes Warning and Returns Empty Array If ACM Certificate List Returns Null" {
+
+ Mock -CommandName Get-ACMCertificateList -ModuleName $moduleForMock -MockWith { return $null }
+ Mock -CommandName Test-IsCollectionNullOrEmpty -ModuleName $moduleForMock -MockWith { return $true }
+ Mock -CommandName Get-ACMCertificateDetail -ModuleName $moduleForMock -MockWith { throw "Test2" }
+
+ Get-ACMCertificateDetailsListByName -DomainName "Test" -ProfileName 'test' -Region 'us-east-1' | Should -BeExactly @()
+
+ Assert-MockCalled -CommandName Write-Warning `
+ -ParameterFilter { $Message -match "No certificates found" } -Times 1 -Exactly -Scope It
+ Assert-MockCalled -CommandName Get-ACMCertificateList -Times 1 -Exactly -Scope It
+ Assert-MockCalled -CommandName Get-ACMCertificateDetail -Times 0 -Exactly -Scope It
+ Assert-MockCalled -CommandName Test-IsCollectionNullOrEmpty -Times 1 -Exactly -Scope It
+ Assert-MockCalled -CommandName Write-Error -Times 0 -Exactly -Scope It
+ }
+
+ It "Writes Warning and Returns Empty Array If ACM Certificate List Returns Empty Array" {
+
+ Mock -CommandName Get-ACMCertificateList -ModuleName $moduleForMock -MockWith { return @() }
+ Mock -CommandName Test-IsCollectionNullOrEmpty -ModuleName $moduleForMock -MockWith { return $true }
+ Mock -CommandName Get-ACMCertificateDetail -ModuleName $moduleForMock -MockWith { throw "Test2" }
+
+ Get-ACMCertificateDetailsListByName -DomainName "Test" -ProfileName 'test' -Region 'us-east-1' | Should -BeExactly @()
+
+ Assert-MockCalled -CommandName Write-Warning `
+ -ParameterFilter { $Message -match "No certificates found" } -Times 1 -Exactly -Scope It
+ Assert-MockCalled -CommandName Get-ACMCertificateList -Times 1 -Exactly -Scope It
+ Assert-MockCalled -CommandName Get-ACMCertificateDetail -Times 0 -Exactly -Scope It
+ Assert-MockCalled -CommandName Test-IsCollectionNullOrEmpty -Times 1 -Exactly -Scope It
+ Assert-MockCalled -CommandName Write-Error -Times 0 -Exactly -Scope It
+ }
+
+ It "Writes Warning and Returns Empty Array If ACM Certificate Detail Throws" {
+
+ Mock -CommandName Get-ACMCertificateList -ModuleName $moduleForMock -MockWith { return @(@{ DomainName = 'Test'; CertificateArn = 'TestArn'}) }
+ Mock -CommandName Test-IsCollectionNullOrEmpty -ModuleName $moduleForMock -MockWith { return $false }
+ Mock -CommandName Get-ACMCertificateDetail -ModuleName $moduleForMock -MockWith { throw "Test2" }
+
+ Get-ACMCertificateDetailsListByName -DomainName "Test" -ProfileName 'test' -Region 'us-east-1' | Should -BeExactly @()
+
+ Assert-MockCalled -CommandName Write-Warning `
+ -ParameterFilter { $Message -match "Unable to retrieve ACM certificate details for ARN \[TestArn\] : Test2" } -Times 1 -Exactly -Scope It
+ Assert-MockCalled -CommandName Get-ACMCertificateList -Times 1 -Exactly -Scope It
+ Assert-MockCalled -CommandName Get-ACMCertificateDetail -Times 1 -Exactly -Scope It
+ Assert-MockCalled -CommandName Test-IsCollectionNullOrEmpty -Times 1 -Exactly -Scope It
+ Assert-MockCalled -CommandName Write-Error -Times 0 -Exactly -Scope It
+ }
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.SystemEngineering/Public/Get-ActiveDirectoryAccount.ps1 b/Modules/Alkami.DevOps.SystemEngineering/Public/Get-ActiveDirectoryAccount.ps1
new file mode 100644
index 0000000..25d7742
--- /dev/null
+++ b/Modules/Alkami.DevOps.SystemEngineering/Public/Get-ActiveDirectoryAccount.ps1
@@ -0,0 +1,72 @@
+function Get-ActiveDirectoryAccount {
+
+ <#
+ .SYNOPSIS
+ Returns the Active Directory account for a user or service account.
+
+ .DESCRIPTION
+ Returns the Active Directory account for a user or service account. Caller must have domain admin rights
+
+ .PARAMETER Identity
+ [string] The identity of the Active Directory account to retrieve.
+
+ .EXAMPLE
+ Get-ActiveDirectoryAccount -Identity "testUser"
+ #>
+
+ [CmdletBinding()]
+ [OutputType([PSObject[]])]
+ param(
+ [Parameter(Mandatory)]
+ [Alias("Account", "AccountName")]
+ [ValidateNotNullOrEmpty()]
+ [string]$Identity
+ )
+
+ $logLead = (Get-LogLeadName)
+ $trimIdentity = $Identity.Trim()
+
+ # Make sure the caller passed in more than just whitespace
+ if ([String]::IsNullOrEmpty($trimIdentity)) {
+ Write-Warning "$logLead : Identity [$Identity] must contain at least one non-whitespace character."
+ return $null
+ }
+
+ # Look for a normal user
+ try {
+
+ Write-Verbose "$logLead : Attempting to find account using Get-ADUser."
+ $result = Get-ADUser -Identity $trimIdentity -Properties *
+
+ } catch {
+
+ Write-Verbose "$logLead : Account named [$Identity] not found using Get-ADUser: $($_.Exception.Message)"
+ }
+
+ # No normal user account? Check for a gMSA/MSA
+ if ($null -eq $result) {
+
+ try {
+
+ Write-Verbose "$logLead : Attempting to find account using Get-ADServiceAccount."
+ $result = Get-ADServiceAccount -Identity $trimIdentity -Properties *
+
+ } catch {
+
+ Write-Verbose "$logLead : Account named [$Identity] not found using Get-ADServiceAccount: $($_.Exception.Message)"
+ }
+ }
+
+ # Still nothing? Tough luck kid. Write a warning.
+ if ($null -eq $result) {
+
+ Write-Warning "$logLead : No account could be located with the supplied account name."
+
+ if (-NOT (Test-IsUserDomainAdmin)) {
+
+ Write-Warning "$logLead : This command is being run without domain administrative privileges. In some cases, elevated permissions may be required to locate accounts."
+ }
+ }
+
+ return $result
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.SystemEngineering/Public/Get-ActiveDirectoryAccount.tests.ps1 b/Modules/Alkami.DevOps.SystemEngineering/Public/Get-ActiveDirectoryAccount.tests.ps1
new file mode 100644
index 0000000..46ae135
--- /dev/null
+++ b/Modules/Alkami.DevOps.SystemEngineering/Public/Get-ActiveDirectoryAccount.tests.ps1
@@ -0,0 +1,123 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ""
+
+Describe "Get-ActiveDirectoryAccount" {
+
+ $fakeAccountName = "FakeyMcFakeAccount"
+
+ Mock -CommandName Get-LogLeadName -ModuleName $moduleForMock -MockWith { return 'Get-ActiveDirectoryAccount.tests' }
+ Mock -CommandName Write-Warning -ModuleName $moduleForMock -MockWith { }
+
+ Context "User Permissions" {
+
+ It "Writes a Warning if No Account Found and the User Does Not Have Domain Admin Rights" {
+
+ Mock Test-IsUserDomainAdmin -ModuleName $moduleForMock -MockWith { return $false }
+ Mock Get-ADUser -ModuleName $moduleForMock -MockWith { }
+ Mock Get-ADServiceAccount -ModuleName $moduleForMock -MockWith { }
+
+ Get-ActiveDirectoryAccount $fakeAccountName | Should -BeNull
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Write-Warning `
+ -ParameterFilter { $Message -match "This command is being run without domain administrative privileges" } -Times 1 -Exactly -Scope It
+ }
+ }
+
+ Context "When Accounts Are Not Found" {
+
+ Mock -CommandName Write-Verbose -ModuleName $moduleForMock -MockWith { }
+
+ It "Catches Exceptions, Writes to Verbose Stream, and Continues When no AD User Found" {
+
+ $expectedExceptionMessage = "Fuzzy Wuzzy Was A Bear"
+
+ Mock Get-ADUser -ModuleName $moduleForMock -MockWith { throw $expectedExceptionMessage }
+ Mock Get-ADServiceAccount -ModuleName $moduleForMock -MockWith { }
+
+ { Get-ActiveDirectoryAccount $fakeAccountName -Verbose } | Should -Not -Throw
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Write-Verbose `
+ -ParameterFilter { $Message -match "Get-ADUser: $expectedExceptionMessage" } -Times 1 -Exactly -Scope It
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Get-ADUser -Times 1 -Exactly -Scope It
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Get-ADServiceAccount -Times 1 -Exactly -Scope It
+ }
+
+ It "Catches Exceptions, Writes to Verbose Stream, and Continues When no AD Service Account Found" {
+
+ $expectedExceptionMessage = "Fuzzy Wuzzy Had No Hair"
+
+ Mock Get-ADUser -ModuleName $moduleForMock -MockWith { }
+ Mock Get-ADServiceAccount -ModuleName $moduleForMock -MockWith { throw $expectedExceptionMessage }
+
+ { Get-ActiveDirectoryAccount $fakeAccountName -Verbose } | Should -Not -Throw
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Write-Verbose `
+ -ParameterFilter { $Message -match "Get-ADServiceAccount: $expectedExceptionMessage" } -Times 1 -Exactly -Scope It
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Get-ADUser -Times 1 -Exactly -Scope It
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Get-ADServiceAccount -Times 1 -Exactly -Scope It
+ }
+ }
+
+ Context "Parameter Validation and Manipulation" {
+
+ Mock Test-IsUserDomainAdmin -ModuleName $moduleForMock -MockWith { return $true }
+
+ It "Outputs an object of type PSObject[]" {
+
+ (Get-Command Get-ActiveDirectoryAccount).OutputType.Type.ToString() | Should -BeExactly "System.Management.Automation.PSObject[]"
+
+ }
+
+ It "Writes a Warning and Exits Early if the Identity Contains Only Whitespace Characters" {
+
+ Mock Get-ADUser -ModuleName $moduleForMock -MockWith { }
+ Mock Get-ADServiceAccount -ModuleName $moduleForMock -MockWith { }
+
+ Get-ActiveDirectoryAccount " " | Should -BeNull
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Write-Warning `
+ -ParameterFilter { $Message -match "Identity \[ \] must contain at least one non-whitespace character." } -Times 1 -Exactly -Scope It
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Get-ADUser -Times 0 -Exactly -Scope It
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Get-ADServiceAccount -Times 0 -Exactly -Scope It
+ }
+
+ It "Does Not Call Get-ADServiceAccount if Get-ADUser Returns User" {
+
+ Mock Get-ADUser -ModuleName $moduleForMock -MockWith { return (New-Object Microsoft.ActiveDirectory.Management.ADAccount($fakeAccountName)) }
+ Mock Get-ADServiceAccount -ModuleName $moduleForMock -MockWith { }
+
+ Get-ActiveDirectoryAccount $fakeAccountName | Should -Not -BeNull
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Write-Warning -Times 0 -Exactly -Scope It
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Get-ADServiceAccount -Times 0 -Exactly -Scope It
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Get-ADUser `
+ -ParameterFilter { $Identity.ToString() -eq $fakeAccountName } -Times 1 -Exactly -Scope It
+ }
+
+ It "Does Call Get-ADServiceAccount if Get-ADUser Returns Null" {
+
+ Mock Get-ADUser -ModuleName $moduleForMock -MockWith { }
+ Mock Get-ADServiceAccount -ModuleName $moduleForMock -MockWith { return (New-Object Microsoft.ActiveDirectory.Management.ADAccount($fakeAccountName)) }
+
+ Get-ActiveDirectoryAccount $fakeAccountName | Should -Not -BeNull
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Write-Warning -Times 0 -Exactly -Scope It
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Get-ADUser `
+ -ParameterFilter { $Identity.ToString() -eq $fakeAccountName } -Times 1 -Exactly -Scope It
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Get-ADServiceAccount `
+ -ParameterFilter { $Identity.ToString() -eq $fakeAccountName } -Times 1 -Exactly -Scope It
+ }
+
+ It "Trims Provided Account Name" {
+
+ Mock Get-ADUser -ModuleName $moduleForMock -MockWith { }
+ Mock Get-ADServiceAccount -ModuleName $moduleForMock -MockWith { return (New-Object Microsoft.ActiveDirectory.Management.ADAccount($fakeAccountName)) }
+
+ Get-ActiveDirectoryAccount " $fakeAccountName " | Should -Not -BeNull
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Write-Warning -Times 0 -Exactly -Scope It
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Get-ADUser `
+ -ParameterFilter { $Identity.ToString() -eq $fakeAccountName } -Times 1 -Exactly -Scope It
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Get-ADServiceAccount `
+ -ParameterFilter { $Identity.ToString() -eq $fakeAccountName } -Times 1 -Exactly -Scope It
+ }
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.SystemEngineering/Public/Get-BitLockerRecoveryKeys.ps1 b/Modules/Alkami.DevOps.SystemEngineering/Public/Get-BitLockerRecoveryKeys.ps1
new file mode 100644
index 0000000..fda0c7d
--- /dev/null
+++ b/Modules/Alkami.DevOps.SystemEngineering/Public/Get-BitLockerRecoveryKeys.ps1
@@ -0,0 +1,65 @@
+function Get-BitLockerRecoveryKeys {
+
+ <#
+ .SYNOPSIS
+ Returns the BitLocker recovery key for a computer
+
+ .DESCRIPTION
+ Returns the BitLocker recovery key for a computer. Caller must have domain admin rights
+
+ .PARAMETER HostNames
+ [string[]] The hostname or array of hostnames to return
+
+ .EXAMPLE
+ Get-BitLockerRecoveryKey "ALK-DELL1234"
+
+ .EXAMPLE
+ Get-BitLockerRecoveryKey @("ALK-DELL1234", "ALK-DELL23456")
+ #>
+
+ [CmdletBinding()]
+ [OutputType([System.Object[]])]
+ param(
+ [Parameter(Mandatory)]
+ [Alias("Computers","ComputerNames")]
+ [string[]]$HostNames
+ )
+
+ $logLead = (Get-LogLeadName)
+
+ if (!(Test-IsUserDomainAdmin)) {
+
+ Write-Warning "$logLead : You must have domain administrative privileges to run this command"
+ return $null
+ }
+
+ [array]$hostsToCheck = $HostNames
+ $recoveryKeys = @()
+ foreach ($hostName in $hostsToCheck) {
+
+ $computer = Get-ADComputer $hostName -property DistinguishedName
+
+ if ( $null -eq $computer ) {
+ Write-Warning "$logLead : Unable to find host [$hostName] in AD; verify your hostname."
+ continue
+ }
+
+ $bitLockerObject = Get-ADObject -Filter {objectclass -eq 'msFVE-RecoveryInformation'} `
+ -SearchBase $computer.DistinguishedName -Properties *
+ $recoveryKey = $bitLockerObject.'msFVE-RecoveryPassword'
+
+ if ([String]::IsNullOrEmpty($recoveryKey)) {
+
+ Write-Warning "$logLead : Unable to retrieve BitLocker Recovery value for host: [$hostName]"
+ continue
+ }
+
+ $recoveryKeys += New-Object PSObject -Property @{
+
+ HostName = $hostname
+ RecoveryKey = $recoveryKey
+ }
+ }
+
+ return $recoveryKeys
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.SystemEngineering/Public/Get-BitLockerRecoveryKeys.tests.ps1 b/Modules/Alkami.DevOps.SystemEngineering/Public/Get-BitLockerRecoveryKeys.tests.ps1
new file mode 100644
index 0000000..0c4a9b0
--- /dev/null
+++ b/Modules/Alkami.DevOps.SystemEngineering/Public/Get-BitLockerRecoveryKeys.tests.ps1
@@ -0,0 +1,58 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ""
+
+Describe "Get-BitLockerRecoveryKeys" {
+
+ Mock -CommandName Get-LogLeadName -ModuleName $moduleForMock -MockWith { return 'Get-BitLockerRecoveryKeys.tests' }
+ Mock -CommandName Write-Warning -ModuleName $moduleForMock -MockWith { }
+
+ Context "User Permissions" {
+
+ It "Writes a Warning and Exits Early if the User Does Not Have Domain Admin Rights" {
+
+ Mock Test-IsUserDomainAdmin -ModuleName $moduleForMock -MockWith { return $false }
+ Mock Get-ADComputer -ModuleName $moduleForMock -MockWith { }
+ Mock Get-ADObject -ModuleName $moduleForMock -MockWith { }
+
+ Get-BitLockerRecoveryKeys "FAKEHOST123" | Should -BeNull
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Write-Warning `
+ -ParameterFilter { $Message -match "You must have domain administrative privileges" } -Times 1 -Exactly -Scope It
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Get-ADComputer -Times 0 -Exactly -Scope It
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Get-ADObject -Times 0 -Exactly -Scope It
+ }
+ }
+
+ Context "Parameter Validation and Manipulation" {
+
+ Mock Test-IsUserDomainAdmin -ModuleName $moduleForMock -MockWith { return $true }
+ Mock Get-ADObject -ModuleName $moduleForMock -MockWith { return $null }
+
+ It "Writes a Warning and Continues if the Computer Is Not Found" {
+
+ Mock Get-ADComputer -ModuleName $moduleForMock -MockWith { return $null }
+
+ Get-BitLockerRecoveryKeys "FAKEHOST123" | Should -HaveCount 0
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Write-Warning `
+ -ParameterFilter { $Message -match "Unable to find host \[FAKEHOST123\] in AD; verify your hostname." } -Times 1 -Exactly -Scope It
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Get-ADComputer -Times 1 -Exactly -Scope It
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Get-ADObject -Times 0 -Exactly -Scope It
+ }
+
+ It "Writes a Warning and Continues if the AD Object Is Not Found" {
+
+ Mock Get-ADComputer -ModuleName $moduleForMock -MockWith { return @{ DistinguishedName = 'CN=Test,CN=Managed Service Accounts,DC=foo,DC=bar'} }
+
+ Get-BitLockerRecoveryKeys "FAKEHOST123" | Should -HaveCount 0
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Write-Warning `
+ -ParameterFilter { $Message -match "Unable to retrieve BitLocker Recovery value for host: \[FAKEHOST123\]" } -Times 1 -Exactly -Scope It
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Get-ADComputer -Times 1 -Exactly -Scope It
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Get-ADObject `
+ -ParameterFilter { ($SearchBase -match "CN=Test,CN=Managed Service Accounts,DC=foo,DC=bar")} -Times 1 -Exactly -Scope It
+ }
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.SystemEngineering/Public/Get-DnsByIP.ps1 b/Modules/Alkami.DevOps.SystemEngineering/Public/Get-DnsByIP.ps1
new file mode 100644
index 0000000..4d20291
--- /dev/null
+++ b/Modules/Alkami.DevOps.SystemEngineering/Public/Get-DnsByIP.ps1
@@ -0,0 +1,60 @@
+function Get-DnsByIP {
+
+ <#
+ .SYNOPSIS
+ Retrieves all DNS records for a given IP address from Active Directory DNS.
+
+ .DESCRIPTION
+ Retrieves all DNS records for a given IP address from Active Directory DNS.
+
+ .PARAMETER DNSServer
+ [string] The DNS server to query.
+
+ .PARAMETER IPAddress
+ [string] The IP Address to query against.
+
+ .EXAMPLE
+ Get-DnsByIP -TargetIP 192.168.4.55 -DnsServer 'dc314212.fh.local'
+ #>
+
+ [CmdletBinding()]
+ [OutputType([System.Object[]])]
+ param(
+ [Alias("DomainController")]
+ [string]$DNSServer = "localhost",
+
+ [Parameter(Mandatory)]
+ [Alias("TargetIP")]
+ [string]$IPAddress
+ )
+
+ $logLead = (Get-LogLeadName)
+
+ #Verify that the 'Get-DnsServerZone' command is available on the workstation
+ if ($null -ne (Get-Command -Name Get-DnsServerZone -ErrorAction SilentlyContinue)) {
+ #Get all of the DNS Zones
+ $zones = @(Get-DnsServerZone -ComputerName $DNSServer).ZoneName
+
+ #Is the $zones array empty?
+ if (Test-IsCollectionNullOrEmpty -Collection $zones) {
+ Write-Host "$logLead : No zones found"
+ return
+ }
+
+ #Create an array
+ $resources = @()
+
+ #Iterate through each zone and add to $resources array if it matches the $IPAddress parameter value
+ foreach ($zone in $zones) {
+
+ $resources += (Get-DnsServerResourceRecord -ZoneName $zone -ComputerName $DNSServer) | Where-Object {$_.RecordData.IPv4Address.IPAddressToString -eq $IPAddress}
+ }
+
+ return $resources
+ }
+ else {
+
+ Write-Error "$logLead : The command 'Get-DnsServerZone' does not exist on this system. Please verify you are running this on a Domain Controller under an admin accont"
+
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.SystemEngineering/Public/Get-DnsByIP.tests.ps1 b/Modules/Alkami.DevOps.SystemEngineering/Public/Get-DnsByIP.tests.ps1
new file mode 100644
index 0000000..9120de7
--- /dev/null
+++ b/Modules/Alkami.DevOps.SystemEngineering/Public/Get-DnsByIP.tests.ps1
@@ -0,0 +1,111 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ""
+
+Describe "Test Get-DnsByIP" {
+
+ BeforeAll {
+ #create a module for unavailable commands
+ $testModule = New-Module -Name DnsServer -ScriptBlock {
+ function Get-DnsServerZone (){"Get-DnsServerZone"}
+ function Get-DnsServerResourceRecord (){"Get-DnsServerResourceRecord"}
+ }
+
+ $testModule | Import-Module
+ }
+
+ Mock -CommandName Get-LogLeadName -ModuleName $moduleForMock -MockWith { return 'Get-DnsByIP.tests' }
+ Mock -CommandName Write-Error -ModuleName $moduleForMock -MockWith { return $true }
+ # Pretend that the Get-DnsServerZone cmdlet is ALWAYS available
+ Mock -CommandName Get-Command -ModuleName $moduleForMock -MockWith { return $true }
+ Mock -CommandName Get-DnsServerZone -ModuleName $moduleForMock -MockWith {return [pscustomobject]@{"ZoneName" = "TestZone"}}
+ # Provide a set of fake objects to check against at the end of the function
+ $mockRecords = {
+ $records = @()
+ $records += [PSCustomObject]@{
+ Name = 'VALID'
+ RecordData = @{
+ IPv4Address = @{
+ IPAddressToString = '192.168.4.3'
+ }
+ }
+ }
+ $records += [PSCustomObject]@{
+ Name = 'NOT-VALID'
+ RecordData = @{
+ IPv4Address = @{
+ IPAddressToString = '192.168.4.7'
+ }
+ }
+ }
+ return $records
+ }
+
+ Mock -CommandName Get-DnsServerResourceRecord -ModuleName $moduleForMock -MockWith $mockRecords
+
+ Context "Testing" {
+
+ It "Executes properly when Get-Command is not found" {
+
+ Mock -CommandName Get-Command -ModuleName $moduleForMock -MockWith { return $false }
+
+ Get-DnsByIP -TargetIP 192.168.4.55 -DnsServer 'dc314212.fh.local'
+
+ Assert-MockCalled -CommandName Get-Command -Times 1 -Exactly -Scope It
+
+ }
+
+ It "Calls cmdlets the correct amount of times with the -DnsServer parameter" {
+
+ Mock -CommandName Get-Command -ModuleName $moduleForMock -MockWith { return $true }
+
+ Get-DnsByIP -TargetIP 192.168.4.3 -DnsServer 'dc314212.fh.local'
+
+ Assert-MockCalled -CommandName Get-DnsServerResourceRecord -Times 1 -Exactly -Scope It
+ Assert-MockCalled -CommandName Get-DnsServerZone -Times 1 -Exactly -Scope It
+
+ }
+
+ It "Calls cmdlets properly without the -DnsServer parameter" {
+
+ Get-DnsByIP -TargetIP 192.168.4.55
+
+ Assert-MockCalled -CommandName Get-DnsServerZone -Times 1 -Exactly -Scope It
+ Assert-MockCalled -CommandName "Get-DnsServerResourceRecord" -Times 1 -Exactly -Scope It
+
+ }
+
+ It "Properly finds only the right resource records" {
+
+ $result = Get-DnsByIP -TargetIP 192.168.4.3
+
+ $result.RecordData.IPv4Address.IPAddressToString | Should -Be '192.168.4.3'
+
+ }
+
+ It "Returns an empty array if no matches are found, remote DNS Server specified" {
+ $result = Get-DnsByIP -DNSServer "ADC.local" -IPAddress "192.168.4.55"
+
+ $result | Should -Be @()
+ Assert-MockCalled -CommandName Get-DnsServerZone -Times 1 -Exactly -Scope It
+ Assert-MockCalled -CommandName Get-DnsServerResourceRecord -Times 1 -Exactly -Scope It
+
+ }
+
+ It "Returns an empty array if no matches are found, remote DNS Server not specified" {
+ $result = Get-DnsByIP -TargetIP "192.168.4.55"
+
+ $result | Should -Be @()
+ Assert-MockCalled -CommandName Get-DnsServerZone -Times 1 -Exactly -Scope It
+ Assert-MockCalled -CommandName Get-DnsServerResourceRecord -Times 1 -Exactly -Scope It
+
+ }
+
+
+ }
+
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.SystemEngineering/Public/Get-DomainNameDistinguishedName.ps1 b/Modules/Alkami.DevOps.SystemEngineering/Public/Get-DomainNameDistinguishedName.ps1
new file mode 100644
index 0000000..c53a1d0
--- /dev/null
+++ b/Modules/Alkami.DevOps.SystemEngineering/Public/Get-DomainNameDistinguishedName.ps1
@@ -0,0 +1,46 @@
+function Get-DomainNameDistinguishedName {
+
+ <#
+ .SYNOPSIS
+ Splits a domain name string in to distinguished name format.
+
+ .DESCRIPTION
+ Splits a domain name string in to distinguished name format. I.e. DC=sample,DC=domain,DC=local
+
+ .PARAMETER DomainName
+ [string] The Domain Name to Split
+
+ .EXAMPLE
+ Get-DomainNameDistinguishedName "corp.alkamitech.com" -verbose
+VERBOSE: [Get-DomainNameDistinguishedName] : Formatting: [corp.alkamitech.com]
+VERBOSE: [Get-DomainNameDistinguishedName] : Result: [DC=corp,DC=alkamitech,DC=com]
+
+DC=corp,DC=alkamitech,DC=com
+ #>
+
+ [CmdletBinding()]
+ [OutputType([string])]
+ param(
+ [Parameter(Mandatory=$true)]
+ [ValidateNotNullOrEmpty()]
+ [string]$DomainName
+ )
+
+ $logLead = Get-LogLeadName
+
+ if ($DomainName -match "DC=|OU=") {
+
+ Write-Host "$logLead : Domain Name [$DomainName] already appears to be distinguished name format. No transform will occur."
+ return $DomainName
+ }
+
+ Write-Verbose "$logLead : Formatting: [$DomainName]"
+
+ # ToDo: Use Join String when we convert to PS7
+ # Split on the '.' character, prefix each segment with DC=, then join with ','
+ $domainSplitWithPrefix = $DomainName.Split('.') | ForEach-Object { "DC=" + $_ }
+ $domainNameDistinguishedFormat = ($domainSplitWithPrefix -join ",").TrimEnd(',')
+
+ Write-Verbose "$logLead : Result: [$domainNameDistinguishedFormat]"
+ return $domainNameDistinguishedFormat
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.SystemEngineering/Public/Get-DomainNameDistinguishedName.tests.ps1 b/Modules/Alkami.DevOps.SystemEngineering/Public/Get-DomainNameDistinguishedName.tests.ps1
new file mode 100644
index 0000000..415f934
--- /dev/null
+++ b/Modules/Alkami.DevOps.SystemEngineering/Public/Get-DomainNameDistinguishedName.tests.ps1
@@ -0,0 +1,33 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ""
+
+Describe "Get-DomainNameDistinguishedName" {
+
+ Mock -CommandName Get-LogLeadName -ModuleName $moduleForMock -MockWith { return 'Get-DomainNameDistinguishedName.tests' }
+
+ Context "Output Validation" {
+
+ It "Handles One-Segment Domain Names" {
+
+ Get-DomainNameDistinguishedName "SomeDomainName" | Should -Be "DC=SomeDomainName"
+ }
+
+ It "Handles Multi-Segment Domain Names" {
+
+ Get-DomainNameDistinguishedName "Some.Domain.Name" | Should -Be "DC=Some,DC=Domain,DC=Name"
+ }
+ }
+
+ Context 'Parameter Validation' {
+
+ It "Returns the Input Parameter if the Parameter is DN Formatted" {
+
+ Get-DomainNameDistinguishedName "DC=Some,DC=Domain,DC=Name" | Should -Be "DC=Some,DC=Domain,DC=Name"
+ }
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.SystemEngineering/Public/Get-SecurityGroupsForUser.ps1 b/Modules/Alkami.DevOps.SystemEngineering/Public/Get-SecurityGroupsForUser.ps1
new file mode 100644
index 0000000..bb9bdec
--- /dev/null
+++ b/Modules/Alkami.DevOps.SystemEngineering/Public/Get-SecurityGroupsForUser.ps1
@@ -0,0 +1,73 @@
+function Get-SecurityGroupsForUser {
+
+ <#
+ .SYNOPSIS
+ Returns security group membership for a user
+
+ .DESCRIPTION
+ Returns security group membership for a user. Extended security group properties can be accessed from within the Groups property on the return object
+
+ .PARAMETER User
+ [string] The username to query
+
+ .EXAMPLE
+ Get-SecurityGroupsForUser "fake.mcfakeuser"
+
+ #>
+
+ [CmdletBinding()]
+ [OutputType([System.Object[]])]
+ Param(
+ [Parameter(Mandatory)]
+ [Alias("User")]
+ [string]$UserName
+ )
+
+ $logLead = (Get-LogLeadName)
+
+ if ($UserName -match "\\") {
+
+ Write-Verbose "$logLead : Trimming Domain from UserName"
+ $actualUserName = $UserName.Split("\\") | Select-Object -Last 1
+
+ } elseif ($UserName -match "@") {
+
+ Write-Verbose "$logLead : Trimming SAMAccountName Suffix from UserName"
+ $actualUserName = $UserName.Split("@") | Select-Object -First 1
+
+ } else {
+
+ $actualUserName = $UserName
+ }
+
+ Write-Host "$logLead : Looking up user information for user: [$actualUserName]"
+
+ $actualUser = Get-ActiveDirectoryAccount -Identity $actualUserName
+
+ if ($null -eq $actualUser) {
+
+ Write-Warning "$logLead : Could not query user details for user: [$actualUserName]"
+ return $null
+ }
+
+ $userGroupDNs = $actualUser | Select-Object -ExpandProperty memberOf
+
+ $securityGroups = @()
+ foreach ($group in $userGroupDNs) {
+
+ $group = (Get-ADGroup $group)
+ $securityGroup = New-Object PSObject -Property @{
+ Name = $group.Name;
+ SamAccountName = $group.SamAccountName;
+ DistinguishedName = $group.DistinguishedName;
+ SID = $group.SID;
+ Category = $group.GroupCategory;
+ Scope = $group.GroupScope;
+ }
+
+ $securityGroup | Add-Member ScriptMethod ToString { $this.Name } -Force
+ $securityGroups += New-Object PSObject -Property @{ Group = $securityGroup; }
+ }
+
+ return ($securityGroups | Sort-Object -Property {$_.Group.Name})
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.SystemEngineering/Public/Get-SecurityGroupsForUser.tests.ps1 b/Modules/Alkami.DevOps.SystemEngineering/Public/Get-SecurityGroupsForUser.tests.ps1
new file mode 100644
index 0000000..390af9d
--- /dev/null
+++ b/Modules/Alkami.DevOps.SystemEngineering/Public/Get-SecurityGroupsForUser.tests.ps1
@@ -0,0 +1,41 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ""
+
+Describe "Get-SecurityGroupsForUser" {
+
+ Mock -CommandName Get-LogLeadName -ModuleName $moduleForMock -MockWith { return 'Get-SecurityGroupsForUser.tests' }
+ Mock -CommandName Get-ActiveDirectoryAccount -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Get-ADGroup -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Write-Warning -ModuleName $moduleForMock -MockWith {}
+
+ Context "Parameter Validation" {
+
+ It "Strips off Domain Prefixes for User Lookup" {
+
+ Get-SecurityGroupsForUser "CORP\FakeUser"
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Get-ActiveDirectoryAccount `
+ -ParameterFilter { $Identity.ToString() -eq "FakeUser"; } -Times 1 -Exactly -Scope It
+ }
+
+ It "Strips off SAMAccountName Domain Suffixes for User Lookup" {
+
+ Get-SecurityGroupsForUser "FakeUser@corp.alkamitech.com"
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Get-ActiveDirectoryAccount `
+ -ParameterFilter { $Identity.ToString() -eq "FakeUser"; } -Times 1 -Exactly -Scope It
+ }
+
+ It "Writes a Warning and Returns Null if the User Is Not Found" {
+
+ Mock -CommandName Write-Warning -ModuleName $moduleForMock -MockWith {}
+
+ Get-SecurityGroupsForUser "FakeUser" | Should -BeNullOrEmpty
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Write-Warning `
+ -ParameterFilter { $Message -match "Could not query user details for user" } -Times 1 -Exactly -Scope It
+ }
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.SystemEngineering/Public/Get-TerminatedComputersReport.ps1 b/Modules/Alkami.DevOps.SystemEngineering/Public/Get-TerminatedComputersReport.ps1
new file mode 100644
index 0000000..f46daf3
--- /dev/null
+++ b/Modules/Alkami.DevOps.SystemEngineering/Public/Get-TerminatedComputersReport.ps1
@@ -0,0 +1,272 @@
+function Get-TerminatedComputersReport {
+ <#
+ .SYNOPSIS
+ Generates a report of computers that have not "logged in" in the last n days.
+
+ .DESCRIPTION
+ Generates a report of computers that have not "logged in" in the last n days.
+ If an AWS Profile is provided it will also compare against EC2 instances to attempt
+ to validate a confidence level (0 - 4) of termination, 0 indicating it is almost guaranteed
+ to be an active system and 4 meaning almost guaranteed to be inactive and "terminated".
+ If not compared against AWS termination confidence will be 0 or 1. A 1 indicates that
+ the machine password has not been changed in at least 90 days.
+
+ .PARAMETER Domain
+ If providing credentials for an alternate domain, specify the domain.
+
+ .PARAMETER Credential
+ Used when connecting to a non-native domain to generate a report.
+
+ .PARAMETER OU
+ Optional parameter, specify the search base for AD computer objects.
+ Example: "OU=FH Computers,DC=FH,DC=local"
+
+ .PARAMETER NumberOfDays
+ The cut off value for the last logon filter, defaults to 120 days.
+
+ .PARAMETER CheckAWS
+ Will compare AD to AWS instances for increased accuracy of termination confidence.
+
+ .OUTPUTS
+ [pscustomobject]
+
+ .EXAMPLE
+ New-TerminatedComputersReport -Domain fh.local -Credential $credential -OU "OU=POD0,OU=FH Computers,DC=fh,DC=local" -NumberOfDays 90
+
+ This example retrieves all computer objects in the POD0 OU that have not logged on in preceeding 90 days, a [pscredential] was created and passed in as $credential
+ .EXAMPLE
+ New-TerminatedComputersReport -Domain fh.local -Credential $credential -OU "OU=POD0,OU=FH Computers,DC=fh,DC=local" -NumberOfDays 90 -CheckAWS
+
+ This example retrieves all computer objects in the POD0 OU that have not logged on in preceeding 90 days, then retrieves EC2 instance information.
+ A comparison of Name and IP is done to determine a termination confidence score, which is noted in the output. A [pscredential] was created and passed in as $credential.
+ .EXAMPLE
+ New-TerminatedComputersReport -Domain fh.local -Credential $credential -OU "OU=POD0,OU=FH Computers,DC=fh,DC=local" -NumberOfDays 90 -CheckAWS | Export-Csv -Path $env:TEMP\TerminatedComputers.csv -NoTypeInformation
+
+ This example retrieves all computer objects in the POD0 OU that have not logged on in preceeding 90 days, then retrieves EC2 instance information.
+ It is then piped to Export-Csv for later review.
+
+
+ #>
+ [cmdletbinding(DefaultParameterSetName = "Default")]
+ param(
+ [parameter(ParameterSetName = "Credentialed", Mandatory = $true)]
+ [string]
+ [ValidateSet("fh.local", "corp.alkamitech.com")]
+ $Domain,
+ [parameter(ParameterSetName = "Credentialed", Mandatory = $true)]
+ [pscredential]
+ $Credential,
+ [parameter(ParameterSetName = "Credentialed", Mandatory = $false)]
+ [parameter(ParameterSetName = "Default", Mandatory = $false)]
+ [ValidateNotNullorEmpty()]
+ [string]
+ $OU,
+ [parameter(ParameterSetName = "Credentialed", Mandatory = $false)]
+ [parameter(ParameterSetName = "Default", Mandatory = $false)]
+ [uint16]
+ [ValidateRange(1, 365)]
+ $NumberOfDays = 120,
+ [parameter(ParameterSetName = "Credentialed", Mandatory = $false)]
+ [parameter(ParameterSetName = "Default", Mandatory = $false)]
+ [switch]
+ $CheckAWS
+ )
+
+ $outputObjects = [System.Collections.Generic.List[System.Object]]::new()
+ $logLead = Get-LogLeadName
+
+ # Make sure AWS Powershell modul is loaded, if needed
+ if ($PSBoundParameters.ContainsKey("CheckAWS")) {
+ # Set up some sorting buckets
+ $ec2Instances = [System.Collections.Generic.List[System.Object]]::new()
+ $ec2ObjectIndexByIP = [System.Collections.Specialized.OrderedDictionary]::new()
+ $ec2ObjectIndexByName = [System.Collections.Specialized.OrderedDictionary]::new()
+ $ec2IPIndex = 0
+ $ec2NameIndex = 0
+ # Check if this is running in TeamCity, set accounts as appropriate
+ if ((Test-IsTeamCityProcess)) {
+ $awsAccounts = "prod", "dev", "qa", "corp"
+ }
+ else {
+ $awsAccounts = "temp-prod", "temp-dev", "temp-qa", "temp-corp"
+ }
+ # Make sure AWSPowerShell is loaded/can be loaded
+ if (!((Get-Module).Name -contains "AWSPowerShell")) {
+ try {
+ Import-Module AWSPowerShell
+ }
+ catch {
+ Write-Error "$logLead : Unable to load the AWSPowerShell module"
+ return
+ }
+ }
+ }
+
+ # If we are specifying credentials we need to make sure we connect to the correct domain for operations
+ if ($PSBoundParameters.ContainsKey("Credential")) {
+ try {
+ $fastDomainController = ((Resolve-DnsName $Domain -ErrorAction Stop).IPAddress | ForEach-Object { Test-Connection $_ -Count 1 -ErrorAction SilentlyContinue } | Sort-Object ResponseTime)[0].Address
+ }
+ catch {
+ Write-Error "$logLead : Error when resolving DNS for $Domain"
+ return
+ }
+ }
+
+ $date = (Get-Date).AddDays(-$NumberofDays)
+
+ # Build a query for AD
+ $adComputersSplat = @{}
+ $adComputersSplat += @{"Filter" = { lastLogonDate -lt $date } }
+ $adComputersSplat += @{"Properties" = @("lastLogonDate", "PasswordLastSet", "Name", "IPv4Address") }
+ if ($PSBoundParameters.ContainsKey("Credential")) {
+ $adComputersSplat += @{"Credential" = $Credential }
+ $adComputersSplat += @{"Server" = $fastDomainController }
+ }
+ if ($PSBoundParameters.ContainsKey("OU")) { $adComputersSplat += @{"SearchBase" = $OU } }
+ # Get AD Computer objects that match our filter
+ Write-Progress -Activity "Generating Report" -Status "Retrieving AD Objects" -Id 1 -PercentComplete 25
+ try {
+ $adComputers = Get-ADComputer @adComputersSplat
+ } catch [System.Security.Authentication.AuthenticationException] {
+ Write-Error "$logLead : Unable to connect to the target domain, invalid credentials."
+ return
+ } catch {
+ Write-Error "$logLead : Unable to retrieve Computer objects from AD."
+ return
+ }
+
+
+
+ if ($PSBoundParameters.ContainsKey("CheckAWS")) {
+ # Retrieve EC2 data
+ Write-Progress -Activity "Generating Report" -Status "Retrieving EC2 Data" -Id 1 -PercentComplete 50
+ $incrementPercent = 60
+ foreach ($awsAccount in $awsAccounts) {
+ Write-Progress -Activity "Generating Report" -Status "Retrieving EC2 Data from $($awsAccount)" -Id 1 -PercentComplete $incrementPercent
+ try {
+ $ec2Instances += (Get-EC2Instance -ProfileName $AWSAccount -Region us-east-1).Instances
+ } catch {
+ Write-Error "$logLead : An error occurred while retrieving EC2 instances in us-east-1 for account $($awsAccount)."
+ }
+
+ if ($awsAccount -like "*prod*") {
+ try {
+ $ec2Instances += (Get-EC2Instance -ProfileName $AWSAccount -Region us-west-2).Instances
+ } catch {
+ Write-Error "$logLead : An error occurred while retrieving EC2 instances in us-west-2 for account $($awsAccount)."
+ }
+ }
+ $incrementPercent += 5
+ }
+
+ # Build some indexes to speed things up later
+ foreach ($instance in $ec2Instances) {
+ Write-Progress -Activity "Generating Report" -Status "Retrieving EC2 Data" -Id 1 -PercentComplete ($incrementPercent + 5)
+ # Index by IPv4 Address
+ if ($instance.PrivateIpAddress) {
+ $ec2ObjectIndexByIP.Add($instance.PrivateIpAddress, $ec2IPIndex)
+ }
+ $ec2IPIndex++
+ }
+ foreach ($instance in $ec2Instances) {
+ # Index by the assigned Hostname
+ if ($instance.tags.key -contains "alk:hostname") {
+ $name = ($instance.tags | Where-Object { $_.Key -eq "alk:hostname" }).Value.ToLower()
+ # There are some duplicate hostnames, this will append the index number to avoid throwing an error
+ if (!($ec2ObjectIndexByName.Contains($name))) {
+ $ec2ObjectIndexByName.Add($name, $ec2NameIndex)
+ }
+ else {
+ $name = $name + $ec2NameIndex
+ $ec2ObjectIndexByName.Add($name, $ec2NameIndex)
+ }
+ }
+ $ec2NameIndex++
+ }
+ # Now that we have indexes let's see if we have matching EC2 Instances
+ foreach ($computer in $adComputers) {
+ try {
+ $nameIndex = $ec2ObjectIndexByName[$computer.Name.ToLower()]
+ }
+ catch {
+ $nameIndex = $null
+ }
+ try {
+ $ipIndex = $ec2ObjectIndexByIP[$computer.IPv4Address]
+ }
+ catch {
+ $ipIndex = $null
+ }
+ if (!($null -eq $nameIndex)) {
+ $instanceByName = $ec2Instances[$nameIndex]
+ }
+ else {
+ $instanceByName = $null
+ }
+ if (!($null -eq $ipIndex)) {
+ $instanceByIPv4 = $ec2Instances[$ipIndex]
+ }
+ else {
+ $instanceByIPv4 = $null
+ }
+ # Compare results
+ if (!($null -eq $ipIndex) -or !($null -eq $nameIndex)) {
+ $ec2NameMatchesIP = (($instanceByName.InstanceId) -eq ($instanceByIPv4.InstanceId))
+ }
+ else {
+ $ec2NameMatchesIP = $false
+ }
+ # If things look to be lining up lets try and get the designation number if it exists
+ if ($ec2NameMatchesIP -eq $true) {
+ if ($instanceByName.tags.key -contains "alk:designation") {
+ $designation = ($instanceByName.tags | Where-Object { $_.Key -eq "alk:designation" }).Value
+ }
+ }
+ else {
+ $designation = $null
+ }
+ # Build a custom object
+ $tempObj = New-Object -TypeName PSObject
+ # Store values
+ $tempObjProps = [ordered]@{
+ "ADName" = $computer.Name
+ "IsEnabled" = $computer.Enabled
+ "ADIPv4Address" = $computer.IPv4Address
+ "LastLogon" = $computer.lastLogonDate
+ "PasswordLastSet" = $computer.PasswordLastSet
+ "SID" = $computer.SID
+ "InstanceIdByIP" = ($instanceByIPv4.InstanceId)
+ "InstanceIdByName" = ($instanceByName.InstanceId)
+ "DoesInstanceMatch" = $ec2NameMatchesIP
+ "designation" = $designation
+ "TerminationConfidence" = [byte](($null -eq $instanceByIPv4) + ($null -eq $instanceByName) + !($ec2NameMatchesIP) + ($computer.PasswordLastSet -lt (Get-Date).AddDays(-90)))
+ }
+ # Add custom properties
+ $tempObj | Add-Member -NotePropertyMembers $tempObjProps -TypeName ComputerObject
+ $outputObjects.Add($tempObj)
+ }
+ }
+ else {
+ Write-Progress -Activity "Generating Report" -Id 1 -PercentComplete 95
+ foreach ($computer in $adComputers) {
+ # Build a custom object
+ $tempObj = New-Object -TypeName PSObject
+ # Store values
+ $tempObjProps = [ordered]@{
+ "ADName" = $computer.Name
+ "IsEnabled" = $computer.Enabled
+ "ADIPv4Address" = $computer.IPv4Address
+ "LastLogon" = $computer.lastLogonDate
+ "PasswordLastSet" = $computer.PasswordLastSet
+ "SID" = $computer.SID
+ "TerminationConfidence" = [byte]($computer.PasswordLastSet -lt (Get-Date).AddDays(-90))
+ }
+ # Add custom properties
+ $tempObj | Add-Member -NotePropertyMembers $tempObjProps -TypeName ComputerObject
+ $outputObjects.Add($tempObj)
+ }
+ }
+ Write-Progress -Activity "Generating Report" -Status "Finished" -Id 1 -Completed
+ return $outputObjects
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.SystemEngineering/Public/Get-TerminatedComputersReport.tests.ps1 b/Modules/Alkami.DevOps.SystemEngineering/Public/Get-TerminatedComputersReport.tests.ps1
new file mode 100644
index 0000000..3c4a215
--- /dev/null
+++ b/Modules/Alkami.DevOps.SystemEngineering/Public/Get-TerminatedComputersReport.tests.ps1
@@ -0,0 +1,201 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ""
+# We need to use their stupid [Amazon.EC2.Model.Tag], so we have to import their module.
+Import-Module AWSPowerShell
+
+Mock -CommandName Get-LogLeadName -ModuleName $moduleForMock -MockWith { return 'Get-TerminatedComputersReport.tests' }
+Mock -CommandName Write-Progress -ModuleName $moduleForMock -MockWith {}
+Mock -CommandName Import-Module -ModuleName $moduleForMock -MockWith {}
+Mock -CommandName Get-Date -ModuleName $moduleForMock -MockWith {return ([datetime]::Now)}
+Mock -CommandName Write-Error -ModuleName $moduleForMock -MockWith {}
+Mock -CommandName Test-IsTeamCityProcess -ModuleName $moduleForMock -MockWith { return $false }
+
+
+$credential = ([pscredential]::new("test",("test1" | ConvertTo-SecureString -AsPlainText -Force)))
+
+Describe "Get-TerminatedComputersReport" {
+ Context "When a parameter value is not valid or missing" {
+ It "Throws an exception if Credential parameter is blank" {
+ { Get-TerminatedComputersReport -Credential -Domain fh.local } | Should -Throw
+ }
+
+ It "Throws an exception if Domain parameter is blank" {
+ { Get-TerminatedComputersReport -Credential $credential -Domain } | Should -Throw
+ }
+
+ It "Throws an exception if OU is not provided a value" {
+ { Get-TerminatedComputersReport -OU } | Should -Throw
+ }
+
+ It "Throws an exception if NumberOfDays is not provided a value" {
+ { Get-TerminatedComputersReport -NumberOfDays } | Should -Throw
+ }
+
+ It "Throws an exception if NumberOfDays is outside of the valid range" {
+ { Get-TerminatedComputersReport -NumberOfDays 366 } | Should -Throw
+ }
+ }
+
+ Context "Error Handling"{
+ It "Writes an error if AWSPowerShell cannot be loaded" {
+ Mock -CommandName Get-Module -ModuleName $moduleForMock -MockWith {[pscustomobject]@{"Name" = "NotAWSPowerShell"}}
+ Mock -CommandName Import-Module -ModuleName $moduleForMock -MockWith { throw "Module not found."}
+
+ Get-TerminatedComputersReport -CheckAWS
+
+ Assert-MockCalled -CommandName Write-Error -Times 1 -Exactly -ModuleName $moduleForMock -Scope It -ParameterFilter { $Message -like "*Unable to load the AWSPowerShell module*"}
+ }
+
+ It "Writes an error if DNS for the domain cannot be resolved" {
+ Mock -CommandName Resolve-DnsName -ModuleName $moduleForMock -MockWith { throw "It's always DNS."}
+
+ Get-TerminatedComputersReport -Credential $credential -Domain fh.local
+
+ Assert-MockCalled -CommandName Write-Error -Times 1 -Exactly -ModuleName $moduleForMock -Scope It -ParameterFilter { $Message -like "*Error when resolving DNS for*"}
+ }
+
+ It "Writes an error if bad credentials are supplied" {
+ Mock -CommandName Resolve-DnsName -ModuleName $moduleForMock -MockWith { return [pscustomobject]@{"IPAddress"="10.0.0.1"} }
+ Mock -CommandName Test-Connection -ModuleName $moduleForMock -MockWith { return [pscustomobject]@{"Address"="10.0.0.1";"ResponseTime" = 25} }
+ Mock -CommandName Get-ADComputer -ModuleName $moduleForMock -MockWith { throw [System.Security.Authentication.AuthenticationException]::new("The server has rejected the client credentials.") }
+
+ Get-TerminatedComputersReport -Credential $credential -Domain fh.local
+
+ Assert-MockCalled -CommandName Write-Error -Times 1 -Exactly -ModuleName $moduleForMock -Scope It -ParameterFilter { $Message -like "*Unable to connect to the target domain, invalid credentials*"}
+ }
+
+ It "Writes an error if unable to retrieve AD Computer objects" {
+ Mock -CommandName Resolve-DnsName -ModuleName $moduleForMock -MockWith { return [pscustomobject]@{"IPAddress"="10.0.0.1"} }
+ Mock -CommandName Test-Connection -ModuleName $moduleForMock -MockWith { return [pscustomobject]@{"Address"="10.0.0.1";"ResponseTime" = 25} }
+ Mock -CommandName Get-ADComputer -ModuleName $moduleForMock -MockWith { throw "Something bad happened"}
+
+ Get-TerminatedComputersReport -Credential $credential -Domain fh.local
+
+ Assert-MockCalled -CommandName Write-Error -Times 1 -Exactly -ModuleName $moduleForMock -Scope It -ParameterFilter { $Message -like "*Unable to retrieve Computer objects from AD*"}
+ }
+
+ It "Writes an error if unable to retrieve EC2 instances from us-east-1" {
+ Mock -CommandName Get-Module -ModuleName $moduleForMock -MockWith {[pscustomobject]@{"Name" = "AWSPowerShell"}}
+ Mock -CommandName Get-ADComputer -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Get-EC2Instance -ModuleName $moduleForMock -ParameterFilter { $Region -eq "us-east-1"} -MockWith {throw "Error getting EC2 in us-east-1"}
+
+ Get-TerminatedComputersReport -CheckAWS
+
+ Assert-MockCalled -CommandName Write-Error -Times 4 -Exactly -ModuleName $moduleForMock -Scope It -ParameterFilter { $Message -like "*An error occurred while retrieving EC2 instances in us-east-1*"}
+ }
+
+ It "Writes an error if unable to retrieve EC2 instances from us-west-2"{
+ Mock -CommandName Get-Module -ModuleName $moduleForMock -MockWith {[pscustomobject]@{"Name" = "AWSPowerShell"}}
+ Mock -CommandName Get-ADComputer -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Get-EC2Instance -ModuleName $moduleForMock -ParameterFilter { $Region -eq "us-east-1"} -MockWith {}
+ Mock -CommandName Get-EC2Instance -ModuleName $moduleForMock -ParameterFilter { $Region -eq "us-west-2"} -MockWith {throw "Error getting EC2 in us-west-2"}
+
+ Get-TerminatedComputersReport -CheckAWS
+
+ Assert-MockCalled -CommandName Get-EC2Instance -Times 5 -Exactly -ModuleName $moduleForMock -Scope It
+ Assert-MockCalled -CommandName Write-Error -Times 1 -Exactly -ModuleName $moduleForMock -Scope It -ParameterFilter { $Message -like "*An error occurred while retrieving EC2 instances in us-west-2*"}
+ }
+ }
+
+ Context "Returns results" {
+ $testDate = Get-Date
+ It "Returns results without comparing to AWS" {
+ Mock -CommandName Get-ADComputer -ModuleName $moduleForMock -MockWith {
+ return (
+ [pscustomobject]@{
+ "Name" = "TestComputerName"
+ "Enabled" = $true
+ "IPv4Address" = "10.0.0.2"
+ "lastLogonDate" = $testDate
+ "PasswordLastSet" = $testDate
+ "SID" = "The-Sloth"
+ }
+ )
+ }
+
+ $adOnlyTest = Get-TerminatedComputersReport
+ $expected = [pscustomobject]@{
+ "ADName" = "TestComputerName"
+ "IsEnabled" = $true
+ "ADIPv4Address" = "10.0.0.2"
+ "LastLogon" = $testDate
+ "PasswordLastSet" = $testDate
+ "SID" = "The-Sloth"
+ "TerminationConfidence" = [byte](($testDate) -lt ($testDate).AddDays(-90))
+ }
+ (($adOnlyTest).ADName -eq ($expected).ADName) | Should -BeTrue
+ (($adOnlyTest).IsEnabled -eq ($expected).IsEnabled) | Should -BeTrue
+ (($adOnlyTest).ADIPv4Address -eq ($expected).ADIPv4Address) | Should -BeTrue
+ (($adOnlyTest).LastLogon -eq ($expected).LastLogon) | Should -BeTrue
+ (($adOnlyTest).PasswordLastSet -eq ($expected).PasswordLastSet) | Should -BeTrue
+ (($adOnlyTest).SID -eq ($expected).SID) | Should -BeTrue
+ (($adOnlyTest).TerminationConfidence -eq ($expected).TerminationConfidence) | Should -BeTrue
+ }
+
+ It "Returns results comparing to AWS" {
+ Mock -CommandName Get-Module -ModuleName $moduleForMock -MockWith {[pscustomobject]@{"Name" = "AWSPowerShell"}}
+ $script:mockCalled = 0
+ $getEC2InstanceMock = {
+ $script:mockCalled++
+ if($script:mockCalled -eq 1){
+ # This is a hot mess, but we are trying to replicate the blackmagic that AWS has created when returning these objects
+ return (
+ @{"Instances"=[pscustomobject]@{
+ "InstanceId" = "i-AmTheSloth"
+ "PrivateIPAddress" = "10.0.0.2"
+ "tags" = @([Amazon.EC2.Model.Tag]::new("alk:hostname","TestComputerName"),[Amazon.EC2.Model.Tag]::new("alk:designation","1000"))
+ }
+ }
+ )
+ } else {
+ return $null
+ }
+ }
+ Mock -CommandName Get-ADComputer -ModuleName $moduleForMock -MockWith {
+ return (
+ [pscustomobject]@{
+ "Name" = "TestComputerName"
+ "Enabled" = $true
+ "IPv4Address" = "10.0.0.2"
+ "lastLogonDate" = $testDate
+ "PasswordLastSet" = $testDate
+ "SID" = "The-Sloth"
+ }
+ )
+ }
+ Mock -CommandName Get-EC2Instance -ModuleName $moduleForMock -MockWith $getEC2InstanceMock
+
+ $awsTest = Get-TerminatedComputersReport -CheckAWS
+ $expected = [pscustomobject]@{
+ "ADName" = "TestComputerName"
+ "IsEnabled" = $true
+ "ADIPv4Address" = "10.0.0.2"
+ "LastLogon" = $testDate
+ "PasswordLastSet" = $testDate
+ "SID" = "The-Sloth"
+ "InstanceIdByIP" = "i-AmTheSloth"
+ "InstanceIdByName" = "i-AmTheSloth"
+ "DoesInstanceMatch" = $true
+ "Designation" = "1000"
+ "TerminationConfidence" = 0
+ }
+
+ (($awsTest).ADName -eq ($expected).ADName) | Should -BeTrue
+ (($awsTest).IsEnabled -eq ($expected).IsEnabled) | Should -BeTrue
+ (($awsTest).ADIPv4Address -eq ($expected).ADIPv4Address) | Should -BeTrue
+ (($awsTest).LastLogon -eq ($expected).LastLogon) | Should -BeTrue
+ (($awsTest).PasswordLastSet -eq ($expected).PasswordLastSet) | Should -BeTrue
+ (($awsTest).SID -eq ($expected).SID) | Should -BeTrue
+ (($awsTest).InstanceIdByIP -eq ($expected).InstanceIdByIP) | Should -BeTrue
+ (($awsTest).InstanceIdByName -eq ($expected).InstanceIdByName) | Should -BeTrue
+ (($awsTest).DoesInstanceMatch -eq ($expected).DoesInstanceMatch) | Should -BeTrue
+ (($awsTest).Designation -eq ($expected).Designation) | Should -BeTrue
+ (($awsTest).TerminationConfidence -eq ($expected).TerminationConfidence) | Should -BeTrue
+ }
+ }
+}
diff --git a/Modules/Alkami.DevOps.SystemEngineering/Public/Get-WorkspaceBundleList.ps1 b/Modules/Alkami.DevOps.SystemEngineering/Public/Get-WorkspaceBundleList.ps1
new file mode 100644
index 0000000..1fa0d64
--- /dev/null
+++ b/Modules/Alkami.DevOps.SystemEngineering/Public/Get-WorkspaceBundleList.ps1
@@ -0,0 +1,91 @@
+function Get-WorkspaceBundleList {
+
+<#
+.SYNOPSIS
+ Get a list of AWS Workspace bundles.
+
+.DESCRIPTION
+ Get a list of AWS Workspace bundles. This is a convenience wrapper around the AWS PowerShell cmdlet
+ 'Get-WKSWorkspaceBundle' to provide filtering options needed by the System Engineering team.
+
+.PARAMETER ProfileName
+ [string] The AWS profile to use for the operation. If not provided, will default to 'temp-workspaces'.
+
+.PARAMETER Region
+ [string] The AWS region to use for the operation. If not provided, will default to 'us-west-2'.
+
+.PARAMETER Owner
+ [string] The Workspace bundle owner. If not provided, will default to 'Alkami'.
+
+.PARAMETER ComputeTypeFilter
+ [string] Filter to apply to the Workspace bundle list to limit results to a specific compute type.
+ If not provided, no compute type filtering will be applied.
+
+.PARAMETER OsFilter
+ [string] Filter to apply to the Workspace bundle list to limit results to a specific operating system.
+ If not provided, no operating system type filtering will be applied. Note that this relies on the Name
+ of the bundle containing the operating system.
+
+.PARAMETER ProtocolFilter
+ [string] Filter to apply to the Workspace bundle list to limit results to a specific protocol.
+ If not provided, no protocol filtering will be applied. Note that this relies on the Description
+ of the bundle containing the protocol string.
+
+.EXAMPLE
+ Get-WorkspaceBundleList
+# An array of Alkami bundles.
+
+.EXAMPLE
+ Get-WorkspaceBundleList -Owner 'Amazon' -ComputeTypeFilter 'STANDARD' -OsFilter 'Windows 10' -ProtocolFilter 'WorkSpaces Streaming Protocol'
+# An array of AWS-produced WSP Standard Windows 10 bundles.
+#>
+
+ [CmdletBinding()]
+ [OutputType([PSObject[]])]
+ param (
+ [Parameter(Mandatory = $false)]
+ [ValidateNotNullOrEmpty()]
+ [string] $ProfileName = 'temp-workspaces',
+
+ [Parameter(Mandatory = $false)]
+ [ValidateScript({$_ -in (Get-AWSRegion).region})]
+ [string] $Region = 'us-west-2',
+
+ [Parameter(Mandatory = $false)]
+ [ValidateSet('Alkami', 'Amazon')]
+ [string] $Owner = 'Alkami',
+
+ [Parameter(Mandatory = $false)]
+ [ValidateSet('GRAPHICS', 'GRAPHICSPRO', 'PERFORMANCE', 'POWER', 'POWERPRO', 'STANDARD', 'VALUE_TYPE')]
+ [string] $ComputeTypeFilter = $null,
+
+ [Parameter(Mandatory = $false)]
+ [ValidateSet('Windows 10', 'Amazon Linux 2')]
+ [string] $OsFilter = $null,
+
+ [Parameter(Mandatory = $false)]
+ [ValidateSet('WorkSpaces Streaming Protocol', 'PCoIP')]
+ [string] $ProtocolFilter = $null
+ )
+
+ Import-AWSModule
+
+ # To filter to Alkami's bundles, we omit the 'Owner' parameter.
+ $splatParams = @{}
+ if ( $Owner -eq 'Amazon' ) {
+ $splatParams["Owner"] = "AMAZON"
+ }
+
+ # List the bundles using the AWS API. All of the filtering other than Owner must be done after-the-fact because
+ # AWS decided to give us no capabilities on this API endpoint.
+ $list = Get-WKSWorkspaceBundle -ProfileName $ProfileName -Region $Region @splatParams
+
+ # Apply any provided filters to the list.
+ $filteredList = $list | Where-Object {
+ (( [string]::IsNullOrEmpty( $ComputeTypeFilter ) -or ( $_.ComputeType.Name -eq $ComputeTypeFilter )) -and `
+ ( [string]::IsNullOrEmpty( $OsFilter ) -or ( $_.Name -match $OsFilter )) -and `
+ ( [string]::IsNullOrEmpty( $ProtocolFilter ) -or ( $_.Description -match $ProtocolFilter )))
+ }
+
+ return $filteredList
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.SystemEngineering/Public/Get-WorkspaceBundleList.tests.ps1 b/Modules/Alkami.DevOps.SystemEngineering/Public/Get-WorkspaceBundleList.tests.ps1
new file mode 100644
index 0000000..b23e5dd
--- /dev/null
+++ b/Modules/Alkami.DevOps.SystemEngineering/Public/Get-WorkspaceBundleList.tests.ps1
@@ -0,0 +1,107 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ""
+
+Describe "Get-WorkspaceBundleList" {
+
+ Mock -CommandName Get-AWSRegion -ModuleName $moduleForMock -MockWith { return @( @{ 'Region' = 'us-west-2' } ) }
+ Mock -CommandName Import-AWSModule -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Get-WKSWorkspaceBundle -ModuleName $moduleForMock -MockWith {
+ # Build the first test case.
+ $test1 = [PSCustomObject] @{
+ BundleId = 1
+ Name = 'Windows 7'
+ Description = 'Test'
+ ComputeType = [PSCustomObject] @{
+ Name = 'Power'
+ }
+ }
+
+ # Build the second test case.
+ $test2 = [PSCustomObject]@{
+ BundleId = 2
+ Name = 'Windows 10'
+ Description = 'WorkSpaces Streaming Protocol'
+ ComputeType = [PSCustomObject]@{
+ Name = 'GRAPHICS'
+ }
+ }
+
+ # Build the third test case.
+ $test3 = [PSCustomObject]@{
+ BundleId = 3
+ Name = 'Amazon Linux 2'
+ Description = 'PCoIP'
+ ComputeType = [PSCustomObject]@{
+ Name = 'PERFORMANCE'
+ }
+ }
+
+ return @( $test1, $test2, $test3)
+ }
+
+ Context "Parameter Validation" {
+
+ It "Throws if ProfileName is Null" {
+ { Get-WorkspaceBundleList -ProfileName $null } | Should -Throw
+ }
+
+ It "Throws if ProfileName is Empty" {
+ { Get-WorkspaceBundleList -ProfileName '' } | Should -Throw
+ }
+
+ It "Throws if Region is Not in Supported List" {
+ { Get-WorkspaceBundleList -Region 'Test' } | Should -Throw
+ }
+
+ It "Throws if Owner is Not in Supported List" {
+ { Get-WorkspaceBundleList -Owner 'Test' } | Should -Throw
+ }
+
+ It "Throws if ComputeTypeFilter is Not in Supported List" {
+ { Get-WorkspaceBundleList -ComputeTypeFilter 'Test' } | Should -Throw
+ }
+
+ It "Throws if OsFilter is Not in Supported List" {
+ { Get-WorkspaceBundleList -OsFilter 'Test' } | Should -Throw
+ }
+
+ It "Throws if ProtocolFilter is Not in Supported List" {
+ { Get-WorkspaceBundleList -ProtocolFilter 'Test' } | Should -Throw
+ }
+ }
+
+ Context "Logic Validation" {
+
+ It "Returns All Results With No Filtering By Default" {
+
+ $results = Get-WorkspaceBundleList
+ $results | Should -HaveCount 3
+ }
+
+ It "Applies ComputeTypeFilter If Provided" {
+
+ $results = Get-WorkspaceBundleList -ComputeTypeFilter 'POWER'
+ $results | Should -HaveCount 1
+ $results[0].BundleId | Should -BeExactly 1
+ }
+
+ It "Applies OsFilter If Provided" {
+
+ $results = Get-WorkspaceBundleList -OsFilter 'Windows 10'
+ $results | Should -HaveCount 1
+ $results[0].BundleId | Should -BeExactly 2
+ }
+
+ It "Applies ProtocolFilter If Provided" {
+
+ $results = Get-WorkspaceBundleList -ProtocolFilter 'PCoIP'
+ $results | Should -HaveCount 1
+ $results[0].BundleId | Should -BeExactly 3
+ }
+ }
+}
diff --git a/Modules/Alkami.DevOps.SystemEngineering/Public/Move-AccountToDisabledOU.ps1 b/Modules/Alkami.DevOps.SystemEngineering/Public/Move-AccountToDisabledOU.ps1
new file mode 100644
index 0000000..687c61a
--- /dev/null
+++ b/Modules/Alkami.DevOps.SystemEngineering/Public/Move-AccountToDisabledOU.ps1
@@ -0,0 +1,62 @@
+ function Move-AccountToDisabledOU {
+
+ <#
+ .SYNOPSIS
+ Moves an AD Account to the Disabled Accounts OU
+
+ .DESCRIPTION
+ Moves an AD Account to the Disabled Accounts OU
+
+ .PARAMETER AccountDistinguishedName
+ [string] The DistinguishedName of an AD Account to Act Upon
+
+ .PARAMETER DisabledAccountOU
+ [string The OU name for disabled accounts. Defaults to "Disabled Accounts"
+
+ .PARAMETER DomainName
+ [string] The domain name to act upon. Defaults to "fh.local"
+
+ .EXAMPLE
+ Move-AccountToDisabledOU "fake.serviceaccount")
+
+ .EXAMPLE
+ Move-AccountToDisabledOU "fake.serviceaccount") -DisabledAccountOU "Trash Can" -Domain "corp.alkamitech.com"
+ #>
+
+ [CmdletBinding()]
+ param (
+ [Parameter(Mandatory = $true)]
+ [ValidateNotNullOrEmpty()]
+ [string]$AccountDistinguishedName,
+
+ [Parameter(Mandatory = $false)]
+ [string]$DisabledAccountOU = "Disabled Accounts",
+
+ [Parameter(Mandatory = $false)]
+ [string]$DomainName = "fh.local"
+ )
+
+ $logLead = Get-LogLeadName
+
+ if (!(Test-IsUserDomainAdmin)) {
+
+ Write-Warning "$logLead : You must have domain administrative privileges to run this command"
+ return $nulls
+ }
+
+ $domainNameDistinguishedName = Get-DomainNameDistinguishedName $DomainName
+ $disabledAccountOUTrimmed = $DisabledAccountOU.TrimStart("OU=")
+ $disabledAccountsOUDN = "OU=$disabledAccountOUTrimmed"
+ $disabledAccountsOUDistinguishedName = "$disabledAccountsOUDN,$domainNameDistinguishedName"
+
+ Write-Host "$logLead : Acting on Account with Distinguished Name [$AccountDistinguishedName]"
+ if ($AccountDistinguishedName -match $disabledAccountsOUDN) {
+
+ Write-Warning "$logLead : Account is already in Disabled Accounts OU [$disabledAccountsOUDistinguishedName]"
+
+ } else {
+
+ Write-Host "$logLead : Moving account to the Disabled Accounts OU [$disabledAccountsOUDistinguishedName]"
+ Move-ADObject -Identity $AccountDistinguishedName -TargetPath $disabledAccountsOUDistinguishedName
+ }
+ }
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.SystemEngineering/Public/Move-AccountToDisabledOU.tests.ps1 b/Modules/Alkami.DevOps.SystemEngineering/Public/Move-AccountToDisabledOU.tests.ps1
new file mode 100644
index 0000000..04c091b
--- /dev/null
+++ b/Modules/Alkami.DevOps.SystemEngineering/Public/Move-AccountToDisabledOU.tests.ps1
@@ -0,0 +1,62 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ""
+
+Describe "Move-AccountToDisabledOU" {
+
+ $fakeAccountName = "FakeyMcFakeAccount"
+
+ Mock -CommandName Get-LogLeadName -ModuleName $moduleForMock -MockWith { return 'Move-AccountToDisabledOU.tests' }
+ Mock -CommandName Move-ADObject -ModuleName $moduleForMock -MockWith { }
+ Mock -CommandName Write-Warning -ModuleName $moduleForMock -MockWith { }
+
+ Context "User Permissions" {
+
+ It "Writes a Warning and Exits Early if the User Does Not Have Domain Admin Rights" {
+
+ Mock Test-IsUserDomainAdmin -ModuleName $moduleForMock -MockWith { return $false }
+
+ Move-AccountToDisabledOU -AccountDistinguishedName "CN=$fakeAccountName,CN=Managed Service Accounts,OU=Disabled Accounts,DC=fh,DC=local"
+
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Write-Warning `
+ -ParameterFilter { $Message -match "You must have domain administrative privileges" } -Times 1 -Exactly -Scope It
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Move-ADObject -Times 0 -Exactly -Scope It
+ }
+ }
+
+ Context "Logic" {
+
+ Mock Test-IsUserDomainAdmin -ModuleName $moduleForMock -MockWith { return $true }
+
+ It "Writes a Warning and Does Not Move the User if it is already in the Disabled Users OU" {
+
+ Move-AccountToDisabledOU -AccountDistinguishedName "CN=$fakeAccountName,CN=Managed Service Accounts,OU=Disabled Accounts,DC=fh,DC=local"
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Write-Warning `
+ -ParameterFilter { $Message -match "is already in Disabled Accounts OU" } -Times 1 -Exactly -Scope It
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Move-ADObject -Times 0 -Exactly -Scope It
+ }
+
+ It "Moves the User to the Disabled Users OU" {
+
+ Move-AccountToDisabledOU -AccountDistinguishedName "CN=$fakeAccountName,CN=Managed Service Accounts,OU=Active Accounts,DC=foo,DC=bar"
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Move-ADObject -Times 1 -Exactly -Scope It `
+ -ParameterFilter { ($Identity -match "$fakeAccountName") -and ($TargetPath -eq "OU=Disabled Accounts,DC=fh,DC=local") }
+ }
+ }
+
+ Context "Parameter Validation" {
+
+ Mock Test-IsUserDomainAdmin -ModuleName $moduleForMock -MockWith { return $true }
+
+ It "Uses the Supplied Domain and OU for the Disabled OU" {
+
+ Move-AccountToDisabledOU -AccountDistinguishedName "CN=$fakeAccountName,CN=Managed Service Accounts,DC=foo,DC=bar" -DisabledAccountOU "Foobar" -DomainName "foo.bar"
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Move-ADObject -Times 1 -Exactly -Scope It `
+ -ParameterFilter { ($Identity -match "$fakeAccountName") -and ($TargetPath -eq "OU=Foobar,DC=foo,DC=bar") }
+ }
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.SystemEngineering/Public/New-GMSAStack.ps1 b/Modules/Alkami.DevOps.SystemEngineering/Public/New-GMSAStack.ps1
new file mode 100644
index 0000000..f1f56cb
--- /dev/null
+++ b/Modules/Alkami.DevOps.SystemEngineering/Public/New-GMSAStack.ps1
@@ -0,0 +1,171 @@
+function New-GMSAStack {
+
+<#
+.SYNOPSIS
+ Creates new GMSA accounts for ORB.
+
+.DESCRIPTION
+ Creates new GMSA accounts for ORB. This function creates a new security group (if not pre-existing) and creates a set of gMSA accounts associated with the security group.
+ Domain Administrator credentials are required to run this function.
+
+.PARAMETER UserPrefix
+ [string] The name prefix for the gMSA accounts. Must be alphanumeric not exceeding 5 characters in length (e.g. 'pod99').
+
+.PARAMETER TicketNumber
+ [string] The Jira ticket that is being used for tracking the creation of the gMSA accounts (e.g. 'SYSENG-123456').
+
+.PARAMETER TargetEnvironment
+ [string] The name of the target environment for the gMSA accounts; used to determine the correct pre-existing AD groups for ORB functionality.
+
+.PARAMETER DomainPostfix
+ [string] The domain postfix for the domain that the gMSA accounts and the security group will be created in.
+
+.PARAMETER OUPath
+ [string] The path where the group account will be created in Active Directory.
+
+.PARAMETER GroupName
+ [string] The name of the security group that the gMSA accounts will be associated with. If not existing, a new group will be created.
+ The group name must be alphanumeric ending in ' - GMSA'.
+
+.PARAMETER PasswordIntervalDays
+ [UInt16] The maximum value of days for the password to be valid. Minimum is 30 days; maximum is 365 days.
+
+.Example
+ New-GMSAStack -UserPrefix "POD99" -TicketNumber "SYSENG-123456"
+
+.Example
+ New-GMSAStack -UserPrefix "POD99" -TicketNumber "SYSENG-123456" -DomainPostfix "domain.local" -OUPath "OU=ServiceAccounts,OU=Prod,OU=SecurityGroups,DC=domain,DC=local" -GroupName "POD99 - GMSA" -PasswordIntervalDays 365 -TargetEnvironment 'Prod'
+#>
+
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory = $true)]
+ [ValidatePattern("^[a-z0-9]{1,5}$")]
+ [string]$UserPrefix,
+
+ [Parameter(Mandatory = $true)]
+ [ValidatePattern("^[a-z]+-\d+$")]
+ [string]$TicketNumber,
+
+ [Parameter(Mandatory = $false)]
+ [ValidateSet('Dev', 'QA', 'Staging', 'Prod')]
+ [string]$TargetEnvironment = 'Prod',
+
+ [Parameter(Mandatory = $false)]
+ [ValidateNotNullOrEmpty()]
+ [string]$DomainPostfix = 'fh.local',
+
+ [Parameter(Mandatory = $false)]
+ [ValidateNotNullOrEmpty()]
+ [string]$OUPath = "OU=ServiceAccounts,OU=$TargetEnvironment,OU=SecurityGroups,DC=fh,DC=local",
+
+ [Parameter(Mandatory = $false)]
+ [ValidatePattern("^[a-z0-9]+ - GMSA$")]
+ [string]$GroupName = "$UserPrefix - GMSA",
+
+ [Parameter(Mandatory = $false)]
+ [ValidateRange(30, 365)]
+ [uint16]$PasswordIntervalDays = 365
+ )
+
+ $logLead = (Get-LogLeadName)
+
+ # Fast fail if user is not a domain admin.
+ if (!(Test-IsUserDomainAdmin)) {
+
+ Write-Error "$logLead : You must have domain administrative privileges to run this command."
+ return
+ }
+
+ # Search for a pre-existing gMSA security group. If not found, attempt to create the group.
+ $actualGroupName = $GroupName.ToUpperInvariant()
+ $adGroup = Get-ADGroup -Filter { Name -eq $actualGroupName }
+ if ( $null -eq $adGroup ) {
+
+ try {
+
+ Write-Host "$logLead : Creating group '$actualGroupName' in '$OUPath'"
+ $adGroup = (New-ADGroup -Name $actualGroupName -SamAccountName $actualGroupName -GroupCategory Security -GroupScope Global -Path $OUPath -Description $TicketNumber -PassThru)
+
+ } catch {
+
+ Write-Error "$logLead : Creation of group [$actualGroupName] failed. Error encountered was: [$PSItem]"
+ return
+ }
+
+ } else {
+
+ Write-Host "$logLead : Found pre-existing group named $actualGroupName; using this group."
+ }
+
+ # Search for the ORB logs group associated with the target environment. If not found, fail with error.
+ $orbLogsGroupName = "OrbLogs-$TargetEnvironment"
+ $orbLogsGroup = Get-ADGroup -Filter { Name -eq $orbLogsGroupName }
+ if ( $null -eq $orbLogsGroup ) {
+
+ Write-Error "$logLead : Unable to find ORB logs AD group named [$orbLogsGroupName]; verify AD configuration."
+ return
+ }
+
+ # Search for the NYDIG SMB access group associated with the target environment. If not found, fail with error.
+ $nydigAccessGroupName = "NYDIG-SMB-Access-$TargetEnvironment"
+ $nydigAccessGroup = Get-ADGroup -Filter { Name -eq $nydigAccessGroupName }
+ if ( $null -eq $nydigAccessGroup ) {
+
+ Write-Error "$logLead : Unable to find NYDIG SMB access AD group named [$nydigAccessGroupName]; verify AD configuration."
+ return
+ }
+
+ $listOfAccounts = @(
+ "$UserPrefix.audit$",
+ "$UserPrefix.bank$",
+ "$UserPrefix.content$",
+ "$UserPrefix.core$",
+ "$UserPrefix.dbms$",
+ "$UserPrefix.exception$",
+ "$UserPrefix.micro$",
+ "$UserPrefix.msgctr$",
+ "$UserPrefix.multiplx$",
+ "$UserPrefix.nag$",
+ "$UserPrefix.notify$",
+ "$UserPrefix.radium$",
+ "$UserPrefix.rpsts$",
+ "$UserPrefix.schedule$",
+ "$UserPrefix.secmgr$",
+ "$UserPrefix.stsconf$"
+ )
+
+ Write-Host "$logLead : Creating GMSA accounts in security group $actualGroupName"
+ foreach ($gmsa in $listOfAccounts) {
+
+ if ( $null -eq (Get-ADServiceAccount -Filter { Name -eq $gmsa }) ) {
+
+ $gmsaFqdn = "$gmsa.$DomainPostfix"
+
+ try {
+
+ Write-Verbose "$logLead : Creating User [$gmsa] as [$gmsaFqdn]"
+ ( New-ADServiceAccount -name $gmsa -PrincipalsAllowedToRetrieveManagedPassword $adGroup -DNSHostName $gmsaFqdn -Description $TicketNumber -ManagedPasswordIntervalInDays $PasswordIntervalDays ) | Out-Null
+
+ # Add the new account to the ORB logs group.
+ Add-ADGroupMember -Identity $orbLogsGroup -Members $gmsa
+
+ # Add the micro and DBMS accounts to the NYDIG SMB access group.
+ if ( $gmsa -match '(micro|dbms)') {
+
+ Add-ADGroupMember -Identity $nydigAccessGroup -Members $gmsa
+ }
+
+ Write-Host "$logLead : Created [$gmsa]"
+
+ } catch {
+
+ Write-Warning "$logLead : Creation of user [$gmsa] failed. Error encountered was: [$PSItem]"
+ }
+
+ } else {
+
+ Write-Warning "$logLead : Found pre-existing user named $gmsa; check the configuration of this user."
+ }
+ }
+}
diff --git a/Modules/Alkami.DevOps.SystemEngineering/Public/New-GMSAStack.tests.ps1 b/Modules/Alkami.DevOps.SystemEngineering/Public/New-GMSAStack.tests.ps1
new file mode 100644
index 0000000..46f015d
--- /dev/null
+++ b/Modules/Alkami.DevOps.SystemEngineering/Public/New-GMSAStack.tests.ps1
@@ -0,0 +1,334 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ""
+
+Describe "New-GMSAStack" {
+
+ Mock -CommandName Get-LogLeadName -ModuleName $moduleForMock -MockWith { return 'New-GMSAStack.tests' }
+ Mock -CommandName Write-Error -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Write-Warning -ModuleName $moduleForMock -MockWith {}
+
+ Context "Input Validation" {
+
+ It "UserPrefix Should Not Be Empty" {
+ { New-GMSAStack -UserPrefix '' } | Should -Throw
+ }
+
+ It "UserPrefix Should Be Less Than 6 Characters In Length" {
+ { New-GMSAStack -UserPrefix '123456' } | Should -Throw
+ }
+
+ It "UserPrefix Should Be Alphanumeric" {
+ { New-GMSAStack -UserPrefix '#@!--' } | Should -Throw
+ }
+
+ It "TicketNumber Should Not Be Empty" {
+ { New-GMSAStack -UserPrefix 'Test' -TicketNumber '' } | Should -Throw
+ }
+
+ It "TicketNumber Should Match Regular Expression" {
+ { New-GMSAStack -UserPrefix 'Test' -TicketNumber 'Test' } | Should -Throw
+ }
+
+ It "DomainPostfix Should Not Be Null" {
+ { New-GMSAStack -UserPrefix 'Test' -TicketNumber 'Test-123' -DomainPostfix $null } | Should -Throw
+ }
+
+ It "DomainPostfix Should Not Be Empty" {
+ { New-GMSAStack -UserPrefix 'Test' -TicketNumber 'Test-123' -DomainPostfix '' } | Should -Throw
+ }
+
+ It "OUPath Should Not Be Null" {
+ { New-GMSAStack -UserPrefix 'Test' -TicketNumber 'Test-123' -OUPath $null } | Should -Throw
+ }
+
+ It "OUPath Should Not Be Empty" {
+ { New-GMSAStack -UserPrefix 'Test' -TicketNumber 'Test-123' -OUPath '' } | Should -Throw
+ }
+
+ It "GroupName Name Should Not Be Empty" {
+ { New-GMSAStack -UserPrefix 'Test' -TicketNumber 'Test-123' -GroupName '' } | Should -Throw
+ }
+
+ It "GroupName Name Should Match Regular Expression" {
+ { New-GMSAStack -UserPrefix 'Test' -TicketNumber 'Test-123' -GroupName ' - GMSA' } | Should -Throw
+ }
+
+ It "PasswordIntervalDays Should Be Greater Than 30" {
+ { New-GMSAStack -UserPrefix 'Test' -TicketNumber 'Test-123' -PasswordIntervalDays 29 } | Should -Throw
+ }
+
+ It "PasswordIntervalDays Should Be Less Than 365" {
+ { New-GMSAStack -UserPrefix 'Test' -TicketNumber 'Test-123' -PasswordIntervalDays 366 } | Should -Throw
+ }
+
+ It "TargetEnvironment Must Be In Allowable List" {
+ { New-GMSAStack -UserPrefix 'Test' -TicketNumber 'Test-123' -TargetEnvironment 'Test' } | Should -Throw
+ }
+ }
+
+ Context "Result Validation" {
+
+ Mock -CommandName Add-ADGroupMember -ModuleName $moduleForMock -MockWith { return $null }
+
+ It "Should Fast Abort With Error If User Is Not Domain Admin" {
+
+ Mock -CommandName Test-IsUserDomainAdmin -ModuleName $moduleForMock -MockWith { return $false }
+ Mock -CommandName Get-ADGroup -ModuleName $moduleForMock -MockWith { return $null }
+ Mock -CommandName New-ADGroup -ModuleName $moduleForMock -MockWith { return $null }
+ Mock -CommandName Get-ADServiceAccount -ModuleName $moduleForMock -MockWith { return $null }
+ Mock -CommandName New-ADServiceAccount -ModuleName $moduleForMock -MockWith { return $null }
+
+ { New-GMSAStack -UserPrefix 'Test' -TicketNumber 'Test-123' } | Should -Not -Throw
+
+ Assert-MockCalled -CommandName Test-IsUserDomainAdmin -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Get-ADGroup -Times 0 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName New-ADGroup -Times 0 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Get-ADServiceAccount -Times 0 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName New-ADServiceAccount -Times 0 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Write-Error -Times 1 -Exactly -Scope It -ModuleName $moduleForMock `
+ -ParameterFilter { $Message -match "You must have domain administrative privileges to run this command" }
+ }
+
+ It "Should Not Create gMSA Security Group If Group Exists" {
+
+ Mock -CommandName Test-IsUserDomainAdmin -ModuleName $moduleForMock -MockWith { return $true }
+ Mock -CommandName Get-ADGroup -ModuleName $moduleForMock -MockWith { return New-Object Microsoft.ActiveDirectory.Management.ADPrincipal }
+ Mock -CommandName New-ADGroup -ModuleName $moduleForMock -MockWith { return $null }
+ Mock -CommandName Get-ADServiceAccount -ModuleName $moduleForMock -MockWith { return $null }
+ Mock -CommandName New-ADServiceAccount -ModuleName $moduleForMock -MockWith { return $null }
+
+ { New-GMSAStack -UserPrefix 'Test' -TicketNumber 'Test-123' } | Should -Not -Throw
+
+ Assert-MockCalled -CommandName Test-IsUserDomainAdmin -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Get-ADGroup -Times 3 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName New-ADGroup -Times 0 -Exactly -Scope It -ModuleName $moduleForMock
+ }
+
+ It "Should Create gMSA Security Group If Group Does Not Exist" {
+
+ Mock -CommandName Test-IsUserDomainAdmin -ModuleName $moduleForMock -MockWith { return $true }
+ Mock -CommandName Get-ADGroup -ModuleName $moduleForMock -MockWith { return $null }
+ Mock -CommandName New-ADGroup -ModuleName $moduleForMock -MockWith { return New-Object Microsoft.ActiveDirectory.Management.ADPrincipal }
+ Mock -CommandName Get-ADServiceAccount -ModuleName $moduleForMock -MockWith { return $null }
+ Mock -CommandName New-ADServiceAccount -ModuleName $moduleForMock -MockWith { return $null }
+
+ { New-GMSAStack -UserPrefix 'Test' -TicketNumber 'Test-123' } | Should -Not -Throw
+
+ Assert-MockCalled -CommandName Test-IsUserDomainAdmin -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Get-ADGroup -Times 2 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName New-ADGroup -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+ }
+
+ It "Should Create gMSA Security Group With Uppercase Name" {
+
+ Mock -CommandName Test-IsUserDomainAdmin -ModuleName $moduleForMock -MockWith { return $true }
+ Mock -CommandName Get-ADGroup -ModuleName $moduleForMock -MockWith { return $null }
+ Mock -CommandName New-ADGroup -ModuleName $moduleForMock -MockWith { return New-Object Microsoft.ActiveDirectory.Management.ADPrincipal }
+ Mock -CommandName Get-ADServiceAccount -ModuleName $moduleForMock -MockWith { return $null }
+ Mock -CommandName New-ADServiceAccount -ModuleName $moduleForMock -MockWith { return $null }
+
+ { New-GMSAStack -UserPrefix 'test' -TicketNumber 'Test-123' } | Should -Not -Throw
+
+ Assert-MockCalled -CommandName Test-IsUserDomainAdmin -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Get-ADGroup -Times 2 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName New-ADGroup -Times 1 -Exactly -Scope It -ModuleName $moduleForMock `
+ -ParameterFilter { $Name -eq 'TEST - GMSA' }
+ }
+
+ It "Should Create gMSA Security Group In OU Path that Reflects Target Environment" {
+
+ Mock -CommandName Test-IsUserDomainAdmin -ModuleName $moduleForMock -MockWith { return $true }
+ Mock -CommandName Get-ADGroup -ModuleName $moduleForMock -MockWith { return $null }
+ Mock -CommandName New-ADGroup -ModuleName $moduleForMock -MockWith { return New-Object Microsoft.ActiveDirectory.Management.ADPrincipal }
+ Mock -CommandName Get-ADServiceAccount -ModuleName $moduleForMock -MockWith { return $null }
+ Mock -CommandName New-ADServiceAccount -ModuleName $moduleForMock -MockWith { return $null }
+
+ { New-GMSAStack -UserPrefix 'test' -TicketNumber 'Test-123' -TargetEnvironment 'QA' } | Should -Not -Throw
+
+ Assert-MockCalled -CommandName Test-IsUserDomainAdmin -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Get-ADGroup -Times 2 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName New-ADGroup -Times 1 -Exactly -Scope It -ModuleName $moduleForMock `
+ -ParameterFilter { $Path -ceq 'OU=ServiceAccounts,OU=QA,OU=SecurityGroups,DC=fh,DC=local' }
+ }
+
+ It "Should Create gMSA Security Group In Production OU Path By Default" {
+
+ Mock -CommandName Test-IsUserDomainAdmin -ModuleName $moduleForMock -MockWith { return $true }
+ Mock -CommandName Get-ADGroup -ModuleName $moduleForMock -MockWith { return $null }
+ Mock -CommandName New-ADGroup -ModuleName $moduleForMock -MockWith { return New-Object Microsoft.ActiveDirectory.Management.ADPrincipal }
+ Mock -CommandName Get-ADServiceAccount -ModuleName $moduleForMock -MockWith { return $null }
+ Mock -CommandName New-ADServiceAccount -ModuleName $moduleForMock -MockWith { return $null }
+
+ { New-GMSAStack -UserPrefix 'test' -TicketNumber 'Test-123' } | Should -Not -Throw
+
+ Assert-MockCalled -CommandName Test-IsUserDomainAdmin -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Get-ADGroup -Times 2 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName New-ADGroup -Times 1 -Exactly -Scope It -ModuleName $moduleForMock `
+ -ParameterFilter { $Path -ceq 'OU=ServiceAccounts,OU=Prod,OU=SecurityGroups,DC=fh,DC=local' }
+ }
+
+ It "Should Abort With Error If gMSA Security Group Creation Fails" {
+
+ Mock -CommandName Test-IsUserDomainAdmin -ModuleName $moduleForMock -MockWith { return $true }
+ Mock -CommandName Get-ADGroup -ModuleName $moduleForMock -MockWith { return $null }
+ Mock -CommandName New-ADGroup -ModuleName $moduleForMock -MockWith { throw "Test Exception" }
+ Mock -CommandName Get-ADServiceAccount -ModuleName $moduleForMock -MockWith { return $null }
+ Mock -CommandName New-ADServiceAccount -ModuleName $moduleForMock -MockWith { return $null }
+
+ { New-GMSAStack -UserPrefix 'Test' -TicketNumber 'Test-123' } | Should -Not -Throw
+
+ Assert-MockCalled -CommandName Test-IsUserDomainAdmin -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Get-ADGroup -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName New-ADGroup -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Get-ADServiceAccount -Times 0 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName New-ADServiceAccount -Times 0 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Write-Error -Times 1 -Exactly -Scope It -ModuleName $moduleForMock `
+ -ParameterFilter { $Message -match "Creation of group \[.*\] failed\. Error encountered was: \[Test Exception\]" }
+ }
+
+ It "Should Write Error and Not Create Service Accounts If Orb Logs Group Not Found" {
+
+ Mock -CommandName Test-IsUserDomainAdmin -ModuleName $moduleForMock -MockWith { return $true }
+ Mock -CommandName Get-ADGroup -ModuleName $moduleForMock -MockWith { return $null }
+ Mock -CommandName New-ADGroup -ModuleName $moduleForMock -MockWith { return New-Object Microsoft.ActiveDirectory.Management.ADPrincipal }
+ Mock -CommandName Get-ADServiceAccount -ModuleName $moduleForMock -MockWith { return $null }
+ Mock -CommandName New-ADServiceAccount -ModuleName $moduleForMock -MockWith { return $null }
+
+ { New-GMSAStack -UserPrefix 'Test' -TicketNumber 'Test-123' } | Should -Not -Throw
+
+ Assert-MockCalled -CommandName Test-IsUserDomainAdmin -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Get-ADGroup -Times 2 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName New-ADGroup -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Write-Error -Times 1 -Exactly -Scope It -ModuleName $moduleForMock `
+ -ParameterFilter { $Message -match "Unable to find ORB logs AD group named \[.*\]; verify AD configuration" }
+ }
+
+ It "Should Write Error and Not Create Service Accounts If NYDIG SMB Access Group Not Found" {
+
+ Mock -CommandName Test-IsUserDomainAdmin -ModuleName $moduleForMock -MockWith { return $true }
+ Mock -CommandName New-ADGroup -ModuleName $moduleForMock -MockWith { return New-Object Microsoft.ActiveDirectory.Management.ADPrincipal }
+ Mock -CommandName Get-ADServiceAccount -ModuleName $moduleForMock -MockWith { return $null }
+ Mock -CommandName New-ADServiceAccount -ModuleName $moduleForMock -MockWith { return $null }
+
+ Mock -CommandName Get-ADGroup -ModuleName $moduleForMock -MockWith {
+ Switch ( "$Filter" )
+ {
+ { $_ -match 'nydigAccessGroupName' } { $result = $null }
+ default { $result = New-Object Microsoft.ActiveDirectory.Management.ADPrincipal }
+ }
+ return $result
+ }
+
+ { New-GMSAStack -UserPrefix 'Test' -TicketNumber 'Test-123' } | Should -Not -Throw
+
+ Assert-MockCalled -CommandName Test-IsUserDomainAdmin -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Get-ADGroup -Times 3 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName New-ADGroup -Times 0 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Write-Error -Times 1 -Exactly -Scope It -ModuleName $moduleForMock `
+ -ParameterFilter { $Message -match "Unable to find NYDIG SMB access AD group named \[.*\]; verify AD configuration" }
+ }
+
+ It "Should Write Warnings and Not Create Service Accounts If They Already Exist" {
+
+ Mock -CommandName Test-IsUserDomainAdmin -ModuleName $moduleForMock -MockWith { return $true }
+ Mock -CommandName Get-ADGroup -ModuleName $moduleForMock -MockWith { return New-Object Microsoft.ActiveDirectory.Management.ADPrincipal }
+ Mock -CommandName New-ADGroup -ModuleName $moduleForMock -MockWith { return $null }
+ Mock -CommandName Get-ADServiceAccount -ModuleName $moduleForMock -MockWith { return New-Object Microsoft.ActiveDirectory.Management.ADPrincipal }
+ Mock -CommandName New-ADServiceAccount -ModuleName $moduleForMock -MockWith { return $null }
+
+ { New-GMSAStack -UserPrefix 'Test' -TicketNumber 'Test-123' } | Should -Not -Throw
+
+ Assert-MockCalled -CommandName Test-IsUserDomainAdmin -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Get-ADGroup -Times 3 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName New-ADGroup -Times 0 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Get-ADServiceAccount -Times 16 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName New-ADServiceAccount -Times 0 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Add-ADGroupMember -Times 0 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Write-Error -Times 0 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Write-Warning -Times 16 -Exactly -Scope It -ModuleName $moduleForMock `
+ -ParameterFilter { $Message -match "Found pre-existing user named .*; check the configuration of this user" }
+ }
+
+ It "Should Create Service Accounts If They Do Not Already Exist" {
+
+ Mock -CommandName Test-IsUserDomainAdmin -ModuleName $moduleForMock -MockWith { return $true }
+ Mock -CommandName Get-ADGroup -ModuleName $moduleForMock -MockWith { return New-Object Microsoft.ActiveDirectory.Management.ADPrincipal }
+ Mock -CommandName New-ADGroup -ModuleName $moduleForMock -MockWith { return $null }
+ Mock -CommandName Get-ADServiceAccount -ModuleName $moduleForMock -MockWith { return $null }
+ Mock -CommandName New-ADServiceAccount -ModuleName $moduleForMock -MockWith { return New-Object Microsoft.ActiveDirectory.Management.ADPrincipal }
+
+ { New-GMSAStack -UserPrefix 'Test' -TicketNumber 'Test-123' } | Should -Not -Throw
+
+ Assert-MockCalled -CommandName Test-IsUserDomainAdmin -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Get-ADGroup -Times 3 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName New-ADGroup -Times 0 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Get-ADServiceAccount -Times 16 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName New-ADServiceAccount -Times 16 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Write-Error -Times 0 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Write-Warning -Times 0 -Exactly -Scope It -ModuleName $moduleForMock
+ }
+
+ It "Should Add gMSA Service Accounts to Pre-Existing AD Groups" {
+
+ Mock -CommandName Test-IsUserDomainAdmin -ModuleName $moduleForMock -MockWith { return $true }
+ Mock -CommandName New-ADGroup -ModuleName $moduleForMock -MockWith { return $null }
+ Mock -CommandName Get-ADServiceAccount -ModuleName $moduleForMock -MockWith { return $null }
+ Mock -CommandName New-ADServiceAccount -ModuleName $moduleForMock -MockWith { return New-Object Microsoft.ActiveDirectory.Management.ADPrincipal }
+
+ Mock -CommandName Get-ADGroup -ModuleName $moduleForMock -MockWith {
+ Switch ( "$Filter" )
+ {
+ { $_ -match 'orbLogsGroupName' } { $groupName = "OrbLogsGroup" }
+ { $_ -match 'nydigAccessGroupName' } { $groupName = "NydigAccessGroup" }
+ default { $groupName = "DefaultGroup" }
+ }
+ return New-Object Microsoft.ActiveDirectory.Management.ADPrincipal($groupName)
+ }
+
+ { New-GMSAStack -UserPrefix 'Test' -TicketNumber 'Test-123' } | Should -Not -Throw
+
+ Assert-MockCalled -CommandName Test-IsUserDomainAdmin -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Get-ADGroup -Times 3 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName New-ADGroup -Times 0 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Get-ADServiceAccount -Times 16 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName New-ADServiceAccount -Times 16 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Write-Error -Times 0 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Write-Warning -Times 0 -Exactly -Scope It -ModuleName $moduleForMock
+
+ Assert-MockCalled -CommandName Add-ADGroupMember -Times 16 -Exactly -Scope It -ModuleName $moduleForMock `
+ -ParameterFilter { $Identity.ToString() -eq 'OrbLogsGroup' }
+ Assert-MockCalled -CommandName Add-ADGroupMember -Times 0 -Exactly -Scope It -ModuleName $moduleForMock `
+ -ParameterFilter { $Identity.ToString() -eq 'NydigAccessGroup' -and $Members -notmatch '(micro|dbms)' }
+ Assert-MockCalled -CommandName Add-ADGroupMember -Times 1 -Exactly -Scope It -ModuleName $moduleForMock `
+ -ParameterFilter { $Identity.ToString() -eq 'NydigAccessGroup' -and $Members -match 'micro' }
+ Assert-MockCalled -CommandName Add-ADGroupMember -Times 1 -Exactly -Scope It -ModuleName $moduleForMock `
+ -ParameterFilter { $Identity.ToString() -eq 'NydigAccessGroup' -and $Members -match 'dbms' }
+ }
+
+ It "Should Write Warning and Continue If Service Account Creation Fails" {
+
+ Mock -CommandName Test-IsUserDomainAdmin -ModuleName $moduleForMock -MockWith { return $true }
+ Mock -CommandName Get-ADGroup -ModuleName $moduleForMock -MockWith { return New-Object Microsoft.ActiveDirectory.Management.ADPrincipal }
+ Mock -CommandName New-ADGroup -ModuleName $moduleForMock -MockWith { return $null }
+ Mock -CommandName Get-ADServiceAccount -ModuleName $moduleForMock -MockWith { return $null }
+ Mock -CommandName New-ADServiceAccount -ModuleName $moduleForMock -MockWith { throw "Test Exception" }
+
+ { New-GMSAStack -UserPrefix 'Test' -TicketNumber 'Test-123' } | Should -Not -Throw
+
+ Assert-MockCalled -CommandName Test-IsUserDomainAdmin -Times 1 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Get-ADGroup -Times 3 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName New-ADGroup -Times 0 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Get-ADServiceAccount -Times 16 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName New-ADServiceAccount -Times 16 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Add-ADGroupMember -Times 0 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Write-Error -Times 0 -Exactly -Scope It -ModuleName $moduleForMock
+ Assert-MockCalled -CommandName Write-Warning -Times 16 -Exactly -Scope It -ModuleName $moduleForMock `
+ -ParameterFilter { $Message -match "Creation of user \[.*\] failed\. Error encountered was: \[Test Exception\]" }
+ }
+ }
+}
diff --git a/Modules/Alkami.DevOps.SystemEngineering/Public/New-SecurePassword.ps1 b/Modules/Alkami.DevOps.SystemEngineering/Public/New-SecurePassword.ps1
new file mode 100644
index 0000000..32ec803
--- /dev/null
+++ b/Modules/Alkami.DevOps.SystemEngineering/Public/New-SecurePassword.ps1
@@ -0,0 +1,50 @@
+function New-SecurePassword {
+<#
+.SYNOPSIS
+ Generates a secure password.
+
+.DESCRIPTION
+ Generates a secure password containing at least one uppercase, one lowercase, one number
+ and one special character.
+
+.PARAMETER PasswordLength
+ [byte] Length of the desired password.
+
+.PARAMETER ProfileName
+ [string] The AWS profile to use during password generation.
+
+.PARAMETER Region
+ [string] The AWS region to use during password generation.
+
+.PARAMETER ExcludeCharacters
+ [string] String containing characters to exclude from the generated password.
+
+.EXAMPLE
+ New-SecurePassword -PasswordLength 10 -ProfileName 'temp-dev' -Region 'us-east-1'
+
+u48d![N[s^
+#>
+ [CmdletBinding()]
+ [OutputType([string])]
+ param(
+ [Parameter(Mandatory = $false)]
+ [ValidateRange(4, 128)]
+ [byte]$PasswordLength = 15,
+
+ [Parameter(Mandatory = $true)]
+ [ValidateNotNullOrEmpty()]
+ [string] $ProfileName,
+
+ [Parameter(Mandatory = $true)]
+ [ValidateScript({$_ -in (Get-AWSRegion).region})]
+ [string] $Region,
+
+ [Parameter(Mandatory = $false)]
+ [string]$ExcludeCharacter = "`"';@`$``%<>=/\"
+ )
+
+ # This function is just a convenience wrapper to allow us to default the exclusion list to remove "problematic"
+ # characters from generated passwords.
+ Import-AWSModule
+ return ( Get-SECRandomPassword -ProfileName $ProfileName -Region $Region -RequireEachIncludedType $true -PasswordLength $PasswordLength -ExcludeCharacter $ExcludeCharacter )
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.SystemEngineering/Public/New-SecurePassword.tests.ps1 b/Modules/Alkami.DevOps.SystemEngineering/Public/New-SecurePassword.tests.ps1
new file mode 100644
index 0000000..5ab985d
--- /dev/null
+++ b/Modules/Alkami.DevOps.SystemEngineering/Public/New-SecurePassword.tests.ps1
@@ -0,0 +1,52 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ""
+
+Describe "New-SecurePassword" {
+
+ Mock -CommandName Get-AWSRegion -ModuleName $moduleForMock -MockWith { return @( @{ 'Region' = 'us-east-1' } ) }
+ Mock -CommandName Import-AWSModule -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Get-SECRandomPassword -ModuleName $moduleForMock -MockWith { return "Ab1!" }
+
+ Context "Parameter Validation" {
+
+ It "Throws if Password Length Is Too Short" {
+ { New-SecurePassword -PasswordLength 1 -ProfileName 'temp-prod' -Region 'us-east-1' } | Should -Throw
+ }
+
+ It "Throws if Password Length Is Too Long" {
+ { New-SecurePassword -PasswordLength 129 -ProfileName 'temp-prod' -Region 'us-east-1'} | Should -Throw
+ }
+
+ It "Throws if Profile Name is Null" {
+ { New-SecurePassword -ProfileName $null -Region 'us-east-1' } | Should -Throw
+ }
+
+ It "Throws if Profile Name is Empty" {
+ { New-SecurePassword -ProfileName '' -Region 'us-east-1' } | Should -Throw
+ }
+
+ It "Throws if Region is Not in Supported List" {
+ { New-SecurePassword -ProfileName 'temp-prod' -Region 'Test' } | Should -Throw
+ }
+ }
+
+ Context "Logic" {
+
+ It "Returns a String" {
+
+ (Get-Command New-SecurePassword).OutputType.Type.ToString() | Should -BeExactly "System.String"
+ }
+
+ It "Proxies Input Parameters to AWS Call" {
+ New-SecurePassword -PasswordLength 20 -ExcludeCharacter "1" -ProfileName 'temp-prod' -Region 'us-east-1' | Out-Null
+
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName Get-SECRandomPassword -Times 1 -Exactly -Scope It `
+ -ParameterFilter { (( $ProfileName -eq 'temp-prod' ) -and ( $Region -eq 'us-east-1' ) -and ( $PasswordLength -eq 20 ) -and ($ExcludeCharacter -eq '1')) }
+ }
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.SystemEngineering/Public/New-ServerlessServiceAccountPair.ps1 b/Modules/Alkami.DevOps.SystemEngineering/Public/New-ServerlessServiceAccountPair.ps1
new file mode 100644
index 0000000..047a362
--- /dev/null
+++ b/Modules/Alkami.DevOps.SystemEngineering/Public/New-ServerlessServiceAccountPair.ps1
@@ -0,0 +1,214 @@
+function New-ServerlessServiceAccountPair {
+<#
+.SYNOPSIS
+ Top-level function to create serverless service accounts.
+
+.DESCRIPTION
+ Top-level function to create serverless service accounts. Initializes data structures and invokes
+ individual handling functions to create an Active Directory user account pair and an AWS Secrets
+ Manager secret containing the authentication parameters for the users.
+
+.PARAMETER Cred
+ [PSCredential] A credential object for the FH user that has permissions to create new accounts
+ on the domain.
+
+.PARAMETER ServiceName
+ [string] The truncated identifier for the service name. Must be nine characters or less.
+
+.PARAMETER Environment
+ [string] The target environment for the user accounts.
+
+.PARAMETER ProfileName
+ [string] The AWS profile to use during user creation.
+
+.PARAMETER TicketNumber
+ [string] The Jira ticket identifier requesting the new accounts.
+
+.PARAMETER ServiceAccountIamRoleArn
+ [string] The AWS IAM role ARN that should be granted access to the AWS Secrets Manager secret.
+
+.PARAMETER Region
+ [string] The AWS region to use during user creation. If not provided, defaults to 'us-east-1'
+
+.PARAMETER ReplicationRegion
+ [string] The target AWS region for replicated AWS Secrets Manager secrets.
+ To disable replication, set this value to null or empty. If not provided, defaults to 'us-west-2'.
+
+.PARAMETER SecretAccessExtraArns
+ [string[]] An array of AWS ARNs allowed to access the created secret in addition to the defaults.
+
+.PARAMETER RotationSchedule
+ [string] A scheduling expression for password rotation. Refer to https://docs.aws.amazon.com/secretsmanager/latest/userguide/rotate-secrets_schedule.html.
+ To disable rotation, set this value to null or empty.
+ If not provided, the default scheduling expression will rotate passwords at 1400 UTC on the first Tuesday of the month.
+
+.PARAMETER RotationWindow
+ [string] A window duration for password rotation. Refer to https://docs.aws.amazon.com/secretsmanager/latest/userguide/rotate-secrets_schedule.html.
+ If not provided, the default rotation window is two hours.
+
+.INPUTS
+ None. You cannot pipe objects to New-ServerlessServiceAccountPair.
+
+.OUTPUTS
+ [PSObject] New-ServerlessServiceAccountPair returns an object with the following top-level members:
+ [PSCredential[]] Credentials : Contains the usernames and passwords created by this function.
+ [string[]] SecretArns : Contains the ARN(s) of the AWS Secrets Manager secret(s) created by this function.
+
+.EXAMPLE
+ New-ServerlessServiceAccountPair -Cred (Get-AlkamiCredential) `
+ -ServiceName 'Example' `
+ -Environment 'Prod' `
+ -ProfileName 'temp-prod' `
+ -TicketNumber 'SYSENG-1234' `
+ -ServiceAccountIamRoleArn 'arn:aws:iam::000000000000:role/example-services-accounts-role' `
+ -Region 'us-east-1' `
+ -ReplicationRegion 'us-west-2' `
+ -SecretAccessExtraArns @( 'ExampleArn1' )
+
+SecretArns Credentials
+---------- -----------
+{ExampleSecretArn1, ExampleSecretArn2} {System.Management.Automation.PSCredential, System.Management.Automation.PSCredential}
+#>
+ [OutputType([PSObject])]
+ [CmdletBinding()]
+ param (
+ [Parameter(Mandatory = $true)]
+ [PSCredential] $Cred,
+
+ [Parameter(Mandatory = $true)]
+ [ValidateLength(1, 8)]
+ [string] $ServiceName,
+
+ [Parameter(Mandatory = $true)]
+ [ValidateSet('Dev', 'Qa', 'LoadTest', 'Staging', 'Prod', IgnoreCase = $false)]
+ [string] $Environment,
+
+ [Parameter(Mandatory = $true)]
+ [ValidateNotNullOrEmpty()]
+ [string] $ProfileName,
+
+ [Parameter(Mandatory = $true)]
+ [ValidatePattern("^[a-z]+-\d+$")]
+ [string]$TicketNumber,
+
+ [Parameter(Mandatory = $true)]
+ [ValidateNotNullOrEmpty()]
+ [string] $ServiceAccountIamRoleArn,
+
+ [Parameter(Mandatory = $false)]
+ [ValidateScript({$_ -in (Get-AWSRegion).region})]
+ [string] $Region = 'us-east-1',
+
+ [Parameter(Mandatory = $false)]
+ [ValidateScript({([String]::IsNullOrEmpty($_) -or ($_ -in (Get-AWSRegion).region))})]
+ [string] $ReplicationRegion = 'us-west-2',
+
+ [Parameter(Mandatory = $false)]
+ [string[]] $SecretAccessExtraArns = $null,
+
+ [Parameter(Mandatory = $false)]
+ [string] $RotationSchedule = 'cron(0 14 ? * 3#1 *)',
+
+ [Parameter(Mandatory = $false)]
+ [ValidateNotNullOrEmpty()]
+ [string] $RotationWindow = '2h'
+ )
+
+ $logLead = Get-LogLeadName
+ $usernamePrefix = "${Environment}${ServiceName}Svc"
+ $environmentTagValue = ( "{0}shared" -f $Environment.ToLower() )
+ $userOuPathCommon = "/ServiceAccounts/${Environment}"
+ $secretName = "${userOuPathCommon}/${usernamePrefix}"
+ $secretDescription = "Serverless service account secret for $usernamePrefix"
+ $accessArns = @($ServiceAccountIamRoleArn)
+ $result = New-Object PSObject -Property @{
+ Credentials = @()
+ SecretArns = @()
+ }
+
+ # Dump out a list of the decisions we made if the user wants to see them.
+ Write-Verbose "$logLead : Username prefix evaluated to be '$usernamePrefix'."
+ Write-Verbose "$logLead : Environment tag evaluated to be '$environmentTagValue'."
+ Write-Verbose "$logLead : User OU Path that will be used by Active Directory and AWS Secrets code evaluated to be '$userOuPathCommon'."
+ Write-Verbose "$logLead : Secret name evaluated to be '$secretName'."
+ Write-Verbose "$logLead : Secret description evaluated to be '$secretDescription'."
+
+ # Generate two usernames and passwords for multi-user rotation strategy.
+ for ($i = 0; $i -lt 2; $i++) {
+
+ $result.Credentials += ( Get-AlkamiCredential -UserName ( "{0}{1}" -f $usernamePrefix, [char]([byte][char]'A' + $i)) `
+ -Password (New-SecurePassword -PasswordLength 20 -ProfileName $ProfileName -Region $Region ))
+ }
+
+ try {
+
+ # Create users in AD.
+ New-ServerlessServiceAccountActiveDirectoryUserPair -Cred $Cred `
+ -UserDataList $result.Credentials `
+ -UserOuPathCommon $userOuPathCommon `
+ -Environment $Environment `
+ -TicketNumber $TicketNumber
+
+ } catch {
+
+ Write-Error "$logLead : Creation of Active Directory users failed. Error encountered was: [$PSItem]"
+ return $result
+ }
+
+ try {
+ # Create secret
+ $result.SecretArns = New-ServerlessServiceAccountSecret -SecretName $secretName `
+ -UserDataList $result.Credentials `
+ -EnvironmentTag $environmentTagValue `
+ -ProfileName $ProfileName `
+ -Region $Region `
+ -ReplicationRegion $ReplicationRegion `
+ -Description $secretDescription
+
+ } catch {
+
+ Write-Error "$logLead : Creation of AWS secret failed. Error encountered was: [$PSItem]"
+ return $result
+ }
+
+ try {
+
+ New-ServerlessServiceAccountIamPolicy -RoleArn $ServiceAccountIamRoleArn `
+ -ProfileName $ProfileName `
+ -Region $Region `
+ -SecretArns $result.SecretArns
+
+ } catch {
+
+ Write-Warning "$logLead : Creation of IAM policy failed. Error encountered was: [$PSItem]"
+ }
+
+ try {
+ $accessArns += $SecretAccessExtraArns
+ $accessArns += ( Get-YeatsLambdaIamRoleArn -EnvironmentTag $environmentTagValue -ProfileName $ProfileName )
+ $accessArns = $accessArns | Where-Object { $false -eq [string]::IsNullOrWhitespace($_) }
+
+ # Create the resource policy on the secret in AWS.
+ Write-AlkamiSecretResourcePolicy -SecretName $secretName -ProfileName $ProfileName -Region $Region -SecretAccessExtraArns $accessArns
+
+ # Enable rotation on the secret (if the operator didn't turn off that feature).
+ if ( $false -eq [string]::IsNullOrWhitespace( $RotationSchedule )) {
+
+ $rotationLambdaArn = Get-YeatsRotationLambdaArn -EnvironmentTag $environmentTagValue -ProfileName $ProfileName -Region $Region
+ if ( $null -ne $rotationLambdaArn ) {
+
+ # Note that we disable immediate rotation of the secret because we just created the accounts -- the passwords
+ # do not require rotation at this time.
+ Invoke-SECSecretRotation -SecretId $result.SecretArns[0] -RotateImmediately $false -RotationLambdaArn $rotationLambdaArn `
+ -RotationRules_ScheduleExpression $RotationSchedule -RotationRules_Duration $RotationWindow `
+ -ProfileName $ProfileName -Region $Region
+ }
+ }
+
+ } catch {
+
+ Write-Warning "$logLead : Configuration of AWS secret failed. Error encountered was: [$PSItem]"
+ }
+
+ return $result
+}
diff --git a/Modules/Alkami.DevOps.SystemEngineering/Public/New-ServerlessServiceAccountPair.tests.ps1 b/Modules/Alkami.DevOps.SystemEngineering/Public/New-ServerlessServiceAccountPair.tests.ps1
new file mode 100644
index 0000000..431cd2f
--- /dev/null
+++ b/Modules/Alkami.DevOps.SystemEngineering/Public/New-ServerlessServiceAccountPair.tests.ps1
@@ -0,0 +1,291 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$global:functionPath = Join-Path -Path $here -ChildPath $sut
+
+InModuleScope -ModuleName Alkami.DevOps.SystemEngineering -ScriptBlock {
+
+ Write-Host "InModuleScope - Overriding SUT: $global:functionPath"
+ Import-Module $global:functionPath -Force
+ $inScopeModule = "Alkami.DevOps.SystemEngineering"
+
+ Describe "New-ServerlessServiceAccountPair" {
+
+ Mock -CommandName Get-LogLeadName -ModuleName $inScopeModule -MockWith { return 'New-ServerlessServiceAccountPair.tests' }
+ Mock -CommandName Get-AWSRegion -ModuleName $inScopeModule -MockWith { return @( @{ 'Region' = 'us-east-1' }, @{ 'Region' = 'us-west-2' } ) }
+ Mock -CommandName New-SecurePassword -ModuleName $inScopeModule -MockWith { return 'TestPassword' }
+ Mock -CommandName New-ServerlessServiceAccountActiveDirectoryUserPair -ModuleName $inScopeModule -MockWith {}
+ Mock -CommandName New-ServerlessServiceAccountSecret -ModuleName $inScopeModule -MockWith { return @('TestSecretArn1', 'TestSecretArn2') }
+ Mock -CommandName New-ServerlessServiceAccountIamPolicy -ModuleName $inScopeModule -MockWith {}
+ Mock -CommandName Get-YeatsLambdaIamRoleArn -ModuleName $inScopeModule -MockWith { return 'TestYeatsRoleArn' }
+ Mock -CommandName Get-YeatsRotationLambdaArn -ModuleName $inScopeModule -MockWith { return 'TestYeatsLambdaArn' }
+ Mock -CommandName Write-AlkamiSecretResourcePolicy -ModuleName $inScopeModule -MockWith {}
+ Mock -CommandName Invoke-SECSecretRotation -ModuleName $inScopeModule -MockWith {}
+ Mock -CommandName Write-Error -ModuleName $inScopeModule -MockWith {}
+ Mock -CommandName Write-Warning -ModuleName $inScopeModule -MockWith {}
+
+ Mock -CommandName Get-AlkamiCredential -ModuleName $inScopeModule -MockWith {
+ return ( New-Object 'Management.Automation.PsCredential' $UserName, ( ConvertTo-SecureString -AsPlainText -Force -String "$Password" ))
+ }
+
+ $testCredential = New-Object 'Management.Automation.PsCredential' 'Test', ( ConvertTo-SecureString -AsPlainText -Force -String 'Test' )
+
+ $validEnvironment = (Get-Command New-ServerlessServiceAccountPair).Parameters.Environment.Attributes.ValidValues[0]
+
+ Context "Parameter Validation" {
+
+ It "Throws if Service Name Length Is Too Short" {
+ { New-ServerlessServiceAccountPair -Cred $testCredential -ServiceName '' } | Should -Throw
+ }
+
+ It "Throws if Service Name Length Is Too Long" {
+ { New-ServerlessServiceAccountPair -Cred $testCredential -ServiceName 'ThisIsAServiceThatWeShouldDeployEverywhere' } | Should -Throw
+ }
+
+ It "Throws if Environment is Not in Supported List" {
+ { New-ServerlessServiceAccountPair -Cred $testCredential -ServiceName 'TestSvc' -Environment 'Test' } | Should -Throw
+ }
+
+ It "Throws if Environment is in Supported List With Different Casing" {
+ { New-ServerlessServiceAccountPair -Cred $testCredential -ServiceName 'TestSvc' -Environment $validValue.ToLower() } | Should -Throw
+ }
+
+ It "Throws if Profile Name is Null" {
+ { New-ServerlessServiceAccountPair -Cred $testCredential -ServiceName 'TestSvc' -Environment $validEnvironment -ProfileName $null } | Should -Throw
+ }
+
+ It "Throws if Profile Name is Empty" {
+ { New-ServerlessServiceAccountPair -Cred $testCredential -ServiceName 'TestSvc' -Environment $validEnvironment -ProfileName '' } | Should -Throw
+ }
+
+ It "Throws if Ticket Number Does Not Match Regex" {
+ { New-ServerlessServiceAccountPair -Cred $testCredential -ServiceName 'TestSvc' -Environment $validEnvironment -ProfileName 'temp-test' `
+ -TicketNumber 'Test!' } | Should -Throw
+ }
+
+ It "Throws if Role ARN is Null" {
+ { New-ServerlessServiceAccountPair -Cred $testCredential -ServiceName 'TestSvc' -Environment $validEnvironment -ProfileName 'temp-test' `
+ -TicketNumber 'Test-123' -ServiceAccountIamRoleArn $null } | Should -Throw
+ }
+
+ It "Throws if Role ARN is Empty" {
+ { New-ServerlessServiceAccountPair -Cred $testCredential -ServiceName 'TestSvc' -Environment $validEnvironment -ProfileName 'temp-test' `
+ -TicketNumber 'Test-123' -ServiceAccountIamRoleArn '' } | Should -Throw
+ }
+
+ It "Throws if Region is Not in Supported List" {
+ { New-ServerlessServiceAccountPair -Cred $testCredential -ServiceName 'TestSvc' -Environment $validEnvironment -ProfileName 'temp-test' `
+ -TicketNumber 'Test-123' -ServiceAccountIamRoleArn 'TestRoleArn' -Region 'Test' } | Should -Throw
+ }
+
+ It "Throws if Replication Region is Not in Supported List" {
+ { New-ServerlessServiceAccountPair -Cred $testCredential -ServiceName 'TestSvc' -Environment $validEnvironment -ProfileName 'temp-test' `
+ -TicketNumber 'Test-123' -ServiceAccountIamRoleArn 'TestRoleArn' -Region 'us-east-1' -ReplicationRegion 'Test' } | Should -Throw
+ }
+
+ It "Throws if Rotation Window is Null" {
+ { New-ServerlessServiceAccountPair -Cred $testCredential -ServiceName 'TestSvc' -Environment $validEnvironment -ProfileName 'temp-test' `
+ -TicketNumber 'Test-123' -ServiceAccountIamRoleArn 'TestRoleArn' -Region 'us-east-1' -RotationWindow $null } | Should -Throw
+ }
+
+ It "Throws if Rotation Window is Empty" {
+ { New-ServerlessServiceAccountPair -Cred $testCredential -ServiceName 'TestSvc' -Environment $validEnvironment -ProfileName 'temp-test' `
+ -TicketNumber 'Test-123' -ServiceAccountIamRoleArn 'TestRoleArn' -Region 'us-east-1' -RotationWindow '' } | Should -Throw
+ }
+ }
+
+ Context "Logic" {
+
+ It "Creates Two User Passwords" {
+
+ New-ServerlessServiceAccountPair -Cred $testCredential -ServiceName 'TestSvc' -Environment $validEnvironment -ProfileName 'temp-test' `
+ -TicketNumber 'Test-123' -ServiceAccountIamRoleArn 'TestRoleArn' -Region 'us-east-1' | Out-Null
+
+ Assert-MockCalled -ModuleName $inScopeModule -CommandName New-SecurePassword -Times 2 -Exactly -Scope It
+ }
+
+ It "Proxies Provided Arguments to Active Directory Function" {
+
+ New-ServerlessServiceAccountPair -Cred $testCredential -ServiceName 'TestSvc' -Environment $validEnvironment -ProfileName 'temp-test' `
+ -TicketNumber 'Test-123' -ServiceAccountIamRoleArn 'TestRoleArn' -Region 'us-east-1' | Out-Null
+
+ Assert-MockCalled -ModuleName $inScopeModule -CommandName New-ServerlessServiceAccountActiveDirectoryUserPair -Times 1 -Exactly -Scope It `
+ -ParameterFilter { (($Cred.UserName -ceq 'Test') -and ($Environment -ceq $validEnvironment) -and `
+ ($TicketNumber -ceq 'Test-123')) }
+ }
+
+ It "Aborts Processing if Active Directory Function Throws" {
+
+ Mock -CommandName New-ServerlessServiceAccountActiveDirectoryUserPair -ModuleName $inScopeModule -MockWith { throw "Test" }
+
+ New-ServerlessServiceAccountPair -Cred $testCredential -ServiceName 'TestSvc' -Environment $validEnvironment -ProfileName 'temp-test' `
+ -TicketNumber 'Test-123' -ServiceAccountIamRoleArn 'TestRoleArn' -Region 'us-east-1' | Out-Null
+
+ Assert-MockCalled -ModuleName $inScopeModule -CommandName New-ServerlessServiceAccountActiveDirectoryUserPair -Times 1 -Exactly -Scope It
+ Assert-MockCalled -ModuleName $inScopeModule -CommandName New-ServerlessServiceAccountSecret -Times 0 -Exactly -Scope It
+ Assert-MockCalled -ModuleName $inScopeModule -CommandName Write-Error -Times 1 -Exactly -Scope It `
+ -ParameterFilter { $Message -match 'Creation of Active Directory users failed. Error encountered was' }
+
+ # Revert to top-level mock.
+ Mock -CommandName New-ServerlessServiceAccountActiveDirectoryUserPair -ModuleName $inScopeModule -MockWith {}
+ }
+
+ It "Proxies Provided Arguments to AWS Secret Function" {
+
+ New-ServerlessServiceAccountPair -Cred $testCredential -ServiceName 'TestSvc' -Environment $validEnvironment -ProfileName 'temp-test' `
+ -TicketNumber 'Test-123' -ServiceAccountIamRoleArn 'TestRoleArn' -Region 'us-east-1' -ReplicationRegion 'us-east-1' | Out-Null
+
+ Assert-MockCalled -ModuleName $inScopeModule -CommandName New-ServerlessServiceAccountSecret -Times 1 -Exactly -Scope It `
+ -ParameterFilter { (($ProfileName -ceq 'temp-test') -and ($Region -ceq 'us-east-1') -and `
+ ($ReplicationRegion -ceq 'us-east-1')) }
+ }
+
+ It "Aborts Processing if Secret Creation Function Throws" {
+
+ Mock -CommandName New-ServerlessServiceAccountSecret -ModuleName $inScopeModule -MockWith { throw "Test" }
+
+ New-ServerlessServiceAccountPair -Cred $testCredential -ServiceName 'TestSvc' -Environment $validEnvironment -ProfileName 'temp-test' `
+ -TicketNumber 'Test-123' -ServiceAccountIamRoleArn 'TestRoleArn' -Region 'us-east-1' | Out-Null
+
+ Assert-MockCalled -ModuleName $inScopeModule -CommandName New-ServerlessServiceAccountSecret -Times 1 -Exactly -Scope It
+ Assert-MockCalled -ModuleName $inScopeModule -CommandName New-ServerlessServiceAccountIamPolicy -Times 0 -Exactly -Scope It
+ Assert-MockCalled -ModuleName $inScopeModule -CommandName Write-Error -Times 1 -Exactly -Scope It `
+ -ParameterFilter { $Message -match 'Creation of AWS secret failed. Error encountered was' }
+
+ # Revert mock back to top-level.
+ Mock -CommandName New-ServerlessServiceAccountSecret -ModuleName $inScopeModule -MockWith { return @('TestSecretArn1', 'TestSecretArn2') }
+ }
+
+ It "Proxies Provided Arguments to AWS IAM Policy Function" {
+
+ New-ServerlessServiceAccountPair -Cred $testCredential -ServiceName 'TestSvc' -Environment $validEnvironment -ProfileName 'temp-test' `
+ -TicketNumber 'Test-123' -ServiceAccountIamRoleArn 'TestRoleArn' -Region 'us-east-1' | Out-Null
+
+ Assert-MockCalled -ModuleName $inScopeModule -CommandName New-ServerlessServiceAccountIamPolicy -Times 1 -Exactly -Scope It `
+ -ParameterFilter { (($ProfileName -ceq 'temp-test') -and ($Region -ceq 'us-east-1') -and ($RoleArn -ceq 'TestRoleArn')) }
+ }
+
+ It "Continues Processing With Warning if AWS IAM Policy Creation Function Throws" {
+
+ Mock -CommandName New-ServerlessServiceAccountIamPolicy -ModuleName $inScopeModule -MockWith { throw "Test" }
+
+ New-ServerlessServiceAccountPair -Cred $testCredential -ServiceName 'TestSvc' -Environment $validEnvironment -ProfileName 'temp-test' `
+ -TicketNumber 'Test-123' -ServiceAccountIamRoleArn 'TestRoleArn' -Region 'us-east-1' | Out-Null
+
+ Assert-MockCalled -ModuleName $inScopeModule -CommandName New-ServerlessServiceAccountIamPolicy -Times 1 -Exactly -Scope It
+ Assert-MockCalled -ModuleName $inScopeModule -CommandName Get-YeatsLambdaIamRoleArn -Times 1 -Exactly -Scope It
+ Assert-MockCalled -ModuleName $inScopeModule -CommandName Write-Warning -Times 1 -Exactly -Scope It `
+ -ParameterFilter { $Message -match 'Creation of IAM policy failed. Error encountered was' }
+
+ # Revert mock back to top-level.
+ Mock -CommandName New-ServerlessServiceAccountIamPolicy -ModuleName $inScopeModule -MockWith {}
+ }
+
+ It "Grants Supplied IAM Role Access to Created AWS Secret in Resource Policy" {
+
+ New-ServerlessServiceAccountPair -Cred $testCredential -ServiceName 'TestSvc' -Environment $validEnvironment -ProfileName 'temp-test' `
+ -TicketNumber 'Test-123' -ServiceAccountIamRoleArn 'TestRoleArn' -Region 'us-east-1' | Out-Null
+
+ Assert-MockCalled -ModuleName $inScopeModule -CommandName Write-AlkamiSecretResourcePolicy -Times 1 -Exactly -Scope It `
+ -ParameterFilter { $SecretAccessExtraArns -contains 'TestRoleArn' }
+ }
+
+ It "Grants Yeats Lambda IAM Role Access to Created AWS Secret in Resource Policy" {
+
+ New-ServerlessServiceAccountPair -Cred $testCredential -ServiceName 'TestSvc' -Environment $validEnvironment -ProfileName 'temp-test' `
+ -TicketNumber 'Test-123' -ServiceAccountIamRoleArn 'TestRoleArn' -Region 'us-east-1' | Out-Null
+
+ Assert-MockCalled -ModuleName $inScopeModule -CommandName Get-YeatsLambdaIamRoleArn -Times 1 -Exactly -Scope It
+ Assert-MockCalled -ModuleName $inScopeModule -CommandName Write-AlkamiSecretResourcePolicy -Times 1 -Exactly -Scope It `
+ -ParameterFilter { $SecretAccessExtraArns -contains 'TestYeatsRoleArn' }
+ }
+
+ It "Grants Provided Extra ARNs Access to Created AWS Secret in Resource Policy" {
+
+ New-ServerlessServiceAccountPair -Cred $testCredential -ServiceName 'TestSvc' -Environment $validEnvironment -ProfileName 'temp-test' `
+ -TicketNumber 'Test-123' -ServiceAccountIamRoleArn 'TestRoleArn' -Region 'us-east-1' -SecretAccessExtraArns @('TestExtraArn') | Out-Null
+
+ Assert-MockCalled -ModuleName $inScopeModule -CommandName Write-AlkamiSecretResourcePolicy -Times 1 -Exactly -Scope It `
+ -ParameterFilter { $SecretAccessExtraArns -contains 'TestExtraArn' }
+ }
+
+ It "Skips Secret Rotation Configuration if User Explicitly Disabled Feature" {
+
+ New-ServerlessServiceAccountPair -Cred $testCredential -ServiceName 'TestSvc' -Environment $validEnvironment -ProfileName 'temp-test' `
+ -TicketNumber 'Test-123' -ServiceAccountIamRoleArn 'TestRoleArn' -Region 'us-east-1' -RotationSchedule $null | Out-Null
+
+ Assert-MockCalled -ModuleName $inScopeModule -CommandName Get-YeatsRotationLambdaArn -Times 0 -Exactly -Scope It
+ Assert-MockCalled -ModuleName $inScopeModule -CommandName Invoke-SECSecretRotation -Times 0 -Exactly -Scope It
+ }
+
+ It "Skips Secret Rotation Configuration if Yeats Rotation Lambda Is Not Found" {
+
+ Mock -CommandName Get-YeatsRotationLambdaArn -ModuleName $inScopeModule -MockWith { return $null }
+
+ New-ServerlessServiceAccountPair -Cred $testCredential -ServiceName 'TestSvc' -Environment $validEnvironment -ProfileName 'temp-test' `
+ -TicketNumber 'Test-123' -ServiceAccountIamRoleArn 'TestRoleArn' -Region 'us-east-1' | Out-Null
+
+ Assert-MockCalled -ModuleName $inScopeModule -CommandName Get-YeatsRotationLambdaArn -Times 1 -Exactly -Scope It
+ Assert-MockCalled -ModuleName $inScopeModule -CommandName Invoke-SECSecretRotation -Times 0 -Exactly -Scope It
+
+ # Revert mock back to top-level.
+ Mock -CommandName Get-YeatsRotationLambdaArn -ModuleName $inScopeModule -MockWith { return 'TestYeatsLambdaArn' }
+ }
+
+ It "Applies Secret Rotation Configuration if Yeats Rotation Lambda Is Found" {
+
+ New-ServerlessServiceAccountPair -Cred $testCredential -ServiceName 'TestSvc' -Environment $validEnvironment -ProfileName 'temp-test' `
+ -TicketNumber 'Test-123' -ServiceAccountIamRoleArn 'TestRoleArn' -Region 'us-east-1' | Out-Null
+
+ Assert-MockCalled -ModuleName $inScopeModule -CommandName Get-YeatsRotationLambdaArn -Times 1 -Exactly -Scope It
+ Assert-MockCalled -ModuleName $inScopeModule -CommandName Invoke-SECSecretRotation -Times 1 -Exactly -Scope It `
+ -ParameterFilter { $RotationLambdaArn -eq 'TestYeatsLambdaArn' }
+ }
+
+ It "Writes Warning if Configuration of AWS Secret Throws" {
+
+ Mock -CommandName Write-AlkamiSecretResourcePolicy -ModuleName $inScopeModule -MockWith { throw "Test" }
+
+ New-ServerlessServiceAccountPair -Cred $testCredential -ServiceName 'TestSvc' -Environment $validEnvironment -ProfileName 'temp-test' `
+ -TicketNumber 'Test-123' -ServiceAccountIamRoleArn 'TestRoleArn' -Region 'us-east-1' | Out-Null
+
+ Assert-MockCalled -ModuleName $inScopeModule -CommandName Write-AlkamiSecretResourcePolicy -Times 1 -Exactly -Scope It
+ Assert-MockCalled -ModuleName $inScopeModule -CommandName Write-Warning -Times 1 -Exactly -Scope It `
+ -ParameterFilter { $Message -match 'Configuration of AWS secret failed. Error encountered was' }
+
+ # Revert mock back to top-level.
+ Mock -CommandName Write-AlkamiSecretResourcePolicy -ModuleName $inScopeModule -MockWith {}
+ }
+ }
+
+ Context "Output" {
+
+ It "Returns a PSObject" {
+
+ (Get-Command New-ServerlessServiceAccountPair).OutputType.Type.ToString() | Should -BeExactly "System.Management.Automation.PSObject"
+ }
+
+ It "Returned Object Contains Usernames and Passwords for Created Users" {
+
+ $result = New-ServerlessServiceAccountPair -Cred $testCredential -ServiceName 'TestSvc' -Environment $validEnvironment -ProfileName 'temp-test' `
+ -TicketNumber 'Test-123' -ServiceAccountIamRoleArn 'TestRoleArn' -Region 'us-east-1'
+
+ $result.Credentials | Should -HaveCount 2
+ $result.Credentials[0].UserName | Should -BeExactly "${validEnvironment}TestSvcSvcA"
+ ( Get-PasswordFromCredential $result.Credentials[0] ) | Should -BeExactly "TestPassword"
+ $result.Credentials[1].UserName | Should -BeExactly "${validEnvironment}TestSvcSvcB"
+ ( Get-PasswordFromCredential $result.Credentials[1] ) | Should -BeExactly "TestPassword"
+ }
+
+ It "Returned Object Contains Secret ARNs" {
+
+ $result = New-ServerlessServiceAccountPair -Cred $testCredential -ServiceName 'TestSvc' -Environment $validEnvironment -ProfileName 'temp-test' `
+ -TicketNumber 'Test-123' -ServiceAccountIamRoleArn 'TestRoleArn' -Region 'us-east-1'
+
+ $result.SecretArns | Should -HaveCount 2
+ $result.SecretArns[0] | Should -BeExactly "TestSecretArn1"
+ $result.SecretArns[1] | Should -BeExactly "TestSecretArn2"
+ }
+ }
+ }
+}
diff --git a/Modules/Alkami.DevOps.SystemEngineering/Public/New-SftpPasswordHash.ps1 b/Modules/Alkami.DevOps.SystemEngineering/Public/New-SftpPasswordHash.ps1
new file mode 100644
index 0000000..2dc5a98
--- /dev/null
+++ b/Modules/Alkami.DevOps.SystemEngineering/Public/New-SftpPasswordHash.ps1
@@ -0,0 +1,66 @@
+function New-SftpPasswordHash {
+<#
+.SYNOPSIS
+ Generates a password hash for use by SFTP functions.
+
+.PARAMETER Password
+ [string] The password to hash.
+
+.EXAMPLE
+ New-SftpPasswordHash -Password "Example"
+#>
+
+ [CmdletBinding()]
+ [OutputType([string])]
+ param(
+ [Parameter(Mandatory = $true)]
+ [ValidateNotNullOrEmpty()]
+ [string]$Password
+ )
+
+ $logLead = Get-LogLeadName
+
+ $scriptBlock = {
+
+ $modulePath = $args[0]
+ $password = $args[1]
+
+ Import-Module $modulePath
+
+ return (New-PasswordHash $password)
+ }
+
+ # Primary directory for DLL based on installed module paths.
+ $dllPath = [IO.Path]::Combine( "$PSScriptRoot", 'Resources', 'EncryptPassword.dll' )
+
+ if ( $false -eq ( Test-Path $dllPath ) ) {
+
+ # Fallback directory for local testing of this function.
+ $dllPath = [IO.Path]::Combine( "$PSScriptRoot", '..', 'Resources', 'EncryptPassword.dll' )
+ }
+
+ if ( $false -eq ( Test-Path $dllPath ) ) {
+
+ Write-Error "$logLead : Unable to find EncryptPassword.dll! Check your module path and retry."
+ return $null
+ }
+
+ # Wrap the call in a PSSession to prevent the DLL from being loaded into our general context.
+ # If we load the DLL in a new session, the DLL is unloaded when we destroy the session.
+ #
+ # Note that wrapping this functionality in an Invoke-Command does not yield a string; it only yields
+ # an object that behaves like a string unless it is unwrapped by 'ConvertTo-Json'.
+ #
+ # To see this for yourself, run the following command:
+ # ConvertTo-Json ( Invoke-Command -ComputerName localhost -ScriptBlock { return 'Test' })
+ #
+ # To avoid this behavior, we cast the result to a string which appears to trim off those extra properties.
+ #
+ # To see this for yourself, run the following command:
+ # ConvertTo-Json ([string](Invoke-Command -ComputerName localhost -ScriptBlock { return 'Test' }))
+ $tempSession = New-PSSession
+ [string]$result = Invoke-Command -Session $tempSession -ScriptBlock $scriptBlock -ArgumentList @( $dllPath, $Password )
+ Remove-PSSession -Session $tempSession
+
+ return $result
+}
diff --git a/Modules/Alkami.DevOps.SystemEngineering/Public/New-SftpPasswordHash.tests.ps1 b/Modules/Alkami.DevOps.SystemEngineering/Public/New-SftpPasswordHash.tests.ps1
new file mode 100644
index 0000000..ac78004
--- /dev/null
+++ b/Modules/Alkami.DevOps.SystemEngineering/Public/New-SftpPasswordHash.tests.ps1
@@ -0,0 +1,32 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ""
+
+Describe "New-SftpPasswordHash" {
+
+ $fakePassword = "ThisIsAPassword"
+
+ Mock -CommandName Get-LogLeadName -ModuleName $moduleForMock -MockWith { return 'New-SftpPasswordHash.tests' }
+ Mock -CommandName Write-Error -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Invoke-Command -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName New-PSSession -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Remove-PSSession -ModuleName $moduleForMock -MockWith {}
+
+ Context "Error Handling" {
+
+ Mock -CommandName Test-Path -ModuleName $moduleForMock -MockWith { return $false }
+
+ It "Writes Error and Returns Null If Password Hash DLL Not Found" {
+
+ New-SftpPasswordHash -Password $fakePassword | Should -BeNull
+
+ Assert-MockCalled -CommandName Write-Error `
+ -ParameterFilter { $Message -match "Unable to find EncryptPassword.dll" } -Times 1 -Exactly -Scope It
+ Assert-MockCalled -CommandName Invoke-Command -Times 0 -Exactly -Scope It
+ }
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.SystemEngineering/Public/New-SftpUser.ps1 b/Modules/Alkami.DevOps.SystemEngineering/Public/New-SftpUser.ps1
new file mode 100644
index 0000000..76f14eb
--- /dev/null
+++ b/Modules/Alkami.DevOps.SystemEngineering/Public/New-SftpUser.ps1
@@ -0,0 +1,183 @@
+function New-SftpUser {
+ <#
+.SYNOPSIS
+ Creates a new Alkami SFTP user.
+
+.DESCRIPTION
+ Creates a new Alkami SFTP user. Generates the Secrets Manager entry for the user and initializes the SFTP
+ directory structure.
+
+.PARAMETER Username
+ [string] The username of the user to create.
+
+.PARAMETER Password
+ [string] The password of the user to create. If not provided, one will be generated.
+
+.PARAMETER ProfileName
+ [string] The AWS profile to use during user creation. If not provided, will default to 'temp-prod'.
+
+.PARAMETER Region
+ [string] The AWS region to use during user creation. If not provided, will default to 'us-east-1'.
+
+.PARAMETER HomeDirectorySuffixOverride
+ [string] Override the default user home directory with a custom relative path.
+ This option should be used with extreme caution because it can create new users inside
+ the SFTP folder for another user. The only current use case we have for this override is
+ for a third-party vendor inside an FI's home directory.
+
+ Valid characters in the path are alphanumeric, '-', and '/'. Valid paths should omit
+ leading or trailing '/' characters.
+
+.PARAMETER ChildSubdirectories
+ [string[]] Array of subdirectories to create under the user's home directory. By default, will
+ create 'Staging' and 'Production' subdirectories.
+
+ Valid characters in the path are alphanumeric, '-', and '/'. Valid paths should omit
+ leading or trailing '/' characters.
+
+.PARAMETER ReplicationRegion
+ [string] The target AWS region for replicated AWS Secrets Manager secrets. If not provided, will default to 'us-west-2'.
+ To disable replication, set this value to null or empty.
+
+.EXAMPLE
+ New-SftpUser -Username "TestUser-sftp"
+
+.EXAMPLE
+ New-SftpUser -Username "TestUser-sftp" -Password "1nsecure-ShouldHaveUsedGenerated!"
+
+.EXAMPLE
+ New-SftpUser -Username "TestUser-sftp" -HomeDirectorySuffixOverride "i/probably/should/not/do/this"
+
+.EXAMPLE
+ New-SftpUser -Username "TestUser-sftp" -ChildSubdirectories @( 'Subdir1', 'Some/Nested/Subdir' )
+
+.EXAMPLE
+ New-SftpUser -Username "TestUser-sftp" -Password "IHateReplication" -ReplicationRegion ''
+#>
+
+ [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
+ [OutputType([PSObject])]
+ param (
+ [Parameter(Mandatory = $true)]
+ [ValidateNotNullOrEmpty()]
+ [string] $Username,
+
+ [Parameter(Mandatory = $false)]
+ [ValidateNotNullOrEmpty()]
+ [string] $Password = $null,
+
+ [Parameter(Mandatory = $false)]
+ [ValidateSet('temp-qa', 'temp-prod')]
+ [string] $ProfileName = 'temp-prod',
+
+ [Parameter(Mandatory = $false)]
+ [ValidateScript({ $_ -in (Get-SupportedAwsRegions) })]
+ [string] $Region = 'us-east-1',
+
+ [Parameter(Mandatory = $false)]
+ [ValidatePattern('^[a-z\d]+(\-[a-z\d]+)*(\/[a-z\d]+(\-[a-z\d]+)*)*$')]
+ [string] $HomeDirectorySuffixOverride = $null,
+
+ [Parameter(Mandatory = $false)]
+ [ValidatePattern('^[a-z\d]+(\-[a-z\d]+)*(\/[a-z\d]+(\-[a-z\d]+)*)*$')]
+ [string[]] $ChildSubdirectories = @( 'Staging', 'Production' ),
+
+ [Parameter(Mandatory = $false)]
+ [ValidateScript({ ([String]::IsNullOrEmpty($_) -or ($_ -in (Get-SupportedAwsRegions))) })]
+ [string] $ReplicationRegion = 'us-west-2'
+ )
+
+ $logLead = (Get-LogLeadName)
+
+ Import-AWSModule
+
+ # Pull the IAM role ARN for the SFTP Authentication Lambda.
+ # The name is hardcoded in the Terraform where it is created, and we have no convenient way to
+ # reconcile this name with the Terraform, so assume the name hasn't changed out from under us.
+ $sftpAuthLambdaRoleArn = (Get-LMFunctionConfiguration -FunctionName 'sftp-auth' -ProfileName $ProfileName -Region $Region).Role
+
+ if ( $false -eq $PSBoundParameters.ContainsKey( 'Password' ) ) {
+
+ Write-Verbose "$logLead : Generating password for user."
+ $Password = New-SecurePassword -PasswordLength 15 -ProfileName $ProfileName -Region $Region
+ }
+
+ $actualUsername = $Username.ToLower()
+ $passwordHash = New-SftpPasswordHash -Password $Password
+
+ if ( $null -eq $passwordHash ) {
+
+ Write-Error "$logLead : Unable to generate password hash for SFTP user."
+ return $null
+ }
+
+ if ( $PSBoundParameters.ContainsKey( 'HomeDirectorySuffixOverride' ) -and `
+ $PSCmdlet.ShouldProcess( "Override home directory suffix to '$HomeDirectorySuffixOverride",
+ "Using the HomeDirectorySuffixOverride changes the default behavior of this function. Are you sure you want to use '$HomeDirectorySuffixOverride'?",
+ 'STOP!' )) {
+
+ $s3HomeDirectorySuffix = $HomeDirectorySuffixOverride
+
+ } else {
+
+ $s3HomeDirectorySuffix = $actualUsername
+ }
+
+ Write-Verbose "$logLead : S3 home directory suffix determined to be '$s3HomeDirectorySuffix'"
+
+ # Define environmental differences between QA and Production.
+ if ( $profileName -eq 'temp-prod' ) {
+
+ $s3BucketName = 'alkami-customer-sftp-transfer-server'
+ $kmsKeyArn = 'arn:aws:kms:us-east-1:790953160341:key/419df36d-c871-4023-b8de-484876f0a1f4'
+ $transferIamRoleArn = 'arn:aws:iam::790953160341:role/s-822255bad5b544c79-sftp-user-role'
+ $baseUncPrefix = '\\sftpshare.sre.alkami.net'
+
+ } else {
+
+ $s3BucketName = 'alkami-qa-sftp-transfer-server'
+ $kmsKeyArn = 'arn:aws:kms:us-east-1:668894625708:key/44112b4d-9671-4786-9864-25fae070b349'
+ $transferIamRoleArn = 'arn:aws:iam::668894625708:role/s-19391cf5c7d949ca8-sftp-user-role'
+ $baseUncPrefix = '\\sftpshare.qa.alkami.net'
+ }
+
+ $uncHomeDirectory = Join-Path (Join-Path $baseUncPrefix $s3BucketName) $s3HomeDirectorySuffix
+ Write-Verbose "$logLead : UNC home directory string determined to be '$uncHomeDirectory'"
+
+ # Substitute values into the Secrets string.
+ $secretString = Get-SftpUserDefaultSecretString `
+ -BucketName $s3BucketName `
+ -HomeDirSuffix $s3HomeDirectorySuffix `
+ -KmsArn $kmsKeyArn `
+ -RoleArn $transferIamRoleArn `
+ -PasswordHash $passwordHash
+
+ # Create the Secrets Manager object in AWS.
+ $tag1 = New-Object -TypeName PSObject -Property @{ Key = 'alk:project' ; Value = 'sftp' }
+ $tag2 = New-Object -TypeName PSObject -Property @{ Key = 'alk:costcenter' ; Value = 'sre-systemeng' }
+ New-SECSecret -SecretString $secretString -Name $actualUsername -Tag @($tag1, $tag2) -ProfileName $ProfileName -Region $Region | Out-Null
+
+ # Create the resource policy on the secret in AWS.
+ Write-AlkamiSecretResourcePolicy -SecretName $actualUsername -ProfileName $ProfileName -Region $Region -SecretAccessExtraArns @($sftpAuthLambdaRoleArn)
+
+ if ( $false -eq [string]::IsNullOrEmpty( $ReplicationRegion )) {
+
+ $replicaConfig = New-Object Amazon.SecretsManager.Model.ReplicaRegionType
+ $replicaConfig.Region = $ReplicationRegion
+
+ Write-Verbose "$logLead : Replicating newly created secret to $ReplicationRegion."
+ Add-SECSecretToRegion -SecretId $actualUsername -AddReplicaRegion @($replicaConfig) -ProfileName $ProfileName -Region $Region | Out-Null
+ }
+
+ # Create the SMB directories for the user.
+ Write-Verbose "$logLead : Creating UNC path '$uncHomeDirectory' for the user."
+ New-Item $uncHomeDirectory -ItemType 'directory' -Force | Out-Null
+
+ foreach ( $subdir in $ChildSubdirectories ) {
+ $subdirPath = Join-Path $uncHomeDirectory $subdir
+ Write-Verbose "$logLead : Creating subdirectory UNC path '$subdirPath'."
+ New-Item $subdirPath -ItemType 'directory' -Force | Out-Null
+ }
+
+ return New-Object -TypeName PSObject -Property @{ Username = $actualUsername ; Password = $Password }
+}
diff --git a/Modules/Alkami.DevOps.SystemEngineering/Public/New-SftpUser.tests.ps1 b/Modules/Alkami.DevOps.SystemEngineering/Public/New-SftpUser.tests.ps1
new file mode 100644
index 0000000..a339343
--- /dev/null
+++ b/Modules/Alkami.DevOps.SystemEngineering/Public/New-SftpUser.tests.ps1
@@ -0,0 +1,197 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ''
+
+InModuleScope 'Alkami.DevOps.SystemEngineering' {
+ Describe 'New-SftpUser' {
+
+ $fakeAccountName = 'FakeyMcFakeAccount-SFTP'
+ $fakePassword = 'ThisIsAPassword'
+ $generatedPassword = '@ut0Generated'
+
+ Mock -CommandName Get-SupportedAwsRegions -ModuleName $moduleForMock -MockWith { return @( 'us-east-1' ) }
+ Mock -CommandName Get-LogLeadName -ModuleName $moduleForMock -MockWith { return 'New-SftpUser.tests' }
+ Mock -CommandName New-SecurePassword -ModuleName $moduleForMock -MockWith { return $generatedPassword }
+ Mock -CommandName Get-SftpUserDefaultSecretString -ModuleName $moduleForMock -MockWith { return '' }
+ Mock -CommandName Get-LMFunctionConfiguration -ModuleName $moduleForMock -MockWith { return @{ 'Role' = 'TestArn' } }
+ Mock -CommandName New-SECSecret -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Write-AlkamiSecretResourcePolicy -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName New-Item -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Write-Error -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Import-AWSModule -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Write-Verbose -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Add-SECSecretToRegion -ModuleName $moduleForMock -MockWith {}
+
+ Context 'Parameter Validation' {
+
+ It 'Throws if Username is Null' {
+ { New-SftpUser -Username $null } | Should -Throw
+ }
+
+ It 'Throws if Username is Empty' {
+ { New-SftpUser -Username '' } | Should -Throw
+ }
+
+ It 'Throws if Password is Null' {
+ { New-SftpUser -Username $fakeAccountName -Password $null } | Should -Throw
+ }
+
+ It 'Throws if Password is Empty' {
+ { New-SftpUser -Username $fakeAccountName -Password '' } | Should -Throw
+ }
+
+ It 'Throws if Profile Name is Null' {
+ { New-SftpUser -Username $fakeAccountName -ProfileName $null } | Should -Throw
+ }
+
+ It 'Throws if Profile Name is Empty' {
+ { New-SftpUser -Username $fakeAccountName -ProfileName '' } | Should -Throw
+ }
+
+ It 'Throws if Profile Name is Not in Supported List' {
+ { New-SftpUser -Username $fakeAccountName -ProfileName 'temp-test' } | Should -Throw
+ }
+
+ It 'Throws if Region is Not in Supported List' {
+ { New-SftpUser -Username $fakeAccountName -Region 'Test' } | Should -Throw
+ }
+
+ It 'Throws if Home Directory Suffix Override Does Not Match Regex' {
+ { New-SftpUser -Username $fakeAccountName -HomeDirectorySuffixOverride '/---/TEST!!' } | Should -Throw
+ }
+
+ It 'Throws if Child Subdirectories Does Not Match Regex' {
+ { New-SftpUser -Username $fakeAccountName -ChildSubdirectories @('/---/TEST!!') } | Should -Throw
+ }
+
+ It 'Throws if Replication Region is Not in Supported List' {
+ { New-SftpUser -Username $fakeAccountName -ReplicationRegion 'Test' } | Should -Throw
+ }
+ }
+
+ Context 'Error Handling' {
+
+ Mock -CommandName New-SftpPasswordHash -ModuleName $moduleForMock -MockWith { return $null }
+
+ It 'Writes Error and Returns Null If Password Hash Fails' {
+
+ New-SftpUser -Username $fakeAccountName | Should -BeNull
+
+ Assert-MockCalled -CommandName Write-Error `
+ -ParameterFilter { $Message -match 'Unable to generate password hash for SFTP user.' } -Times 1 -Exactly -Scope It
+ Assert-MockCalled -CommandName New-SECSecret -Times 0 -Exactly -Scope It
+ Assert-MockCalled -CommandName New-Item -Times 0 -Exactly -Scope It
+ }
+ }
+
+ Context 'Logic' {
+
+ Mock -CommandName New-SftpPasswordHash -ModuleName $moduleForMock -MockWith { return $fakePassword }
+
+ It 'Converts Username to Lowercase' {
+
+ $result = New-SftpUser -Username $fakeAccountName
+ $result.Username | Should -BeExactly $fakeAccountName.ToLower()
+ }
+
+ It 'Uses Password if Provided' {
+
+ $result = New-SftpUser -Username $fakeAccountName -Password $fakePassword
+ $result.Password | Should -BeExactly $fakePassword
+ }
+
+ It 'Uses Generated Password if Not Provided' {
+
+ $result = New-SftpUser -Username $fakeAccountName
+ $result.Password | Should -BeExactly $generatedPassword
+ }
+
+ It 'Uses Username as Home Directory Suffix By Default' {
+
+ $result = New-SftpUser -Username $fakeAccountName
+ Assert-MockCalled -CommandName Write-Verbose `
+ -ParameterFilter { $Message -match "Home directory suffix determined to be '$($result.Username)'" } -Times 1 -Exactly -Scope It
+ }
+
+ It 'Uses Home Directory Suffix Override Parameter When Provided' {
+
+ $suffix = 'test1-root/test2-subdir'
+ New-SftpUser -Username $fakeAccountName -HomeDirectorySuffixOverride $suffix -Confirm:$false
+ Assert-MockCalled -CommandName Write-Verbose `
+ -ParameterFilter { $Message -match "Home directory suffix determined to be '$suffix'" } -Times 1 -Exactly -Scope It
+ }
+
+ It 'Creates Staging and Production Subdirectories by Default' {
+
+ New-SftpUser -Username $fakeAccountName
+ Assert-MockCalled -CommandName New-item -Times 1 -Exactly -Scope It `
+ -ParameterFilter { $Path.EndsWith('Staging') }
+ Assert-MockCalled -CommandName New-item -Times 1 -Exactly -Scope It `
+ -ParameterFilter { $Path.EndsWith('Production') }
+ }
+
+ It 'Uses ChildSubdirectories Parameter If Provided' {
+
+ New-SftpUser -Username $fakeAccountName -ChildSubdirectories @( 'TestSubdir' )
+ Assert-MockCalled -CommandName New-item -Times 0 -Exactly -Scope It `
+ -ParameterFilter { $Path.EndsWith('Staging') }
+ Assert-MockCalled -CommandName New-item -Times 0 -Exactly -Scope It `
+ -ParameterFilter { $Path.EndsWith('Production') }
+ Assert-MockCalled -CommandName New-item -Times 1 -Exactly -Scope It `
+ -ParameterFilter { $Path.EndsWith('TestSubdir') }
+ }
+
+ It 'Creates AWS Secret for the User' {
+
+ New-SftpUser -Username $fakeAccountName
+ Assert-MockCalled -CommandName New-SECSecret -Times 1 -Exactly -Scope It `
+ -ParameterFilter { $Name -eq $fakeAccountName.ToLower() }
+ }
+
+ It 'Creates Resource Policy on New AWS Secret' {
+
+ New-SftpUser -Username $fakeAccountName
+ Assert-MockCalled -CommandName Get-LMFunctionConfiguration -Times 1 -Exactly -Scope It
+ Assert-MockCalled -CommandName Write-AlkamiSecretResourcePolicy -Times 1 -Exactly -Scope It `
+ -ParameterFilter { $SecretName -eq $fakeAccountName.ToLower() }
+ }
+
+ It 'Resource Policy Contains SFTP Lambda Auth ARN' {
+
+ New-SftpUser -Username $fakeAccountName
+ Assert-MockCalled -CommandName Get-LMFunctionConfiguration -Times 1 -Exactly -Scope It
+ Assert-MockCalled -CommandName Write-AlkamiSecretResourcePolicy -Times 1 -Exactly -Scope It `
+ -ParameterFilter { $SecretAccessExtraArns[0] -ceq 'TestArn' }
+ }
+
+ It 'Replicates New AWS Secret to us-west-2 by Default' {
+
+ New-SftpUser -Username $fakeAccountName
+ Assert-MockCalled -CommandName Write-Verbose -Times 1 -Exactly -Scope It `
+ -ParameterFilter { $Message -match 'Replicating newly created secret to us-west-2' }
+ Assert-MockCalled -CommandName Add-SECSecretToRegion -Times 1 -Exactly -Scope It `
+ -ParameterFilter { $SecretId -eq $fakeAccountName.ToLower() }
+ }
+
+ It 'Does Not Replicate New AWS Secret if Parameter is Null' {
+
+ New-SftpUser -Username $fakeAccountName -ReplicationRegion $null
+ Assert-MockCalled -CommandName Write-Verbose -Times 0 -Exactly -Scope It `
+ -ParameterFilter { $Message -match 'Replicating newly created secret' }
+ Assert-MockCalled -CommandName Add-SECSecretToRegion -Times 0 -Exactly -Scope It
+ }
+
+ It 'Does Not Replicate New AWS Secret if Parameter is Empty' {
+
+ New-SftpUser -Username $fakeAccountName -ReplicationRegion ''
+ Assert-MockCalled -CommandName Write-Verbose -Times 0 -Exactly -Scope It `
+ -ParameterFilter { $Message -match 'Replicating newly created secret' }
+ Assert-MockCalled -CommandName Add-SECSecretToRegion -Times 0 -Exactly -Scope It
+ }
+ }
+ }
+}
diff --git a/Modules/Alkami.DevOps.SystemEngineering/Public/Test-IsUserDomainAdmin.ps1 b/Modules/Alkami.DevOps.SystemEngineering/Public/Test-IsUserDomainAdmin.ps1
new file mode 100644
index 0000000..d6ce6db
--- /dev/null
+++ b/Modules/Alkami.DevOps.SystemEngineering/Public/Test-IsUserDomainAdmin.ps1
@@ -0,0 +1,47 @@
+function Test-IsUserDomainAdmin {
+
+<#
+.SYNOPSIS
+ Tests if a user is a domain administrator
+
+.DESCRIPTION
+ Checks a user's principal group membership for membership in the domain admins group
+
+.PARAMETER User
+ [string] The username to check in SAMAccountName format. If not provided, defaults to current user
+
+.EXAMPLE
+ Test-IsUserDomainAdmin
+
+.EXAMPLE
+ Test-IsUserDomainAdmin "someadmin@corp.alkamitech.com"
+#>
+
+ [CmdletBinding()]
+ [OutputType([System.Boolean])]
+ Param(
+ [Parameter(Mandatory = $false)]
+ [Alias("UserName")]
+ [string]$User
+ )
+
+ $logLead = (Get-LogLeadName)
+
+ if ([String]::IsNullOrEmpty($User)) {
+
+ $userToCheck = [System.Security.Principal.WindowsIdentity]::GetCurrent()
+
+ } else {
+
+ if ($User -notmatch "\@") {
+
+ Write-Warning "$logLead : Username supplied must be SAMAccountName format"
+ return $null
+ }
+
+ $userToCheck = $user
+ }
+
+ $principal = New-Object System.Security.Principal.WindowsPrincipal($userToCheck)
+ return $principal.IsInRole("Domain Admins")
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.SystemEngineering/Public/Test-IsUserDomainAdmin.tests.ps1 b/Modules/Alkami.DevOps.SystemEngineering/Public/Test-IsUserDomainAdmin.tests.ps1
new file mode 100644
index 0000000..0005377
--- /dev/null
+++ b/Modules/Alkami.DevOps.SystemEngineering/Public/Test-IsUserDomainAdmin.tests.ps1
@@ -0,0 +1,23 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ""
+
+Describe "Test-IsUserDomainAdmin" {
+
+ Mock -CommandName Get-LogLeadName -ModuleName $moduleForMock -MockWith { return 'Test-IsUserDomainAdmin.tests' }
+ Mock -CommandName Write-Warning -ModuleName $moduleForMock -MockWith {}
+
+ Context "Parameter Validation" {
+
+ It "Writes a Warning and Returns Null When Username is Not SAM Format" {
+
+ Test-IsUserDomainAdmin "CONTOSO\FakeUser" | Should -BeNull
+ Assert-MockCalled -ModuleName $moduleForMock -CommandName "Write-Warning" -Times 1 -Exactly -Scope It `
+ -ParameterFilter { $Message -match "Username supplied must be SAMAccountName format" }
+ }
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.SystemEngineering/Public/Update-AWSProfile.ps1 b/Modules/Alkami.DevOps.SystemEngineering/Public/Update-AWSProfile.ps1
new file mode 100644
index 0000000..42bfe52
--- /dev/null
+++ b/Modules/Alkami.DevOps.SystemEngineering/Public/Update-AWSProfile.ps1
@@ -0,0 +1,119 @@
+function Update-AWSProfile {
+
+<#
+.SYNOPSIS
+ Update AWS Profile credentials file with temporary assumed role credentials.
+
+.DESCRIPTION
+ Update AWS Profile credentials file with temporary assumed role credentials.
+
+.PARAMETER Profile
+ [string] The AWS profile name to update.
+
+.PARAMETER MfaCode
+ [string] The MFA code from the AWS-associated MFA device. If not provided, will be prompted to enter.
+
+.PARAMETER SessionDurationSeconds
+ [uint16] The session duration in seconds for the temporary assumed role. Valid values are 900 seconds (15 minutes) to 43200 seconds (12 hours).
+ If not provided, will default to 43200.
+
+.EXAMPLE
+ Update-AWSProfile -Profile 'Prod'
+
+.EXAMPLE
+ Update-AWSProfile -Profile 'Prod' -MfaCode '123456'
+#>
+
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory = $true)]
+ [ValidateNotNullOrEmpty()]
+ [string]$Profile,
+
+ [Parameter(Mandatory = $false)]
+ [ValidateNotNullOrEmpty()]
+ [string]$MfaCode = $null,
+
+ [Parameter(Mandatory = $false)]
+ [ValidateRange(900, 43200)]
+ [uint16]$SessionDurationSeconds = 43200
+ )
+
+ $logLead = (Get-LogLeadName)
+ $tempProfile = "temp-" + $Profile.ToLower()
+ $helpUrl = 'https://confluence.alkami.com/x/hrMHB'
+
+ Import-AWSModule
+
+ try {
+
+ Get-STSCallerIdentity -ProfileName $tempProfile | Out-Null
+ Write-Verbose "$logLead : Credentials for profile [$tempProfile] are still valid; exiting."
+ return
+
+ } catch {
+
+ Write-Verbose "$logLead : No valid credentials associated with profile [$tempProfile]; proceeding"
+ }
+
+ $profileLocation = ( Get-AWSCredential -ListProfileDetail | Where-Object { $_.ProfileName -eq 'default' } | Select-Object -First 1 ).ProfileLocation
+ if ( [string]::IsNullOrEmpty( $profileLocation ) ) {
+
+ Write-Error "$logLead : Unable to locate default profile location. Check your configuration per [$helpUrl]."
+ return
+ }
+
+ $profileCred = Get-AWSCredential -ProfileName $Profile
+ if ( $null -eq $profileCred ) {
+
+ Write-Error "$logLead : Unable to locate the profile named [$Profile]. Check your configuration per [$helpUrl]."
+ return
+
+ } elseif ( [string]::IsNullOrEmpty( $profileCred.RoleArn ) ) {
+
+ Write-Error "$logLead : Unable to locate the role ARN for [$Profile]. Check your configuration per [$helpUrl]."
+ return
+
+ } elseif ( [string]::IsNullOrEmpty( $profileCred.Options.MfaSerialNumber ) ) {
+
+ Write-Error "$logLead : Unable to locate the MFA serial number for [$Profile]. Check your configuration per [$helpUrl]."
+ return
+ }
+
+ if ( $false -eq $PSBoundParameters.ContainsKey( 'MfaCode' ) ) {
+
+ $MfaCode = Read-Host -Prompt "Enter MFA code to assume role [$($profileCred.RoleArn)]"
+ }
+
+ $assumedCred = (Use-STSRole -RoleArn $profileCred.RoleArn -SerialNumber $profileCred.Options.MfaSerialNumber `
+ -RoleSessionName $tempProfile -TokenCode $MfaCode -DurationInSeconds $SessionDurationSeconds).Credentials
+ if ( $null -eq $assumedCred ) {
+
+ Write-Error "$logLead : Unable to assume role [$($profileCred.RoleArn)]. Check your MFA code and retry."
+ return
+
+ } elseif ( [string]::IsNullOrEmpty( $assumedCred.AccessKeyId ) ) {
+
+ Write-Error "$logLead : No access key provided by [$($profileCred.RoleArn)] credential."
+ return
+
+ } elseif ( [string]::IsNullOrEmpty( $assumedCred.SecretAccessKey ) ) {
+
+ Write-Error "$logLead : No secret access key provided by [$($profileCred.RoleArn)] credential."
+ return
+
+ } elseif ( [string]::IsNullOrEmpty( $assumedCred.SessionToken ) ) {
+
+ Write-Error "$logLead : No session token provided by [$($profileCred.RoleArn)] credential."
+ return
+ }
+
+ Set-AWSCredential `
+ -StoreAs $tempProfile `
+ -ProfileLocation $profileLocation `
+ -AccessKey $assumedCred.AccessKeyId `
+ -SecretKey $assumedCred.SecretAccessKey `
+ -SessionToken $assumedCred.SessionToken
+
+ Write-Verbose "$logLead : Updated profile [$tempProfile]."
+}
diff --git a/Modules/Alkami.DevOps.SystemEngineering/Public/Update-AWSProfile.tests.ps1 b/Modules/Alkami.DevOps.SystemEngineering/Public/Update-AWSProfile.tests.ps1
new file mode 100644
index 0000000..2d330ed
--- /dev/null
+++ b/Modules/Alkami.DevOps.SystemEngineering/Public/Update-AWSProfile.tests.ps1
@@ -0,0 +1,232 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ""
+
+Describe "Update-AWSProfile" {
+
+ Mock -CommandName Get-LogLeadName -ModuleName $moduleForMock -MockWith { return 'Update-AWSProfile.tests' }
+ Mock -CommandName Write-Error -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Import-AWSModule -ModuleName $moduleForMock -MockWith {}
+
+ Context "Logic" {
+
+ Mock -CommandName Read-Host -ModuleName $moduleForMock -MockWith { return '123456' }
+
+ It "Returns Early If Credential Is Still Valid" {
+
+ Mock -CommandName Get-STSCallerIdentity -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Use-STSRole -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Get-AWSCredential -ModuleName $moduleForMock -MockWith { return @() }
+
+ Update-AWSProfile -Profile "Test"
+
+ Assert-MockCalled -CommandName Get-STSCallerIdentity `
+ -ParameterFilter { $ProfileName -eq "temp-test" } -Times 1 -Exactly -Scope It
+ Assert-MockCalled -CommandName Get-AWSCredential -Times 0 -Exactly -Scope It
+ Assert-MockCalled -CommandName Use-STSRole -Times 0 -Exactly -Scope It
+ }
+
+ It "Writes Error and Aborts if Default Profile Location Not Found" {
+
+ Mock -CommandName Get-STSCallerIdentity -ModuleName $moduleForMock -MockWith { throw "Test Error" }
+ Mock -CommandName Use-STSRole -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Get-AWSCredential -ModuleName $moduleForMock -MockWith { return @() }
+
+ Update-AWSProfile -Profile "Test"
+
+ Assert-MockCalled -CommandName Write-Error `
+ -ParameterFilter { $Message -match "Unable to locate default profile location" } -Times 1 -Exactly -Scope It
+ Assert-MockCalled -CommandName Get-AWSCredential -Times 1 -Exactly -Scope It
+ Assert-MockCalled -CommandName Use-STSRole -Times 0 -Exactly -Scope It
+ }
+
+ It "Writes Error and Aborts if Profile Not Found" {
+
+ Mock -CommandName Get-STSCallerIdentity -ModuleName $moduleForMock -MockWith { throw "Test Error" }
+ Mock -CommandName Use-STSRole -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Get-AWSCredential -ModuleName $moduleForMock -MockWith { return @( @{ProfileLocation = "C:\Temp\test.txt"; ProfileName = "default"}) } `
+ -ParameterFilter { $PSBoundParameters.ContainsKey( 'ListProfileDetail' ) }
+ Mock -CommandName Get-AWSCredential -ModuleName $moduleForMock -MockWith { return $null} `
+ -ParameterFilter { $PSBoundParameters.ContainsKey( 'ProfileName' ) }
+
+ Update-AWSProfile -Profile "Test"
+
+ Assert-MockCalled -CommandName Write-Error `
+ -ParameterFilter { $Message -match "Unable to locate the profile named \[Test\]" } -Times 1 -Exactly -Scope It
+ Assert-MockCalled -CommandName Get-AWSCredential -Times 2 -Exactly -Scope It
+ Assert-MockCalled -CommandName Use-STSRole -Times 0 -Exactly -Scope It
+ }
+
+ It "Writes Error and Aborts if Profile ARN Not Found" {
+
+ Mock -CommandName Get-STSCallerIdentity -ModuleName $moduleForMock -MockWith { throw "Test Error" }
+ Mock -CommandName Use-STSRole -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Get-AWSCredential -ModuleName $moduleForMock -MockWith { return @( @{ProfileLocation = "C:\Temp\test.txt"; ProfileName = "default"}) } `
+ -ParameterFilter { $PSBoundParameters.ContainsKey( 'ListProfileDetail' ) }
+ Mock -CommandName Get-AWSCredential -ModuleName $moduleForMock -MockWith { return @{}} `
+ -ParameterFilter { $PSBoundParameters.ContainsKey( 'ProfileName' ) }
+
+ Update-AWSProfile -Profile "Test"
+
+ Assert-MockCalled -CommandName Write-Error `
+ -ParameterFilter { $Message -match "Unable to locate the role ARN for \[Test\]" } -Times 1 -Exactly -Scope It
+ Assert-MockCalled -CommandName Get-AWSCredential -Times 2 -Exactly -Scope It
+ Assert-MockCalled -CommandName Use-STSRole -Times 0 -Exactly -Scope It
+ }
+
+ It "Writes Error and Aborts if Profile MFA Serial Number Not Found" {
+
+ Mock -CommandName Get-STSCallerIdentity -ModuleName $moduleForMock -MockWith { throw "Test Error" }
+ Mock -CommandName Use-STSRole -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Get-AWSCredential -ModuleName $moduleForMock -MockWith { return @( @{ProfileLocation = "C:\Temp\test.txt"; ProfileName = "default"}) } `
+ -ParameterFilter { $PSBoundParameters.ContainsKey( 'ListProfileDetail' ) }
+ Mock -CommandName Get-AWSCredential -ModuleName $moduleForMock -MockWith { return @{ RoleArn = "TestRole" }} `
+ -ParameterFilter { $PSBoundParameters.ContainsKey( 'ProfileName' ) }
+
+ Update-AWSProfile -Profile "Test"
+
+ Assert-MockCalled -CommandName Write-Error `
+ -ParameterFilter { $Message -match "Unable to locate the MFA serial number for \[Test\]" } -Times 1 -Exactly -Scope It
+ Assert-MockCalled -CommandName Get-AWSCredential -Times 2 -Exactly -Scope It
+ Assert-MockCalled -CommandName Use-STSRole -Times 0 -Exactly -Scope It
+ }
+
+ It "Writes Error and Aborts if Assume Role Fails" {
+
+ Mock -CommandName Get-STSCallerIdentity -ModuleName $moduleForMock -MockWith { throw "Test Error" }
+ Mock -CommandName Use-STSRole -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Get-AWSCredential -ModuleName $moduleForMock -MockWith { return @( @{ProfileLocation = "C:\Temp\test.txt"; ProfileName = "default"}) } `
+ -ParameterFilter { $PSBoundParameters.ContainsKey( 'ListProfileDetail' ) }
+ Mock -CommandName Get-AWSCredential -ModuleName $moduleForMock -MockWith { return @{ RoleArn = "TestRole"; Options = @{ MfaSerialNumber = "TestMfa" } } } `
+ -ParameterFilter { $PSBoundParameters.ContainsKey( 'ProfileName' ) }
+
+ Update-AWSProfile -Profile "Test"
+
+ Assert-MockCalled -CommandName Write-Error `
+ -ParameterFilter { $Message -match "Unable to assume role \[TestRole\]" } -Times 1 -Exactly -Scope It
+ Assert-MockCalled -CommandName Get-AWSCredential -Times 2 -Exactly -Scope It
+ Assert-MockCalled -CommandName Use-STSRole -Times 1 -Exactly -Scope It
+ }
+
+ It "Writes Error and Aborts if Assume Role Credential Lacks Access Key" {
+
+ Mock -CommandName Get-STSCallerIdentity -ModuleName $moduleForMock -MockWith { throw "Test Error" }
+ Mock -CommandName Get-AWSCredential -ModuleName $moduleForMock -MockWith { return @( @{ProfileLocation = "C:\Temp\test.txt"; ProfileName = "default"}) } `
+ -ParameterFilter { $PSBoundParameters.ContainsKey( 'ListProfileDetail' ) }
+ Mock -CommandName Get-AWSCredential -ModuleName $moduleForMock -MockWith { return @{ RoleArn = "TestRole"; Options = @{ MfaSerialNumber = "TestMfa" } } } `
+ -ParameterFilter { $PSBoundParameters.ContainsKey( 'ProfileName' ) }
+ Mock -CommandName Use-STSRole -ModuleName $moduleForMock -MockWith {@{Credentials = @{}}}
+ Mock -CommandName Set-AWSCredential -ModuleName $moduleForMock -MockWith {}
+
+ Update-AWSProfile -Profile "Test"
+
+ Assert-MockCalled -CommandName Write-Error `
+ -ParameterFilter { $Message -match "No access key provided by \[TestRole\] credential." } -Times 1 -Exactly -Scope It
+ Assert-MockCalled -CommandName Get-AWSCredential -Times 2 -Exactly -Scope It
+ Assert-MockCalled -CommandName Use-STSRole -Times 1 -Exactly -Scope It
+ Assert-MockCalled -CommandName Set-AWSCredential -Times 0 -Exactly -Scope It
+ }
+
+ It "Writes Error and Aborts if Assume Role Credential Lacks Secret Access Key" {
+
+ Mock -CommandName Get-STSCallerIdentity -ModuleName $moduleForMock -MockWith { throw "Test Error" }
+ Mock -CommandName Get-AWSCredential -ModuleName $moduleForMock -MockWith { return @( @{ProfileLocation = "C:\Temp\test.txt"; ProfileName = "default"}) } `
+ -ParameterFilter { $PSBoundParameters.ContainsKey( 'ListProfileDetail' ) }
+ Mock -CommandName Get-AWSCredential -ModuleName $moduleForMock -MockWith { return @{ RoleArn = "TestRole"; Options = @{ MfaSerialNumber = "TestMfa" } } } `
+ -ParameterFilter { $PSBoundParameters.ContainsKey( 'ProfileName' ) }
+ Mock -CommandName Use-STSRole -ModuleName $moduleForMock -MockWith {@{Credentials = @{AccessKeyId = "TestAccess"}}}
+ Mock -CommandName Set-AWSCredential -ModuleName $moduleForMock -MockWith {}
+
+ Update-AWSProfile -Profile "Test"
+
+ Assert-MockCalled -CommandName Write-Error `
+ -ParameterFilter { $Message -match "No secret access key provided by \[TestRole\] credential." } -Times 1 -Exactly -Scope It
+ Assert-MockCalled -CommandName Get-AWSCredential -Times 2 -Exactly -Scope It
+ Assert-MockCalled -CommandName Use-STSRole -Times 1 -Exactly -Scope It
+ Assert-MockCalled -CommandName Set-AWSCredential -Times 0 -Exactly -Scope It
+ }
+
+ It "Writes Error and Aborts if Assume Role Credential Lacks Secret Access Key" {
+
+ Mock -CommandName Get-STSCallerIdentity -ModuleName $moduleForMock -MockWith { throw "Test Error" }
+ Mock -CommandName Get-AWSCredential -ModuleName $moduleForMock -MockWith { return @( @{ProfileLocation = "C:\Temp\test.txt"; ProfileName = "default"}) } `
+ -ParameterFilter { $PSBoundParameters.ContainsKey( 'ListProfileDetail' ) }
+ Mock -CommandName Get-AWSCredential -ModuleName $moduleForMock -MockWith { return @{ RoleArn = "TestRole"; Options = @{ MfaSerialNumber = "TestMfa" } } } `
+ -ParameterFilter { $PSBoundParameters.ContainsKey( 'ProfileName' ) }
+ Mock -CommandName Use-STSRole -ModuleName $moduleForMock -MockWith {@{Credentials = @{AccessKeyId = "TestAccess"; SecretAccessKey = "TestSecret"}}}
+ Mock -CommandName Set-AWSCredential -ModuleName $moduleForMock -MockWith {}
+
+ Update-AWSProfile -Profile "Test"
+
+ Assert-MockCalled -CommandName Write-Error `
+ -ParameterFilter { $Message -match "No session token provided by \[TestRole\] credential." } -Times 1 -Exactly -Scope It
+ Assert-MockCalled -CommandName Get-AWSCredential -Times 2 -Exactly -Scope It
+ Assert-MockCalled -CommandName Use-STSRole -Times 1 -Exactly -Scope It
+ Assert-MockCalled -CommandName Set-AWSCredential -Times 0 -Exactly -Scope It
+ }
+
+ It "Saves AWS Credential Upon Success" {
+
+ Mock -CommandName Get-STSCallerIdentity -ModuleName $moduleForMock -MockWith { throw "Test Error" }
+ Mock -CommandName Get-AWSCredential -ModuleName $moduleForMock -MockWith { return @( @{ProfileLocation = "C:\Temp\test.txt"; ProfileName = "default"}) } `
+ -ParameterFilter { $PSBoundParameters.ContainsKey( 'ListProfileDetail' ) }
+ Mock -CommandName Get-AWSCredential -ModuleName $moduleForMock -MockWith { return @{ RoleArn = "TestRole"; Options = @{ MfaSerialNumber = "TestMfa" } } } `
+ -ParameterFilter { $PSBoundParameters.ContainsKey( 'ProfileName' ) }
+ Mock -CommandName Use-STSRole -ModuleName $moduleForMock -MockWith {@{Credentials = @{AccessKeyId = "TestAccess"; SecretAccessKey = "TestSecret"; SessionToken = "TestSession"}}}
+ Mock -CommandName Set-AWSCredential -ModuleName $moduleForMock -MockWith {}
+
+ Update-AWSProfile -Profile "Test"
+
+ Assert-MockCalled -CommandName Write-Error -Times 0 -Exactly -Scope It
+ Assert-MockCalled -CommandName Get-AWSCredential -Times 2 -Exactly -Scope It
+ Assert-MockCalled -CommandName Use-STSRole -Times 1 -Exactly -Scope It
+ Assert-MockCalled -CommandName Set-AWSCredential -Times 1 -Exactly -Scope It
+ }
+ }
+
+ Context "Input" {
+
+ Mock -CommandName Get-STSCallerIdentity -ModuleName $moduleForMock -MockWith { throw "Test Error" }
+ Mock -CommandName Get-AWSCredential -ModuleName $moduleForMock -MockWith { return @( @{ProfileLocation = "C:\Temp\test.txt"; ProfileName = "default"}) } `
+ -ParameterFilter { $PSBoundParameters.ContainsKey( 'ListProfileDetail' ) }
+ Mock -CommandName Get-AWSCredential -ModuleName $moduleForMock -MockWith { return @{ RoleArn = "TestRole"; Options = @{ MfaSerialNumber = "TestMfa" } } } `
+ -ParameterFilter { $PSBoundParameters.ContainsKey( 'ProfileName' ) }
+ Mock -CommandName Use-STSRole -ModuleName $moduleForMock -MockWith {@{Credentials = @{AccessKeyId = "TestAccess"; SecretAccessKey = "TestSecret"; SessionToken = "TestSession"}}}
+ Mock -CommandName Set-AWSCredential -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Read-Host -ModuleName $moduleForMock -MockWith { return '123456' }
+
+ It "Does Not Prompt For MFA Code If Parameter Provided" {
+
+ Update-AWSProfile -Profile "Test" -MfaCode '123456' | Out-Null
+
+ Assert-MockCalled -CommandName Read-Host -Times 0 -Exactly -Scope It
+ }
+
+ It "Prompts For MFA Code If Parameter Not Provided" {
+
+ Update-AWSProfile -Profile "Test" | Out-Null
+
+ Assert-MockCalled -CommandName Read-Host -Times 1 -Exactly -Scope It
+ }
+
+ It "Uses Default Value for Session Duration If Parameter Not Provided" {
+
+ Update-AWSProfile -Profile "Test" -MfaCode '123456' | Out-Null
+
+ Assert-MockCalled -CommandName Use-STSRole -Times 1 -Exactly -Scope It `
+ -ParameterFilter { $DurationInSeconds -eq 43200 }
+ }
+
+ It "Uses Provided Value for Session Duration" {
+
+ Update-AWSProfile -Profile "Test" -MfaCode '123456' -SessionDurationSeconds 4321 | Out-Null
+
+ Assert-MockCalled -CommandName Use-STSRole -Times 1 -Exactly -Scope It `
+ -ParameterFilter { $DurationInSeconds -eq 4321 }
+ }
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.SystemEngineering/Public/Update-SftpPassword.ps1 b/Modules/Alkami.DevOps.SystemEngineering/Public/Update-SftpPassword.ps1
new file mode 100644
index 0000000..93cc342
--- /dev/null
+++ b/Modules/Alkami.DevOps.SystemEngineering/Public/Update-SftpPassword.ps1
@@ -0,0 +1,78 @@
+function Update-SftpPassword {
+ <#
+.SYNOPSIS
+ Updates the password of an Alkami SFTP user.
+
+.DESCRIPTION
+ Updates the password of an Alkami SFTP user by updating the Secrets Manager entry for the user.
+
+.PARAMETER Username
+ [string] The username of the user to update. Casing must be an exact match.
+
+.PARAMETER Password
+ [string] The new password of the user. If not provided, one will be generated.
+
+.PARAMETER ProfileName
+ [string] The AWS profile to use during user modification. If not provided, will default to 'temp-prod'.
+
+.PARAMETER Region
+ [string] The AWS region to use during user modification. If not provided, will default to 'us-east-1'.
+
+.EXAMPLE
+ Update-SftpPassword -Username "TestUser-sftp"
+
+.EXAMPLE
+ Update-SftpPassword -Username "TestUser-sftp" -Password "1nsecure-ShouldHaveUsedGenerated!"
+#>
+
+ [CmdletBinding()]
+ [OutputType([PSObject])]
+ param (
+ [Parameter(Mandatory = $true)]
+ [ValidateNotNullOrEmpty()]
+ [string] $Username,
+
+ [Parameter(Mandatory = $false)]
+ [ValidateNotNullOrEmpty()]
+ [string] $Password = $null,
+
+ [Parameter(Mandatory = $false)]
+ [ValidateSet('temp-qa', 'temp-prod')]
+ [string] $ProfileName = 'temp-prod',
+
+ [Parameter(Mandatory = $false)]
+ [ValidateScript({ $_ -in (Get-SupportedAwsRegions) })]
+ [string] $Region = 'us-east-1'
+ )
+
+ $logLead = (Get-LogLeadName)
+
+ Import-AWSModule
+
+ if ( $false -eq $PSBoundParameters.ContainsKey( 'Password' ) ) {
+
+ Write-Verbose "$logLead : Generating password for user."
+ $Password = New-SecurePassword -PasswordLength 15 -ProfileName $ProfileName -Region $Region
+ }
+
+ $passwordHash = New-SftpPasswordHash -Password $Password
+
+ if ( $null -eq $passwordHash ) {
+
+ Write-Error "$logLead : Unable to generate password hash for SFTP user."
+ return $null
+ }
+
+ $secretObject = Get-SECSecretValue -SecretId $Username -ProfileName $ProfileName -Region $Region
+ if ( $null -eq $secretObject ) {
+
+ Write-Error "$logLead : Unable to retrieve secret for user [$Username] using profile [$ProfileName] and region [$Region]."
+ return $null
+ }
+
+ $secret = ConvertFrom-Json $secretObject.SecretString
+ $secret.Password = $passwordHash
+ Update-SECSecret -SecretId $Username -SecretString (ConvertTo-Json $secret) -ProfileName $ProfileName -Region $Region | Out-Null
+
+ return New-Object -TypeName PSObject -Property @{ Username = $Username ; Password = $Password }
+}
diff --git a/Modules/Alkami.DevOps.SystemEngineering/Public/Update-SftpPassword.tests.ps1 b/Modules/Alkami.DevOps.SystemEngineering/Public/Update-SftpPassword.tests.ps1
new file mode 100644
index 0000000..9a0cd3d
--- /dev/null
+++ b/Modules/Alkami.DevOps.SystemEngineering/Public/Update-SftpPassword.tests.ps1
@@ -0,0 +1,111 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$functionPath = Join-Path -Path $here -ChildPath $sut
+Write-Host "Overriding SUT: $functionPath"
+Import-Module $functionPath -Force
+$moduleForMock = ''
+
+Describe 'Update-SftpPassword' {
+
+ $fakeAccountName = 'FakeyMcFakeAccount-SFTP'
+ $fakePassword = 'ThisIsAPassword'
+ $generatedPassword = '@ut0Generated'
+
+ Mock -CommandName Get-SupportedAwsRegions -ModuleName $moduleForMock -MockWith { return @( 'us-east-1' ) }
+ Mock -CommandName Get-LogLeadName -ModuleName $moduleForMock -MockWith { return 'New-SftpUser.tests' }
+ Mock -CommandName New-SecurePassword -ModuleName $moduleForMock -MockWith { return $generatedPassword }
+ Mock -CommandName Update-SECSecret -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName Write-Error -ModuleName $moduleForMock -MockWith {}
+ Mock -CommandName ConvertFrom-Json -ModuleName $moduleForMock -MockWith { return New-Object -TypeName PSObject -Property @{ Password = $fakePassword } }
+ Mock -CommandName ConvertTo-Json -ModuleName $moduleForMock -MockWith { return '{}' }
+ Mock -CommandName Import-AWSModule -ModuleName $moduleForMock -MockWith {}
+
+ Context 'Parameter Validation' {
+
+ It 'Throws if Username is Null' {
+ { Update-SftpPassword -Username $null } | Should -Throw
+ }
+
+ It 'Throws if Username is Empty' {
+ { Update-SftpPassword -Username '' } | Should -Throw
+ }
+
+ It 'Throws if Password is Null' {
+ { Update-SftpPassword -Username $fakeAccountName -Password $null } | Should -Throw
+ }
+
+ It 'Throws if Password is Empty' {
+ { Update-SftpPassword -Username $fakeAccountName -Password '' } | Should -Throw
+ }
+
+ It 'Throws if Profile Name is Null' {
+ { Update-SftpPassword -Username $fakeAccountName -ProfileName $null } | Should -Throw
+ }
+
+ It 'Throws if Profile Name is Empty' {
+ { Update-SftpPassword -Username $fakeAccountName -ProfileName '' } | Should -Throw
+ }
+
+ It 'Throws if Profile Name is Not in Supported List' {
+ { Update-SftpPassword -Username $fakeAccountName -ProfileName 'temp-test' } | Should -Throw
+ }
+
+ It 'Throws if Region is Not in Supported List' {
+ { Update-SftpPassword -Username $fakeAccountName -Region 'Test' } | Should -Throw
+ }
+ }
+
+ Context 'Error Handling' {
+
+ Mock -CommandName Get-SECSecretValue -ModuleName $moduleForMock -MockWith { return $null }
+
+ It 'Writes Error and Returns Null If Password Hash Fails' {
+
+ Mock -CommandName New-SftpPasswordHash -ModuleName $moduleForMock -MockWith { return $null }
+
+ Update-SftpPassword -Username $fakeAccountName | Should -BeNull
+
+ Assert-MockCalled -CommandName Write-Error `
+ -ParameterFilter { $Message -match 'Unable to generate password hash for SFTP user.' } -Times 1 -Exactly -Scope It
+ Assert-MockCalled -CommandName Update-SECSecret -Times 0 -Exactly -Scope It
+ Assert-MockCalled -CommandName Get-SECSecretValue -Times 0 -Exactly -Scope It
+ }
+
+ It 'Writes Error and Returns Null if AWS Lookup Fails' {
+
+ Mock -CommandName New-SftpPasswordHash -ModuleName $moduleForMock -MockWith { return $fakePassword }
+
+ Update-SftpPassword -Username $fakeAccountName | Should -BeNull
+
+ Assert-MockCalled -CommandName Write-Error `
+ -ParameterFilter { $Message -match 'Unable to retrieve secret for user' } -Times 1 -Exactly -Scope It
+ Assert-MockCalled -CommandName Update-SECSecret -Times 0 -Exactly -Scope It
+ Assert-MockCalled -CommandName Get-SECSecretValue -Times 1 -Exactly -Scope It
+ }
+ }
+
+ Context 'Logic' {
+
+ Mock -CommandName New-SftpPasswordHash -ModuleName $moduleForMock -MockWith { return $fakePassword }
+ Mock -CommandName Get-SECSecretValue -ModuleName $moduleForMock -MockWith { return '{}' }
+
+ It 'Does Not Modify Username' {
+
+ $result = Update-SftpPassword -Username $fakeAccountName
+ $result.Username | Should -BeExactly $fakeAccountName
+ }
+
+ It 'Uses Password if Provided' {
+
+ $result = Update-SftpPassword -Username $fakeAccountName -Password $fakePassword
+ $result.Password | Should -BeExactly $fakePassword
+ }
+
+ It 'Uses Generated Password if Not Provided' {
+
+ $result = Update-SftpPassword -Username $fakeAccountName
+ $result.Password | Should -BeExactly $generatedPassword
+ }
+ }
+}
diff --git a/Modules/Alkami.DevOps.SystemEngineering/Public/Write-AlkamiSecretResourcePolicy.ps1 b/Modules/Alkami.DevOps.SystemEngineering/Public/Write-AlkamiSecretResourcePolicy.ps1
new file mode 100644
index 0000000..c867e85
--- /dev/null
+++ b/Modules/Alkami.DevOps.SystemEngineering/Public/Write-AlkamiSecretResourcePolicy.ps1
@@ -0,0 +1,48 @@
+function Write-AlkamiSecretResourcePolicy {
+<#
+.SYNOPSIS
+ Creates or overwrites the resource policy for an Alkami AWS Secrets Manager secret.
+
+.PARAMETER SecretName
+ [string] The name of the secret to modify.
+
+.PARAMETER ProfileName
+ [string] The AWS profile to use during secret modification.
+
+.PARAMETER Region
+ [string] The AWS region to use during secret modification.
+
+.PARAMETER SecretAccessExtraArns
+ [string[]] An array of AWS ARNs that should be allowed to access the secret in addition to the defaults.
+
+.EXAMPLE
+ Write-AlkamiSecretResourcePolicy -SecretName 'Example' -ProfileName 'temp-prod' -Region 'us-east-1' -SecretAccessExtraArns @( 'ExampleArn1', 'ExampleArn2' )
+#>
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory = $true)]
+ [ValidateNotNullOrEmpty()]
+ [string] $SecretName,
+
+ [Parameter(Mandatory = $true)]
+ [ValidateNotNullOrEmpty()]
+ [string] $ProfileName,
+
+ [Parameter(Mandatory = $true)]
+ [ValidateScript({$_ -in (Get-AWSRegion).region})]
+ [string] $Region,
+
+ [Parameter(Mandatory = $false)]
+ [AllowNull()]
+ [AllowEmptyCollection()]
+ [string[]] $SecretAccessExtraArns = $null
+ )
+
+ $logLead = Get-LogLeadName
+
+ Import-AWSModule
+
+ Write-Verbose "$logLead : Overwriting resource policy on secret '$SecretName'."
+ $policyString = Get-AlkamiSecretResourcePolicyString -ProfileName $ProfileName -SecretAccessExtraArns $SecretAccessExtraArns
+ Write-SECResourcePolicy -SecretId $SecretName -ResourcePolicy $policyString -ProfileName $ProfileName -Region $Region | Out-Null
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.SystemEngineering/Public/Write-AlkamiSecretResourcePolicy.tests.ps1 b/Modules/Alkami.DevOps.SystemEngineering/Public/Write-AlkamiSecretResourcePolicy.tests.ps1
new file mode 100644
index 0000000..a2560f3
--- /dev/null
+++ b/Modules/Alkami.DevOps.SystemEngineering/Public/Write-AlkamiSecretResourcePolicy.tests.ps1
@@ -0,0 +1,63 @@
+. $PSScriptRoot\..\..\Load-PesterModules.ps1
+$here = Split-Path -Parent $MyInvocation.MyCommand.Path
+$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.tests\.', '.'
+$global:functionPath = Join-Path -Path $here -ChildPath $sut
+
+InModuleScope -ModuleName Alkami.DevOps.SystemEngineering -ScriptBlock {
+ Write-Host "InModuleScope - Overriding SUT: $global:functionPath"
+ Import-Module $global:functionPath -Force
+ $inScopeModule = "Alkami.DevOps.SystemEngineering"
+
+ Describe "Write-AlkamiSecretResourcePolicy" {
+
+ Mock -CommandName Get-LogLeadName -ModuleName $inScopeModule -MockWith { return 'Write-AlkamiSecretResourcePolicy.tests' }
+ Mock -CommandName Get-AWSRegion -ModuleName $inScopeModule -MockWith { return @( @{ 'Region' = 'us-east-1' } ) }
+ Mock -CommandName Import-AWSModule -ModuleName $inScopeModule -MockWith {}
+ Mock -CommandName Get-AlkamiSecretResourcePolicyString -ModuleName $inScopeModule -MockWith { return '' }
+ Mock -CommandName Write-SECResourcePolicy -ModuleName $inScopeModule -MockWith {}
+
+ Context "Parameter Validation" {
+
+ It "Throws if SecretName Is Null" {
+ { Write-AlkamiSecretResourcePolicy -SecretName $Null } | Should -Throw
+ }
+
+ It "Throws if SecretName Is Empty" {
+ { Write-AlkamiSecretResourcePolicy -SecretName '' } | Should -Throw
+ }
+
+ It "Throws if ProfileName Is Null" {
+ { Write-AlkamiSecretResourcePolicy -SecretName 'Test' -ProfileName $null } | Should -Throw
+ }
+
+ It "Throws if ProfileName Is Empty" {
+ { Write-AlkamiSecretResourcePolicy -SecretName 'Test' -ProfileName '' } | Should -Throw
+ }
+
+ It "Throws if Region Is Not In Allowable List" {
+ { Write-AlkamiSecretResourcePolicy -SecretName 'Test' -ProfileName 'temp-test' -Region 'Test' } | Should -Throw
+ }
+ }
+
+ Context "Logic" {
+
+ It "Uses Supplied Extra ARNs When Building Resource Policy" {
+
+ Write-AlkamiSecretResourcePolicy -SecretName 'Test' -ProfileName 'temp-test' -Region 'us-east-1' `
+ -SecretAccessExtraArns @('TestArn')
+
+ Assert-MockCalled -ModuleName $inScopeModule -CommandName Get-AlkamiSecretResourcePolicyString -Times 1 -Exactly -Scope It `
+ -ParameterFilter { $SecretAccessExtraArns[0] -match "TestArn" }
+ }
+
+ It "Applies Resource Policy to Secret" {
+
+ Write-AlkamiSecretResourcePolicy -SecretName 'Test' -ProfileName 'temp-test' -Region 'us-east-1' `
+ -SecretAccessExtraArns @('TestArn')
+
+ Assert-MockCalled -ModuleName $inScopeModule -CommandName Write-SECResourcePolicy -Times 1 -Exactly -Scope It `
+ -ParameterFilter { $SecretId -ceq "Test" }
+ }
+ }
+ }
+}
diff --git a/Modules/Alkami.DevOps.SystemEngineering/Resources/BCrypt.Net-Next.dll b/Modules/Alkami.DevOps.SystemEngineering/Resources/BCrypt.Net-Next.dll
new file mode 100644
index 0000000..0e2f5fb
Binary files /dev/null and b/Modules/Alkami.DevOps.SystemEngineering/Resources/BCrypt.Net-Next.dll differ
diff --git a/Modules/Alkami.DevOps.SystemEngineering/Resources/EncryptPassword.dll b/Modules/Alkami.DevOps.SystemEngineering/Resources/EncryptPassword.dll
new file mode 100644
index 0000000..472d875
Binary files /dev/null and b/Modules/Alkami.DevOps.SystemEngineering/Resources/EncryptPassword.dll differ
diff --git a/Modules/Alkami.DevOps.SystemEngineering/Resources/return_aws_credentials.ps1 b/Modules/Alkami.DevOps.SystemEngineering/Resources/return_aws_credentials.ps1
new file mode 100644
index 0000000..a80d1c6
--- /dev/null
+++ b/Modules/Alkami.DevOps.SystemEngineering/Resources/return_aws_credentials.ps1
@@ -0,0 +1,105 @@
+# to setup up the credential process, add this contents to ~\.aws\config
+<#
+[profile temp-]
+region = us-east-1
+credential_process =powershell.exe -noprofile C:\ProgramData\chocolatey\lib\Alkami.DevOps.SystemEngineering\module\Resources\return_aws_credentials.ps1 arn:aws:iam::185809956479:mfa/ arn:aws:iam:::role/
+#>
+# comment out the same profilename in ~\.aws\credentials
+# example for aws dev account
+<#
+[profile temp-dev]
+region = us-east-1
+credential_process =powershell.exe -noprofile C:\ProgramData\chocolatey\lib\Alkami.DevOps.SystemEngineering\module\Resources\return_aws_credentials.ps1 Dev arn:aws:iam::185809:mfa/cookieMonster-cli arn:aws:iam::73722:role/CLI-SRE-ButtonClickers
+#>
+
+param(
+ [Parameter(Mandatory = $false)]
+ [ValidateNotNullOrEmpty()]
+ [string]$ProfileName,
+ [Parameter(Mandatory = $false)]
+ [ValidateNotNullOrEmpty()]
+ [string]$MfaDevice,
+ [Parameter(Mandatory = $false)]
+ [ValidateNotNullOrEmpty()]
+ [string]$ProfileARN
+)
+
+$userProfileName = $env:USERPROFILE
+if ($null -ne $userProfileName) {
+ $credCacheFile = "$($userProfileName)\.aws\$($ProfileName).json"
+} elseif ($IsMacOS) { # $IsMacOS is an automatic variable when running on mac
+ $credCacheFile = "$($HOME)\.aws\$($ProfileName).json"
+}
+
+$tempProfile = "temp-" + $ProfileName.ToLower()
+
+$tryCount = 0
+$maxTryCount = 3
+$MfaCode = $null
+
+do {
+ try {
+ $cacheJson = Get-Content $credCacheFile -Raw
+ $cacheObject = ConvertFrom-Json -InputObject $cacheJson
+ if ([DateTime]$cacheObject.Expiration -gt (Get-Date) ) {
+ Write-Verbose "[$ProfileName] creds aren't expired"
+ return $cacheJson
+ }
+ } catch {
+ Write-Verbose "No valid credentials associated with profile [$tempProfile]; proceeding to renew"
+ }
+ # only load if we need to actually ask
+ [system.console]::Beep() # this is a cross platform sound,
+ $tryCount = $tryCount + 1
+ if ($null -ne $userProfileName) {
+ [void][System.Reflection.Assembly]::LoadWithPartialName('Microsoft.VisualBasic')
+ $MfaCode = [Microsoft.VisualBasic.Interaction]::InputBox("Enter MFA code for profile '$ProfileName'",'MFA Code','')
+ } elseif ($IsMacOS) { # $IsMacOS is an automatic variable when running on mac
+ $macOSMfaCode = "display dialog `"Enter MFA code for profile '$($ProfileName)'`" default answer `"`"" | osascript
+ # to test locally use this #$macOSMfaCode = 'button returned:ok text returned:8675309'
+ # splits the string that is returned becuase $macOSMfaCode is a string like this: 'button returned:ok text returned:8675309'
+ [array]$macOSMfaCodearray = $macOSMfaCode.Split(':')
+ # Make sure an actual item was returned in the string section of the response
+ if ($macOSMfaCodearray.Length -ne 3) {
+ Write-Verbose "Did not get mfa response"
+ continue
+ }
+ # Make sure the string returned is the expected length, this is not the end all test, rather a sanity check
+ if ($macOSMfaCodearray[2].Length -ne 6) {
+ Write-Verbose "Did not get correct mfa response"
+ continue
+ }
+ # assign the the mac code to the final code
+ $mfacode = $macOSMfaCodearray[2]
+ }
+
+ if ([string]::IsNullOrEmpty($MfaCode) -or $mfacode.Length -ne 6) {
+ continue
+ }
+ $assumedCred = (aws sts assume-role --role-arn $ProfileARN --role-session-name $tempProfile --token-code $MfaCode --serial-number $MfaDevice --duration-seconds 43200 | Out-String | ConvertFrom-Json).Credentials
+ if ( $null -eq $assumedCred ) {
+ Write-Error "Unable to assume role [$($ProfileARN)]. Check your MFA code and retry."
+ } elseif ( [string]::IsNullOrEmpty( $assumedCred.AccessKeyId ) ) {
+ Write-Error "No access key provided by [$($ProfileARN)] credential."
+ } elseif ( [string]::IsNullOrEmpty( $assumedCred.SecretAccessKey ) ) {
+ Write-Error "No secret access key provided by [$($ProfileARN)] credential."
+ } elseif ( [string]::IsNullOrEmpty( $assumedCred.SessionToken ) ) {
+ Write-Error "No session token provided by [$($ProfileARN)] credential."
+ } elseif ( [string]::IsNullOrEmpty( $assumedCred.SessionToken ) ) {
+ Write-Error "No session token provided by [$($ProfileARN)] credential."
+ } else {
+ break
+ }
+} while ( $tryCount -lt $maxTryCount)
+
+$returnCredJson = ConvertTo-Json -InputObject @{
+ Expiration = $assumedCred.Expiration
+ AccessKeyId = $assumedCred.AccessKeyId
+ SecretAccessKey = $assumedCred.SecretAccessKey
+ SessionToken = $assumedCred.SessionToken
+ Version = 1
+}
+
+# write to file to cache
+Set-Content -Path $credCacheFile -Value $returnCredJson
+return $returnCredJson
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.SystemEngineering/tools/chocolateyInstall.ps1 b/Modules/Alkami.DevOps.SystemEngineering/tools/chocolateyInstall.ps1
new file mode 100644
index 0000000..502c875
--- /dev/null
+++ b/Modules/Alkami.DevOps.SystemEngineering/tools/chocolateyInstall.ps1
@@ -0,0 +1,44 @@
+[CmdletBinding()]
+Param()
+process {
+ $myCurrentPath = $PSScriptRoot;
+ Write-Verbose "Installing the Module from $myCurrentPath";
+
+ $parentPath = (Split-Path $myCurrentPath);
+ $systemModulePath = "C:\Program Files\WindowsPowerShell\Modules\";
+ $myModulePath = (Join-Path $parentPath "module");
+
+ $metadata = ([Xml](Get-Content (Join-Path $parentPath "*.nuspec"))).package.metadata;
+
+ $id = $metadata.id;
+
+ $version = $metadata.version -replace '-pre.+','';
+
+ $targetModulePath = (Join-Path $systemModulePath $id);
+ $targetModuleVersionPath = (Join-Path $targetModulePath $version);
+
+ if (Test-Path $targetModulePath) {
+ ## If the target folder already existed, remove it, because we are re-installing this package, obviously
+ if (Test-Path $targetModuleVersionPath) {
+ Write-Warning "Found an already existing module at [$targetModuleVersionPath]!!"
+ Remove-Item $targetModuleVersionPath -Recurse -Force;
+ }
+
+ ## Clear previous children for name conflicts
+ (Get-ChildItem $targetModulePath) | ForEach-Object {
+ Write-Information "Removing module located at [$_]";
+ Remove-Item $_.FullName -Recurse -Force;
+ }
+ }
+
+ Write-Host "Copying module $id to [$targetModuleVersionPath]";
+ Copy-Item $myModulePath -Destination $targetModuleVersionPath -Recurse -Force;
+
+ $resourcesFolder = (Join-Path $parentPath "Resources")
+ if (Test-Path $resourcesFolder) {
+
+ Write-Host "Copying resources folder for module $id to [$targetModuleVersionPath]"
+ Copy-Item $resourcesFolder -Destination $targetModuleVersionPath -Recurse -Force
+ }
+
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.SystemEngineering/tools/chocolateyUninstall.ps1 b/Modules/Alkami.DevOps.SystemEngineering/tools/chocolateyUninstall.ps1
new file mode 100644
index 0000000..a4a98bf
--- /dev/null
+++ b/Modules/Alkami.DevOps.SystemEngineering/tools/chocolateyUninstall.ps1
@@ -0,0 +1,23 @@
+[CmdletBinding()]
+Param()
+process {
+ $myCurrentPath = $PSScriptRoot;
+ Write-Verbose "Uninstalling the Module from $myCurrentPath";
+
+ $parentPath = (Split-Path $myCurrentPath);
+ $systemModulePath = "C:\Program Files\WindowsPowerShell\Modules\";
+
+ $metadata = ([Xml](Get-Content (Join-Path $parentPath "*.nuspec"))).package.metadata;
+
+ $id = $metadata.id;
+
+ $version = $metadata.version -replace '-pre.+','';
+
+ $targetModulePath = (Join-Path $systemModulePath $id);
+ $targetModuleVersionPath = (Join-Path $targetModulePath $version);
+
+ if (Test-Path $targetModuleVersionPath) {
+ Write-Information "Removing module at [$targetModuleVersionPath]!!"
+ Remove-Item $targetModuleVersionPath -Recurse -Force;
+ }
+}
diff --git a/Modules/Alkami.DevOps.TeamCity/Alkami.DevOps.TeamCity.nuspec b/Modules/Alkami.DevOps.TeamCity/Alkami.DevOps.TeamCity.nuspec
new file mode 100644
index 0000000..d24b5da
--- /dev/null
+++ b/Modules/Alkami.DevOps.TeamCity/Alkami.DevOps.TeamCity.nuspec
@@ -0,0 +1,26 @@
+
+
+
+ Alkami.DevOps.TeamCity
+ $version$
+ Alkami Platform Modules - DevOps - TeamCity
+ Alkami Technologies
+ Alkami Technologies
+ https://extranet.alkamitech.com/display/ORB/Alkami.DevOps.TeamCity
+ https://www.alkami.com/files/alkamilogo75x75.png
+ http://alkami.com/files/orblicense.html
+ false
+ Installs the Alkami TeamCity module for use with PowerShell.
+
+ PowerShell
+ Copyright (c) 2022 Alkami Technologies
+
+
+
+
+
+
+
+
+
+
diff --git a/Modules/Alkami.DevOps.TeamCity/Alkami.DevOps.TeamCity.psd1 b/Modules/Alkami.DevOps.TeamCity/Alkami.DevOps.TeamCity.psd1
new file mode 100644
index 0000000..4b53a52
--- /dev/null
+++ b/Modules/Alkami.DevOps.TeamCity/Alkami.DevOps.TeamCity.psd1
@@ -0,0 +1,11 @@
+@{
+ RootModule = 'Alkami.DevOps.TeamCity.psm1'
+ ModuleVersion = '1.1.0'
+ GUID = '4a51a6c2-0bcd-45d8-a3b8-e1909d64b022'
+ Author = 'trowton'
+ CompanyName = 'Alkami Technologies, Inc.'
+ Copyright = '(c) 2022 Alkami Technologies, Inc. All rights reserved.'
+ PowerShellVersion = '5.0'
+ RequiredModules = 'Alkami.PowerShell.Common'
+ FunctionsToExport = 'Get-AuthHeader','Get-BearerToken','Get-DotnetExtraRuntimes','Get-DotnetExtraSDKs','Get-EC2ConsoleSnapshot','Get-EC2InstanceState','Get-EC2WindowsDriverVersions','Get-GitCurrentRelease','Get-TeamCityAgentHostnames','Get-TeamCityEc2Instance','Get-TeamCityHostnames','Get-TeamCityServerNodeHostnames','Invoke-AWSConfigureAWSPackage','Invoke-UpdateChrome','Remove-DotnetExtraRuntimes','Remove-DotnetExtraSDKs','Start-TeamCityAgent','Stop-TeamCityAgent','Test-TeamCityComputerIsAvailable'
+}
diff --git a/Modules/Alkami.DevOps.TeamCity/Public/Get-AuthHeader.ps1 b/Modules/Alkami.DevOps.TeamCity/Public/Get-AuthHeader.ps1
new file mode 100644
index 0000000..ef8ff3f
--- /dev/null
+++ b/Modules/Alkami.DevOps.TeamCity/Public/Get-AuthHeader.ps1
@@ -0,0 +1,68 @@
+function Get-AuthHeader {
+ <#
+.SYNOPSIS
+ Returns an HTTP Authentication/Authorization Header
+
+.PARAMETER ApiUserUsername
+ TeamCity User Username to use for building a Basic Auth HTTP Header
+
+.PARAMETER ApiUserPassword
+ TeamCity User Password to use for building a Basic Auth HTTP Header
+
+.PARAMETER BearerToken
+ A Bearer Token to use for building a Bearer Token Auth HTTP Header
+
+.PARAMETER GetBearerToken
+ Call Get-BearerToken function
+
+.PARAMETER TokenFilePath
+ Path(optional) to file containing the bearer token to use when calling Get-BearerToken
+
+#>
+ [CmdletBinding()]
+ [OutputType([string])]
+ param(
+ [Parameter(Mandatory = $false)]
+ [string]$ApiUserUsername = $null,
+ [Parameter(Mandatory = $false)]
+ [string]$ApiUserPassword = $null,
+ [Parameter(Mandatory = $false)]
+ [string]$BearerToken = $null,
+ [Parameter(Mandatory = $false)]
+ [switch]$GetBearerToken,
+ [Parameter(Mandatory = $false)]
+ [Alias("Path")]
+ [string]$TokenFilePath = $null
+ )
+ $logLead = Get-LogLeadName
+
+ $authHeaderPatternBearer = "Bearer {0}"
+ if ($GetBearerToken) {
+ Write-Host "$logLead : Getting token"
+ $splat = @{}
+ if ($null -ne $TokenFilePath) {
+ Write-Host "$logLead : Passing user supplied path - $TokenFilePath"
+ $splat.TokenFilePath = $TokenFilePath
+ }
+ $gotBearerToken = Get-BearerToken @splat
+ $authHeaderValue = ($authHeaderPatternBearer -f $gotBearerToken)
+
+ } elseif ([string]::IsNullOrEmpty($ApiUserUsername) -or [string]::IsNullOrEmpty($ApiUserPassword)) {
+ Write-Host "$logLead : Using supplied token"
+ $authHeaderValue = ($authHeaderPatternBearer -f $BearerToken)
+
+ } else {
+ Write-Host "$logLead : Using auth pair"
+ $authorizationPair = "$($apiUserUsername):$($apiUserPassword)"
+ $encodedCredentials = [System.Convert]::ToBase64String([System.Text.Encoding]::ASCII.GetBytes($authorizationPair))
+ $authHeaderValue = "Basic $encodedCredentials"
+
+ }
+
+ $headers = @{
+ Authorization = $authHeaderValue
+ Accept = "application/json"
+ }
+ Write-Host "$logLead : Header creation complete"
+ return $headers
+}
diff --git a/Modules/Alkami.DevOps.TeamCity/Public/Get-BearerToken.ps1 b/Modules/Alkami.DevOps.TeamCity/Public/Get-BearerToken.ps1
new file mode 100644
index 0000000..f5e9e45
--- /dev/null
+++ b/Modules/Alkami.DevOps.TeamCity/Public/Get-BearerToken.ps1
@@ -0,0 +1,27 @@
+function Get-BearerToken {
+ <#
+.SYNOPSIS
+ Returns a Bearer token from a file
+
+.PARAMETER TokenFilePath
+ Path(optional) to file containing the bearer token to use when calling Get-BearerToken
+#>
+ [CmdletBinding()]
+ [OutputType([string])]
+ param(
+ [Parameter(Mandatory = $false)]
+ [Alias("Path")]
+ [string]$TokenFilePath = "~/.teamcity/credentials"
+ )
+
+ $logLead = Get-LogLeadName
+
+ if ( -NOT (Test-Path $TokenFilePath)) {
+ throw "Token file not found - $TokenFilePath"
+ }
+ Write-Host "$logLead : Retrieving Bearer Token from $TokenFilePath"
+ $bearerTokenFile = Get-ChildItem -Path "$TokenFilePath" -File
+ $bearerToken = Get-Content -Path $bearerTokenFile -Raw
+ Write-Host "$logLead : Returning Bearer Token"
+ return $bearerToken
+}
diff --git a/Modules/Alkami.DevOps.TeamCity/Public/Get-DotnetExtraRuntimes.ps1 b/Modules/Alkami.DevOps.TeamCity/Public/Get-DotnetExtraRuntimes.ps1
new file mode 100644
index 0000000..da43792
--- /dev/null
+++ b/Modules/Alkami.DevOps.TeamCity/Public/Get-DotnetExtraRuntimes.ps1
@@ -0,0 +1,59 @@
+Function Get-DotnetExtraRuntimes {
+ <#
+.SYNOPSIS
+ Gets extra runtimes for dotnet. Ensure that we only keep the latest around.
+ This assumes that minors are as distinct as majors. Supply the flag RemoveNonMajorDupes to get non-latest minors per major.
+
+.PARAMETER RemoveNonMajorDupes
+ Only consider majors as "unique"
+
+.PARAMETER ReturnExpiredRuntimes
+ Returns the out of date runtimes
+
+.EXAMPLE
+ Get-DotnetExtraRuntimes
+#>
+
+ [CmdletBinding()]
+ [OutputType([System.Array])]
+ param(
+ [switch]$RemoveNonMajorDupes,
+ [switch]$ReturnExpiredRuntimes
+ )
+
+ $logLead = Get-LogLeadName
+ $deleteRuntimes = @()
+ $allRuntimes = @{}
+
+ $runtimes = dotnet --list-runtimes
+ Write-Host "$logLead : Dotnet Runtimes installed on local machine:"
+ foreach ($runtime in $runtimes) {
+ Write-Host "$logLead : $runtime"
+ $runtimeParentPath = (($runtime -split '\[')[1] -split '\]')[0]
+ $runtimeApp = ($runtime -split ' ')[0]
+ $runtimeVersion = [System.Version]($runtime -split ' ')[1]
+ $runtimePath = (Join-Path $runtimeParentPath $runtimeVersion)
+
+ if ($allRuntimes.Keys -notcontains $runtimeApp) {
+ Write-Verbose "$logLead : adding version $runtimePath"
+ $allRuntimes[$runtimeApp] = @{ Version = [System.Version]$runtimeVersion; Path = $runtimePath; };
+ }
+ if ($allRuntimes[$runtimeApp].Version.Major -lt $runtimeVersion.Major) {
+ } elseif ($allRuntimes[$runtimeApp].Version.Minor -lt $runtimeVersion.Minor -and $RemoveNonMajorDupes) {
+ Write-Verbose "$logLead : adding delete runtime $($allRuntimes[$runtimeApp].Path)"
+ $deleteRuntimes += $allRuntimes[$runtimeApp].Path;
+ } elseif ($allRuntimes[$runtimeApp].Version.Build -lt $runtimeVersion.Build) {
+ Write-Verbose "$logLead : adding delete runtime $($allRuntimes[$runtimeApp].Path)"
+ $deleteRuntimes += $allRuntimes[$runtimeApp].Path;
+ }
+ $allRuntimes[$runtimeApp] = @{ Version = $runtimeVersion; Path = $runtimePath; };
+ }
+
+ Write-Host "$logLead : Dotnet Runtimes that should be deleted/out of date:"
+ foreach ($deleteRuntime in $deleteRuntimes) {
+ Write-Host "$logLead : $deleteRuntime"
+ }
+ if ($ReturnExpiredRuntimes) {
+ Return $deleteRuntimes
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.TeamCity/Public/Get-DotnetExtraSDKs.ps1 b/Modules/Alkami.DevOps.TeamCity/Public/Get-DotnetExtraSDKs.ps1
new file mode 100644
index 0000000..bd38f8a
--- /dev/null
+++ b/Modules/Alkami.DevOps.TeamCity/Public/Get-DotnetExtraSDKs.ps1
@@ -0,0 +1,59 @@
+Function Get-DotnetExtraSDKs {
+ <#
+.SYNOPSIS
+ Delete extra runtimes for dotnet. Ensure that we only keep the latest around.
+ This assumes that minors are as distinct as majors. Supply the flag RemoveNonMajorDupes to get non-latest minors per major.
+
+.PARAMETER RemoveNonMajorDupes
+ Only consider majors as "unique"
+
+.PARAMETER ReturnExpiredSdks
+ Returns the out of date SDKs
+
+.EXAMPLE
+ Get-DotnetExtraSDKs
+#>
+
+ [CmdletBinding()]
+ [OutputType([System.Array])]
+ param(
+ [switch]$RemoveNonMajorDupes,
+ [switch]$ReturnExpiredSdks
+ )
+
+ $logLead = Get-LogLeadName
+ $deleteSdks = @()
+ $allSdks = @{}
+
+ $sdks = dotnet --list-sdks
+ Write-Host "$logLead : Dotnet SDKs installed on local machine:"
+ foreach ($sdk in $sdks) {
+ Write-Host "$logLead : $sdk"
+ $sdkParentPath = (($sdk -split '\[')[1] -split '\]')[0]
+ $sdkApp = "SDK"
+ $sdkVersion = [System.Version]($sdk -split ' ')[0]
+ $sdkPath = (Join-Path $sdkParentPath $sdkVersion)
+
+ if ($allSdks.Keys -notcontains $sdkApp) {
+ Write-Verbose "$logLead : adding version $sdkPath"
+ $allSdks[$sdkApp] = @{ Version = [System.Version]$sdkVersion; Path = $sdkPath; };
+ }
+ if ($allSdks[$sdkApp].Version.Major -lt $sdkVersion.Major) {
+ } elseif ($allSdks[$sdkApp].Version.Minor -lt $sdkVersion.Minor -and $RemoveNonMajorDupes) {
+ Write-Verbose "$logLead : adding delete sdk $($allSdks[$sdkApp].Path)"
+ $deleteSdks += $allSdks[$sdkApp].Path;
+ } elseif ($allSdks[$sdkApp].Version.Build -lt $sdkVersion.Build) {
+ Write-Verbose "$logLead : adding delete sdk $($allSdks[$sdkApp].Path)"
+ $deleteSdks += $allSdks[$sdkApp].Path;
+ }
+ $allSdks[$sdkApp] = @{ Version = $sdkVersion; Path = $sdkPath; };
+ }
+
+ Write-Host "$logLead : Dotnet SDKs that should be deleted/out of date:"
+ foreach ($deleteSdk in $deleteSdks) {
+ Write-Host "$logLead : $deleteSdk"
+ }
+ if ($ReturnExpiredSdks) {
+ Return $deleteSdks
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.TeamCity/Public/Get-EC2ConsoleSnapshot.ps1 b/Modules/Alkami.DevOps.TeamCity/Public/Get-EC2ConsoleSnapshot.ps1
new file mode 100644
index 0000000..4149a39
--- /dev/null
+++ b/Modules/Alkami.DevOps.TeamCity/Public/Get-EC2ConsoleSnapshot.ps1
@@ -0,0 +1,72 @@
+function Get-EC2ConsoleSnapshot {
+ <#
+.SYNOPSIS
+ Returns an AWS instance console screenshot .jpg to the user's download folder
+
+.PARAMETER ComputerName
+ Hostname(s) of a machine that is located in AWS
+
+.PARAMETER InstanceId
+ AWS owned ID(s) of a machine
+
+.PARAMETER ProfileName
+ AWS environment credential
+
+.PARAMETER Type
+ Optional Parameter. Type of TeamCity host(s) to return: All, Server(s), or Agent(s)
+#>
+
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory = $false)]
+ [string[]]$ComputerName,
+ [Parameter(Mandatory = $false)]
+ [string[]]$InstanceId,
+ [Parameter(Mandatory = $true)]
+ [string]$ProfileName,
+ [ValidateSet("All", "Server", "Agent")]
+ [Parameter(Mandatory = $false)]
+ [string]$Type
+ )
+ $logLead = Get-LogLeadName
+
+ if (!(Test-StringIsNullOrWhitespace -Value $Type)) {
+ $ComputerName = Get-TeamCityHostnames -Type $Type -ProfileName $ProfileName
+ }
+
+ if ((Test-IsCollectionNullOrEmpty -Collection $ComputerName) -and (Test-IsCollectionNullOrEmpty -Collection $InstanceId)) {
+ Write-Warning "$loglead : A ComputerName or InstanceId was not provided, exiting..."
+ Return
+ }
+
+ if (Test-IsCollectionNullOrEmpty -Collection $ComputerName) {
+ $ComputerName = $InstanceId
+ }
+
+ foreach ($name in $ComputerName) {
+ if (Test-IsCollectionNullOrEmpty -Collection $InstanceId) {
+ $hostnameInstanceId = (Get-EC2InstancesByHostname -ProfileName $ProfileName -Servers $name).InstanceId
+ } else {
+ $hostnameInstanceId = $name
+ }
+ try {
+ Write-Verbose "$loglead : Attempting Region : us-east-1"
+ $image = Get-EC2ConsoleScreenshot -InstanceId $hostnameInstanceId -ProfileName $ProfileName -Region "us-east-1"
+ } catch [InvalidOperationException] {
+ Write-Verbose "$loglead : Attempting Region : us-west-2"
+ $image = Get-EC2ConsoleScreenshot -InstanceId $hostnameInstanceId -ProfileName $ProfileName -Region "us-west-2"
+ } catch {
+ Write-Host "$($_.Exception.Message)"
+ }
+
+ $imageData = [Convert]::FromBase64String($image.ImageData)
+ $datetime = Get-Date -Format "MM_dd_yyyy_HHmm"
+ $outFile = "$env:USERPROFILE\Downloads\$($name)_$($datetime).jpg"
+ Write-Host "$loglead : Writing image file to $outFile"
+ try {
+ [IO.File]::WriteAllBytes($outFile, $imageData)
+ } catch {
+ Write-Error "$logLead : $_"
+ }
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.TeamCity/Public/Get-EC2InstanceState.ps1 b/Modules/Alkami.DevOps.TeamCity/Public/Get-EC2InstanceState.ps1
new file mode 100644
index 0000000..da8137b
--- /dev/null
+++ b/Modules/Alkami.DevOps.TeamCity/Public/Get-EC2InstanceState.ps1
@@ -0,0 +1,66 @@
+function Get-EC2InstanceState {
+ <#
+.SYNOPSIS
+ Returns an AWS instance status object
+
+.PARAMETER ComputerName
+ ComputerName(s) of a machine that is located in AWS
+
+.PARAMETER InstanceId
+ AWS owned ID(s) of a machine
+
+.PARAMETER ProfileName
+ AWS environment credential
+
+.PARAMETER Type
+ Optional Parameter. Type of TeamCity host(s) to return: All, Server(s), or Agent(s)
+#>
+
+ [CmdletBinding()]
+ [OutputType([Object])]
+ param(
+ [Parameter(Mandatory = $false)]
+ [string[]]$ComputerName,
+ [Parameter(Mandatory = $false)]
+ [string[]]$InstanceId,
+ [Parameter(Mandatory = $true)]
+ [string]$ProfileName,
+ [ValidateSet("All", "Server", "Agent")]
+ [Parameter(Mandatory = $false)]
+ [string]$Type
+ )
+ $endResults = @()
+
+ if (!(Test-StringIsNullOrWhitespace -Value $Type)) {
+ $ComputerName = Get-TeamCityHostnames -Type $Type -ProfileName $ProfileName
+ }
+
+ if ((Test-IsCollectionNullOrEmpty -Collection $ComputerName) -and (Test-IsCollectionNullOrEmpty -Collection $InstanceId)) {
+ Write-Warning "$loglead : A ComputerName or InstanceId was not provided, exiting..."
+ Return
+ }
+
+ if (Test-IsCollectionNullOrEmpty -Collection $ComputerName) {
+ $ComputerName = $InstanceId
+ }
+
+ foreach ($name in $ComputerName) {
+ if (Test-IsCollectionNullOrEmpty -Collection $InstanceId) {
+ $hostnameInstanceId = (Get-EC2InstancesByHostname -ProfileName $ProfileName -Servers $name).InstanceId
+ }else {
+ $hostnameInstanceId = $name
+ }
+ try {
+ Write-Verbose "$loglead : Attempting Region : us-east-1"
+ $results = Get-EC2InstanceStatus -InstanceId $hostnameInstanceId -ProfileName $ProfileName -Region "us-east-1"
+ $endResults += $results
+ } catch [InvalidOperationException] {
+ Write-Verbose "$loglead : Attempting Region : us-west-2"
+ $results = Get-EC2InstanceStatus -InstanceId $hostnameInstanceId -ProfileName $ProfileName -Region "us-west-2"
+ $endResults += $results
+ } catch {
+ Write-Host "$($_.Exception.Message)"
+ }
+ }
+ Return $endResults
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.TeamCity/Public/Get-EC2WindowsDriverVersions.ps1 b/Modules/Alkami.DevOps.TeamCity/Public/Get-EC2WindowsDriverVersions.ps1
new file mode 100644
index 0000000..d035af4
--- /dev/null
+++ b/Modules/Alkami.DevOps.TeamCity/Public/Get-EC2WindowsDriverVersions.ps1
@@ -0,0 +1,26 @@
+function Get-EC2WindowsDriverVersions {
+ <#
+.SYNOPSIS
+ Get and display the versions of the AWS EC2 Windows Drivers that might need to be updated
+ #>
+ [CmdletBinding()]
+ [OutputType([System.Void])]
+ #TODO: ADD RetVal of Hashtable of DriverName:DriverVersion or something
+
+ # Where all the windows driver downloads come from
+ # https://s3.amazonaws.com/ec2-windows-drivers-downloads
+ $getPVDriverScriptBlock = {
+ Get-ItemProperty HKLM:\SOFTWARE\Amazon\PVDriver
+ }
+ $enaVersionInfoScriptBlock = {
+ (Get-ChildItem "C:\Windows\System32\drivers\ena.sys").VersionInfo
+ }
+ $nvmeVersionInfoScriptBlock = {
+ (Get-ChildItem "C:\Windows\System32\drivers\AWSNvme.sys").VersionInfo
+ }
+
+ $servers = Get-TeamCityHostnames
+ Invoke-Command -ComputerName $servers -ScriptBlock $getPVDriverScriptBlock
+ Invoke-Command -ComputerName $servers -ScriptBlock $enaVersionInfoScriptBlock
+ Invoke-Command -ComputerName $servers -ScriptBlock $nvmeVersionInfoScriptBlock
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.TeamCity/Public/Get-GitCurrentRelease.ps1 b/Modules/Alkami.DevOps.TeamCity/Public/Get-GitCurrentRelease.ps1
new file mode 100644
index 0000000..68d0545
--- /dev/null
+++ b/Modules/Alkami.DevOps.TeamCity/Public/Get-GitCurrentRelease.ps1
@@ -0,0 +1,80 @@
+Function Get-GitCurrentRelease {
+ <#
+.SYNOPSIS
+ Get the current latest release version of Git for Windows
+
+.PARAMETER Uri
+ Uri of the API endpoint for getting the latest release info
+
+.PARAMETER DownloadInstaller
+ Whether to download the 64bit windows installer
+
+.PARAMETER OutFile
+ The file to save the downloaded file as. If a full path is not provided, it will be saved in the current working directory.
+
+.LINK
+ https://jdhitsolutions.com/blog/powershell/5633/get-git-with-powershell/
+#>
+ [cmdletbinding()]
+ Param(
+ [Parameter(Mandatory = $false, ParameterSetName = "Info")]
+ [Parameter(Mandatory = $false, ParameterSetName = "Download")]
+ [ValidateNotNullorEmpty()]
+ [string]$Uri = "https://api.github.com/repos/git-for-windows/git/releases/latest",
+
+ [Parameter(Mandatory = $false, ParameterSetName = "Download")]
+ [switch]$DownloadInstaller,
+
+ [Parameter(Mandatory = $true, ParameterSetName = "Download")]
+ [ValidateNotNullOrEmpty()]
+ [string]$OutFile
+ )
+
+ $logLead = Get-LogLeadName
+
+ Write-Host "$loglead : Getting current release information from $Uri"
+ try {
+ $gitForWindows = Invoke-RestMethod -Uri $Uri -Method Get
+ } catch {
+ $caughtEx = $_
+ Write-Warning "$logLead : Unable to get version data"
+ Write-Warning "$logLead : $($caughtEx.Exception.Message)"
+ }
+
+ if ($gitForWindows.tag_name) {
+ $gitForWindows64Bit = $gitForWindows.Assets.Where({ $_.Name -match "64-bit.exe" })
+ $downloadUrl64Bit = $gitForWindows64Bit.browser_download_url
+
+ [pscustomobject]$gitVersionData = @{
+ Name = $gitForWindows.name
+ Version = $gitForWindows.tag_name
+ Released = $($gitForWindows.published_at -as [datetime])
+ DownloadUrl64Bit = $downloadUrl64Bit
+ }
+
+ if ($PSCmdlet.ParameterSetName -eq "Download") {
+ $outfileDir = Split-Path -Path $OutFile -Parent
+ if ([string]::IsNullOrWhiteSpace($outfileDir)) {
+ Write-Warning "$logLead : NO PATH PROVIDED FOR DOWNLOAD!"
+ Write-Warning "$logLead : Downloading to Current Working Directory"
+ $currentWorkingDir = Get-Location
+ Write-Warning "$logLead : $currentWorkingDir"
+ }
+ try {
+ Write-Host "$logLead : Downloading git for windows`n`tfrom $downloadUrl64Bit`n`tto $Outfile"
+ Invoke-WebRequest -Uri $downloadUrl64Bit -Method GET -OutFile $OutFile
+
+ } catch {
+ $StatusCode = $_.Exception.Response.StatusCode.value__
+ Write-Warning "$logLead : ERROR DOWNLOADING FILE!"
+ Write-Warning "$logLead : HTTP Status $StatusCode"
+ }
+ }
+ return $gitVersionData
+
+
+ } else {
+ Write-Host "$logLead : Not found"
+ return $null
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.TeamCity/Public/Get-TeamCityAgentHostnames.ps1 b/Modules/Alkami.DevOps.TeamCity/Public/Get-TeamCityAgentHostnames.ps1
new file mode 100644
index 0000000..1dd7cd5
--- /dev/null
+++ b/Modules/Alkami.DevOps.TeamCity/Public/Get-TeamCityAgentHostnames.ps1
@@ -0,0 +1,11 @@
+function Get-TeamCityAgentHostnames {
+ <#
+.SYNOPSIS
+ Returns an array of hostnames of the TeamCity agent instances
+#>
+ [CmdletBinding()]
+ [OutputType([string[]])]
+ param()
+
+ return Get-TeamCityHostnames -Type Agent
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.TeamCity/Public/Get-TeamCityEc2Instance.ps1 b/Modules/Alkami.DevOps.TeamCity/Public/Get-TeamCityEc2Instance.ps1
new file mode 100644
index 0000000..3e46088
--- /dev/null
+++ b/Modules/Alkami.DevOps.TeamCity/Public/Get-TeamCityEc2Instance.ps1
@@ -0,0 +1,33 @@
+function Get-TeamCityEc2Instance {
+ <#
+.SYNOPSIS
+ Returns an array of the TeamCity EC2 Instances
+
+.PARAMETER Type
+ Type of TeamCity host(s) to return: All, Server(s), or Agent(s)
+
+.PARAMETER ProfileName
+ AWS ProfileName
+#>
+ [CmdletBinding()]
+ [OutputType([Amazon.EC2.Model.Instance[]])]
+ param(
+ [Parameter(Mandatory = $false)]
+ [ValidateSet("All", "Server", "Agent")]
+ [string]$Type = "All",
+ [Parameter(Mandatory = $true)]
+ [string]$ProfileName
+ )
+
+ $logLead = Get-LogLeadName
+
+ Write-Host "$logLead : Getting hostnames of type $Type"
+ $teamcityHostnames = Get-TeamCityHostnames -Type $Type
+
+ Write-Host "$logLead : Getting instances for hostnames $($teamcityHostnames)"
+ $teamcityInstances = Get-EC2InstancesByHostname -Servers $teamcityHostnames -ProfileName $ProfileName
+
+ Write-Host "$logLead : Found $($teamcityInstances.count)(count) instances to return"
+ return $teamcityInstances
+
+}
diff --git a/Modules/Alkami.DevOps.TeamCity/Public/Get-TeamCityHostnames.ps1 b/Modules/Alkami.DevOps.TeamCity/Public/Get-TeamCityHostnames.ps1
new file mode 100644
index 0000000..0c5c03b
--- /dev/null
+++ b/Modules/Alkami.DevOps.TeamCity/Public/Get-TeamCityHostnames.ps1
@@ -0,0 +1,52 @@
+function Get-TeamCityHostnames {
+ <#
+.SYNOPSIS
+ Returns an array of hostnames of the TeamCity server and agent instances
+
+.PARAMETER Type
+ Type of TeamCity host(s) to return: All, Server(s), or Agent(s)
+#>
+ [CmdletBinding()]
+ [OutputType([string[]])]
+ param(
+ [Parameter(Mandatory = $false)]
+ [ValidateSet("All", "Server", "Agent")]
+ [string]$Type = "All",
+ [Parameter(Mandatory = $false)]
+ [string]$ProfileName
+ )
+
+ $logLead = Get-LogLeadName
+ # Because I don't feel like using SharedVariables (yet)
+ if (Test-StringIsNullOrWhitespace -Value $ProfileName) {
+ $TeamCityHosts = @{
+ All = @("team316179.fh.local", "tea316155.fh.local", "tea31697.fh.local", "tea316229.fh.local", "tea316208.fh.local", "tea46658.fh.local", "tea37021.fh.local", "tea370104.fh.local", "tea37168.fh.local", "tea37079.fh.local", "tea370181.fh.local", "tea370219.fh.local", "tea37020.fh.local", "tea370123.fh.local")
+ Server = @("team316179.fh.local", "tea37020.fh.local", "tea370123.fh.local")
+ Agent = @("tea316155.fh.local", "tea31697.fh.local", "tea316229.fh.local", "tea316208.fh.local", "tea46658.fh.local", "tea37021.fh.local", "tea370104.fh.local", "tea37168.fh.local", "tea37079.fh.local", "tea370181.fh.local", "tea370219.fh.local")
+ }
+ Write-Host "$logLead : Returning TeamCity Hosts of type - $Type - From all AWS Accounts"
+ } elseif ($ProfileName -eq "temp-prod") {
+ $TeamCityHosts = @{
+ All = @("team316179.fh.local", "tea316155.fh.local", "tea31697.fh.local", "tea316229.fh.local", "tea316208.fh.local", "tea46658.fh.local")
+ Server = @("team316179.fh.local")
+ Agent = @("tea316155.fh.local", "tea31697.fh.local", "tea316229.fh.local", "tea316208.fh.local", "tea46658.fh.local")
+ }
+ Write-Host "$logLead : Returning TeamCity Hosts of type - $Type - From the AWS Production Account"
+ } elseif ($ProfileName -eq "temp-mgmt") {
+ $TeamCityHosts = @{
+ All = @("tea37021.fh.local", "tea370104.fh.local", "tea37168.fh.local", "tea37079.fh.local", "tea370181.fh.local", "tea370219.fh.local", "tea37020.fh.local", "tea370123.fh.local")
+ Server = @("tea37020.fh.local", "tea370123.fh.local")
+ Agent = @("tea37021.fh.local", "tea370104.fh.local", "tea37168.fh.local", "tea37079.fh.local", "tea370181.fh.local", "tea370219.fh.local")
+ }
+ Write-Host "$logLead : Returning TeamCity Hosts of type - $Type - From the AWS Management Account"
+ } else {
+ Write-Warning "ProfileName does not fall under the criteria of temp-prod or temp-mgmt, exiting function..."
+ return
+ }
+
+
+
+
+ $hostnamesToReturn = $TeamCityHosts[$Type]
+ return $hostnamesToReturn
+}
diff --git a/Modules/Alkami.DevOps.TeamCity/Public/Get-TeamCityServerNodeHostnames.ps1 b/Modules/Alkami.DevOps.TeamCity/Public/Get-TeamCityServerNodeHostnames.ps1
new file mode 100644
index 0000000..0efa914
--- /dev/null
+++ b/Modules/Alkami.DevOps.TeamCity/Public/Get-TeamCityServerNodeHostnames.ps1
@@ -0,0 +1,10 @@
+function Get-TeamCityServerNodeHostnames {
+ <#
+.SYNOPSIS
+ Returns an array of hostnames of the TeamCity agent instances
+#>
+ [CmdletBinding()]
+ [OutputType([string[]])]
+ param()
+ return Get-TeamCityHostnames -Type Server
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.TeamCity/Public/Invoke-AWSConfigureAWSPackage.ps1 b/Modules/Alkami.DevOps.TeamCity/Public/Invoke-AWSConfigureAWSPackage.ps1
new file mode 100644
index 0000000..aae39d3
--- /dev/null
+++ b/Modules/Alkami.DevOps.TeamCity/Public/Invoke-AWSConfigureAWSPackage.ps1
@@ -0,0 +1,156 @@
+function Invoke-AWSConfigureAWSPackage {
+ <#
+.SYNOPSIS
+ Used to help facilitate updating various driver packages on TeamCity agents
+
+.PARAMETER JobName
+ Used to specify which driver set to update
+
+.PARAMETER InstanceId
+ One or more instance ids to operate on. Defaults to the TC agent machine list
+
+.PARAMETER Comment
+ Please provide a Jira ticket number associated with the work you are doing
+
+.PARAMETER ProfileName
+ The AWS Profile name to use (think temp-prod, unless this is implemented as a job on TC itself, then Prod, etc)
+
+.PARAMETER Region
+ The AWS Region where the command should be run
+
+.LINK
+ https://confluence.alkami.com/display/SRE/How+To+update+AWS+EC2+Drivers+on+TeamCity+Agents
+#>
+ [CmdletBinding()]
+ [OutputType([System.Void])]
+ param (
+ [Parameter(Mandatory = $true)]
+ [ValidateSet('AwsEnaNetworkDriver', 'AWSPVDriver', 'AWSNVMe')]
+ [ArgumentCompleter( { $possibleValues = @('AwsEnaNetworkDriver', 'AWSPVDriver', 'AWSNVMe'); return $possibleValues | ForEach-Object { $_ } })]
+ [string]$JobName,
+ [Parameter(Mandatory = $false)]
+ [ValidateNotNullOrEmpty()]
+ [string[]]$InstanceId = @("i-05993777a46817dfe", "i-0117159b672f24a1a", "i-01c107a3e148d503a", "i-0cc56f29481ad409b", "i-0dfbf5cf56d898966", "i-0b17585457e090315"),
+ [Parameter(Mandatory = $true)]
+ [ValidateNotNullOrEmpty()]
+ [Alias('JiraTicketNumber')]
+ [string]$Comment,
+ [Parameter(Mandatory = $true)]
+ [ValidateNotNullOrEmpty()]
+ [string]$ProfileName,
+ [Parameter(Mandatory = $true)]
+ [ValidateNotNullOrEmpty()]
+ [string]$Region
+ )
+
+ $logLead = Get-LogLeadName
+
+ $targets = @(
+ @{
+ Key = "InstanceIds"
+ Values = @($instanceId) #this value has to be an array even if only one value is given, I think
+ }
+ )
+ # The requirements on the AWS CLI are very odd. I must be doing something wrong
+ $targetsString = '"' + (ConvertTo-Json $targets -Compress).Replace('"', '""') + '"'
+
+ $parameters = @{
+ action = @("Install")
+ installationType = @("Uninstall and reinstall")
+ version = @("")
+ additionalArguments = @("{}")
+ name = @($JobName)
+ }
+ $parametersString = '"' + (ConvertTo-Json $parameters -Compress).Replace('"', '""') + '"'
+
+ Write-Host $targetsString
+ Write-Host $parametersString
+
+ Write-Host "$logLead : Sending this command to AWS CLI:`n`n aws ssm send-command --document-name `"AWS-ConfigureAWSPackage`" --document-version `"1`" --targets=$targetsString --parameters=$parametersString --comment `"$Comment`" --timeout-seconds `"600`" --max-concurrency `"50`" --max-errors `"0`" --output-s3-bucket-name `"teamcity-alkami-bucket`" --region `"$Region`" --profile `"$ProfileName`"`n`n"
+
+ # https://docs.aws.amazon.com/cli/latest/reference/ssm/send-command.html
+ $resultString = (aws ssm send-command --document-name "AWS-ConfigureAWSPackage" --document-version "1" --targets=$targetsString --parameters=$parametersString --comment $Comment --timeout-seconds "600" --max-concurrency "50" --max-errors "0" --output-s3-bucket-name "teamcity-alkami-bucket" --region $Region --profile $ProfileName)
+
+ $result = ConvertFrom-Json $resultString -Depth 100
+
+ $command = $result.Command
+
+ if ($command.ErroCount -gt 0) {
+ Write-Host "$logLead : response was the following:`n$resultString"
+ throw "$logLead : Result had non-zero error count. Please review the logs above."
+ }
+
+ $commandId = $command.CommandId
+ $instanceIds = $command.Targets[0].Values
+
+ <#
+ Potential status fields https://docs.aws.amazon.com/cli/latest/reference/ssm/get-command-invocation.html
+ Pending: The command hasn't been sent to the managed node.
+ In Progress: The command has been sent to the managed node but hasn't reached a terminal state.
+ Delayed: The system attempted to send the command to the target, but the target wasn't available. The managed node might not be available because of network issues, because the node was stopped, or for similar reasons. The system will try to send the command again.
+ Success: The command or plugin ran successfully. This is a terminal state.
+ Delivery Timed Out: The command wasn't delivered to the managed node before the delivery timeout expired. Delivery timeouts don't count against the parent command's MaxErrors limit, but they do contribute to whether the parent command status is Success or Incomplete. This is a terminal state.
+ Execution Timed Out: The command started to run on the managed node, but the execution wasn't complete before the timeout expired. Execution timeouts count against the MaxErrors limit of the parent command. This is a terminal state.
+ Failed: The command wasn't run successfully on the managed node. For a plugin, this indicates that the result code wasn't zero. For a command invocation, this indicates that the result code for one or more plugins wasn't zero. Invocation failures count against the MaxErrors limit of the parent command. This is a terminal state.
+ Cancelled: The command was terminated before it was completed. This is a terminal state.
+ Undeliverable: The command can't be delivered to the managed node. The node might not exist or might not be responding. Undeliverable invocations don't count against the parent command's MaxErrors limit and don't contribute to whether the parent command status is Success or Incomplete. This is a terminal state.
+ Terminated: The parent command exceeded its MaxErrors limit and subsequent command invocations were canceled by the system. This is a terminal state.
+ #>
+
+ # keep trying again
+ $keepLoopingStatuses = @('Pending', 'In Progress', 'Delayed')
+ # stop trying again
+ $stopLoopingStatuses = @('Success', 'Delivery Timed Out', 'Execution Timed Out', 'Failed', 'Cancelled', 'Undeliverable', 'Terminated')
+ # show an error
+ $failureStatuses = @('Delivery Timed Out', 'Execution Timed Out', 'Failed', 'Cancelled', 'Undeliverable', 'Terminated')
+ # show a success
+ $successStatuses = @('Success')
+
+ while ($instanceIds.Count -gt 0) {
+ $finishedInstanceIds = @()
+ foreach ($instanceId in $instanceIds) {
+ try {
+ $instanceResultString = (aws ssm get-command-invocation --command-id $commandId --instance-id $instanceId)
+
+ $instanceResult = ConvertFrom-Json $instanceResultString -Depth 100
+
+ if ($keepLoopingStatuses -contains $instanceResult.StatusDetails) {
+ Write-Host "$logLead : Will recheck status on [$instanceId] after sleep because it is still state [$($instanceResult.StatusDetails)]"
+ }
+ if ($stopLoopingStatuses -contains $instanceResult.StatusDetails) {
+ if ($successStatuses -contains $instanceResult.StatusDetails) {
+ Write-Host "$logLead : Instance [$instanceId] successfully finished the process"
+ }
+ if ($failureStatuses -contains $instanceResult.StatusDetails) {
+ Write-Warning "$logLead : Instance [$instanceId] finished the process with state [$(instanceResult.StatusDetails)]"
+ Write-Host "$logLead : For the following results, please see https://docs.aws.amazon.com/cli/latest/reference/ssm/get-command-invocation.html"
+ }
+ # log "normal" properties
+ foreach ($property in @('Comment', 'StandardOutputContent', 'StandardOutputUrl')) {
+ if (![string]::IsNullOrWhiteSpace($instanceResult.$property)) {
+ Write-Host "$logLead : $instanceId : $property : $($instanceResult.$property)"
+ }
+ }
+ # log "warning" properties
+ foreach ($property in @('StandardErrorUrl', 'StandardErrorContent')) {
+ if (![string]::IsNullOrWhiteSpace($instanceResult.$property)) {
+ Write-Warning "$logLead : $instanceId : $property : $($instanceResult.$property)"
+ }
+ }
+
+ $finishedInstanceIds += $instanceId
+ }
+ } catch {
+ $finishedInstanceIds += $instanceId
+ }
+ }
+
+ $instanceIds = $instanceIds | Where-Object { $finishedInstanceIds -notcontains $PSItem }
+
+ if ($instanceIds.Count -gt 0) {
+ Start-Sleep -Seconds 5
+ }
+
+ Write-Host "$logLead : Still waiting to check the results on [$($instanceIds -join ',')]"
+ }
+}
diff --git a/Modules/Alkami.DevOps.TeamCity/Public/Invoke-UpdateChrome.ps1 b/Modules/Alkami.DevOps.TeamCity/Public/Invoke-UpdateChrome.ps1
new file mode 100644
index 0000000..9d62ad7
--- /dev/null
+++ b/Modules/Alkami.DevOps.TeamCity/Public/Invoke-UpdateChrome.ps1
@@ -0,0 +1,27 @@
+function Invoke-UpdateChrome {
+ <#
+.SYNOPSIS
+ Upgrade Chrome on the given servers using the internally managed Choco package GoogleChrome
+.PARAMETER ComputerName
+ List of servers to run the upgrade on
+.PARAMETER WhatIf
+ Safety valve
+#>
+ [CmdletBinding()]
+ [OutputType([System.Void])]
+ param(
+ [Parameter(Mandatory = $true)]
+ [ValidateNotNullOrEmpty()]
+ [string[]]$ComputerName,
+ [Parameter(Mandatory = $false)]
+ [switch]$WhatIf
+ )
+ $sbChrome = {
+ choco upgrade -y GoogleChrome
+ }
+ if ($WhatIf) {
+ Write-Host "Calling Invoke-Command -ComputerName $ComputerName -ScriptBlock $sbChrome"
+ } else {
+ Invoke-Command -ComputerName $ComputerName -ScriptBlock $sbChrome
+ }
+}
diff --git a/Modules/Alkami.DevOps.TeamCity/Public/Remove-DotnetExtraRuntimes.ps1 b/Modules/Alkami.DevOps.TeamCity/Public/Remove-DotnetExtraRuntimes.ps1
new file mode 100644
index 0000000..4063793
--- /dev/null
+++ b/Modules/Alkami.DevOps.TeamCity/Public/Remove-DotnetExtraRuntimes.ps1
@@ -0,0 +1,22 @@
+Function Remove-DotnetExtraRuntimes {
+ <#
+.SYNOPSIS
+ Delete extra runtimes for dotnet. Ensure that we only keep the latest around.
+ This assumes that minors are as distinct as majors.
+
+.EXAMPLE
+ Remove-DotnetExtraRuntimes
+#>
+
+ [CmdletBinding()]
+ param()
+
+ $logLead = Get-LogLeadName
+
+ $versionsToDelete = Get-DotnetExtraRuntimes -ReturnExpiredRuntimes
+
+ foreach ($deleteRuntime in $versionsToDelete) {
+ Write-Host "$logLead : Deleting [$deleteRuntime]"
+ Remove-Item -Recurse -Force -Path $deleteRuntime
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.TeamCity/Public/Remove-DotnetExtraSDKs.ps1 b/Modules/Alkami.DevOps.TeamCity/Public/Remove-DotnetExtraSDKs.ps1
new file mode 100644
index 0000000..e90da37
--- /dev/null
+++ b/Modules/Alkami.DevOps.TeamCity/Public/Remove-DotnetExtraSDKs.ps1
@@ -0,0 +1,22 @@
+Function Remove-DotnetExtraSDKs {
+ <#
+.SYNOPSIS
+ Delete extra runtimes for dotnet. Ensure that we only keep the latest around.
+ This assumes that minors are as distinct as majors.
+
+.EXAMPLE
+ Remove-DotnetExtraSDKs
+#>
+
+ [CmdletBinding()]
+ param()
+
+ $logLead = Get-LogLeadName
+
+ $sdksToDelete = Get-DotnetExtraSDKs -ReturnExpiredSdks
+
+ foreach ($deleteSdk in $sdksToDelete) {
+ Write-Host "$logLead : Deleting [$deleteSdk]"
+ Remove-Item -Recurse -Force -Path $deleteSdk
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.TeamCity/Public/Start-TeamCityAgent.ps1 b/Modules/Alkami.DevOps.TeamCity/Public/Start-TeamCityAgent.ps1
new file mode 100644
index 0000000..1c90365
--- /dev/null
+++ b/Modules/Alkami.DevOps.TeamCity/Public/Start-TeamCityAgent.ps1
@@ -0,0 +1,79 @@
+function Start-TeamCityAgent {
+ <#
+.SYNOPSIS
+ Start the TeamCity Agent service(s) on a given host or on the host(s) in a given Agent Pool
+
+.PARAMETER ComputerName
+ FQDN hostname of the TeamCity agent host where you want to start the Agent service
+
+.PARAMETER AgentPool
+ TeamCity Agent Pool name of the TeamCity agents you want to start
+
+.LINK
+ Get-TeamCityAgentHostnames
+
+.LINK
+ Stop-TeamCityAgent
+
+.LINK
+ https://ci.corp.alkamitech.com/agents.html?tab=agentPools
+
+.LINK
+ https://ci.corp.alkamitech.com/app/rest/agentPools
+#>
+ [CmdletBinding()]
+ [OutputType([System.Void])]
+ param(
+ [Parameter(Mandatory = $true, ParameterSetName = "ComputerName")]
+ $ComputerName,
+ [Parameter(Mandatory = $true, ParameterSetName = "AgentPool")]
+ $AgentPool
+ )
+
+ # Why would you name the Keys like that, Tom?
+ #
+ # Because that's what they're named in TeamCity. Ideally, we'd be able to pull from the API
+ # For a lot of things eventually and I don't want to make names up.
+ # If you don't know the name, look it up in TeamCity. That's the name.
+ # Also...
+ # There are other pools that we don't touch the hosts of. "All" isn't really "All"
+ $agentPoolToServiceNameFragments = @{
+ "Sandbox" = @('buildSandbox', 'migrateSandbox')
+ "Dev/QA" = @('migrateQaDev', 'buildQaDev')
+ "Production/Staging" = @('buildagent', 'migrateagent')
+ "All" = @('buildSandbox', 'migrateSandbox', 'migrateQaDev', 'buildQaDev', 'buildagent', 'migrateagent')
+ }
+ # I hate everything about this except the last entry
+ # I really want to be getting these from the REST API. Or something. ANYthing else.
+ $agentPoolToAgentHostname = @{
+ "Sandbox" = @("tea316229.fh.local", "tea37021.fh.local")
+ "Dev/QA" = @("tea316208.fh.local", "tea370104.fh.local")
+ "Production/Staging" = @("tea316155.fh.local", "tea31697.fh.local")
+ "All" = Get-TeamCityAgentHostnames
+ }
+
+
+ if ($PSCmdlet.ParameterSetName -eq "AgentPool") {
+ $ComputerName = $agentPoolToAgentHostname[$AgentPool]
+ }
+
+ if ($PSCmdlet.ParameterSetName -eq "ComputerName") {
+ $AgentPool = "All"
+ }
+
+ $agentServiceFragmentNames = $agentPoolToServiceNameFragments[$AgentPool]
+
+ $sbStartAgent = {
+ param($sbServiceNameFragments)
+ $serviceNames = @()
+ foreach ($serviceName in $sbServiceNameFragments) {
+ $temp = (Get-ServiceInfoByCIMFragment $serviceName).name
+ if ($null -ne $temp) {
+ $serviceNames += $temp
+ }
+ }
+ Start-Service $serviceNames
+ }
+ Invoke-ParallelServers -Servers $ComputerName -Script $sbStartAgent -Arguments ($agentServiceFragmentNames)
+
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.TeamCity/Public/Stop-TeamCityAgent.ps1 b/Modules/Alkami.DevOps.TeamCity/Public/Stop-TeamCityAgent.ps1
new file mode 100644
index 0000000..0def6f3
--- /dev/null
+++ b/Modules/Alkami.DevOps.TeamCity/Public/Stop-TeamCityAgent.ps1
@@ -0,0 +1,79 @@
+function Stop-TeamCityAgent {
+ <#
+.SYNOPSIS
+ Stop the TeamCity Agent service(s) on a given host or on the host(s) in a given Agent Pool
+
+.PARAMETER ComputerName
+ FQDN hostname of the TeamCity agent host where you want to stop the Agent service
+
+.PARAMETER AgentPool
+ TeamCity Agent Pool name of the TeamCity agents you want to stop - this must be the EXACT NAME FROM TEAMCITY
+
+.LINK
+ Get-TeamCityAgentHostnames
+
+.LINK
+ Start-TeamCityAgent
+
+.LINK
+ https://ci.corp.alkamitech.com/agents.html?tab=agentPools
+
+.LINK
+ https://ci.corp.alkamitech.com/app/rest/agentPools
+#>
+ [CmdletBinding()]
+ [OutputType([System.Void])]
+ param(
+ [Parameter(Mandatory = $true, ParameterSetName = "ComputerName")]
+ $ComputerName,
+ [Parameter(Mandatory = $true, ParameterSetName = "AgentPool")]
+ $AgentPool
+ )
+
+ # Why would you name the Keys like that, Tom?
+ #
+ # Because that's what they're named in TeamCity. Ideally, we'd be able to pull from the API
+ # For a lot of things eventually and I don't want to make names up.
+ # If you don't know the name, look it up in TeamCity. That's the name.
+ # Also...
+ # There are other pools that we don't touch the hosts of. "All" isn't really "All"
+ $agentPoolToServiceNameFragments = @{
+ "Sandbox" = @('buildSandbox', 'migrateSandbox')
+ "Dev/QA" = @('migrateQaDev', 'buildQaDev')
+ "Production/Staging" = @('buildagent', 'migrateagent')
+ "All" = @('buildSandbox', 'migrateSandbox', 'migrateQaDev', 'buildQaDev', 'buildagent', 'migrateagent')
+ }
+ # I hate everything about this except the last entry
+ # I really want to be getting these from the REST API. Or something. ANYthing else.
+ $agentPoolToAgentHostname = @{
+ "Sandbox" = @("tea316229.fh.local", "tea37021.fh.local")
+ "Dev/QA" = @("tea316208.fh.local", "tea370104.fh.local")
+ "Production/Staging" = @("tea316155.fh.local", "tea31697.fh.local")
+ "All" = Get-TeamCityAgentHostnames
+ }
+
+ if ($PSCmdlet.ParameterSetName -eq "AgentPool") {
+ $ComputerName = $agentPoolToAgentHostname[$AgentPool]
+ }
+
+ if ($PSCmdlet.ParameterSetName -eq "ComputerName") {
+ $AgentPool = "All"
+ }
+
+ $agentServiceFragmentNames = $agentPoolToServiceNameFragments[$AgentPool]
+
+ $sbStopAgent = {
+ param($sbServiceNameFragments)
+ $serviceNames = @()
+ foreach ($serviceName in $sbServiceNameFragments) {
+ $temp = (Get-ServiceInfoByCIMFragment $serviceName).name
+ if ($null -ne $temp) {
+ $serviceNames += $temp
+ }
+ }
+ Stop-Service $serviceNames
+ }
+
+ Invoke-ParallelServers -Servers $ComputerName -Script $sbStopAgent -Arguments ($agentServiceFragmentNames)
+
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.TeamCity/Public/Test-TeamCityComputerIsAvailable.ps1 b/Modules/Alkami.DevOps.TeamCity/Public/Test-TeamCityComputerIsAvailable.ps1
new file mode 100644
index 0000000..d185a58
--- /dev/null
+++ b/Modules/Alkami.DevOps.TeamCity/Public/Test-TeamCityComputerIsAvailable.ps1
@@ -0,0 +1,35 @@
+function Test-TeamCityComputerIsAvailable {
+ <#
+ .SYNOPSIS
+ Test that given computer(s) are on and able to be connected to
+
+ .PARAMETER ComputerName
+ [string] Computer(s) to connect to. Assumes .fh.local domain
+
+ .PARAMETER Type
+ Optional Parameter. Type of TeamCity host(s) to return: All, Server(s), or Agent(s)
+
+ #>
+ [CmdletBinding()]
+ [OutputType([Object])]
+ param (
+ [Parameter(Mandatory = $false)]
+ [string[]]$ComputerName,
+ [ValidateSet("All", "Server", "Agent")]
+ [Parameter(Mandatory = $false)]
+ [string]$Type
+ )
+ $logLead = Get-LogLeadName
+ $resultObject = @{}
+
+ if (!(Test-StringIsNullOrWhitespace -Value $Type)) {
+ $ComputerName = Get-TeamCityHostnames -Type $Type
+ }
+
+ foreach ($computer in $ComputerName) {
+ $result = Test-ComputerIsAvailable -ComputerName $computer
+ Write-Host "$logLead : $computer : $result"
+ $resultObject += @{$computer = "$result"}
+ }
+ Return $resultObject
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.TeamCity/thunder-collection_TeamCity.json b/Modules/Alkami.DevOps.TeamCity/thunder-collection_TeamCity.json
new file mode 100644
index 0000000..958b9a1
--- /dev/null
+++ b/Modules/Alkami.DevOps.TeamCity/thunder-collection_TeamCity.json
@@ -0,0 +1 @@
+{"client":"Thunder Client","collectionName":"TeamCity","dateExported":"2022-05-13T20:18:58.311Z","version":"1.1","folders":[{"_id":"c1df835a-dd84-43c1-a128-a6ba6b2c21d6","name":"Modules","containerId":"","created":"2022-04-21T16:19:50.730Z","sortNum":10000},{"_id":"69c6e672-6574-4c03-a8e4-a9e84e139fca","name":"PinnedBuilds","containerId":"","created":"2022-05-13T20:15:06.282Z","sortNum":20000}],"requests":[{"_id":"52e170cd-91fb-43aa-89fa-eee41988333b","colId":"825fbd0a-c490-4e6f-a8b1-728bb82ee2f4","containerId":"c1df835a-dd84-43c1-a128-a6ba6b2c21d6","name":"PushBranchModules","url":"https://ci.corp.alkamitech.com/app/rest/builds/id:2158091","method":"GET","sortNum":10000,"created":"2022-04-21T16:06:23.478Z","modified":"2022-04-25T19:32:55.423Z","headers":[{"name":"Accept","value":"application/json"}],"params":[],"auth":{"type":"bearer","bearer":"{{TCTOKEN}}"},"tests":[]},{"_id":"231dbe19-00a8-4662-944d-bc79982d8e67","colId":"825fbd0a-c490-4e6f-a8b1-728bb82ee2f4","containerId":"c1df835a-dd84-43c1-a128-a6ba6b2c21d6","name":"PushBranchModules Copy","url":"https://ci.corp.alkamitech.com/app/rest/builds/id:2158091","method":"GET","sortNum":12500,"created":"2022-04-27T14:02:02.004Z","modified":"2022-04-27T14:02:25.900Z","headers":[{"name":"Accept","value":"application/json"}],"params":[],"auth":{"type":"basic","basic":{"username":"sreapiuser","password":"Ffa4NPQdA7Cw"}},"tests":[]},{"_id":"adab8186-6ad3-4573-a39e-0c8c5907f467","colId":"825fbd0a-c490-4e6f-a8b1-728bb82ee2f4","containerId":"c1df835a-dd84-43c1-a128-a6ba6b2c21d6","name":"PushBranchModulesSnapshotDeps","url":"https://ci.corp.alkamitech.com/app/rest/builds/id:2158091?fields=snapshot-dependencies(count,build)","method":"GET","sortNum":15000,"created":"2022-04-22T14:59:40.041Z","modified":"2022-04-25T19:33:17.255Z","headers":[{"name":"Accept","value":"application/json"}],"params":[{"name":"fields","value":"snapshot-dependencies(count,build)","isPath":false}],"auth":{"type":"bearer","bearer":"{{TCTOKEN}}"},"tests":[]},{"_id":"57b18827-fd6b-423f-8b3f-972c6ed8dbfb","colId":"825fbd0a-c490-4e6f-a8b1-728bb82ee2f4","containerId":"","name":"TCServer","url":"https://ci.corp.alkamitech.com/app/rest","method":"GET","sortNum":20000,"created":"2021-05-18T15:08:20.177Z","modified":"2021-05-18T15:08:43.302Z","headers":[],"auth":{"type":"bearer","bearer":"{{TCTOKEN}}"},"tests":[]},{"_id":"b01917ba-063b-4337-b6e5-78e80a273a69","colId":"825fbd0a-c490-4e6f-a8b1-728bb82ee2f4","containerId":"c1df835a-dd84-43c1-a128-a6ba6b2c21d6","name":"BranchModuleDependency","url":"https://ci.corp.alkamitech.com/app/rest/builds/id:2158073","method":"GET","sortNum":20000,"created":"2022-04-21T16:08:35.911Z","modified":"2022-04-25T19:34:25.661Z","headers":[{"name":"Accept","value":"application/json"}],"params":[],"auth":{"type":"bearer","bearer":"{{TCTOKEN}}"},"tests":[]},{"_id":"04b81ce0-4f3b-465b-bd9a-af9858f1502e","colId":"825fbd0a-c490-4e6f-a8b1-728bb82ee2f4","containerId":"","name":"TCServer Copy","url":"https://ci.corp.alkamitech.com/app/rest/server/$help","method":"GET","sortNum":20625,"created":"2022-04-21T16:29:24.379Z","modified":"2022-04-21T16:31:30.428Z","headers":[],"params":[],"auth":{"type":"bearer","bearer":"{{TCTOKEN}}"},"tests":[]},{"_id":"5bd0f061-43eb-475b-94a0-ff23da62fc4a","colId":"825fbd0a-c490-4e6f-a8b1-728bb82ee2f4","containerId":"","name":"TCAudit","url":"https://ci.corp.alkamitech.com/app/rest/audit?locator=$help","method":"GET","sortNum":21250,"created":"2021-06-03T17:40:16.648Z","modified":"2021-11-10T15:29:46.192Z","headers":[],"params":[{"name":"locator","value":"$help","isPath":false}],"auth":{"type":"bearer","bearer":"{{TCTOKEN}}"},"tests":[]},{"_id":"3627b058-cb00-472b-a0bf-96d237172e10","colId":"825fbd0a-c490-4e6f-a8b1-728bb82ee2f4","containerId":"","name":"TCBuild","url":"https://ci.corp.alkamitech.com/app/rest/builds/id:1780304","method":"GET","sortNum":22500,"created":"2021-05-20T15:46:18.817Z","modified":"2021-11-05T17:46:52.576Z","headers":[{"name":"Accept","value":"application/json"}],"params":[],"auth":{"type":"bearer","bearer":"{{TCTOKEN}}"},"tests":[]},{"_id":"934a768d-e4fc-481a-b6a3-5f82c4439604","colId":"825fbd0a-c490-4e6f-a8b1-728bb82ee2f4","containerId":"","name":"TCBuildWithTriggeredInfo","url":"https://ci.corp.alkamitech.com/app/rest/builds/1780304?fields=statusText,branchName,triggered(user,build,buildType),status,testOccurrences,buildType(id,name),running-info,properties(property)","method":"GET","sortNum":23750,"created":"2021-05-20T18:17:30.882Z","modified":"2022-04-13T16:02:36.453Z","headers":[{"name":"Accept","value":"application/json"}],"params":[{"name":"fields","value":"statusText,branchName,triggered(user,build,buildType),status,testOccurrences,buildType(id,name),running-info,properties(property)","isPath":false}],"auth":{"type":"bearer","bearer":"{{TCTOKEN}}"},"tests":[]},{"_id":"efe8ead8-bf06-45ed-a293-ccc0222799ae","colId":"825fbd0a-c490-4e6f-a8b1-728bb82ee2f4","containerId":"","name":"TCUser","url":"https://ci.corp.alkamitech.com/app/rest/users/trowton","method":"GET","sortNum":25000,"created":"2021-05-19T19:17:44.482Z","modified":"2021-11-10T14:57:51.575Z","headers":[{"name":"Accept","value":"application/json"}],"params":[],"auth":{"type":"bearer","bearer":"{{TCTOKEN}}"},"tests":[]},{"_id":"4bfd72fa-9f20-4f52-a3fd-9bc26ac2828a","colId":"825fbd0a-c490-4e6f-a8b1-728bb82ee2f4","containerId":"c1df835a-dd84-43c1-a128-a6ba6b2c21d6","name":"BranchModuleDependency Copy","url":"https://ci.corp.alkamitech.com/app/rest/builds/id:2158073?fields=number,branchName,triggered(user(username)),properties(property)","method":"GET","sortNum":25000,"created":"2022-04-25T18:49:10.088Z","modified":"2022-04-25T22:02:31.270Z","headers":[{"name":"Accept","value":"application/json"}],"params":[{"name":"fields","value":"number,branchName,triggered(user(username)),properties(property)","isPath":false}],"auth":{"type":"bearer","bearer":"{{TCTOKEN}}"},"tests":[]},{"_id":"a7601a7d-bb1e-46e3-bc97-3d1dfec339bc","colId":"825fbd0a-c490-4e6f-a8b1-728bb82ee2f4","containerId":"","name":"BuildType","url":"https://ci.corp.alkamitech.com/app/rest/buildTypes/id:MaintenancePages_Patelco_API_PatelcoMaintenance","method":"GET","sortNum":30000,"created":"2021-05-18T15:09:45.772Z","modified":"2022-02-19T19:00:14.728Z","headers":[{"name":"Accept","value":"application/json"}],"params":[],"auth":{"type":"bearer","bearer":"{{TCTOKEN}}"},"tests":[]},{"_id":"115d5db4-b0e0-4332-b623-fd5027c0adb4","colId":"825fbd0a-c490-4e6f-a8b1-728bb82ee2f4","containerId":"c1df835a-dd84-43c1-a128-a6ba6b2c21d6","name":"BranchModuleDependencyArtifacts","url":"https://ci.corp.alkamitech.com/app/rest/builds/id:2139979/artifacts/children?fields=file(name)","method":"GET","sortNum":30000,"created":"2022-04-21T16:11:56.603Z","modified":"2022-04-21T16:22:12.464Z","headers":[{"name":"Accept","value":"application/json"}],"params":[{"name":"fields","value":"file(name)","isPath":false}],"auth":{"type":"bearer","bearer":"{{TCTOKEN}}"},"tests":[]},{"_id":"2e0f3337-072c-4aac-9245-ab2c03ed8488","colId":"825fbd0a-c490-4e6f-a8b1-728bb82ee2f4","containerId":"","name":"BuildType Copy","url":"https://ci.corp.alkamitech.com/app/rest/projects/id:MaintenancePages","method":"GET","sortNum":32500,"created":"2022-04-05T20:23:18.782Z","modified":"2022-04-05T20:35:41.171Z","headers":[{"name":"Accept","value":"application/json"}],"params":[],"auth":{"type":"bearer","bearer":"{{TCTOKEN}}"},"tests":[]},{"_id":"5b0bc07b-6eaf-41c0-9a49-60f71710d3ea","colId":"825fbd0a-c490-4e6f-a8b1-728bb82ee2f4","containerId":"","name":"BuildType Triggers","url":"https://ci.corp.alkamitech.com/app/rest/buildTypes/id:MaintenancePages_Patelco_API_PatelcoMaintenance/triggers","method":"GET","sortNum":35000,"created":"2022-03-09T15:47:47.311Z","modified":"2022-03-09T15:49:32.883Z","headers":[{"name":"Accept","value":"application/json"}],"params":[],"auth":{"type":"bearer","bearer":"{{TCTOKEN}}"},"tests":[]},{"_id":"43ea9496-bc8a-46de-8322-273a3b2c6bba","colId":"825fbd0a-c490-4e6f-a8b1-728bb82ee2f4","containerId":"","name":"Snapshot Deps","url":"https://ci.corp.alkamitech.com/app/rest/buildTypes/id:DeployV3_DeploymentDependencies_PostDeployStartToChat/snapshot-dependencies","method":"GET","sortNum":40000,"created":"2021-05-18T15:15:43.690Z","modified":"2021-05-24T18:03:07.306Z","headers":[{"name":"Accept","value":"application/json"}],"auth":{"type":"bearer","bearer":"{{TCTOKEN}}"},"tests":[]},{"_id":"86f1b3eb-0648-475e-8bb9-1008b6f189b0","colId":"825fbd0a-c490-4e6f-a8b1-728bb82ee2f4","containerId":"c1df835a-dd84-43c1-a128-a6ba6b2c21d6","name":"BranchModulesBuildModulesZipFile","url":"https://ci.corp.alkamitech.com/app/rest/builds/id:2139968","method":"GET","sortNum":40000,"created":"2022-04-21T16:15:19.382Z","modified":"2022-04-21T16:20:33.942Z","headers":[{"name":"Accept","value":"application/json"}],"params":[],"auth":{"type":"bearer","bearer":"{{TCTOKEN}}"},"tests":[]},{"_id":"ba7f6b68-82d3-4f1c-8029-37ed4a4f0178","colId":"825fbd0a-c490-4e6f-a8b1-728bb82ee2f4","containerId":"","name":"Snapshot Deps Update NR","url":"https://ci.corp.alkamitech.com/app/rest/buildTypes/id:DeployV3_DeploymentDependencies_PostNewRelicDeploymentInformation/snapshot-dependencies","method":"GET","sortNum":50000,"created":"2021-05-25T13:32:56.248Z","modified":"2021-05-25T13:33:18.396Z","headers":[{"name":"Accept","value":"application/json"}],"auth":{"type":"bearer","bearer":"{{TCTOKEN}}"},"tests":[]},{"_id":"1a429957-3016-4e5e-ab02-3d4fa9fffd1f","colId":"825fbd0a-c490-4e6f-a8b1-728bb82ee2f4","containerId":"c1df835a-dd84-43c1-a128-a6ba6b2c21d6","name":"BranchModulesBuildModulesZipFileArtifact","url":"https://ci.corp.alkamitech.com/app/rest/builds/id:2139968/artifacts/children","method":"GET","sortNum":50000,"created":"2022-04-21T16:18:17.541Z","modified":"2022-04-21T16:20:38.533Z","headers":[{"name":"Accept","value":"application/json"}],"params":[],"auth":{"type":"bearer","bearer":"{{TCTOKEN}}"},"tests":[]},{"_id":"ebcb0d0a-c241-406a-81af-7b7166a73489","colId":"825fbd0a-c490-4e6f-a8b1-728bb82ee2f4","containerId":"","name":"Snapshot Deps Deploy Log4Net Defaults","url":"https://ci.corp.alkamitech.com/app/rest/buildTypes/id:DeployV3_DeploymentDependencies_CallBounceServices/snapshot-dependencies","method":"GET","sortNum":60000,"created":"2021-05-25T13:41:32.888Z","modified":"2021-05-25T13:57:41.879Z","headers":[{"name":"Accept","value":"application/json"}],"auth":{"type":"bearer","bearer":"{{TCTOKEN}}"},"tests":[]},{"_id":"7d12d9b0-ac3e-4c20-a3b7-7d77cbef7871","colId":"825fbd0a-c490-4e6f-a8b1-728bb82ee2f4","containerId":"","name":"Snapshot Deps CallBounceServices","url":"https://ci.corp.alkamitech.com/app/rest/buildTypes/id:DeployV3_DeploymentDependencies_DeployLog4netDefaults/snapshot-dependencies","method":"GET","sortNum":70000,"created":"2021-05-25T13:57:05.278Z","modified":"2021-05-25T13:57:05.278Z","headers":[{"name":"Accept","value":"application/json"}],"auth":{"type":"bearer","bearer":"{{TCTOKEN}}"},"tests":[]},{"_id":"3a5d6b22-11a4-45b3-883a-8b17f82c8f05","colId":"825fbd0a-c490-4e6f-a8b1-728bb82ee2f4","containerId":"69c6e672-6574-4c03-a8e4-a9e84e139fca","name":"DeployV3ProdPinned","url":"https://ci.corp.alkamitech.com/app/rest/buildTypes/project%3ADeployV3_Production/builds?onlyPinned=true","method":"GET","sortNum":80000,"created":"2022-05-13T20:15:30.008Z","modified":"2022-05-13T20:16:34.699Z","headers":[],"params":[{"name":"onlyPinned","value":"true","isPath":false}],"auth":{"type":"bearer","bearer":"{{TCTOKEN}}"},"tests":[]},{"_id":"d7585c74-2aad-47ae-8792-ace510a39f58","colId":"825fbd0a-c490-4e6f-a8b1-728bb82ee2f4","containerId":"69c6e672-6574-4c03-a8e4-a9e84e139fca","name":"BuildWithPinnedInfo","url":"https://ci.corp.alkamitech.com/app/rest/builds/id:1077853?fields=href,webUrl,pinInfo,statusText","method":"GET","sortNum":90000,"created":"2022-05-13T20:17:11.108Z","modified":"2022-05-13T20:17:45.676Z","headers":[],"params":[{"name":"fields","value":"href,webUrl,pinInfo,statusText","isPath":false}],"auth":{"type":"bearer","bearer":"{{TCTOKEN}}"},"tests":[]}]}
diff --git a/Modules/Alkami.DevOps.TeamCity/tools/chocolateyInstall.ps1 b/Modules/Alkami.DevOps.TeamCity/tools/chocolateyInstall.ps1
new file mode 100644
index 0000000..74befcf
--- /dev/null
+++ b/Modules/Alkami.DevOps.TeamCity/tools/chocolateyInstall.ps1
@@ -0,0 +1,38 @@
+[CmdletBinding()]
+Param()
+process {
+ $myCurrentPath = $PSScriptRoot;
+ Write-Verbose "Installing the Module from $myCurrentPath";
+
+ $parentPath = (Split-Path $myCurrentPath);
+ $systemModulePath = "C:\Program Files\WindowsPowerShell\Modules\";
+ $myModulePath = (Join-Path $parentPath "module");
+
+ $metadata = ([Xml](Get-Content (Join-Path $parentPath "*.nuspec"))).package.metadata;
+
+ $id = $metadata.id;
+ $version = $metadata.version -replace '-pre.+','';
+
+ $targetModulePath = (Join-Path $systemModulePath $id);
+ $targetModuleVersionPath = (Join-Path $targetModulePath $version);
+
+ if (Test-Path $targetModulePath) {
+ ## If the target folder already existed, remove it, because we are re-installing this package, obviously
+ if (Test-Path $targetModuleVersionPath) {
+ Write-Warning "Found an already existing module at [$targetModuleVersionPath]!!"
+ Remove-Item $targetModuleVersionPath -Recurse -Force;
+ }
+
+ ## Clear previous children for name conflicts
+ (Get-ChildItem $targetModulePath) | ForEach-Object {
+ Write-Information "Removing module located at [$_]";
+ Remove-Item $_.FullName -Recurse -Force;
+ }
+ }
+
+ Write-Host "Copying module $id to [$targetModuleVersionPath]";
+ Copy-Item $myModulePath -Destination $targetModuleVersionPath -Recurse -Force;
+
+ ## Ensure the module was able to load
+ Import-Module $id -Global;
+}
diff --git a/Modules/Alkami.DevOps.TeamCity/tools/chocolateyUninstall.ps1 b/Modules/Alkami.DevOps.TeamCity/tools/chocolateyUninstall.ps1
new file mode 100644
index 0000000..29b2f77
--- /dev/null
+++ b/Modules/Alkami.DevOps.TeamCity/tools/chocolateyUninstall.ps1
@@ -0,0 +1,23 @@
+[CmdletBinding()]
+Param()
+process {
+ $myCurrentPath = $PSScriptRoot;
+ Write-Verbose "Uninstalling the Module from $myCurrentPath";
+
+ $parentPath = (Split-Path $myCurrentPath);
+ $systemModulePath = "C:\Program Files\WindowsPowerShell\Modules\";
+ $myModulePath = (Join-Path $parentPath "module");
+
+ $metadata = ([Xml](Get-Content (Join-Path $parentPath "*.nuspec"))).package.metadata;
+
+ $id = $metadata.id;
+ $version = $metadata.version -replace '-pre.+','';
+
+ $targetModulePath = (Join-Path $systemModulePath $id);
+ $targetModuleVersionPath = (Join-Path $targetModulePath $version);
+
+ if (Test-Path $targetModuleVersionPath) {
+ Write-Information "Removing module at [$targetModuleVersionPath]!!"
+ Remove-Item $targetModuleVersionPath -Recurse -Force;
+ }
+}
diff --git a/Modules/Alkami.DevOps.Validations/Alkami.DevOps.Validations.nuspec b/Modules/Alkami.DevOps.Validations/Alkami.DevOps.Validations.nuspec
new file mode 100644
index 0000000..791da77
--- /dev/null
+++ b/Modules/Alkami.DevOps.Validations/Alkami.DevOps.Validations.nuspec
@@ -0,0 +1,27 @@
+
+
+
+ Alkami.DevOps.Validations
+ $version$
+ Alkami Platform Modules - DevOps - Validations
+ Alkami Technologies
+ Alkami Technologies
+ https://extranet.alkamitech.com/display/ORB/Alkami.DevOps.Validations
+ https://www.alkami.com/files/alkamilogo75x75.png
+ http://alkami.com/files/orblicense.html
+ false
+ Installs the Alkami DevOps Validations module for use with PowerShell.
+
+ PowerShell
+ Copyright (c) 2020 Alkami Technologies
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Validations/Alkami.DevOps.Validations.psd1 b/Modules/Alkami.DevOps.Validations/Alkami.DevOps.Validations.psd1
new file mode 100644
index 0000000..1217bfb
--- /dev/null
+++ b/Modules/Alkami.DevOps.Validations/Alkami.DevOps.Validations.psd1
@@ -0,0 +1,19 @@
+@{
+ RootModule = 'Alkami.DevOps.Validations.psm1'
+ ModuleVersion = '1.4.5'
+ GUID = 'd3b31a83-eb01-4d3a-a0af-a37b08b4bf5b'
+ Author = 'SRE,jcoburn'
+ CompanyName = 'Alkami Technologies, Inc.'
+ Copyright = '(c) 2020 Alkami Technologies, Inc.. All rights reserved.'
+ Description = 'Functions used to validate ORB deployment success using web requests.'
+ FileList = @('Resources\ObjectAsTableTemplate.html')
+ FunctionsToExport = 'Get-CoreUrlsToTest','Get-LoginsToTest','Get-WebTestLogPath','Invoke-Endpoint','Invoke-WebTests','Reset-WebTestLogFolder','Set-RedisToken','Start-WebTests','Test-HaveAccountCount','Test-HaveContentThatMatches','Test-HaveResponseHeader','Test-HaveStatusCode','Test-Passthrough','Test-Should'
+ PrivateData = @{
+ PSData = @{
+ Tags = @('powershell', 'module', 'validations', 'smoke', 'smoketest')
+ ProjectUri = 'https://extranet.alkamitech.com/display/SRE/Alkami.DevOps.Validations+Module'
+ IconUri = 'https://www.alkami.com/files/alkamilogo75x75.png'
+ }
+ }
+ HelpInfoURI = 'https://extranet.alkamitech.com/display/SRE/Alkami.DevOps.Validations+Module'
+}
diff --git a/Modules/Alkami.DevOps.Validations/AlkamiManifest.xml b/Modules/Alkami.DevOps.Validations/AlkamiManifest.xml
new file mode 100644
index 0000000..abf7751
--- /dev/null
+++ b/Modules/Alkami.DevOps.Validations/AlkamiManifest.xml
@@ -0,0 +1,12 @@
+
+
+ 1.0
+
+ Alkami
+ Alkami.DevOps.Validations
+ SREModule
+
+
+ Production
+
+
diff --git a/Modules/Alkami.DevOps.Validations/Public/Get-CoreUrlsToTest.ps1 b/Modules/Alkami.DevOps.Validations/Public/Get-CoreUrlsToTest.ps1
new file mode 100644
index 0000000..bd718da
--- /dev/null
+++ b/Modules/Alkami.DevOps.Validations/Public/Get-CoreUrlsToTest.ps1
@@ -0,0 +1,49 @@
+function Get-CoreUrlsToTest {
+ <#
+ .SYNOPSIS
+ Gets the URLs of the websites to test
+
+ .DESCRIPTION
+ Currently pulls from S3 bucket to get a .json file of URLs.
+
+ .PARAMETER AwsProfile
+ Aws profile for connecting to S3
+
+ .NOTES
+ Returns a JSON string
+ #>
+ [cmdletbinding()]
+ [OutputType([System.String])]
+ Param (
+ [Parameter(Mandatory = $true)]
+ [string]$AwsProfile
+ )
+
+ $logLead = (Get-LogLeadName)
+ $environmentType = $AwsProfile.Replace("temp-","").ToLower()
+ $bucketName = "alkami-devops-validations-$environmentType"
+ $key = "coreurls.json"
+ $fileName = [System.IO.Path]::GetRandomFileName()
+
+ $S3Script = {
+ param($sbBucketName, $sbKey, $sbFileName, $sbAwsProfile)
+ (Read-S3Object -BucketName $sbBucketName -Key $sbKey -File $sbFileName -ProfileName $sbAwsProfile) | Out-Null
+ }
+
+ try{
+ Write-Host "$logLead Getting file from S3."
+ (Invoke-CommandWithRetry -Arguments ($bucketName, $key, $fileName, $AwsProfile) -MaxRetries 3 -Exponential -ScriptBlock $S3Script)
+ Write-Host "$logLead Getting content from file."
+ $contentJson = Get-Content -Path ".\$fileName" -Raw -ErrorAction SilentlyContinue
+ if($null -eq $contentJson) {
+ throw "Could not get Json content from file!"
+ }
+ } finally {
+ if(Test-Path ".\$fileName" -PathType Leaf) {
+ Write-Host "$logLead Removing file [$fileName]"
+ (Remove-FileSystemItem -Path ".\$fileName") | Out-Null
+ }
+ }
+
+ return ($contentJson | Out-String)
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Validations/Public/Get-LoginsToTest.ps1 b/Modules/Alkami.DevOps.Validations/Public/Get-LoginsToTest.ps1
new file mode 100644
index 0000000..cfb4746
--- /dev/null
+++ b/Modules/Alkami.DevOps.Validations/Public/Get-LoginsToTest.ps1
@@ -0,0 +1,49 @@
+function Get-LoginsToTest {
+ <#
+ .SYNOPSIS
+ Gets logins to perform validation web tests against.
+
+ .DESCRIPTION
+ Currently pulls from S3 bucket to get a .json file of logins.
+
+ .PARAMETER AwsProfile
+ Aws profile for connecting to S3
+
+ .NOTES
+ Returns a JSON string
+ #>
+ [cmdletbinding()]
+ [OutputType([System.String])]
+ Param (
+ [Parameter(Mandatory = $true)]
+ [string]$AwsProfile
+ )
+
+ $logLead = (Get-LogLeadName)
+ $environmentType = $AwsProfile.Replace("temp-","").ToLower()
+ $bucketName = "alkami-devops-validations-$environmentType"
+ $key = "logins.json"
+ $fileName = [System.IO.Path]::GetRandomFileName()
+
+ $S3Script = {
+ param($sbBucketName, $sbKey, $sbFileName, $sbAwsProfile)
+ (Read-S3Object -BucketName $sbBucketName -Key $sbKey -File $sbFileName -ProfileName $sbAwsProfile) | Out-Null
+ }
+
+ try{
+ Write-Host "$logLead Getting file from S3."
+ (Invoke-CommandWithRetry -Arguments ($bucketName, $key, $fileName, $AwsProfile) -MaxRetries 3 -Exponential -ScriptBlock $S3Script)
+ Write-Host "$logLead Getting content from file."
+ $contentJson = Get-Content -Path ".\$fileName" -Raw -ErrorAction SilentlyContinue
+ if($null -eq $contentJson) {
+ throw "Could not get Json content from file!"
+ }
+ } finally {
+ if(Test-Path ".\$fileName" -PathType Leaf) {
+ Write-Host "$logLead Removing file [$fileName]"
+ (Remove-FileSystemItem -Path ".\$fileName") | Out-Null
+ }
+ }
+
+ return ($contentJson | Out-String)
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Validations/Public/Get-WebTestLogPath.ps1 b/Modules/Alkami.DevOps.Validations/Public/Get-WebTestLogPath.ps1
new file mode 100644
index 0000000..efe9df4
--- /dev/null
+++ b/Modules/Alkami.DevOps.Validations/Public/Get-WebTestLogPath.ps1
@@ -0,0 +1,63 @@
+function Get-WebTestLogPath {
+<#
+.SYNOPSIS
+ Get the web test log path for logging data mid-run so we can track what happens on disk during runtime testing.
+ This function is intended to be used in ancillary test logging.
+
+.DESCRIPTION
+ The impetus for this change is that some things were breaking unexpectedly during testing and crashing the entire host.
+ We needed a way to track where it was crashing the host mid-process. Hence this function and ..
+ Write-* "message"
+ becomes
+ "message" | Tee-Object -Append -Path $thisPath | Write-*
+
+.PARAMETER BankUrl
+ [Required] [string] The bank url to log for
+
+.PARAMETER Widget
+ [Optional] [string] The widget to log for.
+#>
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory = $true)]
+ [Alias("LogName")]
+ [string]$BankUrl,
+
+ [Parameter(Mandatory = $false)]
+ [string]$Widget
+ )
+
+ $logFilePath = (Join-Path (Get-OrbLogsPath) WebTests)
+ if (!(Test-Path $logFilePath)) {
+ New-Item -ItemType Directory -Path $logFilePath -Force
+ }
+
+ $logUrlFilename = $BankUrl
+ if ($BankUrl.ToLower().StartsWith("http")) {
+ $uri = [System.Uri]::new($BankUrl)
+ $logUrlFilename = $uri.DnsSafeHost
+
+ # If the widget wasn't passed in
+ if ([string]::IsNullOrWhiteSpace($Widget)) {
+ # and the url had a path
+ $potentialWidget = $uri.LocalPath.Split('/')[1]
+ if (![string]::IsNullOrWhiteSpace($potentialWidget)) {
+ # grab the first part of that path as the widget name
+ $Widget = $potentialWidget
+ }
+ }
+ }
+
+ $baseLogUrlFilename = $logUrlFilename
+ if (![string]::IsNullOrWhiteSpace($Widget)) {
+ $logUrlFilename = "$logUrlFilename.$Widget"
+ }
+
+ $finalLogFilePath = (Join-Path $logFilePath "$logUrlFilename.log")
+
+ if (!(Test-Path $finalLogFilePath)) {
+ $finalLogFilePath = (Join-Path $logFilePath "$baseLogUrlFilename.log")
+ }
+
+ return $finalLogFilePath
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Validations/Public/Invoke-Endpoint.ps1 b/Modules/Alkami.DevOps.Validations/Public/Invoke-Endpoint.ps1
new file mode 100644
index 0000000..ebe635b
--- /dev/null
+++ b/Modules/Alkami.DevOps.Validations/Public/Invoke-Endpoint.ps1
@@ -0,0 +1,190 @@
+function Invoke-Endpoint {
+ <#
+ .SYNOPSIS
+ Invokes a website and returns the response of the URL
+
+ .DESCRIPTION
+ Wraps Invoke-WebRequest with retry logic
+
+ .PARAMETER Uri
+ URI of the website to invoke
+
+ .PARAMETER Method
+ HTTP method, Get, Post, etc.
+
+ .PARAMETER Baseuri
+ Optional parameter taking the base Uri and concatinating with Uri. "$Baseuri$Uri".
+ Helper to avoid having to concatinate before invoking site.
+
+ .PARAMETER retryStatusCodes
+ [System.Net.HttpStatusCode][] objects that are eligible for retry logic.
+ [System.Net.HttpStatusCode]::ServiceUnavailable is default.
+
+ .PARAMETER RetryDelay
+ Seconds to wait between retries. Defaults to 5. Is exponential backoff.
+
+ .PARAMETER RetryCount
+ Total number of retry attempts before failing.
+
+ .PARAMETER Body
+ Used for POST Method
+
+ .PARAMETER Headers
+ String of Http headers to pass to the request.
+
+ .PARAMETER WebSession
+ Shared session to use for subsequent requests
+
+ .PARAMETER UseNewSession
+ Bool to determine if a new blank session will be used and returned instead of the passed-in WebSession.
+
+ .PARAMETER TimeoutSeconds
+ Int in seconds for the web timeout.
+
+ .EXAMPLE
+ $r = Invoke-Endpoint -Uri http://google.com
+ #>
+ [cmdletbinding()]
+ param(
+ [Parameter(Mandatory, ValueFromPipeline)]
+ $Uri,
+ [Parameter(Mandatory = $false)]
+ $Method = 'GET',
+ [Parameter(Mandatory = $false)]
+ $Baseuri = '',
+ [Parameter(Mandatory=$false)]
+ [System.Net.HttpStatusCode[]] $retryStatusCodes,
+ [Parameter(Mandatory=$false)]
+ $RetryDelay = 5,
+ [Parameter(Mandatory=$false)]
+ $RetryCount = 3,
+ [Parameter(Mandatory=$false)]
+ $Body = $null,
+ [Parameter(Mandatory=$false)]
+ $Headers = $null,
+ [Parameter(Mandatory=$false)]
+ $WebSession = $null,
+ [Parameter(Mandatory=$false)]
+ [bool]$UseNewSession = $false,
+ [Parameter(Mandatory=$false)]
+ [int]$TimeoutSeconds = 120
+ )
+ begin {
+ $auldProgressPreference = $ProgressPreference
+ $ProgressPreference = 'silentlycontinue'
+ }
+ process {
+ $logLead = (Get-LogLeadName)
+ $logFilePath = (Get-WebTestLogPath -BankUrl "$Baseuri$Uri")
+
+ $retryCounter = 0
+
+ $totalRuntime = 0
+ $errorsArray = @()
+ $runtimeArray = @()
+ $result = $null
+
+ if($null -eq $retryStatusCodes) {
+ $retryStatusCodes = @(
+ [System.Net.HttpStatusCode]::BadGateway,
+ [System.Net.HttpStatusCode]::BadRequest,
+ [System.Net.HttpStatusCode]::GatewayTimeout,
+ [System.Net.HttpStatusCode]::InternalServerError,
+ [System.Net.HttpStatusCode]::ServiceUnavailable
+ )
+ }
+
+ do {
+ $StopWatch = [System.Diagnostics.StopWatch]::StartNew()
+ try {
+ "$logLead : Performing web request to $($baseuri)$($uri)" | Tee-OutFile -Append -FilePath $logFilePath | Write-Verbose
+ if($UseNewSession) {
+ $result = Invoke-WebRequest "$Baseuri$Uri" -Method $Method -Body $Body -Headers $Headers -SessionVariable NewWebSession -UseBasicParsing -TimeoutSec $TimeoutSeconds -DisableKeepAlive -UserAgent "SRE Web Test"
+ $WebSession = $NewWebSession
+ } else {
+ $result = Invoke-WebRequest "$Baseuri$Uri" -Method $Method -Body $Body -Headers $Headers -WebSession $WebSession -UseBasicParsing -TimeoutSec $TimeoutSeconds -DisableKeepAlive -UserAgent "SRE Web Test"
+ }
+ # On success, stop executing
+ break
+ }
+ catch [System.Net.WebException] {
+ "$logLead : Exception: $_" | Tee-OutFile -Append -FilePath $logFilePath | Write-Verbose
+ $errorsArray += $_.Exception.Message
+ if ($null -eq $_.Exception.Response -and $_.Exception.Message -eq "The operation has timed out.") {
+ "$logLead : No response object found. Request timed out." | Tee-OutFile -Append -FilePath $logFilePath | Write-Verbose
+ $retryCounter += 1
+ $hasRetriedEnough = $retryCounter -gt ($retryCount)
+ if ($hasRetriedEnough) {
+ "$logLead : Returning response object" | Tee-OutFile -Append -FilePath $logFilePath | Write-Verbose
+ break
+ } else {
+ $sleepTime = $retryDelay*$retryCounter
+ "$logLead : Sleeping for $sleepTime seconds" | Tee-OutFile -Append -FilePath $logFilePath | Write-Verbose
+ Start-Sleep -Seconds $sleepTime
+ $resp = $null
+ }
+ } else {
+ $resp = $_.Exception.Response
+ "$logLead : Response code: $($resp.StatusCode)" | Tee-OutFile -Append -FilePath $logFilePath | Write-Verbose
+ $retryCounter += 1
+ $isARetryCode = $retryStatusCodes -contains $resp.StatusCode
+ "$logLead : Is retry code: $isARetryCode" | Tee-OutFile -Append -FilePath $logFilePath | Write-Verbose
+ $hasRetriedEnough = $retryCounter -gt ($retryCount)
+ "$logLead : Has retried enough: $hasRetriedEnough" | Tee-OutFile -Append -FilePath $logFilePath | Write-Verbose
+ if ($hasRetriedEnough -or(-not($isARetryCode))) {
+ "$logLead : Returning response object" | Tee-OutFile -Append -FilePath $logFilePath | Write-Verbose
+ break
+ } else {
+ $sleepTime = $retryDelay*$retryCounter
+ "$logLead : Sleeping for $sleepTime seconds" | Tee-OutFile -Append -FilePath $logFilePath | Write-Verbose
+ Start-Sleep -Seconds $sleepTime
+ }
+ }
+ }
+ catch {
+ $errorsArray += $_.Exception.Message
+ "$logLead : Got a generic exception. Quitting immediately" | Tee-OutFile -Append -FilePath $logFilePath | Write-Verbose
+ break
+ } finally {
+ $StopWatch.Stop()
+ $totalRuntime += $StopWatch.ElapsedMilliseconds
+ $runtimeArray += $StopWatch.Elapsed.ToString("ss\.fffffff")
+
+ $isSuccess = $true
+
+ if($result.StatusCode -ne 200){
+ if($null -eq $resp){
+ # If we got a timeout, send a null result
+ Write-Verbose "$logLead : Request timed out."
+ $result = $null
+ } else {
+ # If we got a failure response, map the most recent exception's data onto the result and give that back.
+ Write-Verbose "$logLead : Got a $($resp.StatusCode)"
+ $result = $resp
+ }
+
+ $isSuccess = $false
+ }
+
+ $responseObject = @{
+ ErrorResults = $errorsArray;
+ LastError = $errorsArray[-1];
+ HasErrors = $errorsArray.Count -gt 0;
+ Result = $result;
+ Success = $isSuccess;
+ StatusCode = $result.StatusCode;
+ WebSession = $WebSession;
+ Runtimes = $runtimeArray;
+ LastRuntime = $runtimeArray[-1];
+ TotalRuntime = [System.TimeSpan]::FromMilliseconds($totalRuntime).ToString()
+ }
+ $result = $null
+ }
+ } while ($true)
+
+ return $responseObject
+ }
+ end {
+ $ProgressPreference = $auldProgressPreference
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Validations/Public/Invoke-WebTests.ps1 b/Modules/Alkami.DevOps.Validations/Public/Invoke-WebTests.ps1
new file mode 100644
index 0000000..773b8ef
--- /dev/null
+++ b/Modules/Alkami.DevOps.Validations/Public/Invoke-WebTests.ps1
@@ -0,0 +1,172 @@
+function Invoke-WebTests {
+ <#
+ .SYNOPSIS
+ Helper function that wraps Start-WebTests and sets up the variables needed for Start-WebTests.
+
+ .DESCRIPTION
+ This is intended to be used from a TeamCity agent as it uses Invoke-Command -Script {Start-WebTests}
+ which are to be run on each web-tier server.
+
+ .PARAMETER Servers
+ String CSV of servers to run the Start-WebTests on.
+
+ .PARAMETER AwsProfile
+ Aws Profile to use for S3 bucket.
+
+ .PARAMETER EnvironmentType
+ Which environment (dev, prod) to run the tests on. Uses different URLs based on environmentType.
+
+ .PARAMETER SitesToSkip
+ A list of sites to skip web tests on. Http/https will be trimmed
+
+ .PARAMETER SkipWidgetWarmup
+ Bool to skip warming up the widgets with a throw-away http request to all configured widgets on a site.
+ Defaults to true (skip widget warmup).
+
+ .NOTES
+ Keeps track of all the tests and will return bool if they all pass or any fail.
+ return $PassedAllTests
+ #>
+ [cmdletbinding()]
+ [OutputType([System.Boolean])]
+ param(
+ [Parameter(Mandatory = $true)]
+ [string[]]$Servers,
+ [Parameter(Mandatory = $false)]
+ [string]$AwsProfile,
+ [Parameter(Mandatory = $false)]
+ [string[]]$SitesToSkip,
+ [Parameter(Mandatory = $false)]
+ [bool]$SkipWidgetWarmUp = $true,
+ [Parameter(Mandatory = $false)]
+ [int]$MaximumSitesToTest = 20,
+ [Parameter(Mandatory = $false)]
+ [int]$SiteThreads = 4,
+ [Parameter(Mandatory = $false)]
+ [int]$WidgetThreads = 8,
+ [Parameter(Mandatory = $false)]
+ [string[]]$ServerTargetOverride
+ )
+
+ if([string]::IsNullOrEmpty($AwsProfile)) {
+ $AwsProfile = "prod"
+ }
+
+ $logLead = (Get-LogLeadName)
+ $passedAllTests = $true
+
+ $sitesToSkipArray = @()
+ # We do a type check here to determine if this was called directly from TC or not. If yes, clean it up.
+ if($null -ne $SitesToSkip -and $SitesToSkip -match ",|\s+") {
+ Write-Host "Got called from TC directly. Doing the splits for sites to skip."
+ # Split out individual sites, divided by commas or spaces. Ignore blanks.
+ $sitesToSkipArray = ($SitesToSkip -split ',|\s+') | Where-Object {("" -ne $_)}
+ } else {
+ $sitesToSkipArray = $SitesToSkip
+ }
+
+ if (![String]::IsNullOrEmpty($ServerTargetOverride)) {
+ # Same type check here. Split override servers if there's more than one.
+ # Also split servers, because we're coming from TC, so it must be a string as well.
+ if(($Servers -match ",") -or ($ServerTargetOverride -match ",|\s+")) {
+ Write-Host "Got called from TC directly. Doing the splits for override servers."
+ [array]$overrideArray = ($ServerTargetOverride -split ',|\s+') | Where-Object {("" -ne $_)}
+ [array]$serverArray = $Servers.split(',')
+
+ Write-Host "$logLead : ServerArray $serverArray"
+ Write-Host "$logLead : OverrideArray $overrideArray"
+ } else {
+ Write-Host "Got Arrays, passing through"
+ [array]$overrideArray = $ServerTargetOverride
+ [array]$serverArray = $Servers
+ }
+
+ $unexpectedServers = $overrideArray.Where{ $_ -notin $serverArray }
+
+ if (($null -ne $unexpectedServers) -and ($unexpectedServers.Count -gt 0)) {
+ throw "$logLead : Received unexpected servers/values in the Server Target Override List. Re-examine your parameter list. Unexpected: $unexpectedServers"
+ } else {
+ $finalServers = $overrideArray
+ }
+ } else {
+ $finalServers = $Servers
+ }
+
+ # Get the logins
+ try{
+ $loginsJson = Get-LoginsToTest -AwsProfile $AwsProfile
+ } catch {
+ Write-Warning "$logLead : Exception getting logins to test!"
+ $ex = $_
+ Resolve-Error -ErrorRecord $ex
+ Write-Host "$logLead : Setting test results to failed, returning False."
+ return $false
+ }
+
+ # Get the Urls
+ try {
+ $coreUrlsJson = Get-CoreUrlsToTest -AwsProfile $AwsProfile
+ } catch {
+ Write-Warning "$logLead : Exception getting core URLs to test!"
+ $ex = $_
+ Resolve-Error -ErrorRecord $ex
+ Write-Host "$logLead : Setting test results to failed, returning False."
+ return $false
+ }
+
+ # Kick off the webtests on the servers
+ try {
+ $serverScriptArguments = @{
+ LoginsJson = $loginsJson
+ CoreUrlsJson = $coreUrlsJson
+ SitesToSkip = $sitesToSkipArray
+ MaximumSitesToTest = $MaximumSitesToTest
+ SkipWidgetWarmUp = $SkipWidgetWarmUp
+ SiteThreads = $SiteThreads
+ WidgetThreads = $WidgetThreads
+ }
+
+ $results = Invoke-ParallelServers -Servers $finalServers -ReturnObjects -Arguments $serverScriptArguments -Script {
+ param($inputArguments)
+
+ $server = (Get-FullyQualifiedServerName)
+
+ $sbLoginsJson = $inputArguments.LoginsJson
+ $sbCoreUrlsJson = $inputArguments.CoreUrlsJson
+ $sbSitesToSkip = $inputArguments.SitesToSkip
+ $sbMaximumSitesToTest = $inputArguments.MaximumSitesToTest
+ $sbSkipWidgetWarmUp = $inputArguments.SkipWidgetWarmUp
+ $sbSiteThreads = $inputArguments.SiteThreads
+ $sbWidgetThreads = $inputArguments.WidgetThreads
+
+ Write-Host "##teamcity[blockOpened name='$server']"
+
+ $result = (Start-WebTests -LoginsJson $sbLoginsJson `
+ -CoreUrlsJson $sbCoreUrlsJson `
+ -SitesToSkip $sbSitesToSkip `
+ -MaximumSitesToTest $sbMaximumSitesToTest `
+ -SkipWidgetWarmUp $sbSkipWidgetWarmup `
+ -SiteThreads $sbSiteThreads `
+ -WidgetThreads $sbWidgetThreads)
+
+ Write-Host "##teamcity[blockClosed name='$server']"
+
+ return $result
+ }
+
+ foreach($result in $results) {
+ if(!$result) {
+ $passedAllTests = $false
+ }
+ }
+ } catch {
+ Write-Warning "$logLead : Error during web tests!"
+ $ex = $_
+ Resolve-Error -ErrorRecord $ex
+ Write-Host "$logLead : Setting test results to failed, returning False."
+ $passedAllTests = $false
+ }
+
+ Write-Host "$logLead : Has passed all tests? : $passedAllTests"
+ return $passedAllTests
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Validations/Public/Reset-WebTestLogFolder.ps1 b/Modules/Alkami.DevOps.Validations/Public/Reset-WebTestLogFolder.ps1
new file mode 100644
index 0000000..f4dadd4
--- /dev/null
+++ b/Modules/Alkami.DevOps.Validations/Public/Reset-WebTestLogFolder.ps1
@@ -0,0 +1,42 @@
+function Reset-WebTestLogFolder {
+<#
+.SYNOPSIS
+ Reset the folder for web tests so this run is pristine
+
+.DESCRIPTION
+ The impetus for this change is that some things were breaking unexpectedly during testing and crashing the entire host.
+ We needed a way to track where it was crashing the host mid-process. Hence this function and ..
+ Write-* "message"
+ becomes
+ "message" | Tee-Object -Append -Path $thisPath | Write-*
+
+.PARAMETER BaseLogFileName
+ [Required] [string] The bank url to log for
+#>
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory = $true)]
+ [Alias("LogFileName")]
+ [string]$BaseLogFileName
+ )
+
+ $logFilePath = (Join-Path (Get-OrbLogsPath) WebTests)
+ if (!(Test-Path $logFilePath)) {
+ (New-Item -ItemType Directory -Path $logFilePath -Force) | Out-Null
+ }
+
+ $archiveFolderPath = (Join-Path $logFilePath Archive)
+ if (!(Test-Path $archiveFolderPath)) {
+ (New-Item -ItemType Directory -Path $archiveFolderPath -Force) | Out-Null
+ }
+
+ $baseLogFilePath = (Join-Path $logFilePath "$BaseLogFileName.log")
+
+ if (Test-Path $baseLogFilePath) {
+ $timestamp = (Get-Item $baseLogFilePath).CreationTime.ToString("yyyyMMddhhmm")
+ $zipFilePath = (Join-Path $archiveFolderPath "$BaseLogFileName.$timestamp.zip")
+ $files = (Get-ChildItem -File -Path $logFilePath).FullName
+ (Compress-Archive -DestinationPath $zipFilePath -Path $files -Force) | Out-Null
+ (Remove-Item -Path $files -Force) | Out-Null
+ }
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Validations/Public/Set-RedisToken.ps1 b/Modules/Alkami.DevOps.Validations/Public/Set-RedisToken.ps1
new file mode 100644
index 0000000..a9f34b4
--- /dev/null
+++ b/Modules/Alkami.DevOps.Validations/Public/Set-RedisToken.ps1
@@ -0,0 +1,68 @@
+function Set-RedisToken {
+ <#
+ .SYNOPSIS
+ Method to set a Redis token to simulate a login event from Synthetic user.
+
+ .PARAMETER BankUrl
+ Full URL of the bank we're going to auth to.
+
+ .PARAMETER UserName
+ Username of the Synthetic
+
+ .PARAMETER ComparisonOrbVersion
+ Supply a string of the orb version you want to compare against for determining redis key string formatting
+
+ .EXAMPLE
+ $nonce = Set-RedisToken -BankUrl https://cu1-red9.dev.alkamitech.com -UserName -mike.brady
+
+ .NOTES
+ Returns a guid representing the nonce that was inserted
+ #>
+ [cmdletbinding()]
+ param (
+ [Parameter(Mandatory = $true)]
+ $BankUrl,
+ [Parameter(Mandatory = $true)]
+ $UserName,
+ [Parameter(Mandatory = $false)]
+ $ComparisonOrbVersion = "2021.4"
+ )
+
+ $logLead = (Get-LogLeadName)
+ $logFilePath = (Get-WebTestLogPath -BankUrl $BankUrl)
+
+ $orbVersion = Get-OrbVersion
+ Write-Host "$loglead : orb version detected: $orbVersion" -ForegroundColor Green
+ $comparedSemVer = Compare-SemVer -Version1 $orbVersion -Version2 $ComparisonOrbVersion
+ Write-Host "$loglead : Compared SemVer Result: $comparedSemVer" -ForegroundColor Green
+ if($comparedSemVer -eq -1){
+ Write-Host "$loglead : Detected orb version is LESS than $ComparisonOrbVersion" -ForegroundColor Green
+ Write-Host "$loglead : Setting Redis Key with 'E:' prefix" -ForegroundColor Green
+ $key = "E:{${BankUrl}}:System.String:${UserName}__AUTHENTICATION_NONCE__"
+ } else {
+ Write-Host "$loglead : Detected orb version is Not LESS than $ComparisonOrbVersion" -ForegroundColor Green
+ Write-Host "$loglead : Setting Redis Key without 'E:' prefix" -ForegroundColor Green
+ $key = "{${BankUrl}}:System.String:${UserName}__AUTHENTICATION_NONCE__"
+ }
+
+ $guid = New-Guid
+ $nonce = "`"$guid`""
+
+ $redisConnectionString = Get-ConnectionString -name "RedisSetting"
+
+ "$logLead : key : $key" | Tee-OutFile -Append -FilePath $logFilePath | Write-Verbose
+ "$logLead : nonce : $nonce" | Tee-OutFile -Append -FilePath $logFilePath | Write-Verbose
+
+ try {
+ Invoke-RedisScript -ConnectionString $redisConnectionString -ScriptBlock {
+ $ttlTimespan = [System.TimeSpan]::new(0,10,0)
+ Add-RedisKey -Key $key -Value $nonce -TTL $ttlTimespan | Out-Null
+ }
+ } catch {
+ $_.exception | Tee-OutFile -Append -FilePath $logFilePath | Write-Error
+ }
+
+ # do we need to do an audit to the audit table?
+
+ return $guid
+}
\ No newline at end of file
diff --git a/Modules/Alkami.DevOps.Validations/Public/Start-WebTests.ps1 b/Modules/Alkami.DevOps.Validations/Public/Start-WebTests.ps1
new file mode 100644
index 0000000..4e318e3
--- /dev/null
+++ b/Modules/Alkami.DevOps.Validations/Public/Start-WebTests.ps1
@@ -0,0 +1,686 @@
+function Start-WebTests {
+<#
+.SYNOPSIS
+ Runs web tests on web-tier servers only.
+
+.DESCRIPTION
+ Site must be available in IIS. Keeps track of glogal pass/fail and returns a bool
+
+.PARAMETER LoginsJson
+ Json file containing logins to test with. Pulled from Get-LoginsToTest function.
+
+.PARAMETER CoreUrlsJson
+ Json file containing urls to test with. Pulled from Get-CoreUrlsToTest function.
+
+.PARAMETER SitesToSkip
+ A list (array) of sites to skip web tests on. Http/https will be trimmed
+#>
+ [CmdletBinding()]
+ [OutputType([System.Boolean])]
+ param(
+ [Parameter(Mandatory)]
+ [string]$LoginsJson,
+ [Parameter(Mandatory)]
+ [string]$CoreUrlsJson,
+ [Parameter(Mandatory = $false)]
+ [bool]$SkipAdmin = $false,
+ [Parameter(Mandatory = $false)]
+ [bool]$SkipAccountCount = $true,
+ [Parameter(Mandatory = $false)]
+ [bool]$SkipWidgetWarmUp = $true,
+ [Parameter(Mandatory = $false)]
+ [int]$MaximumSitesToTest = 20,
+ [Parameter(Mandatory = $false)]
+ [string[]]$SitesToSkip,
+ [Parameter(Mandatory = $false)]
+ [int]$SiteThreads = 4,
+ [Parameter(Mandatory = $false)]
+ [int]$WidgetThreads = 8
+ )
+ begin {
+ $previousGlobalVerbosity = $global:VerbosePreference
+ # Quiet the logs for now.
+ # $global:VerbosePreference = 'Continue'
+ }
+ process {
+ $logLead = (Get-LogLeadName)
+ $WebTestBaseName = "Start-WebTests"
+ (Reset-WebTestLogFolder -BaseLogFileName $WebTestBaseName)
+ $logFilePath = (Get-WebTestLogPath -LogName $WebTestBaseName)
+
+ # Only run on Web-tier servers.
+ if(!(Test-IsWebServer)) {
+ "$logLead : Current server is not a Web-tier server. Skipping." | Tee-OutFile -Append -FilePath $logFilePath | Write-Warning
+ return $true
+ }
+
+ Import-Module -Name PsRedis -Global
+
+ # Variables for setup
+ $logins = ($LoginsJson | ConvertFrom-Json)
+ $coreurls = ($CoreUrlsJson | ConvertFrom-Json)
+
+ # Get all of the URLs in the file that are on this web server.
+ $siteNames = ((Get-IISSiteList).Bindings.Host).Where({!(Test-IsCollectionNullOrEmpty $logins.$_)}) | Sort-Object -Unique
+ $siteNamesFormatted = ($siteNames | Format-Table -AutoSize | Out-String)
+ "$logLead : Sites available to test:`n$siteNamesFormatted" | Tee-OutFile -Append -FilePath $logFilePath | Write-Host
+ "$logLead : SitesToSkip: $SitesToSkip" | Tee-OutFile -Append -FilePath $logFilePath | Write-Host
+
+ if(!(Test-IsCollectionNullOrEmpty $SitesToSkip))
+ {
+ # Trim any leading "http" nonsense, if necessary.
+ $SitesToSkip = $SitesToSkip | ForEach-Object {
+ try {
+ if ($_.StartsWith("http")) {
+ $site = $_
+ [System.Uri]::new($_).Host
+ } else { $_ }
+ }
+ catch {
+ "$logLead : $site could not be parsed as a site to skip. It is being ignored." | Tee-OutFile -Append -FilePath $logFilePath | Write-Warning
+ Resolve-Error
+ }
+ }
+
+ $siteNames = $siteNames | Where-Object{$_ -notin $SitesToSkip}
+ "$logLead : Site Names after exclusions: $siteNames" | Tee-OutFile -Append -FilePath $logFilePath | Write-Host
+ }
+
+ # If no sites found that match on this webserver, return.
+ if(Test-IsCollectionNullOrEmpty $siteNames) {
+ "$logLead : No sites to test on this web server!" | Tee-OutFile -Append -FilePath $logFilePath | Write-Warning
+ return $true
+ }
+
+ # Limit the number of sites to test.
+ $siteNames = ($siteNames | Get-Random -Count $MaximumSitesToTest)
+ $siteNamesFormatted = ($siteNames | Format-Table -AutoSize | Out-String)
+ "$logLead : Limiting the number of sites to test to $MaximumSitesToTest. Actual number of sites under test is $($siteNames.Count)." | Tee-OutFile -Append -FilePath $logFilePath | Write-Host
+ "$logLead : Limited Site Names:`n$siteNamesFormatted" | Tee-OutFile -Append -FilePath $logFilePath | Write-Host
+
+ $allTestResults = @()
+
+ $StopWatch = [system.diagnostics.StopWatch]::StartNew()
+ "$logLead : Starting Web Tests" | Tee-OutFile -Append -FilePath $logFilePath | Write-Host
+
+ if($SkipAdmin) {
+ "$logLead : Skipping Admin Tests" | Tee-OutFile -Append -FilePath $logFilePath | Write-Host
+ }
+ if($SkipAccountCount) {
+ "$logLead : Skipping Account Count Tests" | Tee-OutFile -Append -FilePath $logFilePath | Write-Host
+ }
+ if($SkipWidgetWarmUp) {
+ "$logLead : Skipping Site Widget Warm Up" | Tee-OutFile -Append -FilePath $logFilePath | Write-Host
+ }
+
+ # Output some inputs
+ "$logLead : Maximum Sites To Test: $MaximumSitesToTest" | Tee-OutFile -Append -FilePath $logFilePath | Write-Host
+ "$logLead : Site Threads: $SiteThreads" | Tee-OutFile -Append -FilePath $logFilePath | Write-Host
+ "$logLead : Widget Threads: $WidgetThreads" | Tee-OutFile -Append -FilePath $logFilePath | Write-Host
+
+ # Iterate over the sites and get Synthetic accounts from $logins
+ $siteTestResult = Invoke-Parallel -Objects $siteNames -ReturnObjects -ThreadPerObject -NumThreads $SiteThreads -Arguments @($logins, $coreurls, $SkipAdmin, $SkipAccountCount, $SkipWidgetWarmUp, $WidgetThreads) -script {
+ Param(
+ $siteName,
+ $inputArguments
+ )
+ try {
+
+ $logLead = "[Invoke-Parallel `$siteNames ($siteName)]"
+
+ $logFilePath = (Get-WebTestLogPath -BankUrl $siteName)
+
+ $sb_logins = $inputArguments[0]
+ $sb_coreurls = $inputArguments[1]
+ $sb_skipAdmin = $inputArguments[2]
+ $sb_skipAccountCount = $inputArguments[3]
+ $sb_skipWidgetWarmUp = $inputArguments[4]
+ $sb_widgetThreads = $inputArguments[5]
+
+ $siteLogins = @($sb_logins.$siteName)
+
+ if (Test-IsCollectionNullOrEmpty $siteLogins) {
+ "$logLead : No logins to find, can't test the site!" | Tee-OutFile -Append -FilePath $logFilePath | Write-Warning
+ $allTestResults += (Test-Should -Result $adminResponse -Predicate ${function:Test-Passthrough} -Widget "Site Logins" -Route "Logins not available" -Login @{UrlSignature = $siteName; Username = "" } $true "There was no corresponding site entry for running this test against. Can not verify if the site is up or down.")
+
+ continue
+ }
+
+ $siteTestResults = @()
+
+ if (!$sb_skipWidgetWarmUp) {
+ # Iterate over all of the widgets to warm them up.
+ $widgetStartResult = Invoke-Parallel -Objects $siteLogins.Widgets -ReturnObjects -ThreadPerObject -NumThreads $sb_widgetThreads -Arguments @($siteLogins.UrlSignature) -script {
+ Param(
+ $widgetName,
+ $inputArguments
+ )
+ try {
+ $logLead = "[Invoke-Parallel `$siteLogins.Widgets ($widgetName)]"
+
+ $urlSignature = $inputArguments[0]
+ $urlSignature = "https://$urlSignature/"
+ $status = "unknown"
+
+ $logFilePath = (Get-WebTestLogPath -BankUrl $urlSignature)
+
+ "$logLead : Widget Warmup $urlSignature$widgetName" | Tee-OutFile -Append -FilePath $logFilePath | Write-Verbose
+ $result = (Invoke-Endpoint -Baseuri $urlSignature -Uri $widgetName)
+ $status = $result.StatusCode
+
+ if ([string]::IsNullOrWhiteSpace($status)) {
+ $status = $result.LastError
+ }
+
+ if ([string]::IsNullOrWhiteSpace($status)) {
+ $status = "Unknown"
+ }
+
+ $resultWidgetObj = New-Object PSObject -Property $([ordered]@{
+ FunctionName = "Site Warmup"
+ ReturnValue = $true
+ ReturnMessage = "Status code: $status"
+ Username = $null
+ Url = $urlSignature
+ Route = $widgetName
+ Widget = "Site Warmup"
+ Parameters = $null
+ Runtime = $result.TotalRuntime
+ MachineName = (Get-FullyQualifiedServerName)
+ })
+ return $resultWidgetObj
+
+ } catch {
+ Write-Warning "$logLead : Failed to properly warm up widgets."
+ Write-Warning "$logLead : $_"
+
+ $resultWidgetObj = New-Object PSObject -Property $([ordered]@{
+ FunctionName = "Site Warmup"
+ ReturnValue = $false
+ ReturnMessage = $_.Exception.Message
+ Username = $null
+ Url = $inputArguments[0]
+ Route = $widgetName
+ Widget = "Site Warmup"
+ Parameters = $null
+ Runtime = $null
+ MachineName = (Get-FullyQualifiedServerName)
+ })
+
+ return $resultWidgetObj
+ }
+ }
+ $siteTestResults += $widgetStartResult
+
+ # Give the machine a break.
+ Start-Sleep -Seconds 3
+ }
+
+ # Each site base-url only has one admin site url to test
+ # Let's test that one first so we don't loop it when we loop users
+ # Cole says these will always be the same for a given FI in the above json
+ if (!$sb_skipAdmin) {
+ $adminSignature = ($siteLogins | Select-Object -First 1).AdminUrlSignature
+
+ # Test admin variant of URL.
+ $fakeAdminLogin = @{UrlSignature = $adminSignature; Username = "" } # This is for logging purposes only
+
+ $bankAdminUrl = "https://$($adminSignature)"
+ "$logLead : Testing Admin URL : $bankAdminUrl" | Tee-OutFile -Append -FilePath $logFilePath | Write-Verbose
+ $adminResponse = Invoke-Endpoint -Uri $bankAdminUrl
+ if ($adminResponse.HasErrors -and !($adminResponse.success)) {
+ $resultObj = New-Object PSObject -Property $([ordered]@{
+ FunctionName = "Invoke-Endpoint"
+ ReturnValue = $false
+ ReturnMessage = $adminResponse.LastError
+ Username = $null
+ Url = $authUrl
+ Route = $null
+ Widget = "Admin Site Warmup"
+ Parameters = $null
+ Runtime = $adminResponse.TotalRuntime
+ MachineName = (Get-FullyQualifiedServerName)
+ })
+ $siteTestResults += $resultObj
+ "$logLead : Error caught for Admin URL: $authUrl. Skipping tests!" | Tee-OutFile -Append -FilePath $logFilePath | Write-Warning
+ "$logLead : Exception: $($adminResponse.LastError)" | Tee-OutFile -Append -FilePath $logFilePath | Write-Warning
+ }
+
+ if ($adminResponse.Success) {
+ "$logLead : Running test Test-HaveStatusCode." | Tee-OutFile -Append -FilePath $logFilePath | Write-Verbose
+ $testResult = (Test-Should -Result $adminResponse.Result -Predicate ${function:Test-HaveStatusCode} -Widget "Admin" -Route "Admin" -Login $fakeAdminLogin 200)
+ $siteTestResults += $testResult
+
+ "$logLead : Running test Test-HaveResponseHeader." | Tee-OutFile -Append -FilePath $logFilePath | Write-Verbose
+ $testResult = (Test-Should -Result $adminResponse.Result -Predicate ${function:Test-HaveResponseHeader} -Widget "Admin" -Route "Admin" -Login $fakeAdminLogin 'Content-Type' 'text/html')
+ $siteTestResults += $testResult
+
+ # This is awful, but it's less awful than the rest of the options. TR
+ # Could I oneliner it? Yes. Would that be harder to maintain - or even read - in 6 months? Definitely. TR
+ $Content = $adminResponse.Result.Content
+ $newLine = [System.Environment]::NewLine
+ $lines = $Content.Split($newLine)
+ $iframe = $lines.Where( { $_ -match "