fix: stop coercing server-side false/0 to Null on read - #13
Open
ysyneu wants to merge 2 commits into
Open
Conversation
types.Bool and types.Int64 in terraform-plugin-framework are Null by
default, so `if cond { result.X = types.BoolValue(true) }` (no else)
silently leaves the attribute Null whenever the server's value is the
type's zero value. A config that explicitly sets the attribute to
false/0 then diffs against a Null state on every subsequent plan -
drift that never converges.
Fixed the read/mapping helpers for attributes where zero is a real,
documented, distinct value:
- flashduty_channel: group.all_equals_required, group.time_window
("0 means merge until incident closes")
- flashduty_route: cases[].fallthrough
- flashduty_schedule: layers[].fair_rotation, layers[].handoff_time,
layers[].restrict_mode ("0 = none"),
layers[].mask_continuous_enabled, notify.advance_in_time
Each of these was Optional-only (not Computed) in its schema, so
simply removing the `if` and always writing a concrete value would
trade the drift for a stricter failure: "Provider produced
inconsistent result after apply" whenever a config omits the
attribute and the provider now writes a concrete zero into it.
Instead each attribute schema is changed to Optional+Computed with a
static default of false/0, following the pattern already used
elsewhere in this provider (e.g. channel.is_private), and the mapping
helper now writes the value unconditionally.
group.i_score_threshold and layers[].layer_end keep their original
guarded behavior: i_score_threshold's documented valid range is
0.5-1.0, so 0 is never a legal configured value; layer_end is a Unix
timestamp where 0 has no meaning as a real end time distinct from "no
end configured", so collapsing it to Null loses nothing a user could
have intentionally set.
flashduty_schedule's readNotify() also collapses to a nil notify
block when the server returns an all-zero-value struct for a resource
that was never configured with `notify` (avoiding drift the other
direction). That check previously read the mapped model's
AdvanceInTime.IsNull(), which always becomes false now that
AdvanceInTime is written unconditionally; it's rewritten to check the
raw response struct instead, so the empty-notify case still collapses
correctly.
Added unit tests for the affected mapping helpers asserting that a
server-side false/0 produces a concrete BoolValue(false)/Int64Value(0)
rather than Null, and that a fully empty schedule notify still
collapses to nil.
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.
Problem
types.Boolandtypes.Int64in terraform-plugin-framework are Null at their zero value, notfalse/0. Several read/mapping helpers in this provider used the shape:so whenever the server's value was the zero value, the attribute was left Null. A practitioner who wrote
all_equals_required = false(or any other zero value) in HCL would then see state disagree with config on every subsequentplan— a diff that never converges.Fix
For each attribute where the server's zero value is a real, documented, distinct value, the mapping helper now writes a concrete value unconditionally instead of guarding with
if.The remedy depends on how the attribute is declared in the schema:
flashduty_channel.group.all_equals_requiredOptionalonlyOptional+Computed+Default(false), write unconditionallyfalseis the common/default case a practitioner would set explicitlyflashduty_channel.group.time_windowOptionalonlyOptional+Computed+Default(0), write unconditionallyflashduty_channel.group.i_score_thresholdOptionalonly0is never a legal configured value; it only ever means "not applicable"flashduty_route.cases[].fallthroughOptionalonlyOptional+Computed+Default(false), write unconditionallyfalseis the common caseflashduty_schedule.layers[].fair_rotationOptionalonlyOptional+Computed+Default(false), write unconditionallyfalseis the common caseflashduty_schedule.layers[].mask_continuous_enabledOptionalonlyOptional+Computed+Default(false), write unconditionallyfalseis the common caseflashduty_schedule.layers[].restrict_modeOptionalonlyOptional+Computed+Default(0), write unconditionallyflashduty_schedule.layers[].handoff_timeOptionalonlyOptional+Computed+Default(0), write unconditionally0(no handoff buffer) is a legitimate, distinguishable operational choice, the same way0is meaningful for the sibling duration fields in this same structflashduty_schedule.layers[].layer_endOptionalonly0(1970-01-01) is never a real end time a practitioner would configure — it only ever functions as a "no end configured" sentinel, which is operationally identical to omitting the attribute. There's no distinct value being lost by collapsing it to Nullflashduty_schedule.notify.advance_in_timeOptionalonlyOptional+Computed+Default(0), write unconditionally0= no advance notice — a real, distinct valueWhere an attribute needed a concrete zero value, always writing into a plain
Optionalattribute would trade the drift bug for a worse failure mode:"Provider produced inconsistent result after apply", whenever a config omits the attribute but the provider now writes a concretefalse/0into state. So each of those attributes is changed toOptional+Computedwith a static default, following the pattern already used elsewhere in this provider (e.g.channel.is_private), and the mapping helper writes unconditionally only after that schema change.One extra wrinkle:
flashduty_schedule'sreadNotify()That function also collapses to a
nilnotify block when the server returns an all-zero-value struct for a resource that was never configured with anotifyblock (this avoids drift in the opposite direction — anotifyblock appearing in state that isn't in config). That check previously tested the mapped model'sAdvanceInTime.IsNull(), which becomes permanentlyfalseonceAdvanceInTimeis written unconditionally. It's rewritten to test the raw response struct instead, so the "fully empty notify" case still collapses tonilcorrectly. Covered by a dedicated test (TestScheduleReadNotifyFullyEmptyCollapsesToNil).Testing
Added
internal/provider/flashduty_read_path_zero_value_test.go— unit tests for the affected mapping helpers, asserting a server-sidefalse/0produces a concreteBoolValue(false)/Int64Value(0)rather than Null (confirmed red before the fix, green after), plus the notify-collapse regression test above. NoTF_ACCneeded.Full suite output: all
TestAcc*testsSKIP(requireTF_ACC=1or specific env vars — pre-existing, not run here per instructions not to exercise the live API), the 5 new unit testsPASS, no failures before or after this change.