From 641b820baac4d1af4dcb521ee7ee64327341f784 Mon Sep 17 00:00:00 2001 From: James Bardin Date: Tue, 18 Nov 2025 13:43:47 -0500 Subject: [PATCH 1/3] terraform_data sensitive and write-only tools Add a new object to the terraform_data resource to handle sensitive and write-only attributes. The new `store` object has input and output values, which work much like the top-level input and output. The difference is that the `store.input` is truly "write only" and can also accept ephemeral values. Additional arguments for the `store` object are: - `version`, allows the user to determine exactly when the input value will be replaced in `store.output`. - `sensitive`, conditionally directs the stored value between `output` or `sensitive_output`. - `replace` signals that any change to either output should trigger replacement of the entire resource. --- .../providers/terraform/provider_test.go | 14 +- .../providers/terraform/resource_data.go | 293 +++++++++-- .../providers/terraform/resource_data_test.go | 468 ++++++++++++++++-- internal/configs/configschema/coerce_value.go | 2 + internal/configs/configschema/decoder_spec.go | 2 - .../configs/configschema/internal_validate.go | 2 +- 6 files changed, 670 insertions(+), 111 deletions(-) diff --git a/internal/builtin/providers/terraform/provider_test.go b/internal/builtin/providers/terraform/provider_test.go index 72309d2936..d018b91b16 100644 --- a/internal/builtin/providers/terraform/provider_test.go +++ b/internal/builtin/providers/terraform/provider_test.go @@ -42,12 +42,14 @@ func TestMoveResourceState_DataStore(t *testing.T) { t.Errorf("unexpected diagnostics: %s", resp.Diagnostics.Err()) } - expectedTargetState := cty.ObjectVal(map[string]cty.Value{ - "id": cty.StringVal("test"), - "input": cty.NullVal(cty.DynamicPseudoType), - "output": cty.NullVal(cty.DynamicPseudoType), - "triggers_replace": cty.NullVal(cty.DynamicPseudoType), - }) + expected, err := dataStoreResourceSchema().Body.CoerceValue(cty.EmptyObjectVal) + if err != nil { + t.Fatal(err) + } + expectedMap := expected.AsValueMap() + + expectedMap["id"] = cty.StringVal("test") + expectedTargetState := cty.ObjectVal(expectedMap) if !resp.TargetState.RawEquals(expectedTargetState) { t.Errorf("expected state was:\n%#v\ngot state is:\n%#v\n", expectedTargetState, resp.TargetState) diff --git a/internal/builtin/providers/terraform/resource_data.go b/internal/builtin/providers/terraform/resource_data.go index 6cadb7dc26..bd82438a32 100644 --- a/internal/builtin/providers/terraform/resource_data.go +++ b/internal/builtin/providers/terraform/resource_data.go @@ -19,12 +19,44 @@ func dataStoreResourceSchema() providers.Schema { return providers.Schema{ Body: &configschema.Block{ Attributes: map[string]*configschema.Attribute{ - "input": {Type: cty.DynamicPseudoType, Optional: true}, - "output": {Type: cty.DynamicPseudoType, Computed: true}, + "id": {Type: cty.String, Computed: true}, + + // forces replacement of the entire resource when changed "triggers_replace": {Type: cty.DynamicPseudoType, Optional: true}, - "id": {Type: cty.String, Computed: true}, + + // input is reflected in output after apply, and changes to + // input always result in a re-computation of output. + "input": {Type: cty.DynamicPseudoType, Optional: true}, + "output": {Type: cty.DynamicPseudoType, Computed: true}, + }, + BlockTypes: map[string]*configschema.NestedBlock{ + "store": { + Nesting: configschema.NestingSingle, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + // The input attribute will be exposed as a stable + // value in store.output or store.sensitive_output. + "input": {Type: cty.DynamicPseudoType, Optional: true, WriteOnly: true}, + "output": {Type: cty.DynamicPseudoType, Computed: true}, + "sensitive_output": {Type: cty.DynamicPseudoType, Computed: true, Sensitive: true}, + // If there is a version value, a change in that + // value will trigger a change in the stored output + // or sensitive_output. If there is no version + // value, then input will be compared directly + // against output. + "version": {Type: cty.DynamicPseudoType, Optional: true}, + + "sensitive": {Type: cty.Bool, Optional: true}, + + // replace causes the resource to be replaced when + // there is a change to a store output value. + "replace": {Type: cty.Bool, Optional: true}, + }, + }, + }, }, }, + Identity: dataStoreResourceIdentitySchema().Body, } } @@ -61,8 +93,9 @@ func validateDataStoreResourceConfig(req providers.ValidateResourceConfigRequest } func upgradeDataStoreResourceState(req providers.UpgradeResourceStateRequest) (resp providers.UpgradeResourceStateResponse) { - ty := dataStoreResourceSchema().Body.ImpliedType() - val, err := ctyjson.Unmarshal(req.RawStateJSON, ty) + // We've only added new nullable block attributes, so unmarshaling from json + // will complete the data structure correctly. + val, err := ctyjson.Unmarshal(req.RawStateJSON, dataStoreResourceSchema().Body.ImpliedType()) if err != nil { resp.Diagnostics = resp.Diagnostics.Append(err) return resp @@ -79,59 +112,193 @@ func upgradeDataStoreResourceIdentity(providers.UpgradeResourceIdentityRequest) func readDataStoreResourceState(req providers.ReadResourceRequest) (resp providers.ReadResourceResponse) { resp.NewState = req.PriorState + resp.Private = req.Private return resp } func planDataStoreResourceChange(req providers.PlanResourceChangeRequest) (resp providers.PlanResourceChangeResponse) { + resp.PlannedState = req.ProposedNewState + if req.ProposedNewState.IsNull() { // destroy op - resp.PlannedState = req.ProposedNewState return resp } planned := req.ProposedNewState.AsValueMap() + prior := req.PriorState - input := req.ProposedNewState.GetAttr("input") - trigger := req.ProposedNewState.GetAttr("triggers_replace") + // first determine if this is a create or replace + if !prior.IsNull() && !prior.GetAttr("triggers_replace").RawEquals(req.ProposedNewState.GetAttr("triggers_replace")) { + // trigger changed, so we need to replace the entire instance + resp.RequiresReplace = append(resp.RequiresReplace, cty.GetAttrPath("triggers_replace")) - switch { - case req.PriorState.IsNull(): - // Create - // Set the id value to unknown. + // set the prior value to null so that that everything else is treated + // as if it's a new instance + prior = cty.NullVal(req.ProposedNewState.Type()) + } + + // creating a new instance, so we need a new ID + if prior.IsNull() { + // New instances, so set the id value to unknown. planned["id"] = cty.UnknownVal(cty.String).RefineNotNull() + } + + // check the input/output for changes + input := req.ProposedNewState.GetAttr("input") + priorInput := cty.NullVal(cty.DynamicPseudoType) + if !prior.IsNull() { + priorInput = prior.GetAttr("input") + } - // Output type must always match the input, even when it's null. + if !priorInput.RawEquals(input) { if input.IsNull() { - planned["output"] = input + // we reflect the type even if the value is null + planned["output"] = cty.NullVal(input.Type()) } else { + // input changed, so we need to re-compute output planned["output"] = cty.UnknownVal(input.Type()) } + } - resp.PlannedState = cty.ObjectVal(planned) - return resp + // check the store object for changes + if store := req.ProposedNewState.GetAttr("store"); !store.IsNull() { + objMap := storeMap(store.AsValueMap()) + priorVersion := cty.NullVal(cty.DynamicPseudoType) + priorSensitive := cty.NullVal(cty.Bool) + + for _, mustKnow := range []string{"sensitive", "replace"} { + if !store.GetAttr(mustKnow).IsKnown() { + resp.Diagnostics = resp.Diagnostics.Append(tfdiags.AttributeValue( + tfdiags.Error, + "unexpected unknown value", + fmt.Sprintf("the %q attribute must be known in order to plan changes to this resource", mustKnow), + cty.GetAttrPath("store").GetAttr(mustKnow), + )) + return resp + } + } - case !req.PriorState.GetAttr("triggers_replace").RawEquals(trigger): - // trigger changed, so we need to replace the entire instance - resp.RequiresReplace = append(resp.RequiresReplace, cty.GetAttrPath("triggers_replace")) - planned["id"] = cty.UnknownVal(cty.String).RefineNotNull() + if !prior.IsNull() && !prior.GetAttr("store").IsNull() { + priorVersion = prior.GetAttr("store").GetAttr("version") + priorSensitive = prior.GetAttr("store").GetAttr("sensitive") + } - // We need to check the input for the replacement instance to compute a - // new output. - if input.IsNull() { - planned["output"] = input - } else { - planned["output"] = cty.UnknownVal(input.Type()) + // if sensitive changed, just move the data between outputs + if !priorSensitive.RawEquals(objMap.sensitive()) { + objMap.swapOutputs() } - case !req.PriorState.GetAttr("input").RawEquals(input): - // only input changed, so we only need to re-compute output - planned["output"] = cty.UnknownVal(input.Type()) + // Plan an update if the version changed, or if the input and output don't + // match in the absence of a version value. + switch { + // if input and outputs are all null, just pass through a possible null type. + case objMap.valuesNull(): + objMap.storeNull() + + // if there is a version, checked if it has changed + case objMap.hasVersion(): + // The version value comparison is done within this case, because we + // don't want to fall into the input comparison case when there is a + // version, nor do we want to prevent evaluating that case if the + // input and output changed. + if !objMap.version().RawEquals(priorVersion) { + objMap.storeChange() + } + + // if there is no version, we automatically update if the input and output + // don't match + case objMap.hasChange(): + objMap.storeChange() + } + + // see if we want store to replace the resource + if objMap.replace() { + planned["id"] = cty.UnknownVal(cty.String) + resp.RequiresReplace = append(resp.RequiresReplace, cty.GetAttrPath("store").GetAttr("input")) + } + + // and the input must always be returned as the unset null value because it + // is write-only + objMap.clearInput() + + planned["store"] = cty.ObjectVal(objMap) } resp.PlannedState = cty.ObjectVal(planned) return resp } +// storeMap encapsulates some of the logic around handling the various +// combinations of the object attributes. There are a few accessors and simple +// set functions just to make accessing the data consistent, so nothing needs to +// index the map directly. +type storeMap map[string]cty.Value + +func (d storeMap) valuesNull() bool { + return d["input"].IsNull() && d["output"].IsNull() && d["sensitive_output"].IsNull() +} + +func (d storeMap) isSensitive() bool { + return !d["sensitive"].IsNull() && d["sensitive"].True() +} + +func (d storeMap) sensitive() cty.Value { + return d["sensitive"] +} + +func (d storeMap) hasVersion() bool { + return !d["version"].IsNull() +} + +func (d storeMap) version() cty.Value { + return d["version"] +} + +func (d storeMap) storeNull() { + d.write(cty.NullVal(d["input"].Type())) +} + +func (d storeMap) storeChange() { + d.write(cty.UnknownVal(d["input"].Type())) +} + +func (d storeMap) swapOutputs() { + output := d["output"] + if tmp := d["sensitive_output"]; !tmp.IsNull() { + output = tmp + } + + d.write(output) +} + +func (d storeMap) write(v cty.Value) { + if d.isSensitive() { + d["sensitive_output"] = v + d["output"] = cty.NullVal(cty.DynamicPseudoType) + return + } + + d["output"] = v + d["sensitive_output"] = cty.NullVal(cty.DynamicPseudoType) +} + +func (d storeMap) clearInput() { + d["input"] = cty.NullVal(cty.DynamicPseudoType) +} + +func (d storeMap) hasChange() bool { + old := d["output"] + if !d["sensitive_output"].IsNull() { + old = d["sensitive_output"] + } + + return !old.RawEquals(d["input"]) +} + +func (d storeMap) replace() bool { + return d["replace"].True() && !(d["output"].IsKnown() && d["sensitive_output"].IsKnown()) +} + var testUUIDHook func() string func applyDataStoreResourceChange(req providers.ApplyResourceChangeRequest) (resp providers.ApplyResourceChangeResponse) { @@ -140,35 +307,51 @@ func applyDataStoreResourceChange(req providers.ApplyResourceChangeRequest) (res return resp } - newState := req.PlannedState.AsValueMap() - - if !req.PlannedState.GetAttr("output").IsKnown() { - newState["output"] = req.PlannedState.GetAttr("input") - } - - if !req.PlannedState.GetAttr("id").IsKnown() { - idString, err := uuid.GenerateUUID() - // Terraform would probably never get this far without a good random - // source, but catch the error anyway. - if err != nil { - diag := tfdiags.AttributeValue( - tfdiags.Error, - "Error generating id", - err.Error(), - cty.GetAttrPath("id"), - ) - - resp.Diagnostics = resp.Diagnostics.Append(diag) + // Applying a plan only consists of filling in any unknown values. We can + // write this as a single transformation, and base the logic on the path of + // the transform value. + resp.NewState, _ = cty.Transform(req.PlannedState, func(path cty.Path, val cty.Value) (cty.Value, error) { + if val.IsKnown() { + return val, nil } - if testUUIDHook != nil { - idString = testUUIDHook() + // val is unknown, so find the correct value based on our path + switch { + case path.Equals(cty.GetAttrPath("id")): + idString, err := uuid.GenerateUUID() + // Terraform would probably never get this far without a good random + // source, but catch the error anyway. + if err != nil { + diag := tfdiags.AttributeValue( + tfdiags.Error, + "Error generating id", + err.Error(), + cty.GetAttrPath("id"), + ) + + resp.Diagnostics = resp.Diagnostics.Append(diag) + } + + if testUUIDHook != nil { + idString = testUUIDHook() + } + return cty.StringVal(idString), nil + + case path.Equals(cty.GetAttrPath("output")): + return req.PlannedState.GetAttr("input"), nil + + case path.Equals(cty.GetAttrPath("store").GetAttr("output")): + // input is write-only, so won't be in the planned state. We ned to get + // the latest ephemeral value directly from the config. + return req.Config.GetAttr("store").GetAttr("input"), nil + + case path.Equals(cty.GetAttrPath("store").GetAttr("sensitive_output")): + // input is write-only, so won't be in the planned state. We ned to get + // the latest ephemeral value directly from the config. + return req.Config.GetAttr("store").GetAttr("input"), nil } - - newState["id"] = cty.StringVal(idString) - } - - resp.NewState = cty.ObjectVal(newState) + return val, nil + }) return resp } diff --git a/internal/builtin/providers/terraform/resource_data_test.go b/internal/builtin/providers/terraform/resource_data_test.go index 4fb20f6179..9c5a435fdf 100644 --- a/internal/builtin/providers/terraform/resource_data_test.go +++ b/internal/builtin/providers/terraform/resource_data_test.go @@ -47,27 +47,43 @@ func TestManagedDataValidate(t *testing.T) { } func TestManagedDataUpgradeState(t *testing.T) { - schema := dataStoreResourceSchema() - ty := schema.Body.ImpliedType() + rawState := `{ + "id": "not-quite-unique", + "input": { + "value": "input", + "type": "string" + }, + "output": { + "value": "input", + "type": "string" + }, + "triggers_replace": { + "value": [ + "a", + "b" + ], + "type": [ + "list", + "string" + ] + } +}` - state := cty.ObjectVal(map[string]cty.Value{ + upgradedState, err := dataStoreResourceSchema().Body.CoerceValue(cty.ObjectVal(map[string]cty.Value{ "input": cty.StringVal("input"), "output": cty.StringVal("input"), "triggers_replace": cty.ListVal([]cty.Value{ cty.StringVal("a"), cty.StringVal("b"), }), "id": cty.StringVal("not-quite-unique"), - }) - - jsState, err := ctyjson.Marshal(state, ty) + })) if err != nil { t.Fatal(err) } - // empty req := providers.UpgradeResourceStateRequest{ TypeName: "terraform_data", - RawStateJSON: jsState, + RawStateJSON: []byte(rawState), } resp := upgradeDataStoreResourceState(req) @@ -75,8 +91,8 @@ func TestManagedDataUpgradeState(t *testing.T) { t.Error("upgrade state error:", resp.Diagnostics.ErrWithWarnings()) } - if !resp.UpgradedState.RawEquals(state) { - t.Errorf("prior state was:\n%#v\nupgraded state is:\n%#v\n", state, resp.UpgradedState) + if !resp.UpgradedState.RawEquals(upgradedState) { + t.Errorf("prior state was:\n%s\nupgraded state is:\n%#v\n", rawState, resp.UpgradedState) } } @@ -135,12 +151,49 @@ func TestManagedDataPlan(t *testing.T) { "output": cty.NullVal(cty.DynamicPseudoType), "triggers_replace": cty.NullVal(cty.DynamicPseudoType), "id": cty.NullVal(cty.String), + + "store": cty.ObjectVal(map[string]cty.Value{ + "input": cty.NullVal(cty.Number), + "output": cty.NullVal(cty.DynamicPseudoType), + }), + }), + planned: cty.ObjectVal(map[string]cty.Value{ + "input": cty.NullVal(cty.String), + "output": cty.NullVal(cty.String), + "triggers_replace": cty.NullVal(cty.DynamicPseudoType), + "id": cty.UnknownVal(cty.String).RefineNotNull(), + "store": cty.ObjectVal(map[string]cty.Value{ + // write-only values are always returned as null + "input": cty.NullVal(cty.DynamicPseudoType), + "output": cty.NullVal(cty.Number), + }), + }), + }, + + "create-typed-null-sensitive-write-only": { + prior: cty.NullVal(ty), + proposed: cty.ObjectVal(map[string]cty.Value{ + "input": cty.NullVal(cty.String), + "output": cty.NullVal(cty.DynamicPseudoType), + "triggers_replace": cty.NullVal(cty.DynamicPseudoType), + "id": cty.NullVal(cty.String), + + "store": cty.ObjectVal(map[string]cty.Value{ + "input": cty.NullVal(cty.Number), + "output": cty.NullVal(cty.DynamicPseudoType), + "sensitive": cty.BoolVal(true), + }), }), planned: cty.ObjectVal(map[string]cty.Value{ "input": cty.NullVal(cty.String), "output": cty.NullVal(cty.String), "triggers_replace": cty.NullVal(cty.DynamicPseudoType), "id": cty.UnknownVal(cty.String).RefineNotNull(), + "store": cty.ObjectVal(map[string]cty.Value{ + "input": cty.NullVal(cty.DynamicPseudoType), + "sensitive_output": cty.NullVal(cty.Number), + "sensitive": cty.BoolVal(true), + }), }), }, @@ -228,26 +281,279 @@ func TestManagedDataPlan(t *testing.T) { "id": cty.UnknownVal(cty.String).RefineNotNull(), }), }, + + "update-store-trigger": { + prior: cty.ObjectVal(map[string]cty.Value{ + "store": cty.ObjectVal(map[string]cty.Value{ + "input": cty.NullVal(cty.DynamicPseudoType), + "version": cty.NumberIntVal(1), + "output": cty.StringVal("ephem"), + }), + "id": cty.StringVal("not-quite-unique"), + }), + proposed: cty.ObjectVal(map[string]cty.Value{ + "store": cty.ObjectVal(map[string]cty.Value{ + "input": cty.StringVal("ephem"), + "version": cty.NumberIntVal(2), + "output": cty.StringVal("ephem"), + }), + "id": cty.StringVal("not-quite-unique"), + }), + planned: cty.ObjectVal(map[string]cty.Value{ + "store": cty.ObjectVal(map[string]cty.Value{ + "input": cty.NullVal(cty.DynamicPseudoType), + "version": cty.NumberIntVal(2), + "output": cty.UnknownVal(cty.String), + }), + "id": cty.StringVal("not-quite-unique"), + }), + }, + + "update-store-trigger-to-sensitive": { + prior: cty.ObjectVal(map[string]cty.Value{ + "store": cty.ObjectVal(map[string]cty.Value{ + "input": cty.NullVal(cty.DynamicPseudoType), + "version": cty.NumberIntVal(1), + "output": cty.StringVal("ephem"), + }), + "id": cty.StringVal("not-quite-unique"), + }), + proposed: cty.ObjectVal(map[string]cty.Value{ + "store": cty.ObjectVal(map[string]cty.Value{ + "input": cty.StringVal("ephem"), + "sensitive": cty.BoolVal(true), + "version": cty.NumberIntVal(2), + "output": cty.StringVal("ephem"), + }), + "id": cty.StringVal("not-quite-unique"), + }), + planned: cty.ObjectVal(map[string]cty.Value{ + "store": cty.ObjectVal(map[string]cty.Value{ + "input": cty.NullVal(cty.DynamicPseudoType), + "sensitive": cty.BoolVal(true), + "version": cty.NumberIntVal(2), + "output": cty.NullVal(cty.DynamicPseudoType), + "sensitive_output": cty.UnknownVal(cty.String), + }), + "id": cty.StringVal("not-quite-unique"), + }), + }, + + "update-store-auto": { + prior: cty.ObjectVal(map[string]cty.Value{ + "store": cty.ObjectVal(map[string]cty.Value{ + "input": cty.NullVal(cty.DynamicPseudoType), + "output": cty.StringVal("ephem_2"), + }), + "id": cty.StringVal("not-quite-unique"), + }), + proposed: cty.ObjectVal(map[string]cty.Value{ + "store": cty.ObjectVal(map[string]cty.Value{ + "input": cty.StringVal("ephem_1"), + "output": cty.StringVal("ephem_2"), + }), + "id": cty.StringVal("not-quite-unique"), + }), + planned: cty.ObjectVal(map[string]cty.Value{ + "store": cty.ObjectVal(map[string]cty.Value{ + "input": cty.NullVal(cty.DynamicPseudoType), + "output": cty.UnknownVal(cty.String), + }), + "id": cty.StringVal("not-quite-unique"), + }), + }, + + "update-store-auto-replace": { + prior: cty.ObjectVal(map[string]cty.Value{ + "store": cty.ObjectVal(map[string]cty.Value{ + "input": cty.NullVal(cty.DynamicPseudoType), + "output": cty.StringVal("ephem_2"), + }), + "id": cty.StringVal("not-quite-unique"), + }), + proposed: cty.ObjectVal(map[string]cty.Value{ + "store": cty.ObjectVal(map[string]cty.Value{ + "input": cty.StringVal("ephem_1"), + "output": cty.StringVal("ephem_2"), + "replace": cty.BoolVal(true), + }), + "id": cty.StringVal("not-quite-unique"), + }), + planned: cty.ObjectVal(map[string]cty.Value{ + "store": cty.ObjectVal(map[string]cty.Value{ + "input": cty.NullVal(cty.DynamicPseudoType), + "output": cty.UnknownVal(cty.String), + "replace": cty.BoolVal(true), + }), + "id": cty.UnknownVal(cty.String), + }), + }, + + "remove-value-auto-replace": { + prior: cty.ObjectVal(map[string]cty.Value{ + "store": cty.ObjectVal(map[string]cty.Value{ + "input": cty.NullVal(cty.DynamicPseudoType), + "output": cty.StringVal("ephem_1"), + "replace": cty.BoolVal(true), + }), + "id": cty.StringVal("not-quite-unique"), + }), + proposed: cty.ObjectVal(map[string]cty.Value{ + "store": cty.ObjectVal(map[string]cty.Value{ + "input": cty.NullVal(cty.DynamicPseudoType), + "output": cty.StringVal("ephem_1"), + "replace": cty.BoolVal(true), + }), + "id": cty.StringVal("not-quite-unique"), + }), + planned: cty.ObjectVal(map[string]cty.Value{ + "store": cty.ObjectVal(map[string]cty.Value{ + "input": cty.NullVal(cty.DynamicPseudoType), + "output": cty.UnknownVal(cty.DynamicPseudoType), + "replace": cty.BoolVal(true), + }), + "id": cty.UnknownVal(cty.String), + }), + }, + + "update-store-auto-sensitive": { + prior: cty.ObjectVal(map[string]cty.Value{ + "store": cty.ObjectVal(map[string]cty.Value{ + "input": cty.NullVal(cty.DynamicPseudoType), + "sensitive": cty.BoolVal(true), + "sensitive_output": cty.StringVal("ephem_2"), + }), + "id": cty.StringVal("not-quite-unique"), + }), + proposed: cty.ObjectVal(map[string]cty.Value{ + "store": cty.ObjectVal(map[string]cty.Value{ + "input": cty.StringVal("ephem_1"), + "sensitive": cty.BoolVal(true), + "sensitive_output": cty.StringVal("ephem_2"), + }), + "id": cty.StringVal("not-quite-unique"), + }), + planned: cty.ObjectVal(map[string]cty.Value{ + "store": cty.ObjectVal(map[string]cty.Value{ + "input": cty.NullVal(cty.DynamicPseudoType), + "sensitive": cty.BoolVal(true), + "sensitive_output": cty.UnknownVal(cty.String), + }), + "id": cty.StringVal("not-quite-unique"), + }), + }, + + "no-update-store-trigger": { + prior: cty.ObjectVal(map[string]cty.Value{ + "store": cty.ObjectVal(map[string]cty.Value{ + "input": cty.NullVal(cty.DynamicPseudoType), + "version": cty.NumberIntVal(1), + "output": cty.StringVal("ephem"), + }), + "id": cty.StringVal("not-quite-unique"), + }), + proposed: cty.ObjectVal(map[string]cty.Value{ + "store": cty.ObjectVal(map[string]cty.Value{ + "input": cty.StringVal("ephem 2"), + "version": cty.NumberIntVal(1), + "output": cty.StringVal("ephem"), + }), + "id": cty.StringVal("not-quite-unique"), + }), + planned: cty.ObjectVal(map[string]cty.Value{ + "store": cty.ObjectVal(map[string]cty.Value{ + "input": cty.NullVal(cty.DynamicPseudoType), + "version": cty.NumberIntVal(1), + "output": cty.StringVal("ephem"), + }), + "id": cty.StringVal("not-quite-unique"), + }), + }, + + "no-update-store-auto": { + prior: cty.ObjectVal(map[string]cty.Value{ + "store": cty.ObjectVal(map[string]cty.Value{ + "input": cty.NullVal(cty.DynamicPseudoType), + "output": cty.StringVal("ephem_2"), + }), + "id": cty.StringVal("not-quite-unique"), + }), + proposed: cty.ObjectVal(map[string]cty.Value{ + "store": cty.ObjectVal(map[string]cty.Value{ + "input": cty.StringVal("ephem_2"), + "output": cty.StringVal("ephem_2"), + }), + "id": cty.StringVal("not-quite-unique"), + }), + planned: cty.ObjectVal(map[string]cty.Value{ + "store": cty.ObjectVal(map[string]cty.Value{ + "input": cty.NullVal(cty.DynamicPseudoType), + "output": cty.StringVal("ephem_2"), + }), + "id": cty.StringVal("not-quite-unique"), + }), + }, + + "swap-sensitive": { + prior: cty.ObjectVal(map[string]cty.Value{ + "store": cty.ObjectVal(map[string]cty.Value{ + "input": cty.NullVal(cty.DynamicPseudoType), + "output": cty.StringVal("ephem_1"), + "replace": cty.BoolVal(true), + }), + "id": cty.StringVal("not-quite-unique"), + }), + proposed: cty.ObjectVal(map[string]cty.Value{ + "store": cty.ObjectVal(map[string]cty.Value{ + "input": cty.StringVal("ephem_1"), + "output": cty.StringVal("ephem_1"), + "replace": cty.BoolVal(true), + // sensitive changes, swap the outputs + "sensitive": cty.BoolVal(true), + }), + "id": cty.StringVal("not-quite-unique"), + }), + planned: cty.ObjectVal(map[string]cty.Value{ + "store": cty.ObjectVal(map[string]cty.Value{ + "input": cty.NullVal(cty.DynamicPseudoType), + "replace": cty.BoolVal(true), + "sensitive_output": cty.StringVal("ephem_1"), + "sensitive": cty.BoolVal(true), + }), + // swapping outputs does not replace, the value is not changing + "id": cty.StringVal("not-quite-unique"), + }), + }, } { t.Run("plan-"+name, func(t *testing.T) { req := providers.PlanResourceChangeRequest{ TypeName: "terraform_data", - PriorState: tc.prior, - ProposedNewState: tc.proposed, + PriorState: mustCoerceManagedData(t, tc.prior), + ProposedNewState: mustCoerceManagedData(t, tc.proposed), } resp := planDataStoreResourceChange(req) if resp.Diagnostics.HasErrors() { t.Fatal(resp.Diagnostics.ErrWithWarnings()) } + expectedPlanned := mustCoerceManagedData(t, tc.planned) - if !resp.PlannedState.RawEquals(tc.planned) { - t.Errorf("expected:\n%#v\ngot:\n%#v\n", tc.planned, resp.PlannedState) + if !resp.PlannedState.RawEquals(expectedPlanned) { + t.Errorf("expected:\n%#v\ngot:\n%#v\n", expectedPlanned, resp.PlannedState) } }) } } +func mustCoerceManagedData(t *testing.T, v cty.Value) cty.Value { + schema := dataStoreResourceSchema().Body + v, err := schema.CoerceValue(v) + if err != nil { + t.Fatalf("failed to coerce value: %s", err) + } + return v +} + func TestManagedDataApply(t *testing.T) { testUUIDHook = func() string { return "not-quite-unique" @@ -257,15 +563,14 @@ func TestManagedDataApply(t *testing.T) { }() schema := dataStoreResourceSchema().Body - ty := schema.ImpliedType() for name, tc := range map[string]struct { - prior cty.Value + config cty.Value planned cty.Value state cty.Value }{ "create": { - prior: cty.NullVal(ty), + config: schema.EmptyValue(), planned: cty.ObjectVal(map[string]cty.Value{ "input": cty.NullVal(cty.DynamicPseudoType), "output": cty.NullVal(cty.DynamicPseudoType), @@ -281,7 +586,12 @@ func TestManagedDataApply(t *testing.T) { }, "create-output": { - prior: cty.NullVal(ty), + config: cty.ObjectVal(map[string]cty.Value{ + "input": cty.StringVal("input"), + "output": cty.NullVal(cty.DynamicPseudoType), + "triggers_replace": cty.NullVal(cty.DynamicPseudoType), + "id": cty.NullVal(cty.String), + }), planned: cty.ObjectVal(map[string]cty.Value{ "input": cty.StringVal("input"), "output": cty.UnknownVal(cty.String), @@ -297,11 +607,11 @@ func TestManagedDataApply(t *testing.T) { }, "update-input": { - prior: cty.ObjectVal(map[string]cty.Value{ - "input": cty.StringVal("input"), - "output": cty.StringVal("input"), + config: cty.ObjectVal(map[string]cty.Value{ + "input": cty.StringVal("new-input"), + "output": cty.NullVal(cty.DynamicPseudoType), "triggers_replace": cty.NullVal(cty.DynamicPseudoType), - "id": cty.StringVal("not-quite-unique"), + "id": cty.NullVal(cty.String), }), planned: cty.ObjectVal(map[string]cty.Value{ "input": cty.ListVal([]cty.Value{cty.StringVal("new-input")}), @@ -318,11 +628,11 @@ func TestManagedDataApply(t *testing.T) { }, "update-trigger": { - prior: cty.ObjectVal(map[string]cty.Value{ + config: cty.ObjectVal(map[string]cty.Value{ "input": cty.StringVal("input"), - "output": cty.StringVal("input"), - "triggers_replace": cty.NullVal(cty.DynamicPseudoType), - "id": cty.StringVal("not-quite-unique"), + "output": cty.NullVal(cty.DynamicPseudoType), + "triggers_replace": cty.StringVal("new-value"), + "id": cty.NullVal(cty.String), }), planned: cty.ObjectVal(map[string]cty.Value{ "input": cty.StringVal("input"), @@ -339,13 +649,13 @@ func TestManagedDataApply(t *testing.T) { }, "update-input-trigger": { - prior: cty.ObjectVal(map[string]cty.Value{ - "input": cty.StringVal("input"), - "output": cty.StringVal("input"), + config: cty.ObjectVal(map[string]cty.Value{ + "input": cty.ListVal([]cty.Value{cty.StringVal("new-input")}), + "output": cty.NullVal(cty.DynamicPseudoType), "triggers_replace": cty.MapVal(map[string]cty.Value{ "key": cty.StringVal("value"), }), - "id": cty.StringVal("not-quite-unique"), + "id": cty.NullVal(cty.String), }), planned: cty.ObjectVal(map[string]cty.Value{ "input": cty.ListVal([]cty.Value{cty.StringVal("new-input")}), @@ -364,12 +674,69 @@ func TestManagedDataApply(t *testing.T) { "id": cty.StringVal("not-quite-unique"), }), }, + + "update-store-trigger": { + config: cty.ObjectVal(map[string]cty.Value{ + "store": cty.ObjectVal(map[string]cty.Value{ + "input": cty.StringVal("new_ephem"), + "version": cty.NumberIntVal(1), + "output": cty.NullVal(cty.DynamicPseudoType), + "replace": cty.NullVal(cty.Bool), + }), + "id": cty.NullVal(cty.String), + }), + planned: cty.ObjectVal(map[string]cty.Value{ + "store": cty.ObjectVal(map[string]cty.Value{ + "input": cty.NullVal(cty.DynamicPseudoType), + "version": cty.NumberIntVal(2), + "output": cty.UnknownVal(cty.String), + "replace": cty.NullVal(cty.Bool), + }), + "id": cty.StringVal("not-quite-unique"), + }), + state: cty.ObjectVal(map[string]cty.Value{ + "store": cty.ObjectVal(map[string]cty.Value{ + "input": cty.NullVal(cty.DynamicPseudoType), + "version": cty.NumberIntVal(2), + "output": cty.StringVal("new_ephem"), + "replace": cty.NullVal(cty.Bool), + }), + "id": cty.StringVal("not-quite-unique"), + }), + }, + + "update-store-auto": { + config: cty.ObjectVal(map[string]cty.Value{ + "store": cty.ObjectVal(map[string]cty.Value{ + "input": cty.StringVal("new_ephem"), + "output": cty.StringVal("ephem"), + "replace": cty.NullVal(cty.Bool), + }), + "id": cty.NullVal(cty.String), + }), + planned: cty.ObjectVal(map[string]cty.Value{ + "store": cty.ObjectVal(map[string]cty.Value{ + "input": cty.NullVal(cty.DynamicPseudoType), + "output": cty.UnknownVal(cty.String), + "replace": cty.NullVal(cty.Bool), + }), + "id": cty.StringVal("not-quite-unique"), + }), + state: cty.ObjectVal(map[string]cty.Value{ + "store": cty.ObjectVal(map[string]cty.Value{ + "input": cty.NullVal(cty.DynamicPseudoType), + "output": cty.StringVal("new_ephem"), + "replace": cty.NullVal(cty.Bool), + }), + "id": cty.StringVal("not-quite-unique"), + }), + }, } { t.Run("apply-"+name, func(t *testing.T) { req := providers.ApplyResourceChangeRequest{ TypeName: "terraform_data", - PriorState: tc.prior, - PlannedState: tc.planned, + Config: mustCoerceManagedData(t, tc.config), + PlannedState: mustCoerceManagedData(t, tc.planned), } resp := applyDataStoreResourceChange(req) @@ -377,8 +744,10 @@ func TestManagedDataApply(t *testing.T) { t.Fatal(resp.Diagnostics.ErrWithWarnings()) } - if !resp.NewState.RawEquals(tc.state) { - t.Errorf("expected:\n%#v\ngot:\n%#v\n", tc.state, resp.NewState) + expected := mustCoerceManagedData(t, tc.state) + + if !resp.NewState.RawEquals(expected) { + t.Errorf("expected:\n%#v\ngot:\n%#v\n", expected, resp.NewState) } }) } @@ -409,12 +778,14 @@ func TestMoveDataStoreResourceState_Id(t *testing.T) { t.Errorf("unexpected diagnostics: %s", resp.Diagnostics.Err()) } - expectedTargetState := cty.ObjectVal(map[string]cty.Value{ - "id": cty.StringVal("test"), - "input": cty.NullVal(cty.DynamicPseudoType), - "output": cty.NullVal(cty.DynamicPseudoType), - "triggers_replace": cty.NullVal(cty.DynamicPseudoType), - }) + expected, err := dataStoreResourceSchema().Body.CoerceValue(cty.EmptyObjectVal) + if err != nil { + t.Fatal(err) + } + expectedMap := expected.AsValueMap() + + expectedMap["id"] = cty.StringVal("test") + expectedTargetState := cty.ObjectVal(expectedMap) if !resp.TargetState.RawEquals(expectedTargetState) { t.Errorf("expected state was:\n%#v\ngot state is:\n%#v\n", expectedTargetState, resp.TargetState) @@ -475,14 +846,17 @@ func TestMoveDataStoreResourceState_Triggers(t *testing.T) { t.Errorf("unexpected diagnostics: %s", resp.Diagnostics.Err()) } - expectedTargetState := cty.ObjectVal(map[string]cty.Value{ - "id": cty.StringVal("test"), - "input": cty.NullVal(cty.DynamicPseudoType), - "output": cty.NullVal(cty.DynamicPseudoType), - "triggers_replace": cty.ObjectVal(map[string]cty.Value{ - "testkey": cty.StringVal("testvalue"), - }), + expected, err := dataStoreResourceSchema().Body.CoerceValue(cty.EmptyObjectVal) + if err != nil { + t.Fatal(err) + } + expectedMap := expected.AsValueMap() + + expectedMap["id"] = cty.StringVal("test") + expectedMap["triggers_replace"] = cty.ObjectVal(map[string]cty.Value{ + "testkey": cty.StringVal("testvalue"), }) + expectedTargetState := cty.ObjectVal(expectedMap) if !resp.TargetState.RawEquals(expectedTargetState) { t.Errorf("expected state was:\n%#v\ngot state is:\n%#v\n", expectedTargetState, resp.TargetState) diff --git a/internal/configs/configschema/coerce_value.go b/internal/configs/configschema/coerce_value.go index c32ec2087c..029cc7b174 100644 --- a/internal/configs/configschema/coerce_value.go +++ b/internal/configs/configschema/coerce_value.go @@ -31,6 +31,7 @@ func (b *Block) CoerceValue(in cty.Value) (cty.Value, error) { func (b *Block) coerceValue(in cty.Value, path cty.Path) (cty.Value, error) { convType := b.specType() + impliedType := convType.WithoutOptionalAttributesDeep() switch { @@ -85,6 +86,7 @@ func (b *Block) coerceValue(in cty.Value, path cty.Path) (cty.Value, error) { if err != nil { return cty.UnknownVal(impliedType), append(path, cty.GetAttrStep{Name: name}).NewError(err) } + attrs[name] = val } diff --git a/internal/configs/configschema/decoder_spec.go b/internal/configs/configschema/decoder_spec.go index 08864aeab6..6592bb648b 100644 --- a/internal/configs/configschema/decoder_spec.go +++ b/internal/configs/configschema/decoder_spec.go @@ -103,7 +103,6 @@ func (b *Block) DecoderSpec() hcldec.Spec { } childSpec := blockS.Block.DecoderSpec() - switch blockS.Nesting { case NestingSingle, NestingGroup: ret[name] = &hcldec.BlockSpec{ @@ -178,7 +177,6 @@ func (b *Block) DecoderSpec() hcldec.Spec { continue } } - decoderSpecCache.set(b, ret) return ret } diff --git a/internal/configs/configschema/internal_validate.go b/internal/configs/configschema/internal_validate.go index d246f1f8ac..cbbf06a488 100644 --- a/internal/configs/configschema/internal_validate.go +++ b/internal/configs/configschema/internal_validate.go @@ -149,7 +149,7 @@ func (a *Attribute) internalValidate(name, prefix string) error { if a.NestedType != nil { switch a.NestedType.Nesting { - case NestingSingle, NestingMap: + case NestingSingle, NestingMap, NestingGroup: // no validations to perform case NestingList, NestingSet: if a.NestedType.Nesting == NestingSet { From b477351cf06b645319c4a3783e943d2673a2af83 Mon Sep 17 00:00:00 2001 From: James Bardin Date: Mon, 16 Mar 2026 14:08:35 -0400 Subject: [PATCH 2/3] update schemas where they are referenced in various tests Update a stacks test to ensure we create up-to-date values for the terraform_data resource. We also remove the encoded test values for easier reading. Update the copy of the schema necessary for the rpcapi test --- internal/rpcapi/dependencies_test.go | 42 ++++++++++++++++ internal/stacks/stackruntime/plan_test.go | 58 +++++++---------------- 2 files changed, 60 insertions(+), 40 deletions(-) diff --git a/internal/rpcapi/dependencies_test.go b/internal/rpcapi/dependencies_test.go index f50d6c3f70..8d391f7efa 100644 --- a/internal/rpcapi/dependencies_test.go +++ b/internal/rpcapi/dependencies_test.go @@ -347,6 +347,7 @@ func TestDependenciesProviderSchema(t *testing.T) { } { got := schemaResp.Schema + want := &dependencies.ProviderSchema{ ProviderConfig: &dependencies.Schema{ Block: &dependencies.Schema_Block{ @@ -431,6 +432,47 @@ func TestDependenciesProviderSchema(t *testing.T) { Optional: true, }, }, + BlockTypes: []*dependencies.Schema_NestedBlock{ + { + TypeName: "store", + Nesting: dependencies.Schema_NestedBlock_SINGLE, + Block: &dependencies.Schema_Block{ + Attributes: []*dependencies.Schema_Attribute{ + { + Name: "input", + Type: []byte(`"dynamic"`), + Optional: true, + }, + { + Name: "output", + Type: []byte(`"dynamic"`), + Computed: true, + }, + { + Name: "replace", + Type: []byte(`"bool"`), + Optional: true, + }, + { + Name: "sensitive", + Type: []byte(`"bool"`), + Optional: true, + }, + { + Name: "sensitive_output", + Type: []byte(`"dynamic"`), + Computed: true, + Sensitive: true, + }, + { + Name: "version", + Type: []byte(`"dynamic"`), + Optional: true, + }, + }, + }, + }, + }, }, }, }, diff --git a/internal/stacks/stackruntime/plan_test.go b/internal/stacks/stackruntime/plan_test.go index 33c699ed22..db79e0e529 100644 --- a/internal/stacks/stackruntime/plan_test.go +++ b/internal/stacks/stackruntime/plan_test.go @@ -18,6 +18,7 @@ import ( "github.com/hashicorp/hcl/v2" "github.com/zclconf/go-cty-debug/ctydebug" "github.com/zclconf/go-cty/cty" + "github.com/zclconf/go-cty/cty/msgpack" "github.com/hashicorp/terraform/internal/checks" "github.com/hashicorp/terraform/internal/depsfile" @@ -26,6 +27,7 @@ import ( "github.com/hashicorp/terraform/internal/addrs" + "github.com/hashicorp/terraform/internal/builtin/providers/terraform" terraformProvider "github.com/hashicorp/terraform/internal/builtin/providers/terraform" "github.com/hashicorp/terraform/internal/collections" "github.com/hashicorp/terraform/internal/configs" @@ -1987,6 +1989,20 @@ func TestPlanWithSingleResource(t *testing.T) { return fmt.Sprintf("%T", ic) < fmt.Sprintf("%T", jc) }) + // extract the schema from the builtin provider so we can generate correctly + // shaped plan objects. + schema := terraform.NewProvider().GetProviderSchema().ResourceTypes["terraform_data"] + wantMap := schema.Body.EmptyValue().AsValueMap() + wantMap["id"] = cty.UnknownVal(cty.String).RefineNotNull() + wantMap["input"] = cty.StringVal("hello") + wantMap["output"] = cty.UnknownVal(cty.String) + + wantVal := cty.ObjectVal(wantMap) + wantValEncoded, err := msgpack.Marshal(wantVal, schema.Body.ImpliedType()) + if err != nil { + t.Fatal(err) + } + wantChanges := []stackplan.PlannedChange{ &stackplan.PlannedChangeApplyable{ Applyable: true, @@ -2062,26 +2078,7 @@ func TestPlanWithSingleResource(t *testing.T) { ChangeSrc: plans.ChangeSrc{ Action: plans.Create, Before: mustPlanDynamicValue(cty.NullVal(cty.DynamicPseudoType)), - After: plans.DynamicValue{ - // This is an object conforming to the terraform_data - // resource type's schema. - // - // FIXME: Should write this a different way that is - // scrutable and won't break each time something gets - // added to the terraform_data schema. (We can't use - // mustPlanDynamicValue here because the resource type - // uses DynamicPseudoType attributes, which require - // explicitly-typed encoding.) - 0x84, 0xa2, 0x69, 0x64, 0xc7, 0x03, 0x0c, 0x81, - 0x01, 0xc2, 0xa5, 0x69, 0x6e, 0x70, 0x75, 0x74, - 0x92, 0xc4, 0x08, 0x22, 0x73, 0x74, 0x72, 0x69, - 0x6e, 0x67, 0x22, 0xa5, 0x68, 0x65, 0x6c, 0x6c, - 0x6f, 0xa6, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, - 0x92, 0xc4, 0x08, 0x22, 0x73, 0x74, 0x72, 0x69, - 0x6e, 0x67, 0x22, 0xd4, 0x00, 0x00, 0xb0, 0x74, - 0x72, 0x69, 0x67, 0x67, 0x65, 0x72, 0x73, 0x5f, - 0x72, 0x65, 0x70, 0x6c, 0x61, 0x63, 0x65, 0xc0, - }, + After: plans.DynamicValue(wantValEncoded), }, }, @@ -2089,26 +2086,7 @@ func TestPlanWithSingleResource(t *testing.T) { // type from the real terraform.io/builtin/terraform provider // maintained elsewhere in this codebase. If that schema changes // in future then this should change to match it. - Schema: providers.Schema{ - Body: &configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "input": {Type: cty.DynamicPseudoType, Optional: true}, - "output": {Type: cty.DynamicPseudoType, Computed: true}, - "triggers_replace": {Type: cty.DynamicPseudoType, Optional: true}, - "id": {Type: cty.String, Computed: true}, - }, - }, - Identity: &configschema.Object{ - Attributes: map[string]*configschema.Attribute{ - "id": { - Type: cty.String, - Description: "The unique identifier for the data store.", - Required: true, - }, - }, - Nesting: configschema.NestingSingle, - }, - }, + Schema: schema, }, } From a9d997cb296a677f71090883da5cd61ae4da1d94 Mon Sep 17 00:00:00 2001 From: James Bardin Date: Fri, 20 Mar 2026 08:51:54 -0400 Subject: [PATCH 3/3] CHANGELOG --- .changes/v1.16/NEW FEATURES-20260320-085123.yaml | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changes/v1.16/NEW FEATURES-20260320-085123.yaml diff --git a/.changes/v1.16/NEW FEATURES-20260320-085123.yaml b/.changes/v1.16/NEW FEATURES-20260320-085123.yaml new file mode 100644 index 0000000000..10b03bd0c8 --- /dev/null +++ b/.changes/v1.16/NEW FEATURES-20260320-085123.yaml @@ -0,0 +1,5 @@ +kind: NEW FEATURES +body: New store block in terraform_data that can handle ephemeral and sensitive values +time: 2026-03-20T08:51:23.141458-04:00 +custom: + Issue: "38298"