Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Evaluate data sources in plan when necessary #24904

Merged
merged 21 commits into from
May 20, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions plans/changes.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,22 @@ func (c *Changes) ResourceInstance(addr addrs.AbsResourceInstance) *ResourceInst
}

return nil

}

// InstancesForConfigResource returns the planned change for the current objects
// of the resource instances of the given address, if any. Returns nil if no
// changes are planned.
func (c *Changes) InstancesForConfigResource(addr addrs.ConfigResource) []*ResourceInstanceChangeSrc {
var changes []*ResourceInstanceChangeSrc
for _, rc := range c.Resources {
resAddr := rc.Addr.ContainingResource().Config()
if resAddr.Equal(addr) && rc.DeposedKey == states.NotDeposed {
changes = append(changes, rc)
}
}

return changes
}

// ResourceInstanceDeposed returns the plan change of a deposed object of
Expand Down
23 changes: 23 additions & 0 deletions plans/changes_sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,29 @@ func (cs *ChangesSync) GetResourceInstanceChange(addr addrs.AbsResourceInstance,
panic(fmt.Sprintf("unsupported generation value %#v", gen))
}

// GetChangesForConfigResource searched the set of resource instance
// changes and returns all changes related to a given configuration address.
// This is be used to find possible changes related to a configuration
// reference.
//
// If no such changes exist, nil is returned.
//
// The returned objects are a deep copy of the change recorded in the plan, so
// callers may mutate them although it's generally better (less confusing) to
// treat planned changes as immutable after they've been initially constructed.
func (cs *ChangesSync) GetChangesForConfigResource(addr addrs.ConfigResource) []*ResourceInstanceChangeSrc {
if cs == nil {
panic("GetChangesForConfigResource on nil ChangesSync")
}
cs.lock.Lock()
defer cs.lock.Unlock()
var changes []*ResourceInstanceChangeSrc
for _, c := range cs.changes.InstancesForConfigResource(addr) {
changes = append(changes, c.DeepCopy())
}
return changes
}

