diff --git a/package.json b/package.json index 18c40037c..02f2ece72 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,13 @@ "tf-plan-ci": "SCRIPT_PATH=\"$PWD/scripts/tf-plan-smart.sh\"; ./scripts/tf-walker-parallel.sh \"terraform init && bash \\\"$SCRIPT_PATH\\\"\" \"tf-plan-ci-$(date +%Y%m%d-%H%M%S)\" true 4 ci", "tf-plan-blueprints": "SCRIPT_PATH=\"$PWD/scripts/tf-plan-smart.sh\"; ./scripts/tf-walker-parallel.sh \"terraform init && bash \\\"$SCRIPT_PATH\\\"\" \"tf-plan-blueprints-$(date +%Y%m%d-%H%M%S)\" true 4 blueprints", "tf-plan-all": "SCRIPT_PATH=\"$PWD/scripts/tf-plan-smart.sh\"; ./scripts/tf-walker-parallel.sh \"terraform init && bash \\\"$SCRIPT_PATH\\\"\" \"tf-plan-all-$(date +%Y%m%d-%H%M%S)\" true 4", + "go-test": "pwsh -File ./scripts/linting/Invoke-GoTest.ps1", + "go-test:changed": "pwsh -File ./scripts/linting/Invoke-GoTest.ps1 -ChangedOnly", + "go-test:contract:terraform": "cd blueprints/full-single-node-cluster/tests && go test -v -run TestTerraformOutputsContract", + "go-test:contract:bicep": "cd blueprints/full-single-node-cluster/tests && go test -v -run TestBicepOutputsContract", + "go-test:deploy": "npm run go-test:deploy:terraform && npm run go-test:deploy:bicep", + "go-test:deploy:terraform": "cd blueprints/full-single-node-cluster/tests && go test -v -run TestTerraformFullSingleNodeClusterDeploy -timeout 2h", + "go-test:deploy:bicep": "cd blueprints/full-single-node-cluster/tests && go test -v -run TestBicepFullSingleNodeClusterDeploy -timeout 2h", "format-tables": "pwsh -File ./scripts/linting/Format-Markdown-Tables.ps1", "format-tables-check": "pwsh -File ./scripts/linting/Format-Markdown-Tables.ps1 -Check", "checkov-changes": "pwsh -Command \"& $PWD/scripts/build/Detect-Folder-Changes.ps1 -OutputJson | & $PWD/scripts/Run-Checkov.ps1 -OutputFolder './checkov-results' -OutputFile 'code-analysis.xml'\"", diff --git a/scripts/build/Detect-Folder-Changes.Tests.ps1 b/scripts/build/Detect-Folder-Changes.Tests.ps1 index 08d887dd9..3027c1354 100644 --- a/scripts/build/Detect-Folder-Changes.Tests.ps1 +++ b/scripts/build/Detect-Folder-Changes.Tests.ps1 @@ -99,6 +99,80 @@ Describe 'Test-RustHasChange' -Tag 'Unit' { } } +Describe 'Test-IsGoContractTestChangeFile' -Tag 'Unit' { + Context 'when evaluating Go contract-test trigger paths' { + It 'Detects Go contract-test relevant files' { + Test-IsGoContractTestChangeFile -Path 'blueprints/full-single-node-cluster/tests/go.mod' | Should -BeTrue + Test-IsGoContractTestChangeFile -Path 'blueprints/full-single-node-cluster/tests/output_contract_test.go' | Should -BeTrue + Test-IsGoContractTestChangeFile -Path 'blueprints/full-single-node-cluster/terraform/outputs.tf' | Should -BeTrue + Test-IsGoContractTestChangeFile -Path 'blueprints/full-single-node-cluster/bicep/main.bicep' | Should -BeTrue + Test-IsGoContractTestChangeFile -Path 'scripts/linting/Invoke-GoTest.ps1' | Should -BeTrue + Test-IsGoContractTestChangeFile -Path 'scripts/tests/linting/Invoke-GoTest.Tests.ps1' | Should -BeTrue + Test-IsGoContractTestChangeFile -Path 'src/900-tools-utilities/904-test-utilities/contract.go' | Should -BeTrue + Test-IsGoContractTestChangeFile -Path 'src/900-tools-utilities/904-test-utilities/go.mod' | Should -BeTrue + Test-IsGoContractTestChangeFile -Path 'scripts/install-terraform-docs.sh' | Should -BeTrue + Test-IsGoContractTestChangeFile -Path 'scripts/build/Detect-Folder-Changes.ps1' | Should -BeTrue + Test-IsGoContractTestChangeFile -Path '.github/workflows/go-tests.yml' | Should -BeTrue + Test-IsGoContractTestChangeFile -Path '.github/workflows/matrix-folder-check.yml' | Should -BeTrue + Test-IsGoContractTestChangeFile -Path '.github/workflows/pr-validation.yml' | Should -BeTrue + Test-IsGoContractTestChangeFile -Path 'package.json' | Should -BeTrue + Test-IsGoContractTestChangeFile -Path 'package-lock.json' | Should -BeTrue + } + + It 'Ignores unrelated files' { + Test-IsGoContractTestChangeFile -Path 'docs/readme.md' | Should -BeFalse + Test-IsGoContractTestChangeFile -Path 'src/000-cloud/010-security-identity/terraform/main.tf' | Should -BeFalse + Test-IsGoContractTestChangeFile -Path '.github/workflows/rust-tests.yml' | Should -BeFalse + Test-IsGoContractTestChangeFile -Path 'Cargo.toml' | Should -BeFalse + Test-IsGoContractTestChangeFile -Path 'blueprints/full-single-node-cluster/tests/start.sh' | Should -BeFalse + Test-IsGoContractTestChangeFile -Path '' | Should -BeFalse + } + } +} + +Describe 'Test-GoContractTestHasChange' -Tag 'Unit' { + Context 'when evaluating changed file collections' { + It 'Returns false for null or empty inputs' { + Test-GoContractTestHasChange -ChangedFiles $null | Should -BeFalse + Test-GoContractTestHasChange -ChangedFiles @() | Should -BeFalse + } + + It 'Returns true when any file should trigger Go contract tests' { + Test-GoContractTestHasChange -ChangedFiles @('docs/readme.md', 'blueprints/full-single-node-cluster/tests/go.mod') | Should -BeTrue + Test-GoContractTestHasChange -ChangedFiles @('blueprints/full-single-node-cluster/terraform/outputs.tf') | Should -BeTrue + Test-GoContractTestHasChange -ChangedFiles @('.github/workflows/go-tests.yml') | Should -BeTrue + Test-GoContractTestHasChange -ChangedFiles @('docs/readme.md', 'src/900-tools-utilities/904-test-utilities/contract.go') | Should -BeTrue + Test-GoContractTestHasChange -ChangedFiles @('docs/readme.md', 'scripts/install-terraform-docs.sh') | Should -BeTrue + } + + It 'Returns false when no files should trigger Go contract tests' { + Test-GoContractTestHasChange -ChangedFiles @('docs/readme.md', 'src/000-cloud/010-security-identity/terraform/main.tf') | Should -BeFalse + } + } +} + +Describe 'Test-IsTerraformInstallChangeFile' -Tag 'Unit' { + Context 'when evaluating Terraform module-test trigger paths' { + It 'Detects Terraform source and variable files' { + Test-IsTerraformInstallChangeFile -Path 'blueprints/full-single-node-cluster/terraform/outputs.tf' | Should -BeTrue + Test-IsTerraformInstallChangeFile -Path 'src/000-cloud/010-security-identity/terraform/main.tf' | Should -BeTrue + Test-IsTerraformInstallChangeFile -Path 'blueprints/full-single-node-cluster/terraform/test.tfvars' | Should -BeTrue + Test-IsTerraformInstallChangeFile -Path 'blueprints/full-single-node-cluster/terraform/backend.hcl' | Should -BeTrue + } + + It 'Ignores Go contract-test tooling and non-Terraform files' { + Test-IsTerraformInstallChangeFile -Path 'blueprints/full-single-node-cluster/tests/output_contract_test.go' | Should -BeFalse + Test-IsTerraformInstallChangeFile -Path 'blueprints/full-single-node-cluster/tests/go.mod' | Should -BeFalse + Test-IsTerraformInstallChangeFile -Path 'scripts/linting/Invoke-GoTest.ps1' | Should -BeFalse + Test-IsTerraformInstallChangeFile -Path 'src/900-tools-utilities/904-test-utilities/contract.go' | Should -BeFalse + Test-IsTerraformInstallChangeFile -Path 'scripts/install-terraform-docs.sh' | Should -BeFalse + Test-IsTerraformInstallChangeFile -Path '.github/workflows/go-tests.yml' | Should -BeFalse + Test-IsTerraformInstallChangeFile -Path 'package.json' | Should -BeFalse + Test-IsTerraformInstallChangeFile -Path '' | Should -BeFalse + } + } +} + Describe 'Get-BicepFullValidationReason' -Tag 'Unit' { It 'returns bicepconfig for root Bicep configuration changes' { @(Get-BicepFullValidationReason -Files @('bicepconfig.json')) | Should -Contain 'bicepconfig' diff --git a/scripts/build/Detect-Folder-Changes.ps1 b/scripts/build/Detect-Folder-Changes.ps1 index b7082be85..6bee6dab6 100644 --- a/scripts/build/Detect-Folder-Changes.ps1 +++ b/scripts/build/Detect-Folder-Changes.ps1 @@ -326,6 +326,7 @@ $fuzzPythonFolders = [PSCustomObject]@{ folderName = @() } $fuzzJsHasChanges = $false $fuzzJsFolders = [PSCustomObject]@{ folderName = @() } $rustHasChanges = $false +$goContractTestsHasChanges = $false # Use native PowerShell commands where possible and minimize redundant operations @@ -349,6 +350,38 @@ function Test-IsRustChangeFile { return $Path -match '(\.rs$|(^|/)Cargo\.(toml|lock)$|^\.github/workflows/(rust-tests|pr-validation)\.yml$|^codecov\.yml$)' } +function Test-IsGoContractTestChangeFile { + <# + .SYNOPSIS + Returns $true when a repo-relative path should trigger the go-tests workflow. + .DESCRIPTION + Matches the full-single-node-cluster Terraform, Bicep, and Go contract test + inputs plus workflow and detector files that influence the static Go contract + test pipeline. + #> + param ( + [Parameter(Mandatory = $true)] + [AllowEmptyString()] + [string]$Path + ) + + return $Path -match '(^blueprints/full-single-node-cluster/(terraform|bicep)/|^blueprints/full-single-node-cluster/tests/([^/]+\.go|go\.(mod|sum))$|^src/900-tools-utilities/904-test-utilities/|^scripts/install-terraform-docs\.sh$|^scripts/linting/Invoke-GoTest\.ps1$|^scripts/tests/linting/Invoke-GoTest\.Tests\.ps1$|^\.github/workflows/(go-tests|matrix-folder-check|pr-validation)\.yml$|^scripts/build/Detect-Folder-Changes\.ps1$|^package(-lock)?\.json$)' +} + +function Test-IsTerraformInstallChangeFile { + <# + .SYNOPSIS + Returns $true when a repo-relative path should trigger Terraform module tests. + #> + param ( + [Parameter(Mandatory = $true)] + [AllowEmptyString()] + [string]$Path + ) + + return $Path -match '\.(tf|tfvars|tfstate|hcl)$' +} + function Test-RustHasChange { <# .SYNOPSIS @@ -373,6 +406,30 @@ function Test-RustHasChange { return $false } +function Test-GoContractTestHasChange { + <# + .SYNOPSIS + Returns $true when any path in $ChangedFiles matches the Go contract-test gating ruleset. + #> + param ( + [Parameter(Mandatory = $false)] + [AllowNull()] + [string[]]$ChangedFiles + ) + + if ($null -eq $ChangedFiles -or $ChangedFiles.Count -eq 0) { + return $false + } + + foreach ($file in $ChangedFiles) { + if (Test-IsGoContractTestChangeFile -Path $file) { + return $true + } + } + + return $false +} + function Get-ChangedFileData { param ( [switch]$IncludeAll, @@ -712,7 +769,7 @@ if ($IncludeAllIaC -and $VerbosePreference -ne 'SilentlyContinue') { } # Process IaC changes - ALWAYS scan, switch controls filter -$tfFiles = $changedFiles | Where-Object { $_ -match '\.(tf|tfvars|tfstate|hcl)$' } +$tfFiles = $changedFiles | Where-Object { Test-IsTerraformInstallChangeFile -Path $_ } $bicepFiles = $changedFiles | Where-Object { $_ -match '\.bicep$' } # Process paths in single batch operations @@ -860,6 +917,12 @@ $jsonOutput | Add-Member -MemberType NoteProperty -Name "rust" -Value ([PSCustom has_changes = [bool]$rustHasChanges }) +$goContractTestsHasChanges = Test-GoContractTestHasChange -ChangedFiles $changedFiles + +$jsonOutput | Add-Member -MemberType NoteProperty -Name "goContractTests" -Value ([PSCustomObject]@{ + has_changes = [bool]$goContractTestsHasChanges + }) + # Convert to JSON $jsonString = $jsonOutput | ConvertTo-Json -Depth 10 diff --git a/scripts/linting/Invoke-GoTest.ps1 b/scripts/linting/Invoke-GoTest.ps1 new file mode 100644 index 000000000..5c7ea8f6a --- /dev/null +++ b/scripts/linting/Invoke-GoTest.ps1 @@ -0,0 +1,232 @@ +#!/usr/bin/env pwsh +# Copyright (c) Microsoft Corporation. +# SPDX-License-Identifier: MIT +#Requires -Version 7.0 + +<# +.SYNOPSIS +Runs static Go contract tests for the full single-node cluster blueprint. + +.DESCRIPTION +Executes the Go contract tests that validate Terraform and Bicep output shape without requiring Azure authentication or deployments. The wrapper can optionally skip execution when the current change set does not affect the contract tests or their supporting workflow. + +.PARAMETER RepoRoot +Repository root path. Defaults to the current working directory. + +.PARAMETER BaseBranch +Base branch used when ChangedOnly is specified. + +.PARAMETER ChangedOnly +Runs the contract tests only when relevant files have changed. + +.PARAMETER ChangedFiles +Optional repo-relative changed file list used by tests or callers that already computed changes. + +.PARAMETER CommandRunner +Optional command runner script block used by tests to avoid invoking external tools. + +.EXAMPLE +./scripts/linting/Invoke-GoTest.ps1 + +.EXAMPLE +./scripts/linting/Invoke-GoTest.ps1 -ChangedOnly -BaseBranch origin/main + +.NOTES +This script intentionally limits execution to static contract tests only. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory = $false)] + [string]$RepoRoot = (Get-Location).Path, + + [Parameter(Mandatory = $false)] + [string]$BaseBranch = 'origin/main', + + [Parameter(Mandatory = $false)] + [switch]$ChangedOnly, + + [Parameter(Mandatory = $false)] + [string[]]$ChangedFiles, + + [Parameter(Mandatory = $false)] + [scriptblock]$CommandRunner +) + +$ErrorActionPreference = 'Stop' + +$ciHelpersPath = Join-Path -Path $PSScriptRoot -ChildPath '../ci/Modules/CIHelpers.psm1' +$lintingHelpersPath = Join-Path -Path $PSScriptRoot -ChildPath 'Modules/LintingHelpers.psm1' +Import-Module -Name $ciHelpersPath -Force +Import-Module -Name $lintingHelpersPath -Force + +#region Functions +function Test-IsGoContractTestChangeFile { + <# + .SYNOPSIS + Returns true when a path should trigger static Go contract tests. + #> + [CmdletBinding()] + [OutputType([bool])] + param( + [Parameter(Mandatory = $true)] + [AllowEmptyString()] + [string]$Path + ) + + return $Path -match '(^blueprints/full-single-node-cluster/(terraform|bicep)/|^blueprints/full-single-node-cluster/tests/([^/]+\.go|go\.(mod|sum))$|^src/900-tools-utilities/904-test-utilities/|^scripts/install-terraform-docs\.sh$|^scripts/linting/Invoke-GoTest\.ps1$|^scripts/tests/linting/Invoke-GoTest\.Tests\.ps1$|^\.github/workflows/(go-tests|matrix-folder-check|pr-validation)\.yml$|^scripts/build/Detect-Folder-Changes\.ps1$|^package(-lock)?\.json$)' +} + +function Test-GoContractTestHasChange { + <# + .SYNOPSIS + Returns true when any changed path should trigger static Go contract tests. + #> + [CmdletBinding()] + [OutputType([bool])] + param( + [Parameter(Mandatory = $false)] + [AllowNull()] + [string[]]$ChangedFiles + ) + + if ($null -eq $ChangedFiles -or $ChangedFiles.Count -eq 0) { + return $false + } + + foreach ($file in $ChangedFiles) { + if (Test-IsGoContractTestChangeFile -Path $file) { + return $true + } + } + + return $false +} + +function Test-GoCommandAvailable { + <# + .SYNOPSIS + Returns true when the Go CLI is available on PATH. + #> + [CmdletBinding()] + [OutputType([bool])] + param() + + return $null -ne (Get-Command -Name go -ErrorAction SilentlyContinue) +} + +function Invoke-GoTestCommand { + <# + .SYNOPSIS + Executes go with the supplied argument list. + #> + [CmdletBinding()] + [OutputType([int])] + param( + [Parameter(Mandatory = $true)] + [string[]]$Arguments + ) + + if (-not (Test-GoCommandAvailable)) { + Write-Error "Go CLI was not found on PATH. Install Go or use the CI workflow with actions/setup-go." + return 1 + } + + & go @Arguments + return $LASTEXITCODE +} + +function Invoke-GoTestCore { + <# + .SYNOPSIS + Runs static Go contract tests when context and change gates allow execution. + #> + [CmdletBinding()] + [OutputType([int])] + param( + [Parameter(Mandatory = $false)] + [string]$RepoRoot = (Get-Location).Path, + + [Parameter(Mandatory = $false)] + [string]$BaseBranch = 'origin/main', + + [Parameter(Mandatory = $false)] + [switch]$ChangedOnly, + + [Parameter(Mandatory = $false)] + [string[]]$ChangedFiles, + + [Parameter(Mandatory = $false)] + [scriptblock]$CommandRunner + ) + + $resolvedRepoRoot = (Resolve-Path -Path $RepoRoot).Path + $testPath = Join-Path -Path $resolvedRepoRoot -ChildPath 'blueprints/full-single-node-cluster/tests' + $goModPath = Join-Path -Path $testPath -ChildPath 'go.mod' + + if (-not (Test-Path -Path $goModPath -PathType Leaf)) { + Write-Error "Go contract test context was not found at '$goModPath'." + return 1 + } + + $filesToEvaluate = $ChangedFiles + if ($ChangedOnly -and $null -eq $filesToEvaluate) { + Push-Location -Path $resolvedRepoRoot + try { + $filesToEvaluate = Get-ChangedFilesFromGit -Extension @('.go', '.mod', '.sum', '.tf', '.bicep', '.ps1', '.yml', '.yaml', '.json') -BaseBranch $BaseBranch | + ForEach-Object { + $resolvedPath = (Resolve-Path -Path $_).Path + [System.IO.Path]::GetRelativePath($resolvedRepoRoot, $resolvedPath).Replace('\\', '/') + } + } + finally { + Pop-Location + } + } + + if ($ChangedOnly -and -not (Test-GoContractTestHasChange -ChangedFiles $filesToEvaluate)) { + Write-Host 'No Go contract test relevant changes detected. Skipping static Go contract tests.' + Set-CIOutput -Name 'goContractTestsRan' -Value 'false' + Write-CIStepSummary -Content 'Static Go contract tests skipped because no relevant files changed.' + return 0 + } + + $testFilter = '^(TestTerraformOutputsContract|TestBicepOutputsContract)$' + $goArguments = @('test', '.', '-run', $testFilter) + Write-Host "Running static Go contract tests in '$testPath'." + Push-Location -Path $testPath + try { + $exitCode = if ($null -ne $CommandRunner) { + & $CommandRunner -Arguments $goArguments + } + else { + Invoke-GoTestCommand -Arguments $goArguments + } + } + finally { + Pop-Location + } + + Set-CIOutput -Name 'goContractTestsRan' -Value 'true' + Set-CIOutput -Name 'goContractTestsExitCode' -Value ([string]$exitCode) + + if ($exitCode -eq 0) { + Write-CIStepSummary -Content 'Static Go contract tests completed successfully.' + } + else { + Write-CIStepSummary -Content "Static Go contract tests failed with exit code $exitCode." + } + + return $exitCode +} +#endregion + +if ($MyInvocation.InvocationName -ne '.') { + try { + $exitCode = Invoke-GoTestCore -RepoRoot $RepoRoot -BaseBranch $BaseBranch -ChangedOnly:$ChangedOnly -ChangedFiles $ChangedFiles -CommandRunner $CommandRunner + exit $exitCode + } + catch { + Write-Error $_ + exit 1 + } +} diff --git a/scripts/linting/Invoke-PSScriptAnalyzer.ps1 b/scripts/linting/Invoke-PSScriptAnalyzer.ps1 index 60afb67af..6d7ff9faf 100644 --- a/scripts/linting/Invoke-PSScriptAnalyzer.ps1 +++ b/scripts/linting/Invoke-PSScriptAnalyzer.ps1 @@ -16,6 +16,63 @@ if (-not (Get-Module -ListAvailable -Name PSScriptAnalyzer)) { } Import-Module PSScriptAnalyzer -Force +function Get-AnalyzerFilePath { + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory = $true)] + [object]$File + ) + + if ($File -is [System.IO.FileSystemInfo]) { + return $File.FullName + } + + if ($File.PSObject.Properties['ProviderPath']) { + return [string]$File.ProviderPath + } + + if ($File.PSObject.Properties['Path']) { + return [string]$File.Path + } + + return [string]$File +} + +function Invoke-AnalyzerForFile { + [CmdletBinding()] + [OutputType([object[]])] + param( + [Parameter(Mandatory = $true)] + [object]$File, + + [Parameter(Mandatory = $true)] + [string]$SettingsPath + ) + + $filePath = Get-AnalyzerFilePath -File $File + + try { + return @(Invoke-ScriptAnalyzer -Path $filePath -Settings $SettingsPath -ErrorAction Stop) + } catch { + $pathError = $_ + try { + $scriptDefinition = Get-Content -LiteralPath $filePath -Raw + $results = @(Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -Settings $SettingsPath -ErrorAction Stop) + + foreach ($result in $results) { + if ([string]::IsNullOrWhiteSpace($result.ScriptPath)) { + $result | Add-Member -NotePropertyName 'ScriptPath' -NotePropertyValue $filePath -Force + } + } + + return $results + } catch { + throw $pathError + } + } +} + if ($ChangedOnly) { $files = Get-ChangedFilesFromGit -Extension @('.ps1', '.psm1', '.psd1') if ($files.Count -eq 0) { @@ -34,7 +91,7 @@ $allResults = @() $crashedFiles = @() foreach ($file in $files) { try { - $results = Invoke-ScriptAnalyzer -Path $file -Settings $SettingsPath -ReportSummary -ErrorAction Stop + $results = Invoke-AnalyzerForFile -File $file -SettingsPath $SettingsPath $allResults += $results } catch { $crashedFiles += $file @@ -62,6 +119,7 @@ foreach ($result in $allResults) { $errorCount = ($allResults | Where-Object Severity -eq 'Error').Count $warningCount = ($allResults | Where-Object Severity -eq 'Warning').Count $infoCount = ($allResults | Where-Object Severity -eq 'Information').Count +$crashCount = @($crashedFiles).Count $summary = @" ## PSScriptAnalyzer Results @@ -71,13 +129,19 @@ $summary = @" | Error | $errorCount | | Warning | $warningCount | | Information | $infoCount | +| Analyzer internal errors | $crashCount | | **Total** | **$($allResults.Count)** | Files scanned: $($files.Count) "@ Write-CIStepSummary $summary -Set-CIOutput -Name 'has-findings' -Value ($allResults.Count -gt 0).ToString().ToLower() +Set-CIOutput -Name 'has-findings' -Value (($allResults.Count -gt 0 -or $crashCount -gt 0).ToString().ToLower()) + +if ($crashCount -gt 0 -and -not $SoftFail) { + Write-Host "PSScriptAnalyzer failed to analyze $crashCount file(s)." + exit 1 +} if ($allResults.Count -gt 0 -and -not $SoftFail) { Write-Host "Found $($allResults.Count) issue(s)." diff --git a/scripts/linting/Modules/LintingHelpers.psm1 b/scripts/linting/Modules/LintingHelpers.psm1 index a3484fa67..5d3671fd1 100644 --- a/scripts/linting/Modules/LintingHelpers.psm1 +++ b/scripts/linting/Modules/LintingHelpers.psm1 @@ -28,18 +28,37 @@ function Get-FilesRecursive { Returns all files matching specified extensions, excluding configured patterns. #> [CmdletBinding()] + [OutputType([System.IO.FileInfo])] param( [string]$Path = '.', [string[]]$Extension = @('.ps1', '.psm1', '.psd1'), - [string[]]$ExcludePattern = @('node_modules', '.copilot-tracking') + [string[]]$ExcludePattern = @('node_modules', '.copilot-tracking', 'packages', '.git', 'lint-results') ) - $includeGlobs = $Extension | ForEach-Object { "*$_" } + $normalizedExtensions = $Extension | ForEach-Object { + if ($_.StartsWith('.')) { $_ } else { ".$_" } + } + $excludedPathSegments = $ExcludePattern | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } + $rootPath = Resolve-Path -Path $Path -ErrorAction Stop + $rootItem = Get-Item -LiteralPath $rootPath.ProviderPath - Get-ChildItem -Path $Path -Recurse -File -Include $includeGlobs | - Where-Object { - $fullPath = $_.FullName - -not ($ExcludePattern | Where-Object { $fullPath -match [regex]::Escape($_) }) + if ($rootItem -is [System.IO.FileInfo]) { + if ($normalizedExtensions -contains $rootItem.Extension) { return $rootItem } + return + } + + $directoriesToSearch = [System.Collections.Generic.Stack[System.IO.DirectoryInfo]]::new() + $directoriesToSearch.Push($rootItem) + + while ($directoriesToSearch.Count -gt 0) { + $currentDirectory = $directoriesToSearch.Pop() + + Get-ChildItem -LiteralPath $currentDirectory.FullName -Directory -Force -ErrorAction SilentlyContinue | + Where-Object { $excludedPathSegments -notcontains $_.Name } | + ForEach-Object { $directoriesToSearch.Push($_) } + + Get-ChildItem -LiteralPath $currentDirectory.FullName -File -Force -ErrorAction SilentlyContinue | + Where-Object { $normalizedExtensions -contains $_.Extension } } } diff --git a/scripts/security/Update-ActionSHAPinning.ps1 b/scripts/security/Update-ActionSHAPinning.ps1 index 6f1d2c180..4b13e3738 100755 --- a/scripts/security/Update-ActionSHAPinning.ps1 +++ b/scripts/security/Update-ActionSHAPinning.ps1 @@ -565,10 +565,13 @@ function Set-ContentPreservePermission { $OriginalMode = $null if (Test-Path $Path) { try { - # Get file mode using ls -la (cross-platform) - $lsOutput = & ls -la $Path 2>$null - if ($LASTEXITCODE -eq 0 -and $lsOutput -match '^([drwx-]+)') { - $OriginalMode = $Matches[1] + # Get file mode using native ls when available + $lsCommand = Get-Command -Name 'ls' -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($null -ne $lsCommand) { + $lsOutput = & $lsCommand.Source -la $Path 2>$null + if ($LASTEXITCODE -eq 0 -and $lsOutput -match '^([drwx-]+)') { + $OriginalMode = $Matches[1] + } } } catch { diff --git a/scripts/security/Update-DockerSHAPinning.ps1 b/scripts/security/Update-DockerSHAPinning.ps1 index d8ab94a95..5f2b016f0 100755 --- a/scripts/security/Update-DockerSHAPinning.ps1 +++ b/scripts/security/Update-DockerSHAPinning.ps1 @@ -79,10 +79,13 @@ function Set-ContentPreservePermission { $OriginalMode = $null if (Test-Path $Path) { try { - # Get file mode using ls -la (cross-platform) - $lsOutput = & ls -la $Path 2>$null - if ($LASTEXITCODE -eq 0 -and $lsOutput -match '^([drwx-]+)') { - $OriginalMode = $Matches[1] + # Get file mode using native ls when available + $lsCommand = Get-Command -Name 'ls' -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($null -ne $lsCommand) { + $lsOutput = & $lsCommand.Source -la $Path 2>$null + if ($LASTEXITCODE -eq 0 -and $lsOutput -match '^([drwx-]+)') { + $OriginalMode = $Matches[1] + } } } catch { diff --git a/scripts/security/Update-ShellScriptSHAPinning.ps1 b/scripts/security/Update-ShellScriptSHAPinning.ps1 index abd8d8bb4..8390a6c38 100755 --- a/scripts/security/Update-ShellScriptSHAPinning.ps1 +++ b/scripts/security/Update-ShellScriptSHAPinning.ps1 @@ -74,10 +74,13 @@ function Set-ContentPreservePermission { $OriginalMode = $null if (Test-Path $Path) { try { - # Get file mode using ls -la (cross-platform) - $lsOutput = & ls -la $Path 2>$null - if ($LASTEXITCODE -eq 0 -and $lsOutput -match '^([drwx-]+)') { - $OriginalMode = $Matches[1] + # Get file mode using native ls when available + $lsCommand = Get-Command -Name 'ls' -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($null -ne $lsCommand) { + $lsOutput = & $lsCommand.Source -la $Path 2>$null + if ($LASTEXITCODE -eq 0 -and $lsOutput -match '^([drwx-]+)') { + $OriginalMode = $Matches[1] + } } } catch { diff --git a/scripts/tests/linting/Invoke-GoTest.Tests.ps1 b/scripts/tests/linting/Invoke-GoTest.Tests.ps1 new file mode 100644 index 000000000..ce9b6d134 --- /dev/null +++ b/scripts/tests/linting/Invoke-GoTest.Tests.ps1 @@ -0,0 +1,170 @@ +#Requires -Modules Pester +# Copyright (c) Microsoft Corporation. +# SPDX-License-Identifier: MIT + +BeforeAll { + $repoRoot = (Resolve-Path -Path (Join-Path -Path $PSScriptRoot -ChildPath '../../..')).Path + $scriptPath = Join-Path -Path $repoRoot -ChildPath 'scripts/linting/Invoke-GoTest.ps1' + . $scriptPath +} + +Describe 'Test-IsGoContractTestChangeFile' -Tag 'Unit' { + It 'returns true for Go contract test paths' { + Test-IsGoContractTestChangeFile -Path 'blueprints/full-single-node-cluster/tests/outputs_contract_test.go' | Should -BeTrue + Test-IsGoContractTestChangeFile -Path 'blueprints/full-single-node-cluster/tests/go.mod' | Should -BeTrue + Test-IsGoContractTestChangeFile -Path 'blueprints/full-single-node-cluster/terraform/outputs.tf' | Should -BeTrue + Test-IsGoContractTestChangeFile -Path 'blueprints/full-single-node-cluster/bicep/outputs.bicep' | Should -BeTrue + Test-IsGoContractTestChangeFile -Path '.github/workflows/go-tests.yml' | Should -BeTrue + Test-IsGoContractTestChangeFile -Path '.github/workflows/pr-validation.yml' | Should -BeTrue + Test-IsGoContractTestChangeFile -Path 'scripts/linting/Invoke-GoTest.ps1' | Should -BeTrue + Test-IsGoContractTestChangeFile -Path 'src/900-tools-utilities/904-test-utilities/contract.go' | Should -BeTrue + Test-IsGoContractTestChangeFile -Path 'src/900-tools-utilities/904-test-utilities/go.mod' | Should -BeTrue + Test-IsGoContractTestChangeFile -Path 'scripts/install-terraform-docs.sh' | Should -BeTrue + Test-IsGoContractTestChangeFile -Path 'package.json' | Should -BeTrue + Test-IsGoContractTestChangeFile -Path 'package-lock.json' | Should -BeTrue + } + + It 'returns false for unrelated paths' { + Test-IsGoContractTestChangeFile -Path 'docs/README.md' | Should -BeFalse + Test-IsGoContractTestChangeFile -Path 'src/000-cloud/000-resource-group/terraform/main.tf' | Should -BeFalse + Test-IsGoContractTestChangeFile -Path '.github/workflows/rust-tests.yml' | Should -BeFalse + Test-IsGoContractTestChangeFile -Path 'blueprints/full-single-node-cluster/tests/start.sh' | Should -BeFalse + } +} + +Describe 'Test-GoContractTestHasChange' -Tag 'Unit' { + It 'returns true when any changed file is relevant' { + Test-GoContractTestHasChange -ChangedFiles @( + 'docs/README.md', + 'blueprints/full-single-node-cluster/tests/outputs_contract_test.go' + ) | Should -BeTrue + } + + It 'returns true when shared test-utility helper changes' { + Test-GoContractTestHasChange -ChangedFiles @( + 'docs/README.md', + 'src/900-tools-utilities/904-test-utilities/contract.go' + ) | Should -BeTrue + } + + It 'returns true when terraform-docs installer changes' { + Test-GoContractTestHasChange -ChangedFiles @( + 'docs/README.md', + 'scripts/install-terraform-docs.sh' + ) | Should -BeTrue + } + + It 'returns false when no changed files are relevant' { + Test-GoContractTestHasChange -ChangedFiles @( + 'docs/README.md', + 'src/000-cloud/000-resource-group/terraform/main.tf' + ) | Should -BeFalse + } + + It 'returns false when changed files are empty or null' { + Test-GoContractTestHasChange -ChangedFiles @() | Should -BeFalse + Test-GoContractTestHasChange -ChangedFiles $null | Should -BeFalse + } +} + +Describe 'Invoke-GoTestCore' -Tag 'Unit' { + BeforeEach { + $testRepo = Join-Path -Path $TestDrive -ChildPath 'repo' + $contractTestPath = Join-Path -Path $testRepo -ChildPath 'blueprints/full-single-node-cluster/tests' + New-Item -Path $contractTestPath -ItemType Directory -Force | Out-Null + Set-Content -Path (Join-Path -Path $contractTestPath -ChildPath 'go.mod') -Value 'module test' -Encoding utf8 + + Mock Set-CIOutput { } + Mock Write-CIStepSummary { } + } + + It 'constructs the static-only Go test command' { + $capturedArguments = $null + $runner = { + param([string[]]$Arguments) + $script:capturedArguments = $Arguments + return 0 + } + + $exitCode = Invoke-GoTestCore -RepoRoot $testRepo -CommandRunner $runner + + $exitCode | Should -Be 0 + $script:capturedArguments | Should -Be @('test', '.', '-run', '^(TestTerraformOutputsContract|TestBicepOutputsContract)$') + Should -Invoke Set-CIOutput -ParameterFilter { $Name -eq 'goContractTestsRan' -and $Value -eq 'true' } -Times 1 -Exactly + } + + It 'returns a clear failure when Go is unavailable' { + Mock Get-Command { return $null } -ParameterFilter { $Name -eq 'go' } + Mock Write-Error { } + + Invoke-GoTestCommand -Arguments @('test', '.') | Should -Be 1 + Should -Invoke Write-Error -ParameterFilter { $Message -match 'Go CLI was not found on PATH' } -Times 1 -Exactly + } + + It 'runs from the Go contract test directory' { + $observedLocation = $null + $runner = { + param([string[]]$Arguments) + $null = $Arguments + $script:observedLocation = (Get-Location).Path + return 0 + } + + Invoke-GoTestCore -RepoRoot $testRepo -CommandRunner $runner | Should -Be 0 + + $script:observedLocation | Should -Be (Join-Path -Path $testRepo -ChildPath 'blueprints/full-single-node-cluster/tests') + } + + It 'propagates Go test failure exit codes' { + $runner = { + param([string[]]$Arguments) + $null = $Arguments + return 7 + } + + Invoke-GoTestCore -RepoRoot $testRepo -CommandRunner $runner | Should -Be 7 + Should -Invoke Set-CIOutput -ParameterFilter { $Name -eq 'goContractTestsExitCode' -and $Value -eq '7' } -Times 1 -Exactly + } + + It 'returns failure when the Go contract test context is missing' { + $missingRepo = Join-Path -Path $TestDrive -ChildPath 'missing-repo' + New-Item -Path $missingRepo -ItemType Directory -Force | Out-Null + $runner = { + param([string[]]$Arguments) + $null = $Arguments + throw 'go should not be invoked' + } + + { Invoke-GoTestCore -RepoRoot $missingRepo -CommandRunner $runner } | Should -Throw + } + + It 'runs when changed-file gating detects relevant changes' { + $script:invoked = $false + $runner = { + param([string[]]$Arguments) + $null = $Arguments + $script:invoked = $true + return 0 + } + + Invoke-GoTestCore -RepoRoot $testRepo -ChangedOnly -ChangedFiles @('blueprints/full-single-node-cluster/tests/go.mod') -CommandRunner $runner | Should -Be 0 + + $script:invoked | Should -BeTrue + Should -Invoke Set-CIOutput -ParameterFilter { $Name -eq 'goContractTestsRan' -and $Value -eq 'true' } -Times 1 -Exactly + } + + It 'skips when changed-file gating finds no relevant changes' { + $script:invoked = $false + $runner = { + param([string[]]$Arguments) + $null = $Arguments + $script:invoked = $true + return 0 + } + + Invoke-GoTestCore -RepoRoot $testRepo -ChangedOnly -ChangedFiles @('docs/README.md') -CommandRunner $runner | Should -Be 0 + + $script:invoked | Should -BeFalse + Should -Invoke Set-CIOutput -ParameterFilter { $Name -eq 'goContractTestsRan' -and $Value -eq 'false' } -Times 1 -Exactly + } +}