Skip to content

Commit 1f5d7ad

Browse files
authored
Ensure module changes are detected in Diff(...) (#400)
* Ensure module changes are detected in Diff(...) * lint * lint more * try plan -no-refresh in Diff * relax alb example assertions and ues a smaller database in rds example * try pulumi preview --refresh on the RDS example * preview --refresh on the RDS module example in e2e TypeScript tests * pulumi preview --refresh when expecting no changes * skip expect no changes for aws-lambda because of problematic drift
1 parent 7ab44e1 commit 1f5d7ad

File tree

12 files changed

+260
-35
lines changed

12 files changed

+260
-35
lines changed

examples/aws-rds-example/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ new rdsmod.Module("test-rds", {
5252
publicly_accessible: false,
5353
allocated_storage: 20,
5454
max_allocated_storage: 100,
55-
instance_class: "db.t4g.large",
55+
instance_class: "db.t4g.micro",
5656
engine_version: "8.0",
5757
family: "mysql8.0",
5858
db_name: "completeMysql",

pkg/modprovider/module.go

Lines changed: 59 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -71,11 +71,14 @@ func (h *moduleHandler) Check(
7171
}
7272

7373
func (h *moduleHandler) Diff(
74-
_ context.Context,
74+
ctx context.Context,
7575
req *pulumirpc.DiffRequest,
76+
moduleSource TFModuleSource,
77+
moduleVersion TFModuleVersion,
78+
providersConfig map[string]resource.PropertyMap,
79+
inferredModule *InferredModuleSchema,
80+
executor string,
7681
) (*pulumirpc.DiffResponse, error) {
77-
changes := pulumirpc.DiffResponse_DIFF_NONE
78-
7982
oldInputs, err := plugin.UnmarshalProperties(req.GetOldInputs(), h.marshalOpts())
8083
if err != nil {
8184
return nil, err
@@ -88,10 +91,60 @@ func (h *moduleHandler) Diff(
8891

8992
// TODO[pulumi/pulumi-terraform-module#332] detect and correct drift
9093
if !oldInputs.DeepEquals(newInputs) {
91-
changes = pulumirpc.DiffResponse_DIFF_SOME
94+
// Inputs have changed, so we need tell the engine that an update is needed.
95+
return &pulumirpc.DiffResponse{Changes: pulumirpc.DiffResponse_DIFF_SOME}, nil
96+
}
97+
98+
// Here, inputs have not changes but the underlying module might have changed
99+
// perform a plan to see if there were any changes in the module reported by terraform
100+
urn := urn.URN(req.GetUrn())
101+
oldOutputs, err := plugin.UnmarshalProperties(req.GetOlds(), h.marshalOpts())
102+
if err != nil {
103+
return nil, fmt.Errorf("failed to unmarshal old outputs: %w", err)
104+
}
105+
106+
tf, err := h.prepSandbox(
107+
ctx,
108+
urn,
109+
oldInputs,
110+
oldOutputs,
111+
inferredModule,
112+
moduleSource,
113+
moduleVersion,
114+
providersConfig,
115+
executor,
116+
)
117+
if err != nil {
118+
return nil, fmt.Errorf("failed preparing sandbox: %w", err)
119+
}
120+
121+
plan, err := tf.PlanNoRefresh(ctx, newResourceLogger(h.hc, urn))
122+
if err != nil {
123+
return nil, fmt.Errorf("error performing plan during Diff(...) %w", err)
124+
}
125+
126+
resourcesChanged := false
127+
plan.VisitResourcePlans(func(resource *tfsandbox.ResourcePlan) {
128+
if resource.ChangeKind() != tfsandbox.NoOp {
129+
// if there is any resource change that is not a no-op, we need to update.
130+
resourcesChanged = true
131+
}
132+
})
133+
134+
outputsChanged := false
135+
for _, output := range plan.RawPlan().OutputChanges {
136+
if !output.Actions.NoOp() {
137+
outputsChanged = true
138+
break
139+
}
140+
}
141+
142+
if resourcesChanged || outputsChanged {
143+
return &pulumirpc.DiffResponse{Changes: pulumirpc.DiffResponse_DIFF_SOME}, nil
92144
}
93145

94-
return &pulumirpc.DiffResponse{Changes: changes}, nil
146+
// the module has not changed, return DIFF_NONE.
147+
return &pulumirpc.DiffResponse{Changes: pulumirpc.DiffResponse_DIFF_NONE}, nil
95148
}
96149

97150
func (h *moduleHandler) prepSandbox(
@@ -440,7 +493,7 @@ func (h *moduleHandler) Delete(
440493
executor,
441494
)
442495
if err != nil {
443-
return nil, fmt.Errorf("failed preparing tofu sandbox: %w", err)
496+
return nil, fmt.Errorf("failed preparing sandbox: %w", err)
444497
}
445498

446499
// TODO[pulumi/pulumi-terraform-module#247] once the engine is ready to receive view steps multiple times, the

pkg/modprovider/server.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -470,7 +470,9 @@ func (s *server) Diff(
470470
) (*pulumirpc.DiffResponse, error) {
471471
switch {
472472
case req.GetType() == string(moduleTypeToken(s.packageName)):
473-
return s.moduleHandler.Diff(ctx, req)
473+
providersConfig := cleanProvidersConfig(s.providerConfig)
474+
return s.moduleHandler.Diff(ctx, req, s.params.TFModuleSource, s.params.TFModuleVersion, providersConfig,
475+
s.inferredModuleSchema, s.moduleExecutor)
474476
default:
475477
return nil, fmt.Errorf("[Diff]: type %q is not supported yet", req.GetType())
476478
}

pkg/tfsandbox/plan.go

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -50,20 +50,42 @@ func (t *ModuleRuntime) PlanRefreshOnly(ctx context.Context, logger Logger) (*Pl
5050
return p, nil
5151
}
5252

53+
func (t *ModuleRuntime) PlanNoRefresh(ctx context.Context, logger Logger) (*Plan, error) {
54+
plan, err := t.planNoRefresh(ctx, logger)
55+
if err != nil {
56+
return nil, err
57+
}
58+
59+
p, err := NewPlan(plan)
60+
if err != nil {
61+
return nil, err
62+
}
63+
return p, nil
64+
}
65+
5366
func (t *ModuleRuntime) plan(ctx context.Context, logger Logger) (*tfjson.Plan, error) {
54-
return t.planWithOptions(ctx, logger, false /*refreshOnly*/)
67+
return t.planWithOptions(ctx, logger, t.planOptions())
5568
}
5669

5770
func (t *ModuleRuntime) planRefreshOnly(ctx context.Context, logger Logger) (*tfjson.Plan, error) {
58-
return t.planWithOptions(ctx, logger, true /*refreshOnly*/)
71+
return t.planWithOptions(ctx, logger, t.planOptions(tfexec.RefreshOnly(true)))
5972
}
6073

61-
func (t *ModuleRuntime) planWithOptions(ctx context.Context, logger Logger, refreshOnly bool) (*tfjson.Plan, error) {
74+
func (t *ModuleRuntime) planNoRefresh(ctx context.Context, logger Logger) (*tfjson.Plan, error) {
75+
return t.planWithOptions(ctx, logger, t.planOptions(tfexec.Refresh(false)))
76+
}
77+
78+
func (t *ModuleRuntime) planWithOptions(
79+
ctx context.Context,
80+
logger Logger,
81+
options []tfexec.PlanOption,
82+
) (*tfjson.Plan, error) {
6283
planFile := path.Join(t.WorkingDir(), defaultPlanFile)
6384
logWriter := newJSONLogPipe(ctx, logger)
6485
defer logWriter.Close()
65-
_ /*hasChanges*/, err := t.tf.PlanJSON(ctx, logWriter,
66-
t.planOptions(tfexec.Out(planFile), tfexec.RefreshOnly(refreshOnly))...)
86+
87+
planOptions := append(t.planOptions(tfexec.Out(planFile)), options...)
88+
_ /*hasChanges*/, err := t.tf.PlanJSON(ctx, logWriter, planOptions...)
6789
if err != nil {
6890
return nil, fmt.Errorf("error running plan: %w", err)
6991
}

tests/acc_test.go

Lines changed: 117 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,107 @@ func Test_TwoInstances_TypeScript(t *testing.T) {
358358
})
359359
}
360360

361+
// Test that changing the source code of a local module causes the module to be updated
362+
// without any changes to the program code.
363+
// In this specific example, the change in the source code is from changing outputs
364+
func TestChangingLocalModuleSourceCodeOutputsCausesUpdate(t *testing.T) {
365+
localProviderBinPath := ensureCompiledProvider(t)
366+
modulePath := filepath.Join("testdata", "modules", "changing_module_source_outputs")
367+
v1, err := filepath.Abs(filepath.Join(modulePath, "mod_v1"))
368+
assert.NoError(t, err, "failed to get absolute path for v1 module")
369+
v2, err := filepath.Abs(filepath.Join(modulePath, "mod_v2"))
370+
assert.NoError(t, err, "failed to get absolute path for v2 module")
371+
372+
// YAML program that uses the module.
373+
// We don't change anything here
374+
program := filepath.Join("testdata", "programs", "yaml", "changing_module_source_outputs")
375+
376+
integrationTest := pulumitest.NewPulumiTest(t, program,
377+
opttest.SkipInstall(),
378+
opttest.LocalProviderPath(provider, filepath.Dir(localProviderBinPath)))
379+
380+
pulumiPackageAdd(t, integrationTest, localProviderBinPath, v1, "greeting")
381+
382+
resultV1 := integrationTest.Up(t)
383+
assert.Len(t, resultV1.Outputs, 1, "expected one output")
384+
greeting, ok := resultV1.Outputs["greeting"]
385+
require.True(t, ok, "expected output greeting")
386+
require.Equal(t, "Hello, John!", greeting.Value)
387+
388+
// Now change the source code of the module from v1 to v2
389+
v1SourceCode, err := os.ReadFile(filepath.Join(v1, "main.tf"))
390+
require.NoError(t, err, "failed to read v1 source code")
391+
v2SourceCode, err := os.ReadFile(filepath.Join(v2, "main.tf"))
392+
require.NoError(t, err, "failed to read v2 source code")
393+
394+
err = os.WriteFile(filepath.Join(v1, "main.tf"), v2SourceCode, 0600)
395+
require.NoError(t, err, "failed to write v2 source code to v1 module")
396+
397+
t.Cleanup(func() {
398+
// Restore the original source code after the test
399+
err := os.WriteFile(filepath.Join(v1, "main.tf"), v1SourceCode, 0600)
400+
require.NoError(t, err, "failed to restore v1 source code")
401+
})
402+
403+
t.Logf("Changed module source code to v2")
404+
resultV2 := integrationTest.Up(t)
405+
assert.Len(t, resultV2.Outputs, 1, "expected one output")
406+
greeting, ok = resultV2.Outputs["greeting"]
407+
require.True(t, ok, "expected output greeting")
408+
require.Equal(t, "Goodbye, John!", greeting.Value)
409+
}
410+
411+
// Test that changing the source code of a local module causes the module to be updated
412+
// In this specific example, the change in the source code is from changing resources
413+
func TestChangingLocalModuleSourceCodeResourcesCausesUpdate(t *testing.T) {
414+
localProviderBinPath := ensureCompiledProvider(t)
415+
modulePath := filepath.Join("testdata", "modules", "changing_module_source_resources")
416+
v1, err := filepath.Abs(filepath.Join(modulePath, "mod_v1"))
417+
assert.NoError(t, err, "failed to get absolute path for v1 module")
418+
v2, err := filepath.Abs(filepath.Join(modulePath, "mod_v2"))
419+
assert.NoError(t, err, "failed to get absolute path for v2 module")
420+
421+
// YAML program that uses the module.
422+
// We don't change anything here
423+
program := filepath.Join("testdata", "programs", "yaml", "changing_module_source_resources")
424+
425+
integrationTest := pulumitest.NewPulumiTest(t, program,
426+
opttest.SkipInstall(),
427+
opttest.LocalProviderPath(provider, filepath.Dir(localProviderBinPath)))
428+
429+
pulumiPackageAdd(t, integrationTest, localProviderBinPath, v1, "rand")
430+
431+
integrationTest.Up(t)
432+
// assert that the module was created with the expected resource
433+
mustFindDeploymentResourceByType(t, integrationTest, "rand:tf:random_integer")
434+
435+
// Now change the source code of the module from v1 to v2
436+
v1SourceCode, err := os.ReadFile(filepath.Join(v1, "main.tf"))
437+
require.NoError(t, err, "failed to read v1 source code")
438+
v2SourceCode, err := os.ReadFile(filepath.Join(v2, "main.tf"))
439+
require.NoError(t, err, "failed to read v2 source code")
440+
441+
err = os.WriteFile(filepath.Join(v1, "main.tf"), v2SourceCode, 0600)
442+
require.NoError(t, err, "failed to write v2 source code to v1 module")
443+
444+
t.Cleanup(func() {
445+
// Restore the original source code after the test
446+
err := os.WriteFile(filepath.Join(v1, "main.tf"), v1SourceCode, 0600)
447+
require.NoError(t, err, "failed to restore v1 source code")
448+
})
449+
450+
preview := integrationTest.Preview(t)
451+
assert.Equal(t, 1, preview.ChangeSummary[apitype.OpType("delete")],
452+
"expected one resource to be deleted")
453+
assert.Equal(t, 1, preview.ChangeSummary[apitype.OpType("create")],
454+
"expected one resource to be created")
455+
456+
integrationTest.Up(t)
457+
// assert that the resource within the module has changed
458+
// from random_integer into random_pet
459+
mustFindDeploymentResourceByType(t, integrationTest, "rand:tf:random_pet")
460+
}
461+
361462
func TestGenerateTerraformAwsModulesSDKs(t *testing.T) {
362463
t.Parallel()
363464

@@ -651,6 +752,8 @@ func TestE2eTs(t *testing.T) {
651752
upExpect map[string]int
652753
deleteExpect map[string]int
653754
diffNoChangesExpect map[apitype.OpType]int
755+
previewRefresh bool // Whether to run preview with refresh enabled
756+
skipNoChangesExpect bool // Whether to skip the no changes expectation
654757
}
655758

656759
testcases := []testCase{
@@ -677,6 +780,8 @@ func TestE2eTs(t *testing.T) {
677780
moduleName: "terraform-aws-modules/lambda/aws",
678781
moduleVersion: "7.20.1",
679782
moduleNamespace: "lambda",
783+
// aws lambda module creates drift even in terraform
784+
skipNoChangesExpect: true,
680785
previewExpect: map[apitype.OpType]int{
681786
apitype.OpType("create"): 8,
682787
},
@@ -702,6 +807,9 @@ func TestE2eTs(t *testing.T) {
702807
moduleName: "terraform-aws-modules/rds/aws",
703808
moduleVersion: "6.10.0",
704809
moduleNamespace: "rds",
810+
// RDS module has immediate drift after creation,
811+
// so we need to refresh to get the correct preview
812+
previewRefresh: true,
705813
previewExpect: map[apitype.OpType]int{
706814
apitype.OpType("create"): 10,
707815
},
@@ -736,7 +844,11 @@ func TestE2eTs(t *testing.T) {
736844
pulumiPackageAdd(t, integrationTest, localProviderBinPath, tc.moduleName, tc.moduleVersion, tc.moduleNamespace)
737845

738846
// Preview
739-
previewResult := integrationTest.Preview(t, optpreview.Diff())
847+
previewOptions := []optpreview.Option{optpreview.Diff()}
848+
if tc.previewRefresh {
849+
previewOptions = append(previewOptions, optpreview.Refresh())
850+
}
851+
previewResult := integrationTest.Preview(t, previewOptions...)
740852
t.Logf("pulumi preview:\n%s", previewResult.StdOut+previewResult.StdErr)
741853
autogold.Expect(tc.previewExpect).Equal(t, previewResult.ChangeSummary)
742854

@@ -746,9 +858,11 @@ func TestE2eTs(t *testing.T) {
746858
autogold.Expect(&tc.upExpect).Equal(t, upResult.Summary.ResourceChanges)
747859

748860
// Preview expect no changes
749-
previewResult = integrationTest.Preview(t, optpreview.Diff())
861+
previewResult = integrationTest.Preview(t, previewOptions...)
750862
t.Logf("pulumi preview\n%s", previewResult.StdOut+previewResult.StdErr)
751-
autogold.Expect(tc.diffNoChangesExpect).Equal(t, previewResult.ChangeSummary)
863+
if !tc.skipNoChangesExpect {
864+
autogold.Expect(tc.diffNoChangesExpect).Equal(t, previewResult.ChangeSummary)
865+
}
752866

753867
// Delete
754868
destroyResult := integrationTest.Destroy(t)

0 commit comments

Comments
 (0)