Private/Test-AvmTerraformConstraint.ps1
|
function Test-AvmTerraformConstraint { <# .SYNOPSIS Parses a Terraform version constraint string and evaluates it against a candidate target version. .DESCRIPTION Supports: - Exact pins: "0.3.1", "= 0.3.1" - Pessimistic: "~> 0.3", "~> 0.3.1" - Comma-separated simple ranges: ">= 0.3, < 0.5", ">= 0.3.0", "!= 0.4.0" Comma-separated clauses are combined with AND, matching Terraform's own constraint semantics. Anything that doesn't match one of the clause shapes above (e.g. caret ranges, malformed text) is reported as unparseable rather than guessed at. 0.x pessimistic constraints are treated conservatively: AVM modules are 0.x and treat a *minor* bump as potentially breaking (see Get-AvmUpdateRisk), so "~> 0.3" is NOT considered satisfied by "0.4.x" even though Terraform's own resolver would accept it (">= 0.3, < 1.0.0"). Instead, a rewrite to "~> 0.4" is proposed so the bump goes through risk analysis / PR review instead of being silently picked up by a bare `terraform init -upgrade`. For 1.x+ majors, standard pessimistic-operator semantics apply (major is locked, everything to the right of it floats). .PARAMETER Constraint The raw constraint string as written in the .tf file, e.g. '~> 0.3', '>= 0.3, < 0.5'. .PARAMETER TargetVersion The candidate target version (a clean semver string, e.g. from Get-AvmLatestVersion). .OUTPUTS PSCustomObject: - parseable [bool] $false if any clause could not be parsed. - satisfied [bool] $true if TargetVersion already satisfies Constraint. - canRewrite [bool] $true if a safe rewrite proposal could be computed. - kind [string] 'exact' | 'pessimistic' | 'range' | 'unknown'. - baseVersion [string] Clean semver to use for risk-tier / interface-diff comparisons in place of the raw constraint text. - proposedConstraint [string] Rewritten constraint targeting TargetVersion. Only set when canRewrite -and -not satisfied. .EXAMPLE Test-AvmTerraformConstraint -Constraint '~> 0.3' -TargetVersion '0.3.9' # satisfied = $true .EXAMPLE Test-AvmTerraformConstraint -Constraint '~> 0.3' -TargetVersion '0.4.1' # satisfied = $false; canRewrite = $true; proposedConstraint = '~> 0.4' #> [CmdletBinding()] [OutputType([PSCustomObject])] param( [Parameter(Mandatory)] [string]$Constraint, [Parameter(Mandatory)] [string]$TargetVersion ) $result = [PSCustomObject]@{ parseable = $false satisfied = $false canRewrite = $false kind = 'unknown' baseVersion = $null proposedConstraint = $null } $target = ConvertTo-SemVer -Version $TargetVersion if (-not $target) { return $result } $clauseTexts = @($Constraint -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) if ($clauseTexts.Count -eq 0) { return $result } # Operator + up-to-three-part version number, nothing else. $clausePattern = '^(~>|>=|<=|!=|=|>|<)?\s*(\d+(?:\.\d+){0,2})$' $clauses = [System.Collections.Generic.List[PSCustomObject]]::new() foreach ($text in $clauseTexts) { $m = [regex]::Match($text, $clausePattern) if (-not $m.Success) { return $result } # unparseable clause -> whole constraint unparseable $op = if ($m.Groups[1].Success -and $m.Groups[1].Value) { $m.Groups[1].Value } else { '=' } $ver = $m.Groups[2].Value $segmentCount = @($ver -split '\.').Count $sv = ConvertTo-SemVer -Version $ver if (-not $sv) { return $result } $clauses.Add([PSCustomObject]@{ Operator = $op; SegmentCount = $segmentCount; SemVer = $sv }) } $result.parseable = $true if ($clauses.Count -eq 1 -and $clauses[0].Operator -eq '~>') { $result.kind = 'pessimistic' } elseif ($clauses.Count -eq 1 -and $clauses[0].Operator -eq '=') { $result.kind = 'exact' } else { $result.kind = 'range' } # --- Evaluate satisfaction: every clause must hold (AND) --- $satisfied = $true foreach ($c in $clauses) { $clauseSatisfied = switch ($c.Operator) { '=' { $target -eq $c.SemVer } '!=' { $target -ne $c.SemVer } '>' { $target -gt $c.SemVer } '>=' { $target -ge $c.SemVer } '<' { $target -lt $c.SemVer } '<=' { $target -le $c.SemVer } '~>' { if ($c.SegmentCount -eq 2 -and $c.SemVer.Major -eq 0) { # Conservative AVM rule: 0.x + two-part pessimistic locks major AND minor. ($target.Major -eq $c.SemVer.Major) -and ($target.Minor -eq $c.SemVer.Minor) } elseif ($c.SegmentCount -eq 2) { # Standard pessimistic operator: lock major, float minor(+patch). ($target.Major -eq $c.SemVer.Major) -and ($target -ge $c.SemVer) } else { # Three-part: lock major.minor, float patch. ($target.Major -eq $c.SemVer.Major) -and ($target.Minor -eq $c.SemVer.Minor) -and ($target -ge $c.SemVer) } } default { $false } } if (-not $clauseSatisfied) { $satisfied = $false } } $result.satisfied = $satisfied # Used for risk-tier / interface-diff comparisons in place of the raw constraint text. # Single-clause constraints have one obvious "current version"; for ranges we fall back to # the first clause's version as a best-effort baseline. $result.baseVersion = ($clauses | Select-Object -First 1).SemVer.ToString() if (-not $satisfied) { if ($result.kind -eq 'pessimistic') { $c = $clauses[0] $result.canRewrite = $true $result.proposedConstraint = if ($c.SegmentCount -eq 2) { "~> $($target.Major).$($target.Minor)" } else { "~> $($target.Major).$($target.Minor).$($target.Patch)" } } elseif ($result.kind -eq 'exact') { $result.canRewrite = $true $result.proposedConstraint = $target.ToString() } else { # Ranges / multi-clause constraints are never auto-rewritten — too easy to mangle # intent (e.g. a deliberate upper bound). Surfaced as skipped with a clear reason. $result.canRewrite = $false } } return $result } |