Repository navigation
fix: stop flattening expressions to literals when reading the first token - #341
Merged
Merged
Conversation
…g it A custom profile whose precedence came from an expression had that expression replaced by a literal. Every affected profile then shared one precedence, so their relative order collapsed and the wrong WARP profile could match for a user or device. The precedence value was read as string(tokens[0].Bytes), the first token of the expression, and handed to gjson.Parse. For index(local.order, "x") that is the bare word "index", which gjson reports as Number 0, producing 900 + 0 for every such profile. A for_each profile using each.value.precedence collapsed the same way: every instance received one value, guaranteeing a collision. The leading token also decides which wrong answer is produced. "local" is not valid JSON, so gjson reports it as absent, the 100.0 default survives, and local.base_precedence silently became 1000 rather than 900. Same defect, different literal, and neither resembles the original. Parse a bare numeric literal properly and fold it as before, so configs with a literal precedence are byte-for-byte unchanged. Anything else is preserved and offset at apply time as 900 + (<expression>), which keeps both properties the literal path had: the expression still distinguishes the profiles, and the result still lands in the band that keeps migrated precedences away from profiles the account already has. The band also guarantees a positive value, which matters here because index() is zero-based and the API rejects a precedence of 0. The parentheses are load-bearing. A conditional binds more loosely than addition, so "var.use_high ? 5 : 20" would otherwise re-associate to "(900 + var.use_high) ? 5 : 20". An existing integration fixture had enshrined the bug: a for_each profile with precedence = each.value.precedence expected 1000. Updated, and the reported index() case added to the non-e2e input so it stays out of real infrastructure. Verified against provider v5.28.1: the generated configuration validates, two profiles ordered by index() now receive distinct precedences, and emulating the old flattening fails all three new transformation tests.
v4's origin_port_range block becomes a single v5 origin_port string, and
each bound was read as string(tokens[0].Bytes) — the first token of the
expression. A bound that was not a plain number collapsed to its leading
identifier, so
origin_port_range {
start = var.port_start
end = var.port_end
}
migrated to origin_port = "var-var".
This is the same first-token defect as the device profile precedence, but
it fails more quietly. The result is a syntactically valid string, so
terraform validate accepts it; the configuration only breaks when the API
rejects the port range at apply. A mixed range was equally wrong in a more
plausible-looking way: start = 1000 with end = local.lo + 5 produced
"1000-local".
Interpolate any bound that is not a bare numeric literal, so the
expression survives into the v5 string. Ranges with two literal bounds
still go through the existing cty path and are byte-for-byte unchanged.
Literal runs are emitted as one token each, matching how HCL parses the
equivalent hand-written string, so "1000-${local.lo + 5}" compares equal
to the same text written by hand rather than differing only in tokenization.
Shared with the precedence fix via two new helpers, since reading the
first token of an expression is the underlying mistake in both:
tfhcl.LiteralNumber reports whether an expression is a bare number, and
tfhcl.TrimLeadingSpace prepares expression tokens for re-embedding.
origin_port_range had no test coverage at all, which is why this survived.
Added the four permutations of literal and expression bounds as unit
tests. Deliberately not added to the integration fixture: that resource
has no _e2e.tf variant, so its single input file doubles as the e2e
configuration and new cases there would create real Spectrum applications.
Verified against provider v5.28.1: all four permutations validate and are
terraform fmt clean, and restoring the first-token read fails the two
expression tests.
mgirouard
approved these changes
Nov 3, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two resources replaced an expression with a literal, because each read only the first token of the expression. The result looked well-formed in both cases, so neither was caught by validation.
Device profile precedence
A custom profile whose
precedencecame from an expression had that expression replaced by a literal. Every affected profile then shared one precedence, so their relative order collapsed and the wrong WARP profile could match for a user or device.The value was read as
string(tokens[0].Bytes)and handed togjson.Parse. Forindex(local.order, "x")that is the bare wordindex, which gjson reports as Number 0, producing900 + 0.The leading token also decides which wrong answer appears.
localis not valid JSON, so gjson reports it absent, the100.0default survives, andlocal.base_precedencesilently became1000rather than900:index(local.order, "a")900900 + (index(local.order, "a"))index(local.order, "b")900← collides900 + (index(local.order, "b"))local.base_precedence1000900 + (local.base_precedence)var.use_high ? 5 : 20900900 + (var.use_high ? 5 : 20)10910910unchangedNon-literals are preserved and offset at apply time rather than replaced. That keeps both properties the literal path had: the expression still distinguishes the profiles, and the result still lands in the band that keeps migrated precedences away from profiles the account already has. The band also guarantees a positive value, which matters because
index()is zero-based and the API rejects a precedence of0.The parentheses are load-bearing. A conditional binds more loosely than addition, so
var.use_high ? 5 : 20would otherwise re-associate to(900 + var.use_high) ? 5 : 20.An existing integration fixture had enshrined the bug: a
for_eachprofile withprecedence = each.value.precedenceexpected1000— one precedence for every instance, a guaranteed collision locked in by a passing test.Spectrum origin_port_range
Same defect, and it fails more quietly. v4's
origin_port_rangeblock becomes a single v5origin_portstring, and each bound was read the same way:1000,2000"1000-2000"var.port_start,var.port_end"var-var""${var.port_start}-${var.port_end}"1000,local.lo + 5"1000-local""1000-${local.lo + 5}"var.port_start,2000"var-2000""${var.port_start}-2000"The result is a syntactically valid string, so
terraform validateaccepts"var-var"— I confirmed this. The configuration only breaks when the API rejects the port range at apply. Ranges with two literal bounds still take the existingctypath and are byte-for-byte unchanged.Literal runs are emitted as one token each, matching how HCL parses the equivalent hand-written string. Emitting
"1000","-","2000"as three tokens renders identically but broke two pre-existing tests, because the semantic comparator compares token counts.origin_port_rangehad no test coverage at all — no unit case, no fixture — which is why this survived. Added the four permutations as unit tests. Deliberately not added to the integration fixture: that resource has no_e2e.tfvariant, so its single input file doubles as the e2e configuration, and new cases there would create real Spectrum applications.Shared
Both fixes go through two new helpers, since reading the first token of an expression is the underlying mistake in each:
tfhcl.LiteralNumber— reports whether an expression is a bare numeric literal, so only a value that can safely be folded is foldedtfhcl.TrimLeadingSpace— prepares expression tokens for re-embedding, copying the first token rather than mutating tokens shared with the source attributeVerification
Against provider v5.28.1: every case above validates, the Spectrum output is
terraform fmtclean, and two profiles ordered byindex()now receive distinct precedences.Revert-proof — emulating the old first-token read fails the three new precedence tests and the two new Spectrum expression tests.
Green: build,
go vet,go test ./internal/...,go test -race ./integration/...,make lint-testdata, gofmt on all touched files.Note:
gofmt -l internal/flags ~55 files untouched by this branch, all a trailing blank line at EOF. Pre-existing, left alone.