diff --git a/.build/README.md b/.build/README.md new file mode 100644 index 0000000000..536da28a9d --- /dev/null +++ b/.build/README.md @@ -0,0 +1,60 @@ +# DSC Resource Integration Test Optimization + +This document describes the script used to dynamically determine whether DSC +resource integration tests should run in Azure Pipelines. + +## What the Script Does + +The `Test-ShouldRunDscResourceIntegrationTests.ps1` script analyzes git +changes between two references and determines if DSC resource integration tests +need to run. It automatically discovers which public commands are used by DSC +resources and classes, then checks if any relevant files have been modified. + +## How It Works + +The script checks for changes to: + +1. **DSC Resources**: Files under `source/DSCResources/` +1. **Classes**: Files under `source/Classes/` +1. **Public Commands**: Commands that are actually used by DSC resources or + classes (dynamically discovered) +1. **Private Functions**: Functions used by the monitored public commands or + class-based DSC resources +1. **Integration Tests**: DSC resource integration test files under + `tests/Integration/Resources/` + +## Usage + +### Azure Pipelines + +The Azure Pipelines task sets an output variable that downstream stages can +use to conditionally run DSC resource integration tests. The script returns +a boolean value that the pipeline captures, e.g.: + +```yaml +- powershell: | + $shouldRun = ./.build/Test-ShouldRunDscResourceIntegrationTests.ps1 -BaseBranch $targetBranch -CurrentBranch HEAD + Write-Host "##vso[task.setvariable variable=ShouldRunDscResourceIntegrationTests;isOutput=true]$shouldRun" + displayName: 'Determine if DSC resource tests should run' +``` + +Downstream stages reference this output variable using the pattern: +`dependencies.JobName.outputs['StepName.VariableName']` to gate their +execution based on whether DSC resource tests should run. + +### Command Line + +```powershell +# Basic usage (compares current HEAD with origin/main) +.build/Test-ShouldRunDscResourceIntegrationTests.ps1 + +# Custom branches +.build/Test-ShouldRunDscResourceIntegrationTests.ps1 -BaseBranch 'origin/dev' \ + -CurrentBranch 'feature-branch' +``` + +## Dynamic Discovery + +The script automatically discovers public commands used by DSC resources by +scanning source files, eliminating the need to maintain hardcoded lists. +This ensures accuracy and reduces maintenance overhead. diff --git a/.build/Test-ShouldRunDscResourceIntegrationTests.ps1 b/.build/Test-ShouldRunDscResourceIntegrationTests.ps1 new file mode 100644 index 0000000000..81db771725 --- /dev/null +++ b/.build/Test-ShouldRunDscResourceIntegrationTests.ps1 @@ -0,0 +1,499 @@ +<# + .SYNOPSIS + Determines if DSC resource integration tests should run based on changed files. + + .DESCRIPTION + This script analyzes the git diff to determine if DSC resource integration tests + need to run. It checks if changes affect: + - DSC resources themselves + - Public commands used by DSC resources or classes + - Private functions used by those public commands + - Private functions used by class-based DSC resources + - Classes used by DSC resources + + .PARAMETER BaseBranch + The base branch to compare against. Default is 'origin/main'. + + .PARAMETER CurrentBranch + The current branch or commit to compare. Default is 'HEAD'. + + .EXAMPLE + Test-ShouldRunDscResourceIntegrationTests + + .EXAMPLE + Test-ShouldRunDscResourceIntegrationTests -BaseBranch 'origin/main' -CurrentBranch 'HEAD' + + .OUTPUTS + System.Boolean. Returns $true if DSC resource integration tests should run, $false otherwise. +#> +[CmdletBinding()] +param +( + [Parameter()] + [System.String] + $BaseBranch = 'origin/main', + + [Parameter()] + [System.String] + $CurrentBranch = 'HEAD' +) + +<# + .SYNOPSIS + Dynamically discovers public commands used by DSC resources and classes. + + .DESCRIPTION + This function scans all DSC resource and class files to identify which public + commands from the Public directory are actually used. It searches for command + usage patterns in the source code. + + .PARAMETER SourcePath + The source path containing Public, DSCResources, and Classes directories. + Default is 'source'. + + .EXAMPLE + Get-PublicCommandsUsedByDscResources + + .EXAMPLE + Get-PublicCommandsUsedByDscResources -SourcePath 'source' + + .OUTPUTS + System.String[]. Array of public command names that are used by DSC resources and classes. +#> +function Get-PublicCommandsUsedByDscResources +{ + [CmdletBinding()] + param + ( + [Parameter()] + [System.String] + $SourcePath = 'source' + ) + + $usedCommands = @() + + # Get all public command names + $publicCommandFiles = Get-ChildItem -Path (Join-Path -Path $SourcePath -ChildPath 'Public') -Filter '*.ps1' -ErrorAction SilentlyContinue + $publicCommandNames = $publicCommandFiles | ForEach-Object -Process { $_.BaseName } + + if (-not $publicCommandNames) + { + Write-Warning "No public commands found in $SourcePath/Public" + return @() + } + + # Search in DSC Resources + $dscResourcesPath = Join-Path -Path $SourcePath -ChildPath 'DSCResources' + if (Test-Path -Path $dscResourcesPath) + { + $dscResourceFiles = Get-ChildItem -Path $dscResourcesPath -Recurse -Filter '*.ps*' -ErrorAction SilentlyContinue + foreach ($file in $dscResourceFiles) + { + $content = Get-Content -Path $file.FullName -Raw -ErrorAction SilentlyContinue + if ($content) + { + foreach ($commandName in $publicCommandNames) + { + # Look for command usage patterns: commandName, & commandName, or | commandName + if ($content -match "\b$commandName\b") + { + $usedCommands += $commandName + } + } + } + } + } + + # Search in Classes + $classesPath = Join-Path -Path $SourcePath -ChildPath 'Classes' + if (Test-Path -Path $classesPath) + { + $classFiles = Get-ChildItem -Path $classesPath -Filter '*.ps1' -ErrorAction SilentlyContinue + foreach ($file in $classFiles) + { + $content = Get-Content -Path $file.FullName -Raw -ErrorAction SilentlyContinue + if ($content) + { + foreach ($commandName in $publicCommandNames) + { + # Look for command usage patterns: commandName, & commandName, or | commandName + if ($content -match "\b$commandName\b") + { + $usedCommands += $commandName + } + } + } + } + } + + # Return unique commands + return $usedCommands | Sort-Object -Unique +} + +<# + .SYNOPSIS + Gets the list of files changed between two git references. + + .DESCRIPTION + This function retrieves the list of files that have been modified between + two git references using git diff. It handles various scenarios including + different diff syntax and untracked files. + + .PARAMETER From + The source git reference (branch, commit, tag). + + .PARAMETER To + The target git reference (branch, commit, tag). + + .EXAMPLE + Get-ChangedFiles -From 'origin/main' -To 'HEAD' + + .OUTPUTS + System.String[]. Array of file paths that have been changed. +#> +function Get-ChangedFiles +{ + [CmdletBinding()] + param + ( + [Parameter(Mandatory = $true)] + [System.String] + $From, + + [Parameter(Mandatory = $true)] + [System.String] + $To + ) + + try + { + # Try different git diff approaches + $gitDiffOutput = $null + + # First, try the standard diff + $gitDiffOutput = & git diff --name-only "$From..$To" 2>&1 + if ($LASTEXITCODE -eq 0 -and $gitDiffOutput) + { + return $gitDiffOutput | Where-Object -FilterScript { $_ -and $_.Trim() } + } + + # If that fails, try without the range syntax + $gitDiffOutput = & git diff --name-only $From $To 2>&1 + if ($LASTEXITCODE -eq 0 -and $gitDiffOutput) + { + return $gitDiffOutput | Where-Object -FilterScript { $_ -and $_.Trim() } + } + + # If we're comparing with HEAD and have untracked files, include them + if ($To -eq 'HEAD') + { + $untrackedFiles = & git ls-files --others --exclude-standard 2>&1 + if ($LASTEXITCODE -eq 0 -and $untrackedFiles) + { + return $untrackedFiles | Where-Object -FilterScript { $_ -and $_.Trim() } + } + } + + Write-Warning "Failed to get git diff between $From and $To. Exit code: $LASTEXITCODE. Output: $gitDiffOutput" + return @() + } + catch + { + Write-Warning "Error getting changed files: $_" + return @() + } +} + +<# + .SYNOPSIS + Gets private functions that a public command depends on. + + .DESCRIPTION + This function analyzes a public command file to identify which private + functions it depends on by searching for function calls in the source code. + + .PARAMETER CommandName + The name of the public command to analyze. + + .PARAMETER SourcePath + The source path containing Public and Private directories. + + .EXAMPLE + Get-PrivateFunctionsUsedByCommand -CommandName 'Connect-SqlDscDatabaseEngine' -SourcePath 'source' + + .OUTPUTS + System.String[]. Array of private function names that are used by the command. +#> +function Get-PrivateFunctionsUsedByCommand +{ + [CmdletBinding()] + param + ( + [Parameter(Mandatory = $true)] + [System.String] + $CommandName, + + [Parameter(Mandatory = $true)] + [System.String] + $SourcePath + ) + + $commandFile = Join-Path -Path $SourcePath -ChildPath "Public/$CommandName.ps1" + if (-not (Test-Path -Path $commandFile)) + { + return @() + } + + $privateFunctions = @() + $content = Get-Content -Path $commandFile -Raw -ErrorAction SilentlyContinue + # Look for direct function calls to private functions + $privateFunctionFiles = Get-ChildItem -Path (Join-Path -Path $SourcePath -ChildPath "Private") -Filter "*.ps1" | Select-Object -ExpandProperty BaseName + foreach ($privateFunction in $privateFunctionFiles) + { + if ($content -match "\b$privateFunction\b") + { + $privateFunctions += $privateFunction + } + } + + return $privateFunctions +} + +<# + .SYNOPSIS + Gets private functions that class-based DSC resources depend on. + + .DESCRIPTION + This function analyzes class-based DSC resource files (those with [DscResource(...)] + decoration) to identify which private functions they depend on by searching for + function calls in the source code. + + .PARAMETER SourcePath + The source path containing Classes and Private directories. + + .EXAMPLE + Get-PrivateFunctionsUsedByClassResources -SourcePath 'source' + + .OUTPUTS + System.String[]. Array of private function names that are used by class-based DSC resources. +#> +function Get-PrivateFunctionsUsedByClassResources +{ + [CmdletBinding()] + param + ( + [Parameter(Mandatory = $true)] + [System.String] + $SourcePath + ) + + $privateFunctions = @() + $classesPath = Join-Path -Path $SourcePath -ChildPath 'Classes' + + if (-not (Test-Path -Path $classesPath)) + { + return @() + } + + # Get all private function names + $privateFunctionFiles = Get-ChildItem -Path (Join-Path -Path $SourcePath -ChildPath "Private") -Filter "*.ps1" -ErrorAction SilentlyContinue + if (-not $privateFunctionFiles) + { + return @() + } + + $privateFunctionNames = $privateFunctionFiles | Select-Object -ExpandProperty BaseName + + # Get all class files and check if they have [DscResource(...)] decoration + $classFiles = Get-ChildItem -Path $classesPath -Filter '*.ps1' -ErrorAction SilentlyContinue + foreach ($file in $classFiles) + { + $content = Get-Content -Path $file.FullName -Raw -ErrorAction SilentlyContinue + if ($content) + { + # Check if this is a class-based DSC resource (has [DscResource(...)] decoration) + if ($content -match '\[DscResource\([^\]]*\)\]') + { + # Look for private function usage in this class-based DSC resource + foreach ($privateFunction in $privateFunctionNames) + { + if ($content -match "\b$privateFunction\b") + { + $privateFunctions += $privateFunction + } + } + } + } + } + + # Return unique private functions + return $privateFunctions | Sort-Object -Unique +} + +<# + .SYNOPSIS + Main function that determines if DSC resource integration tests should run. + + .DESCRIPTION + This function analyzes the changes between two git references and determines + if DSC resource integration tests should run based on the files that have + been modified. It checks for changes to DSC resources, classes, public + commands used by DSC resources, private functions used by those public commands, + private functions used by class-based DSC resources, and integration + tests. + .PARAMETER BaseBranch + The base branch to compare against. Default is 'origin/main'. + + .PARAMETER CurrentBranch + The current branch or commit to compare. Default is 'HEAD'. + + .PARAMETER SourcePath + The source path containing the source code directories. Default is 'source'. + + .EXAMPLE + Test-ShouldRunDscResourceIntegrationTests + + .EXAMPLE + Test-ShouldRunDscResourceIntegrationTests -BaseBranch 'origin/main' -CurrentBranch 'feature-branch' + + .OUTPUTS + System.Boolean. Returns $true if DSC resource integration tests should run, $false otherwise. +#> +function Test-ShouldRunDscResourceIntegrationTests +{ + [CmdletBinding()] + param + ( + [Parameter()] + [System.String] + $BaseBranch = 'origin/main', + + [Parameter()] + [System.String] + $CurrentBranch = 'HEAD', + + [Parameter()] + [System.String] + $SourcePath = 'source' + ) + + Write-Host "##[section]Analyzing DSC Resource Integration Test Requirements" + Write-Host "Analyzing changes between $BaseBranch and $CurrentBranch..." + Write-Host "" + + # Get list of public commands used by DSC resources dynamically + $PublicCommandsUsedByDscResources = Get-PublicCommandsUsedByDscResources -SourcePath $SourcePath + Write-Host "Discovered $($PublicCommandsUsedByDscResources.Count) public commands used by DSC resources and classes." + Write-Host "" + + $changedFiles = Get-ChangedFiles -From $BaseBranch -To $CurrentBranch + + if (-not $changedFiles) + { + Write-Warning "No changed files detected. DSC resource integration tests will run by default." + Write-Host "" + return $true + } + + Write-Host "##[group]Changed Files" + $changedFiles | ForEach-Object -Process { Write-Host " $_" } + Write-Host "##[endgroup]" + Write-Host "" + + # Check if any DSC resources are directly changed + $changedDscResources = $changedFiles | Where-Object -FilterScript { $_ -match '^source/DSCResources/' -or $_ -match '^source/Classes/' } + if ($changedDscResources) + { + Write-Warning "DSC resources or classes have been modified. DSC resource integration tests will run." + Write-Host "##[group]Changed DSC Resources/Classes" + $changedDscResources | ForEach-Object -Process { Write-Host " $_" } + Write-Host "##[endgroup]" + Write-Host "" + return $true + } + + # Check if any public commands used by DSC resources are changed + $changedPublicCommands = $changedFiles | Where-Object -FilterScript { $_ -match '^source/Public/(.+)\.ps1$' } | + ForEach-Object -Process { [System.IO.Path]::GetFileNameWithoutExtension((Split-Path -Path $_ -Leaf)) } + + $affectedCommands = $changedPublicCommands | Where-Object -FilterScript { $_ -in $PublicCommandsUsedByDscResources } + if ($affectedCommands) + { + Write-Warning "Public commands used by DSC resources have been modified. DSC resource integration tests will run." + Write-Host "##[group]Affected Commands" + $affectedCommands | ForEach-Object -Process { Write-Host " $_" } + Write-Host "##[endgroup]" + Write-Host "" + return $true + } + + # Check if any private functions used by the affected public commands or class-based DSC resources are changed + $changedPrivateFunctions = $changedFiles | Where-Object -FilterScript { $_ -match '^source/Private/(.+)\.ps1$' } | + ForEach-Object -Process { [System.IO.Path]::GetFileNameWithoutExtension((Split-Path -Path $_ -Leaf)) } + + $affectedPrivateFunctions = @() + + # Check private functions used by public commands + foreach ($command in $PublicCommandsUsedByDscResources) + { + $privateFunctionsUsed = Get-PrivateFunctionsUsedByCommand -CommandName $command -SourcePath $SourcePath + $affectedPrivateFunctions += $privateFunctionsUsed | Where-Object -FilterScript { $_ -in $changedPrivateFunctions } + } + + # Check private functions used by class-based DSC resources + $privateFunctionsUsedByClassResources = Get-PrivateFunctionsUsedByClassResources -SourcePath $SourcePath + $affectedPrivateFunctions += $privateFunctionsUsedByClassResources | Where-Object -FilterScript { $_ -in $changedPrivateFunctions } + + # Remove duplicates + $affectedPrivateFunctions = $affectedPrivateFunctions | Sort-Object -Unique + + if ($affectedPrivateFunctions) + { + Write-Warning "Private functions used by DSC resource-related public commands or class-based DSC resources have been modified. DSC resource integration tests will run." + Write-Host "##[group]Affected Private Functions" + $affectedPrivateFunctions | ForEach-Object -Process { Write-Host " $_" } + Write-Host "##[endgroup]" + Write-Host "" + return $true + } + + # Check if integration test files themselves are changed + $changedIntegrationTests = $changedFiles | Where-Object -FilterScript { $_ -match '^tests/Integration/Resources/' } + if ($changedIntegrationTests) + { + Write-Warning "DSC resource integration test files have been modified. DSC resource integration tests will run." + Write-Host "##[group]Changed Integration Test Files" + $changedIntegrationTests | ForEach-Object -Process { Write-Host " $_" } + Write-Host "##[endgroup]" + Write-Host "" + return $true + } + + Write-Host "No changes detected that would affect DSC resources. DSC resource integration tests can be skipped." + Write-Host "" + return $false +} + +# If script is run directly (not imported), execute the main function +if ($MyInvocation.InvocationName -ne '.') +{ + $shouldRun = Test-ShouldRunDscResourceIntegrationTests -BaseBranch $BaseBranch -CurrentBranch $CurrentBranch + + # Provide clear final result with appropriate color coding + Write-Host "##[section]Test Requirements Decision" + if ($shouldRun) + { + Write-Warning "RESULT: DSC resource integration tests WILL RUN" + } + else + { + Write-Host "RESULT: DSC resource integration tests will be SKIPPED" + } + + # Output the result for the calling script to capture + Write-Output -InputObject "" + Write-Output -InputObject "ShouldRunDscResourceIntegrationTests: $shouldRun" + + # Return the boolean value for pipeline script to use + return $shouldRun +} diff --git a/CHANGELOG.md b/CHANGELOG.md index f9e5ceb781..8e9eff8776 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,8 +35,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Remove `windows-2019` images fixes [#2106](https://github.com/dsccommunity/SqlServerDsc/issues/2106). - Move individual tasks to `windows-latest`. - Added integration tests for `Assert-SqlDscLogin` command in Group 2. + - Added conditional logic to skip DSC resource integration tests when + changes don't affect DSC resources, improving CI/CD performance for + non-DSC changes. - `SqlServerDsc.psd1` - Set `CmdletsToExport` to `*` in module manifest to fix issue [#2109](https://github.com/dsccommunity/SqlServerDsc/issues/2109). +- Added optimization for DSC resource integration tests + - Created `.build/Test-ShouldRunDscResourceIntegrationTests.ps1` to analyze + git changes and decide when DSC resource integration tests are needed. + - DSC resource integration test stages now run only when changes affect DSC + resources, public commands used by resources, or related components. + - Unit tests, QA tests, and command integration tests continue to run for + all changes. + - Provides time savings for non-DSC changes while maintaining coverage. ## [17.1.0] - 2025-05-22 diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 50766248b6..11b10b41db 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -74,6 +74,39 @@ stages: displayName: 'Quality Test and Unit Test' dependsOn: Build jobs: + - job: Determine_DSC_Resource_Test_Requirements + displayName: 'Determine DSC Resource Test Requirements' + pool: + vmImage: 'windows-latest' + steps: + - task: DownloadPipelineArtifact@2 + displayName: 'Download Build Artifact' + inputs: + buildType: 'current' + artifactName: $(buildArtifactName) + targetPath: '$(Build.SourcesDirectory)/$(buildFolderName)' + - task: PowerShell@2 + name: determineDscResourceTests + displayName: 'Determine if DSC Resource Integration Tests Should Run' + inputs: + targetType: 'inline' + script: | + # Set the target branch for comparison + $targetBranch = "origin/$(System.PullRequest.TargetBranch)" + if (-not $env:SYSTEM_PULLREQUEST_TARGETBRANCH) { + $targetBranch = "origin/main" + } + + Write-Output "Target branch: $targetBranch" + Write-Output "Current branch: HEAD" + + # Run the script to determine if DSC resource integration tests should run + $shouldRun = ./.build/Test-ShouldRunDscResourceIntegrationTests.ps1 -BaseBranch $targetBranch -CurrentBranch HEAD + + # Set Azure DevOps output variable for pipeline conditions + Write-Host "##vso[task.setvariable variable=ShouldRunDscResourceIntegrationTests;isOutput=true]$shouldRun" + pwsh: true + - job: Test_HQRM displayName: 'HQRM' pool: @@ -401,6 +434,11 @@ stages: - stage: Integration_Test_Resources_SqlServer displayName: 'Integration Test Resources - SQL Server' dependsOn: Quality_Test_and_Unit_Test + condition: | + and( + succeeded(), + eq(dependencies.Quality_Test_and_Unit_Test.outputs['Determine_DSC_Resource_Test_Requirements.determineDscResourceTests.ShouldRunDscResourceIntegrationTests'], 'True') + ) jobs: - job: Test_Integration displayName: 'Integration' @@ -494,6 +532,11 @@ stages: - stage: Integration_Test_Resources_SqlServer_dbatools displayName: 'Integration Test Resources - SQL Server (dbatools)' dependsOn: Integration_Test_Resources_SqlServer + condition: | + and( + succeeded(), + eq(dependencies.Quality_Test_and_Unit_Test.outputs['Determine_DSC_Resource_Test_Requirements.determineDscResourceTests.ShouldRunDscResourceIntegrationTests'], 'True') + ) jobs: - job: Test_Integration displayName: 'Integration' @@ -584,6 +627,11 @@ stages: - stage: Integration_Test_Resources_ReportingServices displayName: 'Integration Test Resources - Reporting Services' dependsOn: Integration_Test_Resources_SqlServer + condition: | + and( + succeeded(), + eq(dependencies.Quality_Test_and_Unit_Test.outputs['Determine_DSC_Resource_Test_Requirements.determineDscResourceTests.ShouldRunDscResourceIntegrationTests'], 'True') + ) jobs: - job: Test_Integration displayName: 'Integration' @@ -655,6 +703,11 @@ stages: - stage: Integration_Test_Resources_PowerBIReportServer displayName: 'Integration Test Resources - Power BI Report Server' dependsOn: Quality_Test_and_Unit_Test + condition: | + and( + succeeded(), + eq(dependencies.Quality_Test_and_Unit_Test.outputs['Determine_DSC_Resource_Test_Requirements.determineDscResourceTests.ShouldRunDscResourceIntegrationTests'], 'True') + ) jobs: - job: Test_Integration displayName: 'Integration' @@ -717,6 +770,11 @@ stages: - stage: Integration_Test_Resources_ReportingServices_dbatools displayName: 'Integration Test Resources - Reporting Services (dbatools)' dependsOn: Integration_Test_Resources_SqlServer + condition: | + and( + succeeded(), + eq(dependencies.Quality_Test_and_Unit_Test.outputs['Determine_DSC_Resource_Test_Requirements.determineDscResourceTests.ShouldRunDscResourceIntegrationTests'], 'True') + ) jobs: - job: Test_Integration displayName: 'Integration' @@ -782,6 +840,7 @@ stages: - Integration_Test_Resources_SqlServer - Integration_Test_Resources_SqlServer_dbatools - Integration_Test_Resources_ReportingServices + - Integration_Test_Resources_PowerBIReportServer - Integration_Test_Resources_ReportingServices_dbatools condition: | and(