microsoft/hve-core
Publicmirrored fromhttps://github.com/microsoft/hve-coreAvailable
scripts/devcontainer/Test-DevcontainerLockfile.ps1
237lines · modecode
| 1 | #!/usr/bin/env pwsh |
| 2 | # Copyright (c) Microsoft Corporation. |
| 3 | # SPDX-License-Identifier: MIT |
| 4 | #Requires -Version 7.0 |
| 5 | |
| 6 | <# |
| 7 | .SYNOPSIS |
| 8 | Validates devcontainer lockfile integrity and feature coverage. |
| 9 | |
| 10 | .DESCRIPTION |
| 11 | Checks that devcontainer-lock.json exists, all features have SHA-256 integrity |
| 12 | hashes and resolved references, and that every feature declared in devcontainer.json |
| 13 | is present in the lockfile. Outputs results as JSON and emits CI annotations for |
| 14 | any violations found. |
| 15 | |
| 16 | .PARAMETER RepoRoot |
| 17 | Root directory of the repository. Defaults to the git working tree root or the |
| 18 | script directory when not inside a git repository. |
| 19 | |
| 20 | .PARAMETER OutputPath |
| 21 | Path where validation results JSON should be saved. Defaults to |
| 22 | 'logs/devcontainer-lockfile-results.json'. |
| 23 | |
| 24 | .PARAMETER FailOnViolation |
| 25 | Exit with code 1 when any validation check fails. |
| 26 | |
| 27 | .EXAMPLE |
| 28 | ./Test-DevcontainerLockfile.ps1 |
| 29 | Validate lockfile in the current repository with default settings. |
| 30 | |
| 31 | .EXAMPLE |
| 32 | ./Test-DevcontainerLockfile.ps1 -FailOnViolation |
| 33 | Validate lockfile and exit with error code on failures. |
| 34 | |
| 35 | .NOTES |
| 36 | Runs via: npm run validate:devcontainer-lockfile |
| 37 | #> |
| 38 | |
| 39 | [CmdletBinding()] |
| 40 | param( |
| 41 | [Parameter(Mandatory = $false)] |
| 42 | [string]$RepoRoot = (git rev-parse --show-toplevel 2>$null), |
| 43 | |
| 44 | [Parameter(Mandatory = $false)] |
| 45 | [string]$OutputPath = 'logs/devcontainer-lockfile-results.json', |
| 46 | |
| 47 | [Parameter(Mandatory = $false)] |
| 48 | [switch]$FailOnViolation |
| 49 | ) |
| 50 | |
| 51 | if ([string]::IsNullOrWhiteSpace($RepoRoot)) { $RepoRoot = $PSScriptRoot } |
| 52 | |
| 53 | $ErrorActionPreference = 'Stop' |
| 54 | |
| 55 | Import-Module (Join-Path $PSScriptRoot '../lib/Modules/CIHelpers.psm1') -Force |
| 56 | |
| 57 | #region Functions |
| 58 | |
| 59 | function Test-LockfileExists { |
| 60 | [CmdletBinding()] |
| 61 | [OutputType([hashtable])] |
| 62 | param( |
| 63 | [Parameter(Mandatory = $true)] |
| 64 | [string]$RepoRoot |
| 65 | ) |
| 66 | |
| 67 | $lockfilePath = Join-Path $RepoRoot '.devcontainer/devcontainer-lock.json' |
| 68 | if (Test-Path $lockfilePath) { |
| 69 | return @{ |
| 70 | Passed = $true |
| 71 | Message = "Lockfile exists at $lockfilePath" |
| 72 | } |
| 73 | } |
| 74 | |
| 75 | return @{ |
| 76 | Passed = $false |
| 77 | Message = "Lockfile not found at $lockfilePath" |
| 78 | } |
| 79 | } |
| 80 | |
| 81 | function Test-FeatureIntegrity { |
| 82 | [CmdletBinding()] |
| 83 | [OutputType([hashtable])] |
| 84 | param( |
| 85 | [Parameter(Mandatory = $true)] |
| 86 | [string]$LockfilePath |
| 87 | ) |
| 88 | |
| 89 | $lockData = Get-Content -Path $LockfilePath -Raw | ConvertFrom-Json |
| 90 | $violations = @() |
| 91 | |
| 92 | foreach ($feature in $lockData.features.PSObject.Properties) { |
| 93 | $name = $feature.Name |
| 94 | $value = $feature.Value |
| 95 | |
| 96 | if (-not $value.resolved) { |
| 97 | $violations += "Feature '$name' is missing a resolved reference" |
| 98 | } |
| 99 | if (-not $value.integrity) { |
| 100 | $violations += "Feature '$name' is missing an integrity hash" |
| 101 | } |
| 102 | elseif (-not $value.integrity.StartsWith('sha256:')) { |
| 103 | $violations += "Feature '$name' has non-SHA-256 integrity: $($value.integrity)" |
| 104 | } |
| 105 | } |
| 106 | |
| 107 | return @{ |
| 108 | Passed = ($violations.Count -eq 0) |
| 109 | Violations = $violations |
| 110 | } |
| 111 | } |
| 112 | |
| 113 | function Test-FeatureCoverage { |
| 114 | [CmdletBinding()] |
| 115 | [OutputType([hashtable])] |
| 116 | param( |
| 117 | [Parameter(Mandatory = $true)] |
| 118 | [string]$LockfilePath, |
| 119 | |
| 120 | [Parameter(Mandatory = $true)] |
| 121 | [string]$ConfigPath |
| 122 | ) |
| 123 | |
| 124 | $lockData = Get-Content -Path $LockfilePath -Raw | ConvertFrom-Json |
| 125 | $configData = Get-Content -Path $ConfigPath -Raw | ConvertFrom-Json |
| 126 | |
| 127 | $lockKeys = @($lockData.features.PSObject.Properties | |
| 128 | ForEach-Object { $_.Name.ToLowerInvariant() }) |
| 129 | $configKeys = @($configData.features.PSObject.Properties | |
| 130 | ForEach-Object { $_.Name.ToLowerInvariant() }) |
| 131 | |
| 132 | $missingKeys = @($configKeys | Where-Object { $_ -notin $lockKeys }) |
| 133 | |
| 134 | return @{ |
| 135 | Passed = ($missingKeys.Count -eq 0) |
| 136 | MissingKeys = $missingKeys |
| 137 | } |
| 138 | } |
| 139 | |
| 140 | function Invoke-LockfileValidation { |
| 141 | [CmdletBinding()] |
| 142 | [OutputType([hashtable])] |
| 143 | param( |
| 144 | [Parameter(Mandatory = $true)] |
| 145 | [string]$RepoRoot |
| 146 | ) |
| 147 | |
| 148 | $lockfilePath = Join-Path $RepoRoot '.devcontainer/devcontainer-lock.json' |
| 149 | $configPath = Join-Path $RepoRoot '.devcontainer/devcontainer.json' |
| 150 | |
| 151 | $details = @() |
| 152 | |
| 153 | # Check 1: Lockfile exists |
| 154 | $existsResult = Test-LockfileExists -RepoRoot $RepoRoot |
| 155 | $details += @{ |
| 156 | CheckName = 'LockfileExists' |
| 157 | Passed = $existsResult.Passed |
| 158 | Message = $existsResult.Message |
| 159 | } |
| 160 | |
| 161 | if (-not $existsResult.Passed) { |
| 162 | Write-CIAnnotation -Level Error -Message $existsResult.Message |
| 163 | return @{ |
| 164 | TotalChecks = 1 |
| 165 | PassedChecks = 0 |
| 166 | FailedChecks = 1 |
| 167 | Details = $details |
| 168 | } |
| 169 | } |
| 170 | |
| 171 | # Check 2: Feature integrity |
| 172 | $integrityResult = Test-FeatureIntegrity -LockfilePath $lockfilePath |
| 173 | $details += @{ |
| 174 | CheckName = 'FeatureIntegrity' |
| 175 | Passed = $integrityResult.Passed |
| 176 | Violations = $integrityResult.Violations |
| 177 | } |
| 178 | |
| 179 | if (-not $integrityResult.Passed) { |
| 180 | foreach ($violation in $integrityResult.Violations) { |
| 181 | Write-CIAnnotation -Level Error -Message $violation |
| 182 | } |
| 183 | } |
| 184 | |
| 185 | # Check 3: Feature coverage |
| 186 | $coverageResult = Test-FeatureCoverage -LockfilePath $lockfilePath -ConfigPath $configPath |
| 187 | $details += @{ |
| 188 | CheckName = 'FeatureCoverage' |
| 189 | Passed = $coverageResult.Passed |
| 190 | MissingKeys = $coverageResult.MissingKeys |
| 191 | } |
| 192 | |
| 193 | if (-not $coverageResult.Passed) { |
| 194 | foreach ($key in $coverageResult.MissingKeys) { |
| 195 | Write-CIAnnotation -Level Error -Message "Feature '$key' declared in devcontainer.json but missing from lockfile" |
| 196 | } |
| 197 | } |
| 198 | |
| 199 | $passedCount = ($details | Where-Object { $_.Passed }).Count |
| 200 | $failedCount = ($details | Where-Object { -not $_.Passed }).Count |
| 201 | |
| 202 | return @{ |
| 203 | TotalChecks = $details.Count |
| 204 | PassedChecks = $passedCount |
| 205 | FailedChecks = $failedCount |
| 206 | Details = $details |
| 207 | } |
| 208 | } |
| 209 | |
| 210 | #endregion Functions |
| 211 | |
| 212 | #region Main Execution |
| 213 | |
| 214 | if ($MyInvocation.InvocationName -ne '.') { |
| 215 | try { |
| 216 | New-Item -ItemType Directory -Force -Path (Split-Path $OutputPath -Parent) | Out-Null |
| 217 | $result = Invoke-LockfileValidation -RepoRoot $RepoRoot |
| 218 | $result | ConvertTo-Json -Depth 10 | Set-Content -Path $OutputPath -Encoding UTF8 |
| 219 | |
| 220 | if ($result.FailedChecks -gt 0) { |
| 221 | Write-CIAnnotation -Level Error -Message "Devcontainer lockfile integrity check failed with $($result.FailedChecks) error(s)" |
| 222 | if ($FailOnViolation) { |
| 223 | exit 1 |
| 224 | } |
| 225 | } |
| 226 | else { |
| 227 | Write-Host "[PASS] Lockfile covers all features with SHA-256 integrity" |
| 228 | } |
| 229 | exit 0 |
| 230 | } |
| 231 | catch { |
| 232 | Write-Error -ErrorAction Continue "Test-DevcontainerLockfile failed: $($_.Exception.Message)" |
| 233 | exit 1 |
| 234 | } |
| 235 | } |
| 236 | |
| 237 | #endregion Main Execution |
| 238 | |