diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 82756de2..cd980335 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -182,6 +182,16 @@ jobs: contents: read id-token: write + # Terraform documentation freshness check + terraform-docs-check: + name: Terraform Docs Check + uses: ./.github/workflows/terraform-docs-check.yml + with: + soft-fail: true + permissions: + contents: read + + # CodeQL security analysis codeql-analysis: name: CodeQL Analysis @@ -213,6 +223,7 @@ jobs: - terraform-tests - go-lint - go-tests + - terraform-docs-check - codeql-analysis name: Release Please runs-on: ubuntu-latest diff --git a/.github/workflows/pester-tests.yml b/.github/workflows/pester-tests.yml index bb3cd5a7..24369545 100644 --- a/.github/workflows/pester-tests.yml +++ b/.github/workflows/pester-tests.yml @@ -108,6 +108,15 @@ jobs: $testPaths += $testFile } } + + # Map scripts/ root source to test: scripts/Foo.ps1 -> shared/ci/tests/scripts/Foo.Tests.ps1 + if ($file -match '^scripts/([^/]+)\.psm?1$') { + $relativePath = $Matches[1] + $testFile = "shared/ci/tests/scripts/$relativePath.Tests.ps1" + if (Test-Path $testFile) { + $testPaths += $testFile + } + } } $uniquePaths = $testPaths | Sort-Object -Unique diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml index 0e9ccabf..4c9ef5a1 100644 --- a/.github/workflows/pr-validation.yml +++ b/.github/workflows/pr-validation.yml @@ -194,6 +194,16 @@ jobs: permissions: contents: read + # Terraform documentation freshness check + terraform-docs-check: + name: Terraform Docs Check + uses: ./.github/workflows/terraform-docs-check.yml + with: + soft-fail: true + changed-files-only: true + permissions: + contents: read + # Go tests go-tests: name: Go Tests @@ -206,6 +216,7 @@ jobs: contents: read id-token: write + # CodeQL security analysis codeql-analysis: name: CodeQL Analysis diff --git a/.github/workflows/terraform-docs-check.yml b/.github/workflows/terraform-docs-check.yml new file mode 100644 index 00000000..d7e61e3c --- /dev/null +++ b/.github/workflows/terraform-docs-check.yml @@ -0,0 +1,87 @@ +name: Terraform Docs Check + +on: + workflow_call: + inputs: + soft-fail: + description: 'Whether to continue on terraform-docs drift detection' + required: false + type: boolean + default: false + changed-files-only: + description: 'Only check directories with changed Terraform files' + required: false + type: boolean + default: false + terraform-docs-version: + description: 'terraform-docs version to install' + required: false + type: string + default: 'v0.21.0' + terraform-docs-sha256: + description: 'SHA256 checksum of terraform-docs linux-amd64 tarball' + required: false + type: string + default: '2fdd81b8d21ff1498cd559af0dcc5d155835f84600db06d3923e217124fc735a' + +permissions: + contents: read + +defaults: + run: + shell: pwsh + +jobs: + terraform-docs-check: + name: Terraform Docs Check + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + fetch-depth: ${{ inputs.changed-files-only && '0' || '1' }} + + - name: Create logs directory + run: New-Item -ItemType Directory -Force -Path logs | Out-Null + + - name: Setup Node.js and install dependencies + uses: ./.github/actions/setup-node-deps + + - name: Install terraform-docs + run: | + $version = '${{ inputs.terraform-docs-version }}' + $expectedSha = '${{ inputs.terraform-docs-sha256 }}' + $tarball = 'terraform-docs.tar.gz' + $url = "https://github.com/terraform-docs/terraform-docs/releases/download/${version}/terraform-docs-${version}-linux-amd64.tar.gz" + + Invoke-WebRequest -Uri $url -OutFile $tarball + + $actualSha = (Get-FileHash -Path $tarball -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actualSha -ne $expectedSha) { + throw "SHA256 mismatch for terraform-docs ${version}: expected '${expectedSha}', got '${actualSha}'" + } + Write-Output "SHA256 verified: ${actualSha}" + + tar -xzf $tarball + sudo mv terraform-docs /usr/local/bin/ + terraform-docs --version + + - name: Run terraform-docs check + continue-on-error: ${{ inputs.soft-fail }} + run: | + $params = @{} + if ('${{ inputs.changed-files-only }}' -eq 'true') { + $params['ChangedFilesOnly'] = $true + } + shared/ci/linting/Invoke-TerraformDocsCheck.ps1 @params + + - name: Upload terraform-docs check results + if: always() + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: terraform-docs-check-results + path: logs/terraform-docs-check-results.json + retention-days: 30 diff --git a/scripts/Update-TerraformDocs.ps1 b/scripts/Update-TerraformDocs.ps1 index 61bce80d..cc371bb4 100644 --- a/scripts/Update-TerraformDocs.ps1 +++ b/scripts/Update-TerraformDocs.ps1 @@ -15,12 +15,21 @@ Check mode: generate docs and verify no uncommitted changes (for CI). .PARAMETER ConfigPreview Print configuration and exit without making changes. +.PARAMETER TerraformDir + Root directory containing Terraform files. Defaults to infrastructure/terraform. +.PARAMETER ConfigPath + Path to the .terraform-docs.yml configuration file. Defaults to repo root. +.PARAMETER PassthroughArgs + Additional arguments. When --check is included, activates check mode. #> [CmdletBinding()] param( [switch]$Check, - [switch]$ConfigPreview + [switch]$ConfigPreview, + [string]$TerraformDir, + [string]$ConfigPath, + [string[]]$PassthroughArgs ) Set-StrictMode -Version Latest @@ -32,7 +41,10 @@ function Update-TerraformDocsCore { [CmdletBinding()] param( [switch]$Check, - [switch]$ConfigPreview + [switch]$ConfigPreview, + [string]$TerraformDir, + [string]$ConfigPath, + [string[]]$PassthroughArgs ) $repoRoot = & git rev-parse --show-toplevel 2>$null @@ -40,8 +52,13 @@ function Update-TerraformDocsCore { $repoRoot = (Get-Item $PSScriptRoot).Parent.FullName } - $tfBaseDir = Join-Path $repoRoot 'infrastructure/terraform' - $configFile = Join-Path $repoRoot '.terraform-docs.yml' + if (-not $TerraformDir) { $TerraformDir = Join-Path $repoRoot 'infrastructure/terraform' } + if (-not $ConfigPath) { $ConfigPath = Join-Path $repoRoot '.terraform-docs.yml' } + + $isCheckMode = $Check.IsPresent + if ($PassthroughArgs -contains '--check') { + $isCheckMode = $true + } # Validate required tools foreach ($tool in @('terraform-docs', 'npx', 'git')) { @@ -51,17 +68,22 @@ function Update-TerraformDocsCore { } } + if (-not (Test-Path $ConfigPath)) { + Write-CIAnnotation -Level Error -Message "Config file not found: $ConfigPath" + return 1 + } + # Discover Terraform directories (exclude tests/ and setup/) - $tfDirs = Get-ChildItem -Path $tfBaseDir -Filter '*.tf' -Recurse -File | + $tfDirs = Get-ChildItem -Path $TerraformDir -Filter '*.tf' -Recurse -File | Where-Object { $_.FullName -notmatch '[/\\]tests[/\\]' -and $_.FullName -notmatch '[/\\]setup[/\\]' } | ForEach-Object { $_.DirectoryName } | Sort-Object -Unique if ($ConfigPreview) { Write-Host '=== Configuration Preview ===' - Write-Host "Base Directory : $tfBaseDir" - Write-Host "Config File : $configFile" - Write-Host "Check Mode : $Check" + Write-Host "Base Directory : $TerraformDir" + Write-Host "Config File : $ConfigPath" + Write-Host "Check Mode : $isCheckMode" Write-Host "Directories : $($tfDirs.Count)" foreach ($dir in $tfDirs) { $relDir = $dir.Substring($repoRoot.Length + 1) @@ -77,7 +99,7 @@ function Update-TerraformDocsCore { foreach ($dir in $tfDirs) { $relDir = $dir.Substring($repoRoot.Length + 1) Write-Host "Processing: $relDir" - & terraform-docs markdown table --config $configFile --output-file TERRAFORM.md $dir + & terraform-docs markdown table --config $ConfigPath --output-file TERRAFORM.md $dir if ($LASTEXITCODE -ne 0) { Write-CIAnnotation -Level Error -Message "terraform-docs failed for $relDir" return 1 @@ -100,13 +122,15 @@ function Update-TerraformDocsCore { } # Check mode: verify no uncommitted changes to generated files - if ($Check) { + if ($isCheckMode) { Write-Host '=== Checking for Changes ===' - $tfDocFiles = Get-ChildItem -Path $tfBaseDir -Filter 'TERRAFORM.md' -Recurse -File | + $tfDocFiles = Get-ChildItem -Path $TerraformDir -Filter 'TERRAFORM.md' -Recurse -File | ForEach-Object { $_.FullName } - & git diff --exit-code -- @tfDocFiles - if ($LASTEXITCODE -ne 0) { - Write-CIAnnotation -Level Error -Message "terraform-docs output is out of date. Run 'npm run docs:generate:tf' to update." + $diffOutput = & git diff -- @tfDocFiles + if ($diffOutput) { + $diffOutput + Write-Host 'Restoring modified files to original state...' + & git checkout -- @tfDocFiles return 1 } } diff --git a/setup-dev.ps1 b/setup-dev.ps1 index 4c2e1052..e048c9a3 100644 --- a/setup-dev.ps1 +++ b/setup-dev.ps1 @@ -1,4 +1,4 @@ -#!/usr/bin/env pwsh +#!/usr/bin/env pwsh # Copyright (c) Microsoft Corporation. # SPDX-License-Identifier: MIT @@ -89,6 +89,48 @@ Write-Host '' Write-Host 'If this script fails, the devcontainer is your fallback.' Write-Host '' +Write-Section 'Git Symlink Resolution' + +# Git symlinks are stored as text files on Windows when core.symlinks=false. +# Replace broken symlinks with junctions (directories) or hard links (files). +$symlinkEntries = git ls-files -s 2>$null | Select-String '120000' | ForEach-Object { + ($_ -split '\s+', 4)[3] +} +$repairedCount = 0 +foreach ($entry in $symlinkEntries) { + $fullPath = Join-Path $ScriptDir $entry + if (-not (Test-Path $fullPath)) { continue } + + $item = Get-Item $fullPath -Force + # Already a junction/symlink — nothing to fix + if ($item.LinkType) { continue } + # Only fix plain text files (broken symlink placeholders) + if ($item.PSIsContainer) { continue } + + $target = (Get-Content $fullPath -Raw).Trim() + $resolvedTarget = Resolve-Path (Join-Path (Split-Path $fullPath) $target) -ErrorAction SilentlyContinue + if (-not $resolvedTarget) { + Write-Warn "Symlink target not found: $entry -> $target" + continue + } + + Remove-Item $fullPath -Force + $targetItem = Get-Item $resolvedTarget.Path + if ($targetItem.PSIsContainer) { + New-Item -ItemType Junction -Path $fullPath -Target $resolvedTarget.Path | Out-Null + } + else { + New-Item -ItemType HardLink -Path $fullPath -Target $resolvedTarget.Path | Out-Null + } + $repairedCount++ +} +if ($repairedCount -gt 0) { + Write-Info "Repaired $repairedCount broken git symlink(s) (junctions/hard links)" +} +else { + Write-Info 'All git symlinks are intact' +} + Write-Section 'Tool Verification' Assert-Tools az, terraform, kubectl, helm, jq diff --git a/shared/ci/linting/Invoke-TerraformDocsCheck.ps1 b/shared/ci/linting/Invoke-TerraformDocsCheck.ps1 new file mode 100644 index 00000000..530bdc76 --- /dev/null +++ b/shared/ci/linting/Invoke-TerraformDocsCheck.ps1 @@ -0,0 +1,167 @@ +#!/usr/bin/env pwsh +# Copyright (c) Microsoft Corporation. +# SPDX-License-Identifier: MIT + +#Requires -Version 7.0 + +<# +.SYNOPSIS + Checks that terraform-docs generated documentation is up to date. +.DESCRIPTION + Runs npm run docs:generate:tf -- --check to compare generated documentation against committed + files. Reports drift via CI annotations and writes JSON results to logs/. +.PARAMETER OutputPath + Path for JSON results. Defaults to logs/terraform-docs-check-results.json. +.PARAMETER ChangedFilesOnly + When set, only check if directories containing changed .tf files have doc drift. +#> + +[CmdletBinding()] +param( + [string]$OutputPath, + [switch]$ChangedFilesOnly +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +Import-Module (Join-Path $PSScriptRoot "Modules/LintingHelpers.psm1") -Force +Import-Module (Join-Path $PSScriptRoot "../../../scripts/lib/Modules/CIHelpers.psm1") -Force + +function Invoke-TerraformDocsCheckCore { + [CmdletBinding()] + param( + [string]$OutputPath, + [switch]$ChangedFilesOnly + ) + + $repoRoot = & git rev-parse --show-toplevel 2>$null + if (-not $repoRoot) { + $repoRoot = (Get-Item $PSScriptRoot).Parent.Parent.Parent.FullName + } + + if (-not $OutputPath) { $OutputPath = Join-Path $repoRoot 'logs/terraform-docs-check-results.json' } + + $outputDir = Split-Path $OutputPath -Parent + if (-not (Test-Path $outputDir)) { + New-Item -ItemType Directory -Force -Path $outputDir | Out-Null + } + + if (-not (Get-Command terraform-docs -ErrorAction SilentlyContinue)) { + Write-CIAnnotation -Level Error -Message 'terraform-docs is not installed or not in PATH' + return 1 + } + + $tdVersion = (& terraform-docs --version 2>&1 | Out-String).Trim() + + # Skip if no relevant files changed + if ($ChangedFilesOnly) { + $changedTf = @(Get-ChangedFilesFromGit -FileExtensions @('*.tf', '*.tfvars')) + $changedConfig = @(Get-ChangedFilesFromGit -FileExtensions @('*.yml') | Where-Object { $_ -match '\.terraform-docs\.yml$' }) + + if ($changedTf.Count -eq 0 -and $changedConfig.Count -eq 0) { + Write-Host 'No Terraform or terraform-docs config files changed — skipping docs check' + + $results = @{ + timestamp = (Get-Date -Format 'o') + terraform_docs_version = $tdVersion + skipped = $true + drift_detected = $false + drifted_files = @() + summary = @{ + files_drifted = 0 + overall_passed = $true + } + } + + $results | ConvertTo-Json -Depth 10 | Out-File -FilePath $OutputPath -Encoding utf8 + Write-Host "Results written to $OutputPath" + + $summaryContent = "### Terraform Docs Check Results`n`n**Status:** ⏭️ Skipped (no relevant files changed)" + Write-CIStepSummary -Content $summaryContent + Write-Host $summaryContent + return 0 + } + } + + # Run terraform-docs check via npm script + Write-Host 'Running npm run docs:generate:tf -- --check...' + $output = & npm run docs:generate:tf -- --check 2>&1 | ForEach-Object { $_.ToString() } + $exitCode = $LASTEXITCODE + $driftDetected = ($exitCode -ne 0) + $driftedFiles = @() + + if ($driftDetected) { + # Parse output for drifted file paths from git diff output + $driftedFiles = @($output | ForEach-Object { + if ($_ -match 'diff --git a/(.+) b/') { $Matches[1] } + } | Where-Object { $_ } | Sort-Object -Unique) + + foreach ($file in $driftedFiles) { + Write-CIAnnotation -Level Error -Message "Documentation is out of date: $file. Run 'npm run docs:generate:tf' to regenerate." -File $file + } + + if ($driftedFiles.Count -eq 0) { + Write-CIAnnotation -Level Error -Message "terraform-docs detected documentation drift. Run 'npm run docs:generate:tf' to regenerate." + } + } + + # Build results + $results = @{ + timestamp = (Get-Date -Format 'o') + terraform_docs_version = $tdVersion + skipped = $false + drift_detected = $driftDetected + drifted_files = $driftedFiles + output = ($output -join "`n") + summary = @{ + files_drifted = $driftedFiles.Count + overall_passed = (-not $driftDetected) + } + } + + $results | ConvertTo-Json -Depth 10 | Out-File -FilePath $OutputPath -Encoding utf8 + Write-Host "Results written to $OutputPath" + + # Step summary + $summaryLines = @() + $summaryLines += '### Terraform Docs Check Results' + $summaryLines += '' + + if ($driftDetected) { + $summaryLines += '**Status:** ❌ Documentation drift detected' + $summaryLines += '' + $summaryLines += 'Run `npm run docs:generate:tf` to regenerate documentation.' + $summaryLines += '' + if ($driftedFiles.Count -gt 0) { + $summaryLines += '| File | Status |' + $summaryLines += '|------|--------|' + foreach ($file in $driftedFiles) { + $summaryLines += "| ``$file`` | ❌ Out of date |" + } + } + } + else { + $summaryLines += '**Status:** ✅ All documentation is up to date' + } + + $summaryContent = $summaryLines -join "`n" + Write-CIStepSummary -Content $summaryContent + Write-Host $summaryContent + + if ($driftDetected) { return 1 } else { return 0 } +} + +#region Main Execution +if ($MyInvocation.InvocationName -ne '.') { + try { + $exitCode = Invoke-TerraformDocsCheckCore @PSBoundParameters + exit $exitCode + } + catch { + Write-Error -ErrorAction Continue "Invoke-TerraformDocsCheck failed: $($_.Exception.Message)" + Write-CIAnnotation -Level Error -Message $_.Exception.Message + exit 1 + } +} +#endregion Main Execution diff --git a/shared/ci/tests/linting/Invoke-TerraformDocsCheck.Tests.ps1 b/shared/ci/tests/linting/Invoke-TerraformDocsCheck.Tests.ps1 new file mode 100644 index 00000000..af08fef7 --- /dev/null +++ b/shared/ci/tests/linting/Invoke-TerraformDocsCheck.Tests.ps1 @@ -0,0 +1,166 @@ +# Copyright (c) Microsoft Corporation. +# SPDX-License-Identifier: MIT + +#Requires -Version 7.0 +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0' } + +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseApprovedVerbs', '', Justification = 'Mock stub for terraform-docs CLI')] +param() + +BeforeAll { + . $PSScriptRoot/../../linting/Invoke-TerraformDocsCheck.ps1 + $ErrorActionPreference = 'Continue' + Import-Module (Join-Path $PSScriptRoot '../Mocks/GitMocks.psm1') -Force + function terraform-docs { } + function npm { } +} + +Describe 'Invoke-TerraformDocsCheckCore' -Tag 'Unit' { + BeforeAll { Save-CIEnvironment } + AfterAll { Restore-CIEnvironment } + + BeforeEach { + $script:MockFiles = Initialize-MockCIEnvironment -Workspace $TestDrive + $script:TestOutputPath = Join-Path $TestDrive 'logs/terraform-docs-check-results.json' + + Mock git { return $TestDrive } -ParameterFilter { $args[0] -eq 'rev-parse' } + Mock Get-Command { return @{ Name = 'terraform-docs' } } -ParameterFilter { $Name -eq 'terraform-docs' } + Mock Write-CIAnnotation {} + Mock Write-CIStepSummary {} + + # Default: npm run docs:generate:tf -- --check succeeds (no drift) + Mock npm { + $global:LASTEXITCODE = 0 + return 'All documents are up to date' + } + } + + AfterEach { + Restore-CIEnvironment + Remove-MockCIFiles -MockFiles $script:MockFiles + } + + Context 'tool availability' { + It 'Returns 1 when terraform-docs is not in PATH' { + Mock Get-Command { return $null } -ParameterFilter { $Name -eq 'terraform-docs' } + $result = Invoke-TerraformDocsCheckCore -OutputPath $script:TestOutputPath + $result | Should -Be 1 + } + + It 'Writes error annotation when terraform-docs missing' { + Mock Get-Command { return $null } -ParameterFilter { $Name -eq 'terraform-docs' } + Invoke-TerraformDocsCheckCore -OutputPath $script:TestOutputPath + Should -Invoke Write-CIAnnotation -Times 1 -ParameterFilter { + $Level -eq 'Error' -and $Message -like '*terraform-docs*not*' + } + } + } + + Context 'clean run (no drift)' { + It 'Returns 0 when npm run docs:generate:tf passes' { + $result = Invoke-TerraformDocsCheckCore -OutputPath $script:TestOutputPath + $result | Should -Be 0 + } + + It 'Creates output JSON file' { + Invoke-TerraformDocsCheckCore -OutputPath $script:TestOutputPath + $script:TestOutputPath | Should -Exist + } + + It 'JSON has correct structure' { + Invoke-TerraformDocsCheckCore -OutputPath $script:TestOutputPath + $json = Get-Content $script:TestOutputPath -Raw | ConvertFrom-Json + $json | Should -Not -BeNullOrEmpty + $json.PSObject.Properties.Name | Should -Contain 'drift_detected' + $json.PSObject.Properties.Name | Should -Contain 'drifted_files' + $json.summary | Should -Not -BeNullOrEmpty + } + + It 'summary.overall_passed is true' { + Invoke-TerraformDocsCheckCore -OutputPath $script:TestOutputPath + $json = Get-Content $script:TestOutputPath -Raw | ConvertFrom-Json + $json.summary.overall_passed | Should -BeTrue + } + + It 'Step summary is written' { + Invoke-TerraformDocsCheckCore -OutputPath $script:TestOutputPath + Should -Invoke Write-CIStepSummary -Times 1 + } + } + + Context 'drift detected' { + BeforeEach { + Mock npm { + $global:LASTEXITCODE = 1 + return @( + 'diff --git a/infrastructure/terraform/README.md b/infrastructure/terraform/README.md', + 'index abc..def 100644', + '--- a/infrastructure/terraform/README.md', + '+++ b/infrastructure/terraform/README.md' + ) + } + } + + It 'Returns 1 when drift detected' { + $result = Invoke-TerraformDocsCheckCore -OutputPath $script:TestOutputPath + $result | Should -Be 1 + } + + It 'Captures drifted files in JSON output' { + Invoke-TerraformDocsCheckCore -OutputPath $script:TestOutputPath + $json = Get-Content $script:TestOutputPath -Raw | ConvertFrom-Json + $json.drifted_files | Should -Contain 'infrastructure/terraform/README.md' + } + + It 'Writes error annotations for drifted files' { + Invoke-TerraformDocsCheckCore -OutputPath $script:TestOutputPath + Should -Invoke Write-CIAnnotation -ParameterFilter { + $Level -eq 'Error' -and $Message -like '*out of date*' + } + } + + It 'drift_detected is true in output' { + Invoke-TerraformDocsCheckCore -OutputPath $script:TestOutputPath + $json = Get-Content $script:TestOutputPath -Raw | ConvertFrom-Json + $json.drift_detected | Should -BeTrue + } + } + + Context 'change detection (ChangedFilesOnly)' { + BeforeEach { + Mock Get-ChangedFilesFromGit { return @() } + } + + It 'Returns 0 early when no terraform files changed' { + $result = Invoke-TerraformDocsCheckCore -OutputPath $script:TestOutputPath -ChangedFilesOnly + $result | Should -Be 0 + } + + It 'Does not invoke npm when no files changed' { + Invoke-TerraformDocsCheckCore -OutputPath $script:TestOutputPath -ChangedFilesOnly + Should -Invoke npm -Times 0 + } + + It 'Sets skipped=true in JSON when no files changed' { + Invoke-TerraformDocsCheckCore -OutputPath $script:TestOutputPath -ChangedFilesOnly + $json = Get-Content $script:TestOutputPath -Raw | ConvertFrom-Json + $json.skipped | Should -BeTrue + } + } + + Context 'change detection with terraform-docs config changes' { + BeforeEach { + Mock Get-ChangedFilesFromGit { + if ($FileExtensions -contains '*.yml') { + return @('.terraform-docs.yml') + } + return @() + } + } + + It 'Runs full check when config file changed' { + Invoke-TerraformDocsCheckCore -OutputPath $script:TestOutputPath -ChangedFilesOnly + Should -Invoke npm -Times 1 + } + } +} diff --git a/shared/ci/tests/pester.config.ps1 b/shared/ci/tests/pester.config.ps1 index f4ba33f5..ff24d548 100644 --- a/shared/ci/tests/pester.config.ps1 +++ b/shared/ci/tests/pester.config.ps1 @@ -57,7 +57,7 @@ if ($CodeCoverage.IsPresent) { $coverageSources = @( (Join-Path $ciRoot 'linting'), (Join-Path $ciRoot 'security'), - (Join-Path $repoRoot 'scripts' 'lib') + (Join-Path $repoRoot 'scripts') ) $coveragePaths = $coverageSources | ForEach-Object { diff --git a/shared/ci/tests/scripts/Update-TerraformDocs.Tests.ps1 b/shared/ci/tests/scripts/Update-TerraformDocs.Tests.ps1 new file mode 100644 index 00000000..b8e6e643 --- /dev/null +++ b/shared/ci/tests/scripts/Update-TerraformDocs.Tests.ps1 @@ -0,0 +1,188 @@ +# Copyright (c) Microsoft Corporation. +# SPDX-License-Identifier: MIT + +#Requires -Version 7.0 +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0' } + +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseApprovedVerbs', '', Justification = 'Mock stub for terraform-docs CLI')] +param() + +BeforeAll { + . $PSScriptRoot/../../../../scripts/Update-TerraformDocs.ps1 + $ErrorActionPreference = 'Continue' + Import-Module (Join-Path $PSScriptRoot '../Mocks/GitMocks.psm1') -Force + function terraform-docs { } +} + +Describe 'Update-TerraformDocsCore' -Tag 'Unit' { + BeforeAll { Save-CIEnvironment } + AfterAll { Restore-CIEnvironment } + + BeforeEach { + $script:MockFiles = Initialize-MockCIEnvironment -Workspace $TestDrive + $script:TestTerraformDir = Join-Path $TestDrive 'infrastructure/terraform' + $script:TestConfigPath = Join-Path $TestDrive '.terraform-docs.yml' + + # Create terraform dir with .tf files + New-Item -ItemType Directory -Force -Path $script:TestTerraformDir | Out-Null + 'resource "azurerm_resource_group" "rg" {}' | Out-File (Join-Path $script:TestTerraformDir 'main.tf') + + # Create config file + 'formatter: "markdown table"' | Out-File $script:TestConfigPath + + # Create subdirectory with .tf files + $vpnDir = Join-Path $script:TestTerraformDir 'vpn' + New-Item -ItemType Directory -Force -Path $vpnDir | Out-Null + 'resource "azurerm_virtual_network_gateway" "gw" {}' | Out-File (Join-Path $vpnDir 'main.tf') + + Mock git { return $TestDrive } -ParameterFilter { $args[0] -eq 'rev-parse' } + Mock Get-Command { return @{ Name = 'terraform-docs' } } -ParameterFilter { $Name -eq 'terraform-docs' } + + # Default: terraform-docs succeeds silently + Mock terraform-docs { $global:LASTEXITCODE = 0 } + # Default: git diff shows no changes (clean) + Mock git { $global:LASTEXITCODE = 0; return '' } -ParameterFilter { $args[0] -eq 'diff' } + Mock git { } -ParameterFilter { $args[0] -eq 'checkout' } + } + + AfterEach { + Restore-CIEnvironment + Remove-MockCIFiles -MockFiles $script:MockFiles + } + + Context 'tool availability' { + It 'Returns 1 when terraform-docs is not in PATH' { + Mock Get-Command { return $null } -ParameterFilter { $Name -eq 'terraform-docs' } + $result = Update-TerraformDocsCore -TerraformDir $script:TestTerraformDir ` + -ConfigPath $script:TestConfigPath + $result | Should -Be 1 + } + + It 'Returns 1 when config file does not exist' { + $result = Update-TerraformDocsCore -TerraformDir $script:TestTerraformDir ` + -ConfigPath (Join-Path $TestDrive 'nonexistent.yml') + $result | Should -Be 1 + } + } + + Context 'directory discovery' { + It 'Discovers root terraform directory' { + Update-TerraformDocsCore -TerraformDir $script:TestTerraformDir ` + -ConfigPath $script:TestConfigPath + Should -Invoke terraform-docs -ParameterFilter { + $args -contains $script:TestTerraformDir + } + } + + It 'Discovers subdirectories containing .tf files' { + Update-TerraformDocsCore -TerraformDir $script:TestTerraformDir ` + -ConfigPath $script:TestConfigPath + $vpnDir = Join-Path $script:TestTerraformDir 'vpn' + Should -Invoke terraform-docs -ParameterFilter { + $args -contains $vpnDir + } + } + + It 'Skips subdirectories without .tf files' { + $emptyDir = Join-Path $script:TestTerraformDir 'empty-module' + New-Item -ItemType Directory -Force -Path $emptyDir | Out-Null + 'not a terraform file' | Out-File (Join-Path $emptyDir 'notes.txt') + + Update-TerraformDocsCore -TerraformDir $script:TestTerraformDir ` + -ConfigPath $script:TestConfigPath + Should -Invoke terraform-docs -Times 0 -ParameterFilter { + $args -contains $emptyDir + } + } + } + + Context 'generate mode (no -Check)' { + It 'Returns 0 on successful generation' { + $result = Update-TerraformDocsCore -TerraformDir $script:TestTerraformDir ` + -ConfigPath $script:TestConfigPath + $result | Should -Be 0 + } + + It 'Invokes terraform-docs for each discovered directory' { + Update-TerraformDocsCore -TerraformDir $script:TestTerraformDir ` + -ConfigPath $script:TestConfigPath + # Root + vpn subdirectory = 2 invocations + Should -Invoke terraform-docs -Times 2 + } + + It 'Passes correct config path to terraform-docs' { + Update-TerraformDocsCore -TerraformDir $script:TestTerraformDir ` + -ConfigPath $script:TestConfigPath + Should -Invoke terraform-docs -ParameterFilter { + $args -contains $script:TestConfigPath + } + } + + It 'Returns 1 when terraform-docs fails' { + Mock terraform-docs { $global:LASTEXITCODE = 1 } + $result = Update-TerraformDocsCore -TerraformDir $script:TestTerraformDir ` + -ConfigPath $script:TestConfigPath + $result | Should -Be 1 + } + } + + Context 'check mode (-Check)' { + It 'Returns 0 when no drift detected' { + $result = Update-TerraformDocsCore -Check ` + -TerraformDir $script:TestTerraformDir -ConfigPath $script:TestConfigPath + $result | Should -Be 0 + } + + It 'Returns 1 when drift detected' { + Mock git { + $global:LASTEXITCODE = 0 + return @( + 'diff --git a/infrastructure/terraform/README.md b/infrastructure/terraform/README.md', + '--- a/infrastructure/terraform/README.md', + '+++ b/infrastructure/terraform/README.md', + '+| Name | Description |' + ) + } -ParameterFilter { $args[0] -eq 'diff' } + + $result = @(Update-TerraformDocsCore -Check ` + -TerraformDir $script:TestTerraformDir -ConfigPath $script:TestConfigPath) + $result[-1] | Should -Be 1 + } + + It 'Outputs diff for CI wrapper to parse' { + Mock git { + $global:LASTEXITCODE = 0 + return @( + 'diff --git a/infrastructure/terraform/README.md b/infrastructure/terraform/README.md', + '--- a/infrastructure/terraform/README.md', + '+++ b/infrastructure/terraform/README.md' + ) + } -ParameterFilter { $args[0] -eq 'diff' } + + $output = Update-TerraformDocsCore -Check ` + -TerraformDir $script:TestTerraformDir -ConfigPath $script:TestConfigPath + $strings = $output | ForEach-Object { "$_" } + $strings | Should -Contain 'diff --git a/infrastructure/terraform/README.md b/infrastructure/terraform/README.md' + } + + It 'Restores original files after check' { + Mock git { + $global:LASTEXITCODE = 0 + return @('diff --git a/infrastructure/terraform/README.md b/infrastructure/terraform/README.md') + } -ParameterFilter { $args[0] -eq 'diff' } + + Update-TerraformDocsCore -Check ` + -TerraformDir $script:TestTerraformDir -ConfigPath $script:TestConfigPath + Should -Invoke git -ParameterFilter { $args[0] -eq 'checkout' } + } + } + + Context 'npm passthrough args (--check)' { + It 'Activates check mode when --check in PassthroughArgs' { + Update-TerraformDocsCore -TerraformDir $script:TestTerraformDir ` + -ConfigPath $script:TestConfigPath -PassthroughArgs @('--check') + # Check mode invokes git diff + Should -Invoke git -ParameterFilter { $args[0] -eq 'diff' } + } + } +}