diff --git a/dbatools.psd1 b/dbatools.psd1 index 84e0e920592..10a17e07651 100644 --- a/dbatools.psd1 +++ b/dbatools.psd1 @@ -435,13 +435,14 @@ 'Import-DbaSpConfigure', 'Import-DbatoolsConfig', 'Import-DbaXESessionTemplate', + 'Install-DbaCommunitySoftware', 'Install-DbaDarlingData', 'Install-DbaFirstResponderKit', 'Install-DbaInstance', - 'Install-DbaMaintenanceSolution', - 'Install-DbaMultiTool', - 'Install-DbaParquet', - 'Install-DbaSqlPackage', + 'Install-DbaMaintenanceSolution', + 'Install-DbaMultiTool', + 'Install-DbaParquet', + 'Install-DbaSqlPackage', 'Install-DbaSqlWatch', 'Install-DbaWhoIsActive', 'Invoke-DbaDbAzSqlTip', @@ -659,9 +660,9 @@ 'Set-DbatoolsPath', 'Show-DbaDbList', 'Show-DbaInstanceFileSystem', - 'Start-DbaAgentJob', - "Start-DbaAzMigration", - 'Start-DbaEndpoint', + 'Start-DbaAgentJob', + "Start-DbaAzMigration", + 'Start-DbaEndpoint', 'Start-DbaMigration', 'Start-DbaPfDataCollectorSet', 'Start-DbaService', @@ -683,7 +684,7 @@ 'Test-DbaAgPolicyState', 'Test-DbaAvailabilityGroup', 'Test-DbaBackupInformation', - 'Test-DbaBackupStorageCompatibility', + 'Test-DbaBackupStorageCompatibility', 'Test-DbaBuild', 'Test-DbaCmConnection', 'Test-DbaComputerCertificateExpiration', diff --git a/dbatools.psm1 b/dbatools.psm1 index a066b71222b..8d0b48a3564 100644 --- a/dbatools.psm1 +++ b/dbatools.psm1 @@ -879,6 +879,7 @@ $script:xplat = @( 'Export-DbaServerRole', 'Get-DbaBuild', 'Update-DbaBuildReference', + 'Install-DbaCommunitySoftware', 'Install-DbaFirstResponderKit', 'Install-DbaSqlPackage', 'Install-DbaWhoIsActive', diff --git a/public/Install-DbaCommunitySoftware.ps1 b/public/Install-DbaCommunitySoftware.ps1 new file mode 100644 index 00000000000..78b06ffb60f --- /dev/null +++ b/public/Install-DbaCommunitySoftware.ps1 @@ -0,0 +1,312 @@ +function Install-DbaCommunitySoftware { + <# + .SYNOPSIS + Installs community-maintained SQL Server tooling through a single command + + .DESCRIPTION + Installs one or more of the community stored procedure kits that dbatools ships installers for, without needing to remember six separate command names. This is the install-side counterpart to Save-DbaCommunitySoftware, which already unifies the download step behind one -Software parameter. + + Each tool is handed off to its dedicated installer, and the objects that installer emits are passed straight back to you: + + - MaintenanceSolution: Install-DbaMaintenanceSolution (Ola Hallengren), SQL Server 2017 and later only + - FirstResponderKit: Install-DbaFirstResponderKit (Brent Ozar Unlimited) + - DarlingData: Install-DbaDarlingData (Erik Darling) + - SQLWATCH: Install-DbaSqlWatch (Marcin Gminski) + - WhoIsActive: Install-DbaWhoIsActive (Adam Machanic) + - DbaMultiTool: Install-DbaMultiTool (John McCall) + + Three behaviors deliberately differ from calling an installer yourself: + + - SQLWATCH is skipped with a warning on PowerShell Core, and the rest of the batch still runs. Install-DbaSqlWatch supports Windows PowerShell only, and left to itself it downloads its payload before reaching that check, so selecting All from PowerShell Core would fetch a file it can never use. + + - WhoIsActive is given master when you do not pass Database. Called directly with no database, Install-DbaWhoIsActive opens an interactive picker, which would stall an unattended run. + - A failure against one tool does not end the batch. The remaining tools still run, and the failure surfaces as a warning naming the tool. With EnableException the first failure throws, as it would anywhere else in dbatools. + + Each installer is called once with the whole instance list rather than once per instance, because they all download their payload before touching the first instance. Passing ten instances therefore fetches each archive once, not ten times. + + A single script that fails partway through is reported rather than thrown. FirstResponderKit, DarlingData and DbaMultiTool catch a failed script themselves, warn, and hand back that row with a Status of Error, so EnableException does not make it terminating here any more than it does when you call those installers directly. Check Status on the returned objects to find them. + + Everything else, including each installer version floor and its own confirmation prompts, is exactly what you get from the installer directly. An instance below a tool version floor is skipped by that installer with a warning while the rest of the batch continues. + + Only the parameters common to most of the installers are surfaced here. When you need tool-specific options such as the Ola Hallengren job scheduling switches, the First Responder Kit script selection, or the SqlWatch pre-release feed, call that installer directly. + + AzSqlTips is deliberately absent. Save-DbaCommunitySoftware downloads it, but it is consumed by Invoke-DbaDbAzSqlTip as a query rather than installed as stored procedures. + + .PARAMETER SqlInstance + The target SQL Server instance or instances. This can be a collection and receive pipeline input to allow the function to be executed against multiple SQL Server instances. + + .PARAMETER SqlCredential + Login to the target instance using alternative credentials. Accepts PowerShell credentials (Get-Credential). + + Windows Authentication, SQL Server Authentication, Active Directory - Password, and Active Directory - Integrated are all supported. + + For MFA support, please use Connect-DbaInstance. + + .PARAMETER Software + Specifies which community tools to install. Accepts multiple values, or All to install every tool in one pass. + + The names match Save-DbaCommunitySoftware so the download and install steps read the same way. + + .PARAMETER Database + Specifies the database to install the tools into. When you leave this off, each installer keeps its own default, which means master for most tools and SQLWATCH for SqlWatch. + + Set this when you want every selected tool in one specific database, such as a dedicated DBA utility database. + + .PARAMETER Branch + Specifies the source branch to download from. Only First Responder Kit, DarlingData and DbaMultiTool support branch selection, and their accepted values differ, so a value valid for one may be rejected by another. + + A warning names any selected tool that has no branch to switch. + + .PARAMETER LocalFile + Specifies a zip archive or SQL script to install from instead of downloading. Use this on servers with no internet access. + + Get the archive from the project release page on a machine that does have access and copy it across. Save-DbaCommunitySoftware does not produce a file for this: it consumes LocalFile the same way, to refresh the local cache. The release page for each tool is listed in the Save-DbaCommunitySoftware help. + + Because an archive belongs to exactly one tool, this can only be combined with a single Software value. + + .PARAMETER Force + If this switch is enabled, the local cached copy of each tool is refreshed before installing and confirmation prompts are suppressed. + + .PARAMETER WhatIf + Shows what would happen if the command were to run. No actions are actually performed. + + .PARAMETER Confirm + Prompts you for confirmation before executing any changing operations within the command. + + .PARAMETER EnableException + By default, when something goes wrong we try to catch it, interpret it and give you a friendly warning message. + This avoids overwhelming you with "sea of red" exceptions, but is inconvenient because it basically disables advanced scripting. + Using this switch turns this "nice by default" feature off and enables you to catch exceptions with your own try/catch. + + .OUTPUTS + PSCustomObject + + Objects are passed through from each installer unchanged, so the property set follows the tool rather than this command. Selecting several tools returns a mix of the shapes below, in the order the tools were requested. All three shapes share ComputerName, InstanceName and SqlInstance, so a mixed batch still groups and formats on those. + + FirstResponderKit, DarlingData, DbaMultiTool and WhoIsActive return one object per script the installer ran: + + - ComputerName (String): The name of the computer where the SQL Server instance resides + - InstanceName (String): The name of the SQL Server instance + - SqlInstance (String): The full SQL Server instance name (computer\instance) + - Database (String): The name of the database the script was installed into + - Name (String): The script base name, such as sp_Blitz or sp_BlitzCache. DarlingData installs from one combined script and so returns a single row named DarlingData; WhoIsActive returns a single row named sp_WhoisActive + - Status (String): Installed when the object was created, Updated when it already existed, Skipped when the script does not apply to that instance version, Error when that script failed. DbaMultiTool never reports Skipped, and WhoIsActive reports only Installed or Updated + - Version (String): WhoIsActive only. The sp_WhoisActive version read out of the installed script, or an empty string when the script carries no version header + + MaintenanceSolution returns one object per instance, not per script, and has no Database, Name or Status: + + - ComputerName (String): The name of the computer where the SQL Server instance resides + - InstanceName (String): The name of the SQL Server instance + - SqlInstance (String): The full SQL Server instance name (computer\instance) + - Results (String): Success or Failed, covering the whole solution install on that instance + + SQLWATCH returns one object per instance and has no Name: + + - ComputerName (String): The name of the computer where the SQL Server instance resides + - InstanceName (String): The name of the SQL Server instance + - SqlInstance (String): The full SQL Server instance name (computer\instance) + - Database (String): The name of the database SqlWatch was published to, SQLWATCH unless you pass Database + - Status (System.Text.RegularExpressions.Match): The last parenthesized fragment of the DACPAC publish result, which renders as its matched text + - DashboardPath (String): The full local file system path to the SqlWatch Dashboard directory + + Note: an instance a tool refuses, such as MaintenanceSolution against anything below SQL Server 2017, produces a warning from that installer and no object, while the rest of the batch continues. + + .NOTES + Tags: Community, Install, MaintenanceSolution, FirstResponderKit, DarlingData, SqlWatch, WhoIsActive, DbaMultiTool + Author: the dbatools team + Claude + + Website: https://dbatools.io + Copyright: (c) 2026 by dbatools, licensed under MIT + License: MIT https://opensource.org/licenses/MIT + + .LINK + https://dbatools.io/Install-DbaCommunitySoftware + + .EXAMPLE + PS C:\> Install-DbaCommunitySoftware -SqlInstance sql2017 -Software All + + Installs every supported community tool on sql2017. Each tool lands in its own default database, so SqlWatch goes to SQLWATCH and the rest go to master. + + .EXAMPLE + PS C:\> Install-DbaCommunitySoftware -SqlInstance sql2017 -Software FirstResponderKit, WhoIsActive -Database DBAtools + + Installs the First Responder Kit and sp_WhoIsActive into the DBAtools database on sql2017. + + .EXAMPLE + PS C:\> Install-DbaCommunitySoftware -SqlInstance sql2017, sql2019 -Software MaintenanceSolution, DarlingData -Force + + Refreshes the local cached copies and installs the Ola Hallengren Maintenance Solution and DarlingData on both instances, without prompting for confirmation. Both instances are SQL Server 2017 or later, which the Maintenance Solution requires. + + .EXAMPLE + PS C:\> Install-DbaCommunitySoftware -SqlInstance sql2017 -Software WhoIsActive -LocalFile C:\temp\sp_whoisactive.zip + + Installs sp_WhoIsActive on sql2017 from an already downloaded file, for a server with no internet access. LocalFile takes exactly one tool at a time. + + .EXAMPLE + PS C:\> Get-Content C:\servers.txt | Install-DbaCommunitySoftware -Software FirstResponderKit -WhatIf + + Shows what the First Responder Kit install would do on every instance listed in servers.txt, without changing anything. + #> + [CmdletBinding(SupportsShouldProcess, ConfirmImpact = "Medium")] + param ( + [Parameter(Mandatory, ValueFromPipeline)] + [DbaInstance[]]$SqlInstance, + [PSCredential]$SqlCredential, + [Parameter(Mandatory)] + [ValidateSet("MaintenanceSolution", "FirstResponderKit", "DarlingData", "SQLWATCH", "WhoIsActive", "DbaMultiTool", "All")] + [string[]]$Software, + [ValidateNotNullOrEmpty()] + [string]$Database, + [string]$Branch, + [string]$LocalFile, + [switch]$Force, + [switch]$EnableException + ) + + begin { + $commandMap = @{ + MaintenanceSolution = "Install-DbaMaintenanceSolution" + FirstResponderKit = "Install-DbaFirstResponderKit" + DarlingData = "Install-DbaDarlingData" + SQLWATCH = "Install-DbaSqlWatch" + WhoIsActive = "Install-DbaWhoIsActive" + DbaMultiTool = "Install-DbaMultiTool" + } + + $canonicalSoftware = @("MaintenanceSolution", "FirstResponderKit", "DarlingData", "SQLWATCH", "WhoIsActive", "DbaMultiTool") + + if ($Software -contains "All") { + $resolvedSoftware = $canonicalSoftware + } else { + # ValidateSet accepts any casing but Select-Object -Unique compares case-sensitively on + # both editions, so "WhoIsActive, whoisactive" would survive as two entries and install + # twice. Fold each name onto its supported spelling and drop repeats, keeping the + # requested order so the messages below read the way the caller typed the command. + $resolvedSoftware = @() + foreach ($requested in $Software) { + $matchedSoftware = $canonicalSoftware | Where-Object { $PSItem -eq $requested } + if ($resolvedSoftware -notcontains $matchedSoftware) { + $resolvedSoftware += $matchedSoftware + } + } + } + + $requestedSoftware = $resolvedSoftware + + # Install-DbaSqlWatch refuses to run on PowerShell Core, but its own check sits in process + # while the download it needs happens in begin, so calling it there refreshes the cache over + # the network and only then fails. Drop it up front rather than pay for that on every run. + if ($PSEdition -eq "Core" -and $resolvedSoftware -contains "SQLWATCH") { + Write-Message -Level Warning -Message "Install-DbaSqlWatch does not support PowerShell Core, so SQLWATCH was skipped. Run it from Windows PowerShell instead." + $resolvedSoftware = @($resolvedSoftware | Where-Object { $PSItem -ne "SQLWATCH" }) + + if ($resolvedSoftware.Count -eq 0) { + Stop-Function -Message "SQLWATCH was the only tool selected and it needs Windows PowerShell, so there is nothing left to install." + return + } + } + + # Counted against what the caller asked for rather than what survived the Core filter + # above. The archive belongs to one named tool, so two names are ambiguous however many + # of them can run on this edition, and dropping SQLWATCH must never leave WhoIsActive + # holding a SqlWatch zip. + if ((Test-Bound -ParameterName LocalFile) -and $requestedSoftware.Count -gt 1) { + $softwareList = $requestedSoftware -join ", " + Stop-Function -Message "LocalFile points at a file for one tool, so it cannot be combined with $($requestedSoftware.Count) values ($softwareList). Run the command once per tool, or leave LocalFile off to download each one." + return + } + + # A whitespace name is never a real database, and letting it through would hand WhoIsActive + # the master default below, quietly writing to a database the caller never asked for. + if ((Test-Bound -ParameterName Database) -and [string]::IsNullOrWhiteSpace($Database)) { + Stop-Function -Message "Database is only whitespace, which is not a database name. Pass the name you want, or leave Database off to let each installer use its own default." + return + } + + if ($Force) { $ConfirmPreference = "none" } + + $splatForward = @{ + SqlCredential = $SqlCredential + Database = $Database + Branch = $Branch + LocalFile = $LocalFile + Force = $Force + EnableException = $EnableException + } + + foreach ($key in @($splatForward.Keys)) { + if (-not $PSBoundParameters.ContainsKey($key)) { + $null = $splatForward.Remove($key) + } + } + + # Cache each installer parameter list once so the per-instance loop can drop what a + # tool does not accept - Branch in particular exists on only three of the six. + $parameterMap = @{ } + foreach ($tool in $resolvedSoftware) { + $installer = Get-Command -Name $commandMap[$tool] -ErrorAction SilentlyContinue + if (-not $installer) { + Stop-Function -Message "$($commandMap[$tool]) is not available in this session, so $tool cannot be installed. Reimport dbatools and try again." + return + } + $parameterMap[$tool] = @($installer.Parameters.Keys) + } + + $targetInstances = @() + } + + process { + if (Test-FunctionInterrupt) { return } + + # Collect rather than install. Every installer downloads its payload in begin and loops + # instances in process, so one call per instance would fetch the same archive once per + # target - and with Force, redownload it every time. + foreach ($instance in $SqlInstance) { + $targetInstances += $instance + } + } + + end { + if (Test-FunctionInterrupt) { return } + + $instanceList = $targetInstances -join ", " + + foreach ($tool in $resolvedSoftware) { + $commandName = $commandMap[$tool] + + $splatInstall = @{ + SqlInstance = $targetInstances + } + + foreach ($key in $splatForward.Keys) { + if ($parameterMap[$tool] -contains $key) { + $splatInstall[$key] = $splatForward[$key] + } + } + + if ($splatForward.ContainsKey("Branch") -and $parameterMap[$tool] -notcontains "Branch") { + Write-Message -Level Warning -Message "$commandName installs from a single source, so Branch was ignored for $tool." + } + + # Install-DbaWhoIsActive has no Database default: left unbound its own "-not $Database" + # test opens an interactive Show-DbaDbList picker that would stall an unattended batch. + if ($tool -eq "WhoIsActive" -and -not $splatInstall.ContainsKey("Database")) { + $splatInstall["Database"] = "master" + } + + Write-Message -Level Verbose -Message "Installing $tool on $instanceList with $commandName" + + try { + & $commandName @splatInstall + } catch { + $splatInstallFailure = @{ + Message = "Failed to install $tool" + ErrorRecord = $PSItem + Target = $targetInstances + Continue = $true + } + Stop-Function @splatInstallFailure + } + } + } +} \ No newline at end of file diff --git a/tests/Install-DbaCommunitySoftware.Tests.ps1 b/tests/Install-DbaCommunitySoftware.Tests.ps1 new file mode 100644 index 00000000000..00e27087480 --- /dev/null +++ b/tests/Install-DbaCommunitySoftware.Tests.ps1 @@ -0,0 +1,519 @@ +#Requires -Module @{ ModuleName="Pester"; ModuleVersion="5.0" } +param( + $ModuleName = "dbatools", + $CommandName = "Install-DbaCommunitySoftware", + $PSDefaultParameterValues = $TestConfig.Defaults +) + +Describe $CommandName -Tag UnitTests { + Context "Parameter validation" { + It "Should have the expected parameters" { + $hasParameters = (Get-Command $CommandName).Parameters.Values.Name | Where-Object { $PSItem -notin ("WhatIf", "Confirm") } + $expectedParameters = $TestConfig.CommonParameters + $expectedParameters += @( + "SqlInstance", + "SqlCredential", + "Software", + "Database", + "Branch", + "LocalFile", + "Force", + "EnableException" + ) + Compare-Object -ReferenceObject $expectedParameters -DifferenceObject $hasParameters | Should -BeNullOrEmpty + } + + It "Should reject a software name that has no installer" { + # AzSqlTips is downloadable through Save-DbaCommunitySoftware but is queried by + # Invoke-DbaDbAzSqlTip rather than installed, so it is deliberately out of the set. + $splatBadSoftware = @{ + SqlInstance = "NotARealInstance" + Software = "AzSqlTips" + EnableException = $true + } + { + Install-DbaCommunitySoftware @splatBadSoftware + } | Should -Throw + } + + It "Rejects an empty database name" { + # An empty value would reach Install-DbaWhoIsActive's own "-not $Database" test and + # open its interactive picker, which would hang an unattended run. + $splatEmptyDatabase = @{ + SqlInstance = "NotARealInstance" + Software = "WhoIsActive" + Database = "" + EnableException = $true + } + { + Install-DbaCommunitySoftware @splatEmptyDatabase + } | Should -Throw + } + + It "Rejects a whitespace database name" { + # ValidateNotNullOrEmpty lets whitespace through, so this reaches the command's own + # guard rather than the attribute, and the message proves which one caught it. + $splatWhitespaceDatabase = @{ + SqlInstance = "NotARealInstance" + Software = "WhoIsActive" + Database = " " + EnableException = $true + } + { + Install-DbaCommunitySoftware @splatWhitespaceDatabase + } | Should -Throw -ExpectedMessage "*Database is only whitespace*" + } + } + + Context "LocalFile guard" { + # These run before any connection or download is attempted, so they need no instance. + + It "Throws when LocalFile is combined with more than one tool" { + $splatMultiple = @{ + SqlInstance = "NotARealInstance" + Software = "FirstResponderKit", "WhoIsActive" + LocalFile = "C:\temp\dbatoolsci_notreal.zip" + EnableException = $true + } + { + Install-DbaCommunitySoftware @splatMultiple + } | Should -Throw -ExpectedMessage "*cannot be combined with 2 values*" + } + + It "Warns instead of throwing when EnableException is not used" { + $splatWarn = @{ + SqlInstance = "NotARealInstance" + Software = "FirstResponderKit", "WhoIsActive" + LocalFile = "C:\temp\dbatoolsci_notreal.zip" + WarningVariable = "localFileWarning" + WarningAction = "SilentlyContinue" + } + $localFileResults = Install-DbaCommunitySoftware @splatWarn + $localFileResults | Should -BeNullOrEmpty + $localFileWarning | Should -Match "cannot be combined with 2 values" + } + + It "Expands All to every installable tool" { + $splatAll = @{ + SqlInstance = "NotARealInstance" + Software = "All" + LocalFile = "C:\temp\dbatoolsci_notreal.zip" + WarningVariable = "allWarning" + WarningAction = "SilentlyContinue" + } + $null = Install-DbaCommunitySoftware @splatAll + + # Two warnings can land here on Core, so join them rather than matching a collection. + $allWarningText = $allWarning -join " " + $allWarningText | Should -Match "cannot be combined with 6 values" + $allWarningText | Should -Match "MaintenanceSolution, FirstResponderKit, DarlingData, SQLWATCH, WhoIsActive, DbaMultiTool" + } + + It "Counts the requested tools even when Core drops one of them" -Skip:($PSEdition -ne "Core") { + # SQLWATCH is filtered out on Core before this guard is reached. Counting survivors + # would leave one tool standing and hand a SqlWatch archive to Install-DbaWhoIsActive. + $splatCoreLocalFile = @{ + SqlInstance = "NotARealInstance" + Software = "SQLWATCH", "WhoIsActive" + LocalFile = "C:\temp\dbatoolsci_sqlwatch.zip" + EnableException = $true + } + { + Install-DbaCommunitySoftware @splatCoreLocalFile -WarningAction SilentlyContinue + } | Should -Throw -ExpectedMessage "*cannot be combined with 2 values*" + } + } +} + +Describe $CommandName -Tag IntegrationTests -Skip:([bool]$env:appveyor) { + # Skip IntegrationTests on AppVeyor because the underlying installers fail there for unknown reasons. + + Context "Installing more than one tool in a single call" { + BeforeAll { + # We want to run all commands in the BeforeAll block with EnableException to ensure that the test fails if the setup fails. + $PSDefaultParameterValues["*-Dba*:EnableException"] = $true + + $multiToolDb = "dbatoolsci_community_$(Get-Random)" + $null = New-DbaDatabase -SqlInstance $TestConfig.InstanceSingle -Name $multiToolDb + + $splatMultiTool = @{ + SqlInstance = $TestConfig.InstanceSingle + Software = "FirstResponderKit", "DarlingData" + Database = $multiToolDb + Force = $true + Verbose = $false + } + $multiToolResults = Install-DbaCommunitySoftware @splatMultiTool + + # We want to run all commands outside of the BeforeAll block without EnableException to be able to test for specific warnings. + $PSDefaultParameterValues.Remove("*-Dba*:EnableException") + } + + AfterAll { + # We want to run all commands in the AfterAll block with EnableException to ensure that the test fails if the cleanup fails. + $PSDefaultParameterValues["*-Dba*:EnableException"] = $true + + $splatCleanupMultiTool = @{ + SqlInstance = $TestConfig.InstanceSingle + Database = $multiToolDb + ErrorAction = "SilentlyContinue" + } + Remove-DbaDatabase @splatCleanupMultiTool + + $PSDefaultParameterValues.Remove("*-Dba*:EnableException") + } + + It "Returns rows from the First Responder Kit" { + @($multiToolResults.Name -match "sp_Blitz").Count | Should -BeGreaterThan 0 + } + + It "Returns rows from DarlingData in the same call" { + # DarlingData installs from one combined script, so it emits a single row named + # for the kit rather than one row per procedure the way the other tools do. + @($multiToolResults.Name -eq "DarlingData").Count | Should -Be 1 + } + + It "Installs every tool into the requested database" { + @($multiToolResults.Database | Select-Object -Unique) | Should -Be $multiToolDb + } + + It "Reports no failures" { + @($multiToolResults | Where-Object Status -eq "Error") | Should -BeNullOrEmpty + } + } + + Context "Handing the whole instance list to each installer" { + BeforeAll { + $PSDefaultParameterValues["*-Dba*:EnableException"] = $true + + $multiInstanceDb = "dbatoolsci_community_$(Get-Random)" + $null = New-DbaDatabase -SqlInstance $TestConfig.InstanceMulti1 -Name $multiInstanceDb + $null = New-DbaDatabase -SqlInstance $TestConfig.InstanceMulti2 -Name $multiInstanceDb + + $splatMultiInstance = @{ + SqlInstance = $TestConfig.InstanceMulti1, $TestConfig.InstanceMulti2 + Software = "WhoIsActive" + Database = $multiInstanceDb + Force = $true + } + # Verbose is captured rather than silenced here: the dispatch message is the only + # observable proof that the installer was entered once instead of once per instance. + $multiInstanceStreams = Install-DbaCommunitySoftware @splatMultiInstance -Verbose 4>&1 + $multiInstanceDispatch = @($multiInstanceStreams | Where-Object { $PSItem -is [System.Management.Automation.VerboseRecord] -and $PSItem.Message -match "Installing WhoIsActive on" }) + $multiInstanceResults = @($multiInstanceStreams | Where-Object { $PSItem -isnot [System.Management.Automation.VerboseRecord] }) + + $PSDefaultParameterValues.Remove("*-Dba*:EnableException") + } + + AfterAll { + $PSDefaultParameterValues["*-Dba*:EnableException"] = $true + + Remove-DbaDatabase -SqlInstance $TestConfig.InstanceMulti1 -Database $multiInstanceDb -ErrorAction SilentlyContinue + Remove-DbaDatabase -SqlInstance $TestConfig.InstanceMulti2 -Database $multiInstanceDb -ErrorAction SilentlyContinue + + $PSDefaultParameterValues.Remove("*-Dba*:EnableException") + } + + It "Enters the installer once no matter how many instances are targeted" { + # Every installer downloads in begin, so a second entry would mean a second download. + $multiInstanceDispatch.Count | Should -Be 1 + } + + It "Installs on both instances from that one call" { + $multiInstanceResults.Count | Should -Be 2 + @($multiInstanceResults.SqlInstance | Select-Object -Unique).Count | Should -Be 2 + @($multiInstanceResults.Name | Select-Object -Unique) | Should -Be "sp_WhoisActive" + } + } + + Context "Passing a single tool straight through" { + BeforeAll { + $PSDefaultParameterValues["*-Dba*:EnableException"] = $true + + $singleToolDb = "dbatoolsci_community_$(Get-Random)" + $null = New-DbaDatabase -SqlInstance $TestConfig.InstanceSingle -Name $singleToolDb + + $splatSingleTool = @{ + SqlInstance = $TestConfig.InstanceSingle + Software = "WhoIsActive" + Database = $singleToolDb + Force = $true + Verbose = $false + } + $singleToolResults = Install-DbaCommunitySoftware @splatSingleTool + + $splatMixedCase = @{ + SqlInstance = $TestConfig.InstanceSingle + Software = "WhoIsActive", "whoisactive" + Database = $singleToolDb + Force = $true + Verbose = $false + } + $mixedCaseResults = Install-DbaCommunitySoftware @splatMixedCase + + $PSDefaultParameterValues.Remove("*-Dba*:EnableException") + } + + AfterAll { + $PSDefaultParameterValues["*-Dba*:EnableException"] = $true + + $splatCleanupSingleTool = @{ + SqlInstance = $TestConfig.InstanceSingle + Database = $singleToolDb + ErrorAction = "SilentlyContinue" + } + Remove-DbaDatabase @splatCleanupSingleTool + + $PSDefaultParameterValues.Remove("*-Dba*:EnableException") + } + + It "Returns the installer output unchanged" { + $singleToolResults.Name | Should -Be "sp_WhoisActive" + $singleToolResults.Database | Should -Be $singleToolDb + $singleToolResults.SqlInstance | Should -Not -BeNullOrEmpty + } + + It "Runs a differently cased duplicate only once" { + # ValidateSet accepts any casing, so the deduplication has to be case-insensitive + # or the same installer runs twice against the same database. + @($mixedCaseResults).Count | Should -Be 1 + } + } + + Context "Defaulting WhoIsActive to master when Database is omitted" { + BeforeAll { + $PSDefaultParameterValues["*-Dba*:EnableException"] = $true + + $defaultServer = Connect-DbaInstance -SqlInstance $TestConfig.InstanceSingle + $masterDatabase = $defaultServer.Databases["master"] + + # The install replaces dbo.sp_WhoisActive in place, and a copy already sitting in master + # may be someone's own. Its full state cannot be put back afterwards - an encrypted + # procedure has no recoverable definition, and permissions granted on it do not survive + # a drop - so leave it alone entirely and skip instead. The schema has to be part of the + # lookup: a procedure of the same name under another schema is not the one we overwrite. + $whoIsActivePreExisted = [bool]$masterDatabase.StoredProcedures["sp_WhoisActive", "dbo"] + + $defaultDatabaseResults = $null + if (-not $whoIsActivePreExisted) { + $splatDefaultDatabase = @{ + SqlInstance = $TestConfig.InstanceSingle + Software = "WhoIsActive" + Force = $true + Verbose = $false + } + $defaultDatabaseResults = Install-DbaCommunitySoftware @splatDefaultDatabase + } + + $PSDefaultParameterValues.Remove("*-Dba*:EnableException") + } + + AfterAll { + $PSDefaultParameterValues["*-Dba*:EnableException"] = $true + + # Only ever remove a procedure this test created. Dropped through SMO rather than + # DROP PROCEDURE IF EXISTS, which needs SQL Server 2016. + if (-not $whoIsActivePreExisted) { + $masterDatabase.StoredProcedures.Refresh() + $installedWhoIsActive = $masterDatabase.StoredProcedures["sp_WhoisActive", "dbo"] + if ($installedWhoIsActive) { + $installedWhoIsActive.Drop() + } + } + + $PSDefaultParameterValues.Remove("*-Dba*:EnableException") + } + + It "Installs into master rather than stalling on the interactive picker" { + if ($whoIsActivePreExisted) { + Set-ItResult -Skipped -Because "master.dbo.sp_WhoisActive already exists and this test will not overwrite an object it did not create" + return + } + $defaultDatabaseResults.Database | Should -Be "master" + $defaultDatabaseResults.Name | Should -Be "sp_WhoisActive" + } + } + + Context "Skipping SQLWATCH on PowerShell Core" { + BeforeAll { + $PSDefaultParameterValues["*-Dba*:EnableException"] = $true + + $coreSkipDb = "dbatoolsci_community_$(Get-Random)" + $null = New-DbaDatabase -SqlInstance $TestConfig.InstanceSingle -Name $coreSkipDb + + $PSDefaultParameterValues.Remove("*-Dba*:EnableException") + + # Save-DbaCommunitySoftware deletes and recreates this directory every time it runs, + # so its write time is the sentinel for whether SqlWatch downloaded anything. Force + # below makes Install-DbaSqlWatch refresh it unconditionally, so an unchanged + # timestamp - or a directory that still does not exist - can only mean the installer + # was never entered. + $sqlWatchCachePath = Join-Path -Path (Get-DbatoolsConfigValue -FullName "Path.DbatoolsData") -ChildPath "SQLWATCH" + $sqlWatchCacheBefore = Get-Item -Path $sqlWatchCachePath -ErrorAction SilentlyContinue + + # Only run the call on Core. Under Windows PowerShell this would start a real SqlWatch + # DACPAC deployment, which is far heavier than anything else in this file. + $coreSkipResults = $null + $sqlWatchCacheAfter = $null + if ($PSEdition -eq "Core") { + $splatCoreSkip = @{ + SqlInstance = $TestConfig.InstanceSingle + Software = "SQLWATCH", "WhoIsActive" + Database = $coreSkipDb + Force = $true + WarningVariable = "coreSkipWarning" + WarningAction = "SilentlyContinue" + } + $coreSkipResults = Install-DbaCommunitySoftware @splatCoreSkip + $sqlWatchCacheAfter = Get-Item -Path $sqlWatchCachePath -ErrorAction SilentlyContinue + } + } + + AfterAll { + $PSDefaultParameterValues["*-Dba*:EnableException"] = $true + + $splatCleanupCoreSkip = @{ + SqlInstance = $TestConfig.InstanceSingle + Database = $coreSkipDb + ErrorAction = "SilentlyContinue" + } + Remove-DbaDatabase @splatCleanupCoreSkip + + $PSDefaultParameterValues.Remove("*-Dba*:EnableException") + } + + It "Warns that SQLWATCH needs Windows PowerShell" -Skip:($PSEdition -ne "Core") { + $coreSkipWarning | Should -Match "SQLWATCH was skipped" + } + + It "Installs the supported tools in the same call" -Skip:($PSEdition -ne "Core") { + @($coreSkipResults).Count | Should -Be 1 + $coreSkipResults.Name | Should -Be "sp_WhoisActive" + } + + It "Never enters the SqlWatch installer at all" -Skip:($PSEdition -ne "Core") { + # Install-DbaSqlWatch downloads in begin and only then hits its own Core check, so + # its refusal message appearing would mean the download had already happened. + $coreSkipWarning | Should -Not -Match "PowerShell Core is not supported" + + # The absent message alone would also fit a download that failed before warning, so + # check the cache itself. Compare write times rather than creation times: Windows + # file system tunneling can carry a creation time onto a recreated directory. + if ($null -eq $sqlWatchCacheBefore) { + $sqlWatchCacheAfter | Should -BeNullOrEmpty + } else { + $sqlWatchCacheAfter.LastWriteTime | Should -Be $sqlWatchCacheBefore.LastWriteTime + } + } + } + + Context "Continuing past an instance that cannot be reached" { + BeforeAll { + $PSDefaultParameterValues["*-Dba*:EnableException"] = $true + + $continueDb = "dbatoolsci_community_$(Get-Random)" + $null = New-DbaDatabase -SqlInstance $TestConfig.InstanceSingle -Name $continueDb + + $PSDefaultParameterValues.Remove("*-Dba*:EnableException") + + # The unreachable host goes first on purpose: it must fail before the real + # instance is reached, so a row back from the real one proves the batch continued. + $splatContinue = @{ + SqlInstance = "dbatoolsci_no_such_host", $TestConfig.InstanceSingle + Software = "WhoIsActive" + Database = $continueDb + WarningAction = "SilentlyContinue" + } + $continueResults = Install-DbaCommunitySoftware @splatContinue + } + + AfterAll { + $PSDefaultParameterValues["*-Dba*:EnableException"] = $true + + $splatCleanupContinue = @{ + SqlInstance = $TestConfig.InstanceSingle + Database = $continueDb + ErrorAction = "SilentlyContinue" + } + Remove-DbaDatabase @splatCleanupContinue + + $PSDefaultParameterValues.Remove("*-Dba*:EnableException") + } + + It "Still installs on the instance that follows the failure" { + @($continueResults).Count | Should -Be 1 + $continueResults.Database | Should -Be $continueDb + } + + It "Reports no row for the instance that failed" { + @($continueResults.SqlInstance) | Should -Not -Contain "dbatoolsci_no_such_host" + } + + It "Throws on the first failure when EnableException is used" { + $splatContinueException = @{ + SqlInstance = "dbatoolsci_no_such_host", $TestConfig.InstanceSingle + Software = "WhoIsActive" + Database = $continueDb + EnableException = $true + } + { + Install-DbaCommunitySoftware @splatContinueException + } | Should -Throw + } + } + + Context "Branch and WhatIf handling" { + BeforeAll { + $PSDefaultParameterValues["*-Dba*:EnableException"] = $true + + $whatIfDb = "dbatoolsci_community_$(Get-Random)" + $null = New-DbaDatabase -SqlInstance $TestConfig.InstanceSingle -Name $whatIfDb + $whatIfServer = Connect-DbaInstance -SqlInstance $TestConfig.InstanceSingle + + $PSDefaultParameterValues.Remove("*-Dba*:EnableException") + + $splatNoBranch = @{ + SqlInstance = $TestConfig.InstanceSingle + Software = "WhoIsActive" + Branch = "main" + WhatIf = $true + WarningVariable = "branchWarning" + WarningAction = "SilentlyContinue" + } + $null = Install-DbaCommunitySoftware @splatNoBranch + + $splatWhatIf = @{ + SqlInstance = $TestConfig.InstanceSingle + Software = "WhoIsActive" + Database = $whatIfDb + WhatIf = $true + } + $null = Install-DbaCommunitySoftware @splatWhatIf + + # A freshly created database has no user procedures at all, so any row here + # means WhatIf let the install through. + $whatIfProcedures = $whatIfServer.Query("SELECT COUNT(*) AS ProcCount FROM $whatIfDb.sys.procedures") + } + + AfterAll { + $PSDefaultParameterValues["*-Dba*:EnableException"] = $true + + $splatCleanupWhatIf = @{ + SqlInstance = $TestConfig.InstanceSingle + Database = $whatIfDb + ErrorAction = "SilentlyContinue" + } + Remove-DbaDatabase @splatCleanupWhatIf + + $PSDefaultParameterValues.Remove("*-Dba*:EnableException") + } + + It "Warns that Branch was ignored for a tool that has no branch to switch" { + $branchWarning | Should -Match "Branch was ignored for WhoIsActive" + } + + It "Installs nothing under WhatIf" { + $whatIfProcedures.ProcCount | Should -Be 0 + } + } +}