Skip to content

fix: stop flattening expressions to literals when reading the first token - #341

Merged
vaishakdinesh merged 2 commits into
mainfrom
fix/device-profile-precedence-expressions
Nov 3, 2026
Merged

vaishakdinesh merged 2 commits into
mainfrom
fix/device-profile-precedence-expressions

Conversation

@vaishakdinesh

Copy link
Copy Markdown
Member

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 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 value was read as string(tokens[0].Bytes) 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.

The leading token also decides which wrong answer appears. local is not valid JSON, so gjson reports it absent, the 100.0 default survives, and local.base_precedence silently became 1000 rather than 900:

v4 before after
index(local.order, "a") 900 900 + (index(local.order, "a"))
index(local.order, "b") 900 ← collides 900 + (index(local.order, "b"))
local.base_precedence 1000 900 + (local.base_precedence)
var.use_high ? 5 : 20 900 900 + (var.use_high ? 5 : 20)
10 910 910 unchanged

Non-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 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 — 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_range block becomes a single v5 origin_port string, and each bound was read the same way:

v4 bounds before after
1000, 2000 "1000-2000" unchanged
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 validate accepts "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 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. Emitting "1000", "-", "2000" as three tokens renders identically but broke two pre-existing tests, because the semantic comparator compares token counts.

origin_port_range had 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.tf variant, 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 folded
  • tfhcl.TrimLeadingSpace — prepares expression tokens for re-embedding, copying the first token rather than mutating tokens shared with the source attribute

Verification

Against provider v5.28.1: every case above validates, the Spectrum output is terraform fmt clean, and two profiles ordered by index() 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.

…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.
@vaishakdinesh
vaishakdinesh requested a review from a team as a code owner October 30, 2026 23:13
@vaishakdinesh
vaishakdinesh merged commit 1a229bb into main Nov 3, 2026
17 checks passed
@vaishakdinesh
vaishakdinesh deleted the fix/device-profile-precedence-expressions branch November 3, 2026 17:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants