diff --git a/.claude/hooks/validate-style.ps1 b/.claude/hooks/validate-style.ps1 index 5478bfe190a..18780887a78 100644 --- a/.claude/hooks/validate-style.ps1 +++ b/.claude/hooks/validate-style.ps1 @@ -26,6 +26,7 @@ $lines = $content -split "`n" # Track state for multi-line constructs $inHereStringSingle = $false $inHereStringDouble = $false +$inBlockComment = $false $inHashtable = $false $hashtableLines = @() $hashtableStart = 0 @@ -33,6 +34,8 @@ $misalignedHashtables = @() # Patterns (using double quotes with escaping) $patternComment = "^\s*#" +$patternBlockCommentStart = "<#" +$patternBlockCommentEnd = "#>" $patternHereStringSingleStart = "@'" $patternHereStringDoubleStart = "@`"" $patternHereStringSingleEnd = "^'@" @@ -55,7 +58,12 @@ $patternTrailingSpace = "\s+$" for ($i = 0; $i -lt $lines.Count; $i++) { $line = $lines[$i].TrimEnd("`r") $lineNum = $i + 1 - $isComment = $line -match $patternComment + + # Track block-comment state (<# ... #>), which $patternComment (line starting with #) does not cover + $lineIsBlockComment = $inBlockComment + if ($line -match $patternBlockCommentStart) { $inBlockComment = $true; $lineIsBlockComment = $true } + if ($line -match $patternBlockCommentEnd) { $inBlockComment = $false; $lineIsBlockComment = $true } + $isComment = ($line -match $patternComment) -or $lineIsBlockComment # Track here-string state if ($line -match $patternHereStringSingleStart) { $inHereStringSingle = $true } diff --git a/public/Save-DbaCommunitySoftware.ps1 b/public/Save-DbaCommunitySoftware.ps1 index 0aa706e1e31..39ed9a857db 100644 --- a/public/Save-DbaCommunitySoftware.ps1 +++ b/public/Save-DbaCommunitySoftware.ps1 @@ -21,8 +21,9 @@ function Save-DbaCommunitySoftware { * AzSqlTips: https://github.com/microsoft/azure-sql-tips/releases/ .PARAMETER Software - Name of the software to download. + Name of the software to download. Accepts an array to download several tools in one call, or All to download every supported tool. Options include: + * All: Downloads every tool listed below. * MaintenanceSolution: SQL Server Maintenance Solution created by Ola Hallengren (https://ola.hallengren.com) * FirstResponderKit: First Responder Kit created by Brent Ozar (http://FirstResponderKit.org) * DarlingData: Erik Darling's stored procedures (https://www.erikdarlingdata.com) @@ -31,10 +32,12 @@ function Save-DbaCommunitySoftware { * DbaMultiTool: John McCall's T-SQL scripts for the long haul: optimizing storage, on-the-fly documentation, and general administrative needs (https://dba-multitool.org) * AzSqlTips: Azure SQL PM team scripts to review Azure SQL Database design, health and performance. + Url, LocalFile and LocalDirectory only apply to a single tool, so they cannot be combined with more than one Software value, including All. + .PARAMETER Branch - Specifies which branch or version to download from the GitHub repository. Defaults to master or main depending on the repository. + Specifies which branch or version to download from the GitHub repository. Defaults to main. Use this when you need a specific development branch or to override default versioning. Only applies to branch-based downloads like MaintenanceSolution, FirstResponderKit, DarlingData, and DbaMultiTool. - For SQLWATCH, use 'prerelease' or 'pre-release' to get preview versions instead of stable releases. + For SQLWATCH, use "prerelease" or "pre-release" to get preview versions instead of stable releases. .PARAMETER LocalFile Specifies the path to a local zip file or SQL script to install from instead of downloading from GitHub. @@ -88,11 +91,21 @@ function Save-DbaCommunitySoftware { Updates the local cache of the First Responder Kit based on the given file. + .EXAMPLE + PS C:\> Save-DbaCommunitySoftware -Software MaintenanceSolution, FirstResponderKit, DarlingData + + Updates the local cache of each of the three named tools. + + .EXAMPLE + PS C:\> Save-DbaCommunitySoftware -Software All + + Updates the local cache of every supported community tool. + #> [CmdletBinding(SupportsShouldProcess, ConfirmImpact = "Medium")] param ( - [ValidateSet('MaintenanceSolution', 'FirstResponderKit', 'DarlingData', 'SQLWATCH', 'WhoIsActive', 'DbaMultiTool', 'AzSqlTips')] - [string]$Software, + [ValidateSet("All", "MaintenanceSolution", "FirstResponderKit", "DarlingData", "SQLWATCH", "WhoIsActive", "DbaMultiTool", "AzSqlTips")] + [string[]]$Software, [string]$Branch, [string]$LocalFile, [string]$Url, @@ -103,342 +116,366 @@ function Save-DbaCommunitySoftware { process { $dbatoolsData = Get-DbatoolsConfigValue -FullName "Path.DbatoolsData" - # Set Branch, Url and LocalDirectory for known Software - if ($Software -eq 'MaintenanceSolution') { - if (-not $Branch) { - $Branch = 'main' - } - if (-not $Url) { - $Url = "https://github.com/olahallengren/sql-server-maintenance-solution/archive/$Branch.zip" - } - if (-not $LocalDirectory) { - $LocalDirectory = Join-Path -Path $dbatoolsData -ChildPath "sql-server-maintenance-solution-$Branch" - } - } elseif ($Software -eq 'FirstResponderKit') { - if (-not $Branch) { - $Branch = 'main' - } - if (-not $Url) { - $Url = "https://github.com/BrentOzarULTD/SQL-Server-First-Responder-Kit/archive/$Branch.zip" - } - if (-not $LocalDirectory) { - $LocalDirectory = Join-Path -Path $dbatoolsData -ChildPath "SQL-Server-First-Responder-Kit-$Branch" - } - } elseif ($Software -eq 'DarlingData') { - if (-not $Branch) { - $Branch = 'main' - } - if (-not $Url) { - $Url = "https://github.com/erikdarlingdata/DarlingData/archive/$Branch.zip" - } - if (-not $LocalDirectory) { - $LocalDirectory = Join-Path -Path $dbatoolsData -ChildPath "DarlingData-$Branch" - } - } elseif ($Software -eq 'SQLWATCH') { - if ($Branch -in 'prerelease', 'pre-release') { - $preRelease = $true - } else { - $preRelease = $false - } - if (-not $Url -and -not $LocalFile) { - $releasesUrl = "https://api.github.com/repos/marcingminski/sqlwatch/releases" - try { - try { - $releasesJson = Invoke-TlsWebRequest -Uri $releasesUrl -UseBasicParsing -ErrorAction Stop - } catch { - # Try with default proxy and usersettings - (New-Object System.Net.WebClient).Proxy.Credentials = [System.Net.CredentialCache]::DefaultNetworkCredentials - $releasesJson = Invoke-TlsWebRequest -Uri $releasesUrl -UseBasicParsing -ErrorAction Stop - } - } catch { - Stop-Function -Message "Unable to get release information from $releasesUrl." -ErrorRecord $_ - return + $allSoftware = @("MaintenanceSolution", "FirstResponderKit", "DarlingData", "SQLWATCH", "WhoIsActive", "DbaMultiTool", "AzSqlTips") + + if ($Software -contains "All") { + $softwareList = $allSoftware + } elseif ($Software) { + $softwareList = $Software | Select-Object -Unique + } else { + $softwareList = @($null) + } + + # Url, LocalFile and LocalDirectory each describe a single tool, so they cannot be reused across multiple Software values. + if ($softwareList.Count -gt 1 -and ($Url -or $LocalFile -or $LocalDirectory)) { + Stop-Function -Message "Url, LocalFile and LocalDirectory can only be used together with a single -Software value." + return + } + + :softwareLoop foreach ($currentSoftware in $softwareList) { + $currentBranch = $Branch + $currentUrl = $Url + $currentLocalDirectory = $LocalDirectory + + # Set Branch, Url and LocalDirectory for known Software + if ($currentSoftware -eq "MaintenanceSolution") { + if (-not $currentBranch) { + $currentBranch = "main" } - $latestRelease = ($releasesJson | ConvertFrom-Json) | Where-Object prerelease -eq $preRelease | Select-Object -First 1 - if ($null -eq $latestRelease) { - Stop-Function -Message "No release found." - return + if (-not $currentUrl) { + $currentUrl = "https://github.com/olahallengren/sql-server-maintenance-solution/archive/$currentBranch.zip" } - $Url = $latestRelease.assets[0].browser_download_url - } - if (-not $LocalDirectory) { - if ($preRelease) { - $LocalDirectory = Join-Path -Path $dbatoolsData -ChildPath "SQLWATCH-prerelease" + if (-not $currentLocalDirectory) { + $currentLocalDirectory = Join-Path -Path $dbatoolsData -ChildPath "sql-server-maintenance-solution-$currentBranch" + } + } elseif ($currentSoftware -eq "FirstResponderKit") { + if (-not $currentBranch) { + $currentBranch = "main" + } + if (-not $currentUrl) { + $currentUrl = "https://github.com/BrentOzarULTD/SQL-Server-First-Responder-Kit/archive/$currentBranch.zip" + } + if (-not $currentLocalDirectory) { + $currentLocalDirectory = Join-Path -Path $dbatoolsData -ChildPath "SQL-Server-First-Responder-Kit-$currentBranch" + } + } elseif ($currentSoftware -eq "DarlingData") { + if (-not $currentBranch) { + $currentBranch = "main" + } + if (-not $currentUrl) { + $currentUrl = "https://github.com/erikdarlingdata/DarlingData/archive/$currentBranch.zip" + } + if (-not $currentLocalDirectory) { + $currentLocalDirectory = Join-Path -Path $dbatoolsData -ChildPath "DarlingData-$currentBranch" + } + } elseif ($currentSoftware -eq "SQLWATCH") { + if ($currentBranch -in "prerelease", "pre-release") { + $preRelease = $true } else { - $LocalDirectory = Join-Path -Path $dbatoolsData -ChildPath "SQLWATCH" + $preRelease = $false } - } - } elseif ($Software -eq 'WhoIsActive') { - # We currently ignore -Branch as there is only one branch and there are no pre-releases. - if (-not $Url -and -not $LocalFile) { - $releasesUrl = "https://api.github.com/repos/amachanic/sp_whoisactive/releases" - try { + if (-not $currentUrl -and -not $LocalFile) { + $releasesUrl = "https://api.github.com/repos/marcingminski/sqlwatch/releases" try { - $releasesJson = Invoke-TlsWebRequest -Uri $releasesUrl -UseBasicParsing -ErrorAction Stop + try { + $releasesJson = Invoke-TlsWebRequest -Uri $releasesUrl -UseBasicParsing -ErrorAction Stop + } catch { + # Try with default proxy and usersettings + (New-Object System.Net.WebClient).Proxy.Credentials = [System.Net.CredentialCache]::DefaultNetworkCredentials + $releasesJson = Invoke-TlsWebRequest -Uri $releasesUrl -UseBasicParsing -ErrorAction Stop + } } catch { - # Try with default proxy and usersettings - (New-Object System.Net.WebClient).Proxy.Credentials = [System.Net.CredentialCache]::DefaultNetworkCredentials - $releasesJson = Invoke-TlsWebRequest -Uri $releasesUrl -UseBasicParsing -ErrorAction Stop + Stop-Function -Message "Unable to get release information from $releasesUrl." -ErrorRecord $_ + continue } - } catch { - Stop-Function -Message "Unable to get release information from $releasesUrl." -ErrorRecord $_ - return + $latestRelease = ($releasesJson | ConvertFrom-Json) | Where-Object prerelease -eq $preRelease | Select-Object -First 1 + if ($null -eq $latestRelease) { + Stop-Function -Message "No release found." + continue + } + $currentUrl = $latestRelease.assets[0].browser_download_url } - $latestRelease = ($releasesJson | ConvertFrom-Json) | Select-Object -First 1 - if ($null -eq $latestRelease) { - Stop-Function -Message "No release found." - return + if (-not $currentLocalDirectory) { + if ($preRelease) { + $currentLocalDirectory = Join-Path -Path $dbatoolsData -ChildPath "SQLWATCH-prerelease" + } else { + $currentLocalDirectory = Join-Path -Path $dbatoolsData -ChildPath "SQLWATCH" + } } - $Url = $latestRelease.zipball_url - } - if (-not $LocalDirectory) { - $LocalDirectory = Join-Path -Path $dbatoolsData -ChildPath "WhoIsActive" - } - } elseif ($Software -eq 'DbaMultiTool') { - if (-not $Branch) { - $Branch = 'master' - } - if (-not $Url) { - $Url = "https://github.com/LowlyDBA/dba-multitool/archive/$Branch.zip" - } - if (-not $LocalDirectory) { - $LocalDirectory = Join-Path -Path $dbatoolsData -ChildPath "dba-multitool-$Branch" - } - } elseif ($Software -eq 'AzSqlTips') { - # We currently ignore -Branch as there is only one branch and there are no pre-releases. - if (-not $Url -and -not $LocalFile) { - $releasesUrl = "https://api.github.com/repos/microsoft/azure-sql-tips/releases" - try { + } elseif ($currentSoftware -eq "WhoIsActive") { + # We currently ignore -Branch as there is only one branch and there are no pre-releases. + if (-not $currentUrl -and -not $LocalFile) { + $releasesUrl = "https://api.github.com/repos/amachanic/sp_whoisactive/releases" try { - $releasesJson = Invoke-TlsWebRequest -Uri $releasesUrl -UseBasicParsing -ErrorAction Stop + try { + $releasesJson = Invoke-TlsWebRequest -Uri $releasesUrl -UseBasicParsing -ErrorAction Stop + } catch { + # Try with default proxy and usersettings + (New-Object System.Net.WebClient).Proxy.Credentials = [System.Net.CredentialCache]::DefaultNetworkCredentials + $releasesJson = Invoke-TlsWebRequest -Uri $releasesUrl -UseBasicParsing -ErrorAction Stop + } } catch { - # Try with default proxy and usersettings - (New-Object System.Net.WebClient).Proxy.Credentials = [System.Net.CredentialCache]::DefaultNetworkCredentials - $releasesJson = Invoke-TlsWebRequest -Uri $releasesUrl -UseBasicParsing -ErrorAction Stop + Stop-Function -Message "Unable to get release information from $releasesUrl." -ErrorRecord $_ + continue } - } catch { - Stop-Function -Message "Unable to get release information from $releasesUrl." -ErrorRecord $_ - return + $latestRelease = ($releasesJson | ConvertFrom-Json) | Select-Object -First 1 + if ($null -eq $latestRelease) { + Stop-Function -Message "No release found." + continue + } + $currentUrl = $latestRelease.zipball_url } - $latestRelease = ($releasesJson | ConvertFrom-Json) | Select-Object -First 1 - if ($null -eq $latestRelease) { - Stop-Function -Message "No release found." - return + if (-not $currentLocalDirectory) { + $currentLocalDirectory = Join-Path -Path $dbatoolsData -ChildPath "WhoIsActive" } - $Url = $latestRelease.zipball_url - } - if (-not $LocalDirectory) { - $LocalDirectory = Join-Path -Path $dbatoolsData -ChildPath "AzSqlTips" - } - } - - # First part is download and extract and we use the temp directory for that and clean up afterwards. - # So we use a file and a folder with a random name to reduce potential conflicts, - # but name them with dbatools to be able to recognize them. - $temp = [System.IO.Path]::GetTempPath() - $random = Get-Random - $zipFile = Join-DbaPath -Path $temp -Child "dbatools_software_download_$random.zip" - $zipFolder = Join-DbaPath -Path $temp -Child "dbatools_software_download_$random" - - if ($Software -eq 'WhoIsActive' -and $LocalFile.EndsWith('.sql')) { - # For WhoIsActive, we allow to pass in the sp_WhoIsActive.sql file or any other sql file with the source code. - # We create the zip folder with a subfolder named WhoIsActive and copy the LocalFile there as sp_WhoIsActive.sql. - $appFolder = Join-DbaPath -Path $zipFolder -Child 'WhoIsActive' - $appFile = Join-DbaPath -Path $appFolder -Child 'sp_WhoIsActive.sql' - $null = New-Item -Path $zipFolder -ItemType Directory - $null = New-Item -Path $appFolder -ItemType Directory - Copy-Item -Path $LocalFile -Destination $appFile - } elseif ($Software -eq 'AzSqlTips' -and $LocalFile.EndsWith('.sql')) { - # For AzSqlTips, we allow to pass in the get-sqldb-tips.sql file or any other sql file with the source code. - # We create the zip folder with a subfolder named AzSqlTips and copy the LocalFile there as get-sqldb-tips.sql. - $appFolder = Join-DbaPath -Path $zipFolder -Child 'AzSqlTips\sqldb-tips' - $appFile = Join-DbaPath -Path $appFolder -Child 'get-sqldb-tips.sql' - $null = New-Item -Path $zipFolder -ItemType Directory - $null = New-Item -Path $appFolder -ItemType Directory - Copy-Item -Path $LocalFile -Destination $appFile - - } elseif ($LocalFile) { - # No download, so we just extract the given file if it exists and is a zip file. - if (-not (Test-Path $LocalFile)) { - Stop-Function -Message "$LocalFile doesn't exist" - return - } - if (-not ($LocalFile.EndsWith('.zip'))) { - Stop-Function -Message "$LocalFile has to be a zip file" - return - } - if ($PSCmdlet.ShouldProcess($LocalFile, "Extracting archive to $zipFolder path")) { - try { - if (-not $IsLinux -and -not $isMac) { - Unblock-File $LocalFile -ErrorAction SilentlyContinue - } - Expand-Archive -LiteralPath $LocalFile -DestinationPath $zipFolder -Force -ErrorAction Stop - } catch { - Stop-Function -Message "Unable to extract $LocalFile to $zipFolder." -ErrorRecord $_ - return + } elseif ($currentSoftware -eq "DbaMultiTool") { + if (-not $currentBranch) { + # dba-multitool's default branch on GitHub was renamed from master to main; + # the old name no longer resolves to a matching archive folder. + $currentBranch = "main" } - } - } else { - if (-not $Url) { - Stop-Function -Message "Url not found. Did you specify any -Software?" - return - } - # Download and extract. - if ($PSCmdlet.ShouldProcess($Url, "Downloading to $zipFile")) { - # Downloads from GitHub fail transiently now and then (rate limiting, connection resets), - # especially on shared CI runners, so retry with a short backoff before giving up. - $downloadAttempts = 3 - foreach ($attempt in 1..$downloadAttempts) { + if (-not $currentUrl) { + $currentUrl = "https://github.com/LowlyDBA/dba-multitool/archive/$currentBranch.zip" + } + if (-not $currentLocalDirectory) { + $currentLocalDirectory = Join-Path -Path $dbatoolsData -ChildPath "dba-multitool-$currentBranch" + } + } elseif ($currentSoftware -eq "AzSqlTips") { + # We currently ignore -Branch as there is only one branch and there are no pre-releases. + if (-not $currentUrl -and -not $LocalFile) { + $releasesUrl = "https://api.github.com/repos/microsoft/azure-sql-tips/releases" try { try { - # Clear any partial file from an earlier request so the existence check - # below can only see the file written by this request. - Remove-Item -Path $zipFile -ErrorAction SilentlyContinue - Invoke-TlsWebRequest -Uri $Url -OutFile $zipFile -UseBasicParsing -ErrorAction Stop + $releasesJson = Invoke-TlsWebRequest -Uri $releasesUrl -UseBasicParsing -ErrorAction Stop } catch { # Try with default proxy and usersettings - Remove-Item -Path $zipFile -ErrorAction SilentlyContinue (New-Object System.Net.WebClient).Proxy.Credentials = [System.Net.CredentialCache]::DefaultNetworkCredentials - Invoke-TlsWebRequest -Uri $Url -OutFile $zipFile -UseBasicParsing -ErrorAction Stop - } - # A download can complete without a terminating error and still not produce the file, - # which otherwise surfaces later as a confusing Expand-Archive path error, so treat - # a missing file as a failed attempt that is eligible for a retry. - if (-not (Test-Path -Path $zipFile)) { - throw "Download of $Url completed without error, but $zipFile was not created." + $releasesJson = Invoke-TlsWebRequest -Uri $releasesUrl -UseBasicParsing -ErrorAction Stop } - break } catch { - # A failed attempt can leave a partial file behind that would mask the failure - # or corrupt the next attempt, so clean it up before retrying or giving up. - Remove-Item -Path $zipFile -ErrorAction SilentlyContinue - if ($attempt -lt $downloadAttempts) { - Write-Message -Level Verbose -Message "Download attempt $attempt of $downloadAttempts for $Url failed, retrying. $PSItem" - Start-Sleep -Seconds (2 * $attempt) - } else { - Stop-Function -Message "Unable to download $Url to $zipFile after $downloadAttempts attempts." -ErrorRecord $_ - return - } + Stop-Function -Message "Unable to get release information from $releasesUrl." -ErrorRecord $_ + continue } - } - } - if ($PSCmdlet.ShouldProcess($zipFile, "Extracting archive to $zipFolder path")) { - try { - if (-not $IsLinux -and -not $isMac) { - Unblock-File $zipFile -ErrorAction SilentlyContinue + $latestRelease = ($releasesJson | ConvertFrom-Json) | Select-Object -First 1 + if ($null -eq $latestRelease) { + Stop-Function -Message "No release found." + continue } - - Expand-Archive -Path $zipFile -DestinationPath $zipFolder -Force -ErrorAction Stop - } catch { - Stop-Function -Message "Unable to extract $zipFile to $zipFolder." -ErrorRecord $_ - Remove-Item -Path $zipFile -ErrorAction SilentlyContinue - return + $currentUrl = $latestRelease.zipball_url + } + if (-not $currentLocalDirectory) { + $currentLocalDirectory = Join-Path -Path $dbatoolsData -ChildPath "AzSqlTips" } } - } - # As a safety net, we test whether the archive contained exactly the desired destination directory. - # But inside of zip files that are downloaded by the user via a webbrowser and not the api, - # the directory name is the name of the zip file. So we have to test for that as well. - if ($PSCmdlet.ShouldProcess($zipFolder, "Testing for correct content")) { - $localDirectoryBase = Split-Path -Path $LocalDirectory - $localDirectoryName = Split-Path -Path $LocalDirectory -Leaf - $sourceDirectory = Get-ChildItem -Path $zipFolder -Directory - $sourceDirectoryName = $sourceDirectory.Name - if ($Software -eq 'SQLWATCH') { - # As this software is downloaded as a release, the directory has a different name. - # Rename the directory from like 'SQLWATCH 4.3.0.23725 20210721131116' to 'SQLWATCH' to be able to handle this like the other software. - if ($sourceDirectoryName -like 'SQLWATCH*') { - # Write a file with version info, to be able to check if version is outdated - Set-Content -Path "$($sourceDirectory.FullName)\version.txt" -Value $sourceDirectoryName - Rename-Item -Path $sourceDirectory.FullName -NewName 'SQLWATCH' - $sourceDirectory = Get-ChildItem -Path $zipFolder -Directory - $sourceDirectoryName = $sourceDirectory.Name + # First part is download and extract and we use the temp directory for that and clean up afterwards. + # So we use a file and a folder with a random name to reduce potential conflicts, + # but name them with dbatools to be able to recognize them. + $temp = [System.IO.Path]::GetTempPath() + $random = Get-Random + $zipFile = Join-DbaPath -Path $temp -Child "dbatools_software_download_$random.zip" + $zipFolder = Join-DbaPath -Path $temp -Child "dbatools_software_download_$random" + + if ($currentSoftware -eq "WhoIsActive" -and $LocalFile.EndsWith(".sql")) { + # For WhoIsActive, we allow to pass in the sp_WhoIsActive.sql file or any other sql file with the source code. + # We create the zip folder with a subfolder named WhoIsActive and copy the LocalFile there as sp_WhoIsActive.sql. + $appFolder = Join-DbaPath -Path $zipFolder -Child "WhoIsActive" + $appFile = Join-DbaPath -Path $appFolder -Child "sp_WhoIsActive.sql" + $null = New-Item -Path $zipFolder -ItemType Directory + $null = New-Item -Path $appFolder -ItemType Directory + Copy-Item -Path $LocalFile -Destination $appFile + } elseif ($currentSoftware -eq "AzSqlTips" -and $LocalFile.EndsWith(".sql")) { + # For AzSqlTips, we allow to pass in the get-sqldb-tips.sql file or any other sql file with the source code. + # We create the zip folder with a subfolder named AzSqlTips and copy the LocalFile there as get-sqldb-tips.sql. + $appFolder = Join-DbaPath -Path $zipFolder -Child "AzSqlTips\sqldb-tips" + $appFile = Join-DbaPath -Path $appFolder -Child "get-sqldb-tips.sql" + $null = New-Item -Path $zipFolder -ItemType Directory + $null = New-Item -Path $appFolder -ItemType Directory + Copy-Item -Path $LocalFile -Destination $appFile + + } elseif ($LocalFile) { + # No download, so we just extract the given file if it exists and is a zip file. + if (-not (Test-Path $LocalFile)) { + Stop-Function -Message "$LocalFile doesn't exist" + continue } - } elseif ($Software -eq 'WhoIsActive') { - # As this software is downloaded as a release, the directory has a different name. - # Rename the directory from like 'amachanic-sp_whoisactive-459d2bc' to 'WhoIsActive' to be able to handle this like the other software. - if ($sourceDirectoryName -like '*sp_whoisactive-*') { - Rename-Item -Path $sourceDirectory.FullName -NewName 'WhoIsActive' - $sourceDirectory = Get-ChildItem -Path $zipFolder -Directory - $sourceDirectoryName = $sourceDirectory.Name + if (-not ($LocalFile.EndsWith(".zip"))) { + Stop-Function -Message "$LocalFile has to be a zip file" + continue } - } elseif ($Software -eq 'FirstResponderKit') { - # As this software is downloadable as a release, the directory might have a different name. - # Rename the directory from like 'SQL-Server-First-Responder-Kit-20211106' to 'SQL-Server-First-Responder-Kit-main' to be able to handle this like the other software. - if ($sourceDirectoryName -like 'SQL-Server-First-Responder-Kit-20*') { - Rename-Item -Path $sourceDirectory.FullName -NewName 'SQL-Server-First-Responder-Kit-main' - $sourceDirectory = Get-ChildItem -Path $zipFolder -Directory - $sourceDirectoryName = $sourceDirectory.Name + if ($PSCmdlet.ShouldProcess($LocalFile, "Extracting archive to $zipFolder path")) { + try { + if (-not $IsLinux -and -not $isMac) { + Unblock-File $LocalFile -ErrorAction SilentlyContinue + } + Expand-Archive -LiteralPath $LocalFile -DestinationPath $zipFolder -Force -ErrorAction Stop + } catch { + Stop-Function -Message "Unable to extract $LocalFile to $zipFolder." -ErrorRecord $_ + continue + } } - } elseif ($Software -eq 'DbaMultiTool') { - # As this software is downloadable as a release, the directory might have a different name. - # Rename the directory from like 'dba-multitool-1.7.5' to 'dba-multitool-master' to be able to handle this like the other software. - if ($sourceDirectoryName -like 'dba-multitool-[0-9]*') { - Rename-Item -Path $sourceDirectory.FullName -NewName 'dba-multitool-master' - $sourceDirectory = Get-ChildItem -Path $zipFolder -Directory - $sourceDirectoryName = $sourceDirectory.Name + } else { + if (-not $currentUrl) { + Stop-Function -Message "Url not found. Did you specify any -Software?" + continue } - } elseif ($Software -eq 'AzSqlTips') { - # As this software is downloaded as a release, the directory has a different name. - # copy the sqldb-tips directory from like 'azure-sql-tips-1.10.zip' to 'AzSqlTips' to be able to handle this like the other software. - if ($sourceDirectoryName -like '*azure-sql-tips-*') { - Rename-Item -Path $sourceDirectory.FullName -NewName 'AzSqlTips' - $sourceDirectory = Get-ChildItem -Path $zipFolder -Directory - $sourceDirectoryName = $sourceDirectory.Name + # Download and extract. + if ($PSCmdlet.ShouldProcess($currentUrl, "Downloading to $zipFile")) { + # Downloads from GitHub fail transiently now and then (rate limiting, connection resets), + # especially on shared CI runners, so retry with a short backoff before giving up. + $downloadAttempts = 3 + foreach ($attempt in 1..$downloadAttempts) { + try { + try { + # Clear any partial file from an earlier request so the existence check + # below can only see the file written by this request. + Remove-Item -Path $zipFile -ErrorAction SilentlyContinue + Invoke-TlsWebRequest -Uri $currentUrl -OutFile $zipFile -UseBasicParsing -ErrorAction Stop + } catch { + # Try with default proxy and usersettings + Remove-Item -Path $zipFile -ErrorAction SilentlyContinue + (New-Object System.Net.WebClient).Proxy.Credentials = [System.Net.CredentialCache]::DefaultNetworkCredentials + Invoke-TlsWebRequest -Uri $currentUrl -OutFile $zipFile -UseBasicParsing -ErrorAction Stop + } + # A download can complete without a terminating error and still not produce the file, + # which otherwise surfaces later as a confusing Expand-Archive path error, so treat + # a missing file as a failed attempt that is eligible for a retry. + if (-not (Test-Path -Path $zipFile)) { + throw "Download of $currentUrl completed without error, but $zipFile was not created." + } + break + } catch { + # A failed attempt can leave a partial file behind that would mask the failure + # or corrupt the next attempt, so clean it up before retrying or giving up. + Remove-Item -Path $zipFile -ErrorAction SilentlyContinue + if ($attempt -lt $downloadAttempts) { + Write-Message -Level Verbose -Message "Download attempt $attempt of $downloadAttempts for $currentUrl failed, retrying. $PSItem" + Start-Sleep -Seconds (2 * $attempt) + } else { + Stop-Function -Message "Unable to download $currentUrl to $zipFile after $downloadAttempts attempts." -ErrorRecord $_ + continue softwareLoop + } + } + } + } + if ($PSCmdlet.ShouldProcess($zipFile, "Extracting archive to $zipFolder path")) { + try { + if (-not $IsLinux -and -not $isMac) { + Unblock-File $zipFile -ErrorAction SilentlyContinue + } + + Expand-Archive -Path $zipFile -DestinationPath $zipFolder -Force -ErrorAction Stop + } catch { + Stop-Function -Message "Unable to extract $zipFile to $zipFolder." -ErrorRecord $_ + Remove-Item -Path $zipFile -ErrorAction SilentlyContinue + continue + } } } - if ($sourceDirectoryName -ne $localDirectoryName) { - if (Test-Path -PathType Container -Path $LocalDirectory) { - $localDirectoryBase = $LocalDirectory - $localDirectoryName = $LocalDirectory = $sourceDirectoryName - } else { - Stop-Function -Message "The archive does not contain the desired directory $localDirectoryName but $sourceDirectoryName, and $LocalDirectory is not a folder." + # As a safety net, we test whether the archive contained exactly the desired destination directory. + # But inside of zip files that are downloaded by the user via a webbrowser and not the api, + # the directory name is the name of the zip file. So we have to test for that as well. + if ($PSCmdlet.ShouldProcess($zipFolder, "Testing for correct content")) { + $localDirectoryBase = Split-Path -Path $currentLocalDirectory + $localDirectoryName = Split-Path -Path $currentLocalDirectory -Leaf + $sourceDirectory = Get-ChildItem -Path $zipFolder -Directory + $sourceDirectoryName = $sourceDirectory.Name + if ($currentSoftware -eq "SQLWATCH") { + # As this software is downloaded as a release, the directory has a different name. + # Rename the directory from like 'SQLWATCH 4.3.0.23725 20210721131116' to 'SQLWATCH' to be able to handle this like the other software. + if ($sourceDirectoryName -like "SQLWATCH*") { + # Write a file with version info, to be able to check if version is outdated + Set-Content -Path "$($sourceDirectory.FullName)\version.txt" -Value $sourceDirectoryName + Rename-Item -Path $sourceDirectory.FullName -NewName "SQLWATCH" + $sourceDirectory = Get-ChildItem -Path $zipFolder -Directory + $sourceDirectoryName = $sourceDirectory.Name + } + } elseif ($currentSoftware -eq "WhoIsActive") { + # As this software is downloaded as a release, the directory has a different name. + # Rename the directory from like 'amachanic-sp_whoisactive-459d2bc' to 'WhoIsActive' to be able to handle this like the other software. + if ($sourceDirectoryName -like "*sp_whoisactive-*") { + Rename-Item -Path $sourceDirectory.FullName -NewName "WhoIsActive" + $sourceDirectory = Get-ChildItem -Path $zipFolder -Directory + $sourceDirectoryName = $sourceDirectory.Name + } + } elseif ($currentSoftware -eq "FirstResponderKit") { + # As this software is downloadable as a release, the directory might have a different name. + # Rename the directory from like 'SQL-Server-First-Responder-Kit-20211106' to 'SQL-Server-First-Responder-Kit-main' to be able to handle this like the other software. + if ($sourceDirectoryName -like "SQL-Server-First-Responder-Kit-20*") { + Rename-Item -Path $sourceDirectory.FullName -NewName "SQL-Server-First-Responder-Kit-main" + $sourceDirectory = Get-ChildItem -Path $zipFolder -Directory + $sourceDirectoryName = $sourceDirectory.Name + } + } elseif ($currentSoftware -eq "DbaMultiTool") { + # As this software is downloadable as a release, the directory might have a different name. + # Rename the directory from like 'dba-multitool-1.7.5' to 'dba-multitool-main' to be able to handle this like the other software. + if ($sourceDirectoryName -like "dba-multitool-[0-9]*") { + Rename-Item -Path $sourceDirectory.FullName -NewName "dba-multitool-main" + $sourceDirectory = Get-ChildItem -Path $zipFolder -Directory + $sourceDirectoryName = $sourceDirectory.Name + } + } elseif ($currentSoftware -eq "AzSqlTips") { + # As this software is downloaded as a release, the directory has a different name. + # copy the sqldb-tips directory from like 'azure-sql-tips-1.10.zip' to 'AzSqlTips' to be able to handle this like the other software. + if ($sourceDirectoryName -like "*azure-sql-tips-*") { + Rename-Item -Path $sourceDirectory.FullName -NewName "AzSqlTips" + $sourceDirectory = Get-ChildItem -Path $zipFolder -Directory + $sourceDirectoryName = $sourceDirectory.Name + } + } + + if ($sourceDirectoryName -ne $localDirectoryName) { + if (Test-Path -PathType Container -Path $currentLocalDirectory) { + $localDirectoryBase = $currentLocalDirectory + $localDirectoryName = $currentLocalDirectory = $sourceDirectoryName + } else { + Stop-Function -Message "The archive does not contain the desired directory $localDirectoryName but $sourceDirectoryName, and $currentLocalDirectory is not a folder." + Remove-Item -Path $zipFile -ErrorAction SilentlyContinue + Remove-Item -Path $zipFolder -Recurse -Force -ErrorAction SilentlyContinue + continue + } + } + + if ((Get-ChildItem -Path $zipFolder).Count -gt 1 -or $sourceDirectoryName -ne $localDirectoryName) { + Stop-Function -Message "The archive does not contain the desired directory $localDirectoryName but $sourceDirectoryName." Remove-Item -Path $zipFile -ErrorAction SilentlyContinue Remove-Item -Path $zipFolder -Recurse -Force -ErrorAction SilentlyContinue - return + continue } } - if ((Get-ChildItem -Path $zipFolder).Count -gt 1 -or $sourceDirectoryName -ne $localDirectoryName) { - Stop-Function -Message "The archive does not contain the desired directory $localDirectoryName but $sourceDirectoryName." - Remove-Item -Path $zipFile -ErrorAction SilentlyContinue - Remove-Item -Path $zipFolder -Recurse -Force -ErrorAction SilentlyContinue - return + # Replace the target directory by the extracted directory. + if ($PSCmdlet.ShouldProcess($zipFolder, "Copying content to $currentLocalDirectory")) { + try { + if (Test-Path -Path $currentLocalDirectory) { + # -Force is required on macOS/Linux: GitHub archives contain dotfiles + # (.github, .gitignore) which PowerShell treats as hidden there, and + # Remove-Item refuses hidden items without it. + Remove-Item -Path $currentLocalDirectory -Recurse -Force -ErrorAction Stop + } + } catch { + Stop-Function -Message "Unable to remove the old target directory $currentLocalDirectory." -ErrorRecord $_ + Remove-Item -Path $zipFile -ErrorAction SilentlyContinue + Remove-Item -Path $zipFolder -Recurse -Force -ErrorAction SilentlyContinue + continue + } + try { + Copy-Item -Path $sourceDirectory.FullName -Destination $localDirectoryBase -Recurse -ErrorAction Stop + } catch { + Stop-Function -Message "Unable to copy the directory $sourceDirectory to the target directory $localDirectoryBase." -ErrorRecord $_ + Remove-Item -Path $zipFile -ErrorAction SilentlyContinue + Remove-Item -Path $zipFolder -Recurse -Force -ErrorAction SilentlyContinue + continue + } } - } - # Replace the target directory by the extracted directory. - if ($PSCmdlet.ShouldProcess($zipFolder, "Copying content to $LocalDirectory")) { - try { - if (Test-Path -Path $LocalDirectory) { - # -Force is required on macOS/Linux: GitHub archives contain dotfiles - # (.github, .gitignore) which PowerShell treats as hidden there, and - # Remove-Item refuses hidden items without it. - Remove-Item -Path $LocalDirectory -Recurse -Force -ErrorAction Stop - } - } catch { - Stop-Function -Message "Unable to remove the old target directory $LocalDirectory." -ErrorRecord $_ + if ($PSCmdlet.ShouldProcess($zipFile, "Removing temporary file")) { Remove-Item -Path $zipFile -ErrorAction SilentlyContinue - Remove-Item -Path $zipFolder -Recurse -Force -ErrorAction SilentlyContinue - return } - try { - Copy-Item -Path $sourceDirectory.FullName -Destination $localDirectoryBase -Recurse -ErrorAction Stop - } catch { - Stop-Function -Message "Unable to copy the directory $sourceDirectory to the target directory $localDirectoryBase." -ErrorRecord $_ - Remove-Item -Path $zipFile -ErrorAction SilentlyContinue + if ($PSCmdlet.ShouldProcess($zipFolder, "Removing temporary folder")) { Remove-Item -Path $zipFolder -Recurse -Force -ErrorAction SilentlyContinue - return } } - - if ($PSCmdlet.ShouldProcess($zipFile, "Removing temporary file")) { - Remove-Item -Path $zipFile -ErrorAction SilentlyContinue - } - if ($PSCmdlet.ShouldProcess($zipFolder, "Removing temporary folder")) { - Remove-Item -Path $zipFolder -Recurse -Force -ErrorAction SilentlyContinue - } } -} \ No newline at end of file +} diff --git a/tests/Save-DbaCommunitySoftware.Tests.ps1 b/tests/Save-DbaCommunitySoftware.Tests.ps1 index 77ef5afe262..cf2df53527b 100644 --- a/tests/Save-DbaCommunitySoftware.Tests.ps1 +++ b/tests/Save-DbaCommunitySoftware.Tests.ps1 @@ -20,6 +20,14 @@ Describe $CommandName -Tag UnitTests { ) Compare-Object -ReferenceObject $expectedParameters -DifferenceObject $hasParameters | Should -BeNullOrEmpty } + + It "Should accept an array of Software values" { + (Get-Command $CommandName).Parameters["Software"].ParameterType | Should -Be ([string[]]) + } + + It "Should allow All as a Software value" { + (Get-Command $CommandName).Parameters["Software"].Attributes.ValidValues | Should -Contain "All" + } } } @@ -65,4 +73,72 @@ Describe $CommandName -Tag IntegrationTests { Get-ChildItem -Path $targetDirectory -Recurse -Filter "CommandExecute.sql" | Should -Not -BeNullOrEmpty Test-Path -Path (Join-Path -Path $targetDirectory -ChildPath "stale.txt") | Should -BeFalse } + + It "warns instead of downloading when LocalDirectory is combined with multiple Software values" { + Save-DbaCommunitySoftware -Software MaintenanceSolution, DarlingData -LocalDirectory $targetDirectory -WarningAction SilentlyContinue + + $WarnVar | Should -Match "single -Software value" + } + + It "warns instead of downloading when LocalDirectory is combined with All" { + Save-DbaCommunitySoftware -Software All -LocalDirectory $targetDirectory -WarningAction SilentlyContinue + + $WarnVar | Should -Match "single -Software value" + } + + Context "Downloading multiple tools to an isolated cache" { + BeforeEach { + # Software's per-tool cache paths default from Path.DbatoolsData, so these tests + # point that config at a throwaway TestDrive folder instead of touching the real + # shared cache, and restore it afterwards. + $originalDbatoolsData = Get-DbatoolsConfigValue -FullName "Path.DbatoolsData" + $isolatedDbatoolsData = Join-Path -Path $TestDrive -ChildPath "dbatoolsdata-$(Get-Random)" + $null = New-Item -Path $isolatedDbatoolsData -ItemType Directory + Set-DbatoolsConfig -FullName "Path.DbatoolsData" -Value $isolatedDbatoolsData + } + + AfterEach { + Set-DbatoolsConfig -FullName "Path.DbatoolsData" -Value $originalDbatoolsData + } + + It "downloads each tool when Software is passed as an array" { + Save-DbaCommunitySoftware -Software MaintenanceSolution, DarlingData -EnableException + + $splatMaintenanceCheck = @{ + Path = Join-Path -Path $isolatedDbatoolsData -ChildPath "sql-server-maintenance-solution-main" + Recurse = $true + Filter = "CommandExecute.sql" + } + Get-ChildItem @splatMaintenanceCheck | Should -Not -BeNullOrEmpty + + $splatDarlingCheck = @{ + Path = Join-Path -Path $isolatedDbatoolsData -ChildPath "DarlingData-main" + Recurse = $true + Filter = "*.sql" + } + Get-ChildItem @splatDarlingCheck | Should -Not -BeNullOrEmpty + } + + It "downloads every tool when Software is All" { + Save-DbaCommunitySoftware -Software All -EnableException + + $expectedFolders = @( + "sql-server-maintenance-solution-main", + "SQL-Server-First-Responder-Kit-main", + "DarlingData-main", + "SQLWATCH", + "WhoIsActive", + "dba-multitool-main", + "AzSqlTips" + ) + foreach ($expectedFolder in $expectedFolders) { + $splatFolderCheck = @{ + Path = Join-Path -Path $isolatedDbatoolsData -ChildPath $expectedFolder + Recurse = $true + File = $true + } + Get-ChildItem @splatFolderCheck | Should -Not -BeNullOrEmpty + } + } + } }