// RemoveResourceInstanceChange searches the set of resource instance changes
// for one matching the given address and generation, and removes it from the
// set if it exists.
Expand Down
72 changes: 71 additions & 1 deletion terraform/context_apply_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8724,7 +8724,20 @@ func TestContext2Apply_destroyNestedModuleWithAttrsReferencingResource(t *testin
// that resource to be applied first.
func TestContext2Apply_dataDependsOn(t *testing.T) {
p := testProvider("null")
m := testModule(t, "apply-data-depends-on")
m := testModuleInline(t, map[string]string{
"main.tf": `
resource "null_instance" "write" {
foo = "attribute"
}

data "null_data_source" "read" {
depends_on = ["null_instance.write"]
}

resource "null_instance" "depends" {
foo = data.null_data_source.read.foo
}
`})

ctx := testContext2(t, &ContextOpts{
Config: m,
Expand Down Expand Up @@ -8782,6 +8795,63 @@ func TestContext2Apply_dataDependsOn(t *testing.T) {
if actual != expected {
t.Fatalf("bad:\n%s", strings.TrimSpace(state.String()))
}

// run another plan to make sure the data source doesn't show as a change
plan, diags := ctx.Plan()
assertNoErrors(t, diags)

for _, c := range plan.Changes.Resources {
if c.Action != plans.NoOp {
t.Fatalf("unexpected change for %s", c.Addr)
}
}

// now we cause a change in the first resource, which should trigger a plan
// in the data source, and the resource that depends on the data source
// must plan a change as well.
m = testModuleInline(t, map[string]string{
"main.tf": `
resource "null_instance" "write" {
foo = "new"
}

data "null_data_source" "read" {
depends_on = ["null_instance.write"]
}

resource "null_instance" "depends" {
foo = data.null_data_source.read.foo
}
`})

p.ApplyFn = func(info *InstanceInfo, s *InstanceState, d *InstanceDiff) (*InstanceState, error) {
// the side effect of the resource being applied
provisionerOutput = "APPLIED_AGAIN"
return testApplyFn(info, s, d)
}

ctx = testContext2(t, &ContextOpts{
Config: m,
State: state,
Providers: map[addrs.Provider]providers.Factory{
addrs.NewDefaultProvider("null"): testProviderFuncFixed(p),
},
})

plan, diags = ctx.Plan()
assertNoErrors(t, diags)

expectedChanges := map[string]plans.Action{
"null_instance.write": plans.Update,
"data.null_data_source.read": plans.Read,
"null_instance.depends": plans.Update,
}

for _, c := range plan.Changes.Resources {
if c.Action != expectedChanges[c.Addr.String()] {
t.Errorf("unexpected %s for %s", c.Action, c.Addr)
}
}
}

func TestContext2Apply_terraformWorkspace(t *testing.T) {
Expand Down
44 changes: 25 additions & 19 deletions terraform/context_plan_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1893,9 +1893,9 @@ func TestContext2Plan_computedInFunction(t *testing.T) {
assertNoErrors(t, diags)

if p.ReadDataSourceCalled {
t.Fatalf("ReadDataSource was called on provider during plan; should not have been called")
// there was no config change to read during plan
t.Fatalf("ReadDataSource should not have been called")
}

}

func TestContext2Plan_computedDataCountResource(t *testing.T) {
Expand Down Expand Up @@ -1993,6 +1993,7 @@ func TestContext2Plan_dataResourceBecomesComputed(t *testing.T) {
DataSources: map[string]*configschema.Block{
"aws_data_source": {
Attributes: map[string]*configschema.Attribute{
"id": {Type: cty.String, Computed: true},
"foo": {Type: cty.String, Optional: true},
},
},
Expand Down Expand Up @@ -4992,8 +4993,10 @@ func TestContext2Plan_createBeforeDestroy_depends_datasource(t *testing.T) {
}
}
p.ReadDataSourceFn = func(req providers.ReadDataSourceRequest) providers.ReadDataSourceResponse {
cfg := req.Config.AsValueMap()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This now being called during planning makes me wonder what implication this has for the -refresh=false option. Would we now expect that it isn't possible to create a plan without reading data resources, and that -refresh=false is now only for disabling drift detection?

At first glance that feels okay to me -- being able to disable data resource reads was only really a side-effect of it being done during refresh, not an explicit requirement -- but it is something I expect will feel like a breaking change in some edge-cases, so I think we'd need to be call it out explicitly in the upgrade guide if so.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, we will need to call this out. I'm sure we'll find someone why relies on using stale data sources, but since we want to eventually move this completely into planning, it's something we need to deal with.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Having had some time away to reflect on this a bit more, I'm feeling like this half-way position where we read data resources during refresh and during plan is likely to be confusing, because it will still be subject to the usual quirks/bugs where data sources get resolved against the "wrong" values (the ones in the prior state, rather than the ones in the config) but it will also no longer be disableable with -refresh=false and so the user model here is kinda odd.

I realize I'm taking a different posture here than my initial reaction to this changeset, but out of curiosity: do you have a sense of the size of the delta from this PR to having data sources not update during refresh at all? I was previously feeling nervous about squeezing that in so late before 0.13, but the effect of this change feels potentially heavy too and so now I'm wondering if we shouldn't just go all-in on the new behavior and get some more benefit to justify the risk. What do you think? 🤔

(I'm also now considering that this would be released at the same time as an initial iteration of #15419, which wasn't true when we originally discussed this, and so that capability could help mitigate the fact that terraform refresh would no longer work to get data sources updated because folks could use terraform apply with no other changes to get there instead.)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The benefit with this PR is that while refresh can be using "old" values for data source config, plan will check again if the data source needs to be read, and update the data source there if needed. While overall it is still not optimal, it should reduce the confusing instances to only those where either the evaluation fails entirely (which is the same situation we have now requiring -refresh=false), or when providers require a change in the data source via a dependency (which again we have today).

Overall there shouldn't be much more work to remove data source reevaluation from a separate refresh cycle, but that's not what we need in the end. The bulk of the remaining work is removing the separate refresh cycle entirely, which requires the merging of the refresh and plan walks for all resources at once, which did not look trivial (I only briefly ventured down that path). I also toyed with both not having data sources refresh, and having data sources refresh from state, but that always fails with the cases around using data sources for provider configuration, especially when the data source is involved in authentication.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm indeed... the abuse of data sources for issuing credentials is definitely a wrench in the works here. Reading data sources twice during a plan is not great for that situation either, because it will presumably then issue two sets of credentials, though that's better than it failing altogether.

This particular data source misuse is going to be problematic for #15419 too (terraform plan will never succeed without generating a new plan to be applied) so I expect we're going to need to address it somehow either way... 😖

Copy link
Member Author

@jbardin jbardin May 19, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The good news is that the plan-time read is conditional, so it only gets issued twice if the configuration has visible changes (or if the data source is doing something incorrectly, which also isn't out of the question with the relaxed legacy SDK constraints).

cfg["id"] = cty.StringVal("data_id")
return providers.ReadDataSourceResponse{
Diagnostics: tfdiags.Diagnostics(nil).Append(fmt.Errorf("ReadDataSource called, but should not have been")),
State: cty.ObjectVal(cfg),
}
}

Expand All @@ -5010,9 +5013,6 @@ func TestContext2Plan_createBeforeDestroy_depends_datasource(t *testing.T) {
// thus the plan call below is forced to produce a deferred read action.

plan, diags := ctx.Plan()
if p.ReadDataSourceCalled {
t.Errorf("ReadDataSource was called on the provider, but should not have been because we didn't refresh")
}
if diags.HasErrors() {
t.Fatalf("unexpected errors: %s", diags.Err())
}
Expand Down Expand Up @@ -5042,38 +5042,30 @@ func TestContext2Plan_createBeforeDestroy_depends_datasource(t *testing.T) {
}
checkVals(t, objectVal(t, schema, map[string]cty.Value{
"num": cty.StringVal("2"),
"computed": cty.UnknownVal(cty.String),
"computed": cty.StringVal("data_id"),
}), ric.After)
case "aws_instance.foo[1]":
if res.Action != plans.Create {
t.Fatalf("resource %s should be created, got %s", ric.Addr, ric.Action)
}
checkVals(t, objectVal(t, schema, map[string]cty.Value{
"num": cty.StringVal("2"),
"computed": cty.UnknownVal(cty.String),
"computed": cty.StringVal("data_id"),
}), ric.After)
case "data.aws_vpc.bar[0]":
if res.Action != plans.Read {
t.Fatalf("resource %s should be read, got %s", ric.Addr, ric.Action)
}
checkVals(t, objectVal(t, schema, map[string]cty.Value{
// In a normal flow we would've read an exact value in
// ReadDataSource, but because this test doesn't run
// cty.Refresh we have no opportunity to do that lookup
// and a deferred read is forced.
"id": cty.UnknownVal(cty.String),
"id": cty.StringVal("data_id"),
"foo": cty.StringVal("0"),
}), ric.After)
case "data.aws_vpc.bar[1]":
if res.Action != plans.Read {
t.Fatalf("resource %s should be read, got %s", ric.Addr, ric.Action)
}
checkVals(t, objectVal(t, schema, map[string]cty.Value{
// In a normal flow we would've read an exact value in
// ReadDataSource, but because this test doesn't run
// cty.Refresh we have no opportunity to do that lookup
// and a deferred read is forced.
"id": cty.UnknownVal(cty.String),
"id": cty.StringVal("data_id"),
"foo": cty.StringVal("1"),
}), ric.After)
default:
Expand Down Expand Up @@ -5513,11 +5505,18 @@ func TestContext2Plan_invalidOutput(t *testing.T) {
data "aws_data_source" "name" {}

output "out" {
value = "${data.aws_data_source.name.missing}"
value = data.aws_data_source.name.missing
}`,
})

p := testProvider("aws")
p.ReadDataSourceResponse = providers.ReadDataSourceResponse{
State: cty.ObjectVal(map[string]cty.Value{
"id": cty.StringVal("data_id"),
"foo": cty.StringVal("foo"),
}),
}

ctx := testContext2(t, &ContextOpts{
Config: m,
Providers: map[addrs.Provider]providers.Factory{
Expand Down Expand Up @@ -5558,6 +5557,13 @@ resource "aws_instance" "foo" {
})

p := testProvider("aws")
p.ReadDataSourceResponse = providers.ReadDataSourceResponse{
State: cty.ObjectVal(map[string]cty.Value{
"id": cty.StringVal("data_id"),
"foo": cty.StringVal("foo"),
}),
}

ctx := testContext2(t, &ContextOpts{
Config: m,
Providers: map[addrs.Provider]providers.Factory{
Expand Down
34 changes: 5 additions & 29 deletions terraform/context_refresh_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -949,32 +949,11 @@ func TestContext2Refresh_dataState(t *testing.T) {

p.ReadDataSourceFn = func(req providers.ReadDataSourceRequest) providers.ReadDataSourceResponse {
m := req.Config.AsValueMap()
m["inputs"] = cty.MapVal(map[string]cty.Value{"test": cty.StringVal("yes")})
readStateVal = cty.ObjectVal(m)

return providers.ReadDataSourceResponse{
State: readStateVal,
}

// FIXME: should the "outputs" value here be added to the reutnred state?
// Attributes: map[string]*ResourceAttrDiff{
// "inputs.#": {
// Old: "0",
// New: "1",
// Type: DiffAttrInput,
// },
// "inputs.test": {
// Old: "",
// New: "yes",
// Type: DiffAttrInput,
// },
// "outputs.#": {
// Old: "",
// New: "",
// NewComputed: true,
// Type: DiffAttrOutput,
// },
// },
}

s, diags := ctx.Refresh()
Expand All @@ -986,14 +965,6 @@ func TestContext2Refresh_dataState(t *testing.T) {
t.Fatal("ReadDataSource should have been called")
}

// mod := s.RootModule()
// if got := mod.Resources["data.null_data_source.testing"].Primary.ID; got != "-" {
// t.Fatalf("resource id is %q; want %s", got, "-")
// }
// if !reflect.DeepEqual(mod.Resources["data.null_data_source.testing"].Primary, p.ReadDataApplyReturn) {
// t.Fatalf("bad: %#v", mod.Resources)
// }

mod := s.RootModule()

newState, err := mod.Resources["data.null_data_source.testing"].Instances[addrs.NoKey].Current.Decode(schema.ImpliedType())
Expand Down Expand Up @@ -1612,6 +1583,11 @@ func TestContext2Refresh_dataResourceDependsOn(t *testing.T) {
},
}
p.DiffFn = testDiffFn
p.ReadDataSourceResponse = providers.ReadDataSourceResponse{
State: cty.ObjectVal(map[string]cty.Value{
"compute": cty.StringVal("value"),
}),
}

state := states.NewState()
root := state.EnsureModule(addrs.RootModuleInstance)
Expand Down
3 changes: 2 additions & 1 deletion terraform/context_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -693,11 +693,12 @@ func testProviderSchema(name string) *ProviderSchema {
Attributes: map[string]*configschema.Attribute{
"id": {
Type: cty.String,
Optional: true,
Computed: true,
},
"foo": {
Type: cty.String,
Optional: true,
Computed: true,
},
},
},
Expand Down
Loading