|
| 1 | +package provider |
| 2 | + |
| 3 | +import ( |
| 4 | + "fmt" |
| 5 | + |
| 6 | + "github.com/hashicorp/terraform-plugin-framework/diag" |
| 7 | + "github.com/hashicorp/terraform-plugin-framework/types" |
| 8 | +) |
| 9 | + |
| 10 | +// reconciler accumulates diagnostics so callers can reconcile many fields |
| 11 | +// without checking errors after each one. |
| 12 | +// |
| 13 | +// When reading is true (set via forRead), every field is treated as computed |
| 14 | +// so the API response is always accepted. This is necessary because during |
| 15 | +// Read (including import), the prior state may be empty — there is no user |
| 16 | +// plan to compare against. |
| 17 | +type reconciler struct { |
| 18 | + diags *diag.Diagnostics |
| 19 | + reading bool |
| 20 | +} |
| 21 | + |
| 22 | +func newReconciler(diags *diag.Diagnostics) *reconciler { |
| 23 | + return &reconciler{diags: diags} |
| 24 | +} |
| 25 | + |
| 26 | +func (r *reconciler) forRead() *reconciler { |
| 27 | + r.reading = true |
| 28 | + return r |
| 29 | +} |
| 30 | + |
| 31 | +func (r *reconciler) str(field string, input, output types.String, computed bool) types.String { |
| 32 | + v, err := reconcileString(field, input, output, computed || r.reading) |
| 33 | + if err != nil { |
| 34 | + r.diags.AddError("State reconciliation error", err.Error()) |
| 35 | + } |
| 36 | + return v |
| 37 | +} |
| 38 | + |
| 39 | +func (r *reconciler) boolean(field string, input, output types.Bool, computed bool) types.Bool { |
| 40 | + v, err := reconcileBool(field, input, output, computed || r.reading) |
| 41 | + if err != nil { |
| 42 | + r.diags.AddError("State reconciliation error", err.Error()) |
| 43 | + } |
| 44 | + return v |
| 45 | +} |
| 46 | + |
| 47 | +func (r *reconciler) int64val(field string, input, output types.Int64, computed bool) types.Int64 { |
| 48 | + v, err := reconcileInt64(field, input, output, computed || r.reading) |
| 49 | + if err != nil { |
| 50 | + r.diags.AddError("State reconciliation error", err.Error()) |
| 51 | + } |
| 52 | + return v |
| 53 | +} |
| 54 | + |
| 55 | +// Reconcile functions handle the four-way matrix of input (plan/state) vs |
| 56 | +// output (API response) emptiness: |
| 57 | +// |
| 58 | +// 1. Input non-empty, Output non-empty → return output (API is authoritative) |
| 59 | +// 2. Input non-empty, Output empty → return input (write-only / sensitive field) |
| 60 | +// 3. Input empty, Output non-empty: |
| 61 | +// - computed=true → return output (server-owned or defaulted field) |
| 62 | +// - computed=false → return error (unexpected value for optional-only field) |
| 63 | +// 4. Input empty, Output empty → return null |
| 64 | +// |
| 65 | +// "Empty" means null, unknown, or (for strings) the zero-length string "". |
| 66 | + |
| 67 | +// reconcileString reconciles a plan/state string with an API response string. |
| 68 | +func reconcileString(field string, input, output types.String, computed bool) (types.String, error) { |
| 69 | + inputEmpty := input.IsNull() || input.IsUnknown() || input.ValueString() == "" |
| 70 | + outputEmpty := output.IsNull() || output.IsUnknown() || output.ValueString() == "" |
| 71 | + |
| 72 | + switch { |
| 73 | + case !inputEmpty && !outputEmpty: |
| 74 | + return output, nil |
| 75 | + case !inputEmpty && outputEmpty: |
| 76 | + return input, nil |
| 77 | + case inputEmpty && !outputEmpty: |
| 78 | + if computed { |
| 79 | + return output, nil |
| 80 | + } |
| 81 | + return types.StringNull(), fmt.Errorf( |
| 82 | + "reconcile %q: API returned %q but field was not set in config and is not computed", |
| 83 | + field, output.ValueString(), |
| 84 | + ) |
| 85 | + default: |
| 86 | + return types.StringNull(), nil |
| 87 | + } |
| 88 | +} |
| 89 | + |
| 90 | +// reconcileBool reconciles a plan/state bool with an API response bool. |
| 91 | +func reconcileBool(field string, input, output types.Bool, computed bool) (types.Bool, error) { |
| 92 | + inputEmpty := input.IsNull() || input.IsUnknown() |
| 93 | + outputEmpty := output.IsNull() || output.IsUnknown() |
| 94 | + |
| 95 | + switch { |
| 96 | + case !inputEmpty && !outputEmpty: |
| 97 | + return output, nil |
| 98 | + case !inputEmpty && outputEmpty: |
| 99 | + return input, nil |
| 100 | + case inputEmpty && !outputEmpty: |
| 101 | + if computed { |
| 102 | + return output, nil |
| 103 | + } |
| 104 | + return types.BoolNull(), fmt.Errorf( |
| 105 | + "reconcile %q: API returned %t but field was not set in config and is not computed", |
| 106 | + field, output.ValueBool(), |
| 107 | + ) |
| 108 | + default: |
| 109 | + return types.BoolNull(), nil |
| 110 | + } |
| 111 | +} |
| 112 | + |
| 113 | +// reconcileInt64 reconciles a plan/state int64 with an API response int64. |
| 114 | +func reconcileInt64(field string, input, output types.Int64, computed bool) (types.Int64, error) { |
| 115 | + inputEmpty := input.IsNull() || input.IsUnknown() |
| 116 | + outputEmpty := output.IsNull() || output.IsUnknown() |
| 117 | + |
| 118 | + switch { |
| 119 | + case !inputEmpty && !outputEmpty: |
| 120 | + return output, nil |
| 121 | + case !inputEmpty && outputEmpty: |
| 122 | + return input, nil |
| 123 | + case inputEmpty && !outputEmpty: |
| 124 | + if computed { |
| 125 | + return output, nil |
| 126 | + } |
| 127 | + return types.Int64Null(), fmt.Errorf( |
| 128 | + "reconcile %q: API returned %d but field was not set in config and is not computed", |
| 129 | + field, output.ValueInt64(), |
| 130 | + ) |
| 131 | + default: |
| 132 | + return types.Int64Null(), nil |
| 133 | + } |
| 134 | +} |
0 commit comments