Skip to content

ESD-1703: Fix MCR prefix filter list ge/le encoding - #177

Open
Phil-Browne wants to merge 6 commits into
mainfrom
feature/ESD-1703-mcr-prefix-list-ge-le-encoding
Open

ESD-1703: Fix MCR prefix filter list ge/le encoding#177
Phil-Browne wants to merge 6 commits into
mainfrom
feature/ESD-1703-mcr-prefix-list-ge-le-encoding

Conversation

@Phil-Browne

@Phil-Browne Phil-Browne commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

The SDK marshals ge/le with the wrong type on MCR prefix filter list create and modify. MCRPrefixFilterList goes straight on the wire, and its entries hold Ge int / Le int with omitempty, so values go out as JSON numbers where the API declares strings, and a prefix length of 0 produces no key at all. NAT gateway prefix lists get the string conversion right but drop the zero with an explicit if e.Ge > 0.

megalith does not default an absent ge: it forwards the field verbatim into netauto under NON_NULL. Absent and "0" are genuinely different requests downstream, so dropping a deliberate 0 is a behavior bug, not only a contract mismatch.

  • Add MCRPrefixFilterList.toAPI(), mirroring the existing inbound converter, and call it from CreatePrefixFilterList and ModifyMCRPrefixFilterList.
  • MCRPrefixListEntry.Ge/.Le and NATGatewayPrefixListEntry.Ge/.Le become *int so unset is distinguishable from a deliberate 0. Breaking for both consumers.
  • NAT's toAPI emits "0" for a set zero instead of dropping it.
  • Both directions share two helpers (prefixLenToAPI / prefixLenFromAPI) rather than repeating the same rule in four places.
  • Decoding errors on a ge/le that is not a number and on a null entry, naming the index. Previously a bad value zeroed the field and a null entry panicked.
  • toAPI refuses to send a null entry, so the SDK cannot write a list its own read path then rejects. megalith validates the entries list but not its elements, so a null faults it rather than earning a 400.
  • Tests now assert the marshaled request body. The old create test stubbed a response and never looked at what it sent.

Migrating a consumer

Wrapping the existing int fields in PtrTo(...) is not a safe migration. Both consumers encode "unset" as 0, and on the provider side that is documented behavior, not an accident.

  • terraform-provider-megaport's NAT prefix list schema declares ge/le as Optional + Computed with Default: int64default.StaticInt64(0), described to users as "Omit or set to 0 to match the prefix's own length". The SDK's if e.Ge > 0 was that sentinel's only implementation. With it gone, every entry the user left unbounded sends a real le: "0" instead of nothing, which is a different request.
  • "Map unset to nil" is not reachable from that schema as written: an attribute with a static default never reports IsNull(), and returning a null on read where the plan holds 0 fails the apply with "Provider produced inconsistent result after apply". A correct migration has to drop the defaults as well, which is a second breaking change for provider users.
  • megaport-cli (mcr_inputs.go) flattens its own pointer to a 0 default, and the provider's mcr_prefix_filter_list_utils.go tests if entry.Ge == 0 || entry.Le == 0.

Those updates land in their own tickets. Treat the provider one as a gate on bumping the SDK there, not a follow-up.

What I could not verify

  • Integration tests were not run. They need staging credentials and create real MCRs.
  • Two integration-test expectations changed from Ge: 0 to Ge: nil. The API looks like it omits ge from the response when it equals the prefix length: the pre-existing test sends Ge: 24 on a /24 and expected 0 back. Under the old int type, 0 meant "absent or zero", so the expectation was ambiguous. Two risks here, both needing a credentialed run: now that a correctly typed "24" goes out, the API may start echoing ge back and these assertions would fail; and if the API ever returns "ge": "0", nil is the wrong expectation.
  • Nothing enforces ge/le anywhere in the declared contract: the spec types them as plain string with no pattern or bounds (its description names 0 to 32 and 0 to 128, but nothing checks it), and megalith holds them as opaque nullable strings. So a negative value is more likely forwarded to netauto than rejected, and ge > le has never been checked. Range validation is deliberately out of scope here.
  • What ge/le of 0 actually does. The spec's description puts 0 inside the valid range and says nothing about its meaning, megalith forwards the string untouched, and netauto declares a bare int32. This is the highest-consequence unknown and the reason the provider migration is a gate: a consumer that migrates a stray 0 sends a deny nobody has characterized.
  • PrefixListRequest declares no id property, but the SDK sends "id": 0 on create and the body's own id on modify (which can disagree with the path). Pre-existing and left alone per the ticket's scope; the spec sets no additionalProperties: false and megalith ignores unknown properties, so it is harmless today. Worth its own ticket.

The outbound path marshaled the user-facing MCRPrefixFilterList straight
onto the wire, so ge/le went out as JSON numbers where PrefixEntryDto
declares strings, and omitempty on an int meant a deliberate 0 produced
no key at all. NAT gateway prefix lists dropped the zero too, via an
explicit `if e.Ge > 0` in toAPI.

Add a toAPI converter for MCRPrefixFilterList mirroring the existing
inbound one, and call it from create and modify. Ge/Le on
MCRPrefixListEntry and NATGatewayPrefixListEntry become *int so an unset
field is distinguishable from a deliberate 0, which megalith forwards to
netauto as a distinct value rather than defaulting.

Tests assert the marshaled request body rather than a stubbed response.
The new MCR converter allocated its entries slice unconditionally, so a
list with a nil Entries marshaled as "entries": [] where direct marshaling
produced null. On the PUT that turns a payload the API should reject into
a well-formed "replace with zero entries" request, so restore null.

Guard the inbound converter against a null entry in the response. It
dereferenced a nil element and panicked, and the outbound path now emits
exactly that shape, so the two directions have to agree.

Cover the gaps the review found by mutation: le set to 0 on both encode
and decode for MCR and NAT (only ge was covered, so the same bug on le
would have shipped), the NAT gateway PUT body (which had no wire-level
coverage at all), and the nil list, nil Entries and nil entry shapes.

Tell consumers in the CHANGELOG to map unset to nil rather than PtrTo(0).
A 0 used to be dropped and is now honored, so a mechanical migration would
silently change which prefixes a filter matches.
Round two aimed at the fix commit itself. Six changes:

- Reject a null prefix list entry on decode instead of returning a nil
  element. The earlier guard only moved the panic into every caller.
- Wrap decode errors with the entry index and the offending field.
- Share prefixLenToAPI / prefixLenFromAPI between MCR and NAT rather
  than repeating the same rule in four places.
- Cover the decode error branches: non-numeric ge, non-numeric le, and
  a null entry on MCR, plus a non-numeric le on NAT.
- Rewrite the CHANGELOG entry, which was longer than the one review
  called too long and claimed a negatives fix MCR never had.
- Reword the integration-test ge comment as an observation, not a
  contract the spec does not declare.
…products

Round three of review found the read and write paths disagreeing with each
other. The read path rejects a null entry as malformed while toAPI still
emitted one, so the SDK could write a list its own read path then refused
forever. megalith validates @NotNull on the entries list only, so a null
element passes validation and faults further in; there is nothing to
preserve. toAPI now returns an error instead.

NAT typed its wire entries by value, so a null decoded to a blank entry and
survived a read-modify-write as {"action":"","prefix":""}. Both products
share one schema, so both now reject it.

Also: a null entries array no longer decodes to a non-nil empty slice, which
made the outbound nil-preservation fix unreachable through a GET. Drop the
CHANGELOG's claim that le: 0 matches nothing, which the spec's own
PrefixEntryDto description contradicts (0 is documented in range, its
semantics unstated). Pin the entry index, the list id passthrough and NAT's
ge wording, all of which survived mutation before.
… null

Verified against staging: both bodies toAPI could emit are 400s. A bare
null and {"entries":null} are rejected, and the tests were pinning them as
expected output. Fail locally instead and spend no round trip on them.

An empty entries array is left alone: the API accepts it and clears the
list, which is a legitimate request.

Also corrects the changelog, which claimed a stray 0 changes which routes
a filter matches. Staging enforces prefixLen <= ge <= le, so a 0 below the
prefix length is a 400.
Mutation testing found several assertions that did not bite. Entry-index
errors were pinned at index 0, where an off-by-one in the loop counter
survives, and the create and update read-backs had no null-entry coverage
of their own. Decode assertions now cover the fields that ride along with
ge/le rather than ge/le alone.

Fixture prefixes now match what staging accepts: a ge or le of 0 is only
legal on a default route. Comments lose the justification they duplicated
from the decision site.
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.

1 participant