From 3d4bf29c56768f87a992de6f51188fd88685733b Mon Sep 17 00:00:00 2001 From: Arel Rabinowitz <30493345+RLRabinowitz@users.noreply.github.com> Date: Tue, 5 Nov 2024 17:16:00 +0200 Subject: [PATCH] Add exclude flag support (#1900) Signed-off-by: RLRabinowitz --- .golangci.yml | 3 + CHANGELOG.md | 1 + internal/backend/backend.go | 1 + internal/backend/local/backend_local.go | 1 + internal/backend/remote/backend_apply.go | 8 + internal/backend/remote/backend_apply_test.go | 39 + internal/backend/remote/backend_context.go | 5 +- internal/backend/remote/backend_plan.go | 8 + internal/backend/remote/backend_plan_test.go | 33 + .../remote-exec/resource_provisioner_test.go | 2 +- internal/cloud/backend_apply.go | 8 + internal/cloud/backend_apply_test.go | 39 + internal/cloud/backend_context.go | 5 +- internal/cloud/backend_plan.go | 8 + internal/cloud/backend_plan_test.go | 33 + internal/command/apply.go | 1 + internal/command/apply_destroy_test.go | 459 +++--- internal/command/apply_test.go | 88 + internal/command/arguments/apply_test.go | 95 +- internal/command/arguments/extended.go | 61 +- internal/command/arguments/plan_test.go | 100 +- internal/command/arguments/refresh_test.go | 85 +- internal/command/meta.go | 5 + internal/command/meta_backend.go | 1 + internal/command/plan.go | 10 +- internal/command/plan_test.go | 86 + internal/command/refresh.go | 9 +- internal/command/refresh_test.go | 94 ++ internal/command/test_test.go | 54 +- .../command/testdata/apply-excluded/main.tf | 9 + .../plans/internal/planproto/planfile.pb.go | 360 +++-- .../plans/internal/planproto/planfile.proto | 5 + internal/plans/plan.go | 1 + internal/plans/planfile/tfplan.go | 12 + internal/tofu/context_apply.go | 7 +- internal/tofu/context_apply2_test.go | 1437 +++++++++++++++++ internal/tofu/context_apply_test.go | 106 +- internal/tofu/context_plan.go | 121 +- internal/tofu/context_plan2_test.go | 249 ++- internal/tofu/context_plan_test.go | 613 ++++++- internal/tofu/context_refresh_test.go | 269 +++ internal/tofu/graph_builder_apply.go | 8 +- internal/tofu/graph_builder_apply_test.go | 37 +- internal/tofu/graph_builder_plan.go | 7 +- internal/tofu/graph_builder_plan_test.go | 21 + internal/tofu/node_resource_abstract.go | 8 + internal/tofu/node_resource_plan.go | 2 +- .../testdata/plan-targeted-orphan/main.tf | 14 +- .../plan-untargeted-resource-output/main.tf | 2 +- internal/tofu/transform_provider_test.go | 32 +- internal/tofu/transform_targets.go | 218 ++- internal/tofu/transform_targets_test.go | 136 +- website/docs/cli/commands/plan.mdx | 36 +- .../docs/language/meta-arguments/for_each.mdx | 2 +- website/docs/language/state/purpose.mdx | 2 +- 55 files changed, 4428 insertions(+), 628 deletions(-) create mode 100644 internal/command/testdata/apply-excluded/main.tf diff --git a/.golangci.yml b/.golangci.yml index 49258d633e..5b0c982e00 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -35,6 +35,9 @@ linters-settings: gocognit: min-complexity: 50 + goconst: + ignore-tests: true # Is documented to be the default behaviour, but that doesn't seem to be the case + issues: exclude-rules: - path: (.+)_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index b0cbbc4f56..56187b076c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ UPGRADE NOTES: * Using the `ghcr.io/opentofu/opentofu` image as a base image for custom images is deprecated and this will be removed in OpenTofu 1.10. Please see https://opentofu.org/docs/intro/install/docker/ for instructions on building your own image. NEW FEATURES: +* Add support for `-exclude` flag, to allow excluding specific resources and modules with resource targeting ([#426](https://github.com/opentofu/opentofu/issues/426)) ENHANCEMENTS: * State encryption key providers now support customizing the metadata key via `encrypted_metadata_alias` ([#1605](https://github.com/opentofu/opentofu/issues/1605)) diff --git a/internal/backend/backend.go b/internal/backend/backend.go index 232eca005a..f103f7fc57 100644 --- a/internal/backend/backend.go +++ b/internal/backend/backend.go @@ -282,6 +282,7 @@ type Operation struct { PlanMode plans.Mode AutoApprove bool Targets []addrs.Targetable + Excludes []addrs.Targetable ForceReplace []addrs.AbsResourceInstance // Injected by the command creating the operation (plan/apply/refresh/etc...) Variables map[string]UnparsedVariableValue diff --git a/internal/backend/local/backend_local.go b/internal/backend/local/backend_local.go index 9ae703eb4e..1392ad53bc 100644 --- a/internal/backend/local/backend_local.go +++ b/internal/backend/local/backend_local.go @@ -203,6 +203,7 @@ func (b *Local) localRunDirect(op *backend.Operation, run *backend.LocalRun, cor planOpts := &tofu.PlanOpts{ Mode: op.PlanMode, Targets: op.Targets, + Excludes: op.Excludes, ForceReplace: op.ForceReplace, SetVariables: variables, SkipRefresh: op.Type != backend.OperationTypeRefresh && !op.PlanRefresh, diff --git a/internal/backend/remote/backend_apply.go b/internal/backend/remote/backend_apply.go index eafb424829..22246828be 100644 --- a/internal/backend/remote/backend_apply.go +++ b/internal/backend/remote/backend_apply.go @@ -161,6 +161,14 @@ func (b *Remote) opApply(stopCtx, cancelCtx context.Context, op *backend.Operati } } + if len(op.Excludes) != 0 { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "-exclude option is not supported", + "The -exclude option is not currently supported for remote plans.", + )) + } + // Return if there are any errors. if diags.HasErrors() { return nil, diags.Err() diff --git a/internal/backend/remote/backend_apply_test.go b/internal/backend/remote/backend_apply_test.go index 85a847bb78..34afd1012c 100644 --- a/internal/backend/remote/backend_apply_test.go +++ b/internal/backend/remote/backend_apply_test.go @@ -466,6 +466,45 @@ func TestRemote_applyWithTarget(t *testing.T) { } } +// Applying with an exclude flag should error +func TestRemote_applyWithExclude(t *testing.T) { + b, bCleanup := testBackendDefault(t) + defer bCleanup() + + op, configCleanup, done := testOperationApply(t, "./testdata/apply") + defer configCleanup() + + addr, _ := addrs.ParseAbsResourceStr("null_resource.foo") + + op.Workspace = backend.DefaultStateName + op.Excludes = []addrs.Targetable{addr} + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + output := done(t) + if run.Result == backend.OperationSuccess { + t.Fatal("expected apply operation to fail") + } + if !run.PlanEmpty { + t.Fatalf("expected plan to be empty") + } + + errOutput := output.Stderr() + if !strings.Contains(errOutput, "-exclude option is not supported") { + t.Fatalf("expected -exclude option is not supported error, got: %v", errOutput) + } + + stateMgr, _ := b.StateMgr(backend.DefaultStateName) + // An error suggests that the state was not unlocked after apply + if _, err := stateMgr.Lock(statemgr.NewLockInfo()); err != nil { + t.Fatalf("unexpected error locking state after failed apply: %s", err.Error()) + } +} + func TestRemote_applyWithTargetIncompatibleAPIVersion(t *testing.T) { b, bCleanup := testBackendDefault(t) defer bCleanup() diff --git a/internal/backend/remote/backend_context.go b/internal/backend/remote/backend_context.go index a2e6fb8de6..2bf1e836fd 100644 --- a/internal/backend/remote/backend_context.go +++ b/internal/backend/remote/backend_context.go @@ -27,8 +27,9 @@ func (b *Remote) LocalRun(op *backend.Operation) (*backend.LocalRun, statemgr.Fu var diags tfdiags.Diagnostics ret := &backend.LocalRun{ PlanOpts: &tofu.PlanOpts{ - Mode: op.PlanMode, - Targets: op.Targets, + Mode: op.PlanMode, + Targets: op.Targets, + Excludes: op.Excludes, }, } diff --git a/internal/backend/remote/backend_plan.go b/internal/backend/remote/backend_plan.go index e4ea0fb64f..1355d8f280 100644 --- a/internal/backend/remote/backend_plan.go +++ b/internal/backend/remote/backend_plan.go @@ -128,6 +128,14 @@ func (b *Remote) opPlan(stopCtx, cancelCtx context.Context, op *backend.Operatio } } + if len(op.Excludes) != 0 { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "-exclude option is not supported", + "The -exclude option is not currently supported for remote plans.", + )) + } + if !op.PlanRefresh { desiredAPIVersion, _ := version.NewVersion("2.4") diff --git a/internal/backend/remote/backend_plan_test.go b/internal/backend/remote/backend_plan_test.go index 06bec10c61..4f673416e9 100644 --- a/internal/backend/remote/backend_plan_test.go +++ b/internal/backend/remote/backend_plan_test.go @@ -537,6 +537,39 @@ func TestRemote_planWithTargetIncompatibleAPIVersion(t *testing.T) { } } +// Planning with an exclude flag should error +func TestRemote_planWithExclude(t *testing.T) { + b, bCleanup := testBackendDefault(t) + defer bCleanup() + + op, configCleanup, done := testOperationPlan(t, "./testdata/plan") + defer configCleanup() + + addr, _ := addrs.ParseAbsResourceStr("null_resource.foo") + + op.Workspace = backend.DefaultStateName + op.Excludes = []addrs.Targetable{addr} + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + output := done(t) + if run.Result == backend.OperationSuccess { + t.Fatal("expected apply operation to fail") + } + if !run.PlanEmpty { + t.Fatalf("expected plan to be empty") + } + + errOutput := output.Stderr() + if !strings.Contains(errOutput, "-exclude option is not supported") { + t.Fatalf("expected -exclude option is not supported error, got: %v", errOutput) + } +} + func TestRemote_planWithReplace(t *testing.T) { b, bCleanup := testBackendDefault(t) defer bCleanup() diff --git a/internal/builtin/provisioners/remote-exec/resource_provisioner_test.go b/internal/builtin/provisioners/remote-exec/resource_provisioner_test.go index 16d7501d44..521a74c998 100644 --- a/internal/builtin/provisioners/remote-exec/resource_provisioner_test.go +++ b/internal/builtin/provisioners/remote-exec/resource_provisioner_test.go @@ -330,4 +330,4 @@ func TestResourceProvisioner_nullsInOptionals(t *testing.T) { func normaliseNewlines(input string) string { return strings.ReplaceAll(input, "\r\n", "\n") -} \ No newline at end of file +} diff --git a/internal/cloud/backend_apply.go b/internal/cloud/backend_apply.go index e941ad2278..e3e7b8e741 100644 --- a/internal/cloud/backend_apply.go +++ b/internal/cloud/backend_apply.go @@ -78,6 +78,14 @@ func (b *Cloud) opApply(stopCtx, cancelCtx context.Context, op *backend.Operatio )) } + if len(op.Excludes) != 0 { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "-exclude option is not supported", + "The -exclude option is not currently supported for remote plans.", + )) + } + // Return if there are any errors. if diags.HasErrors() { return nil, diags.Err() diff --git a/internal/cloud/backend_apply_test.go b/internal/cloud/backend_apply_test.go index b67105eda0..bbbf52bf97 100644 --- a/internal/cloud/backend_apply_test.go +++ b/internal/cloud/backend_apply_test.go @@ -623,6 +623,45 @@ func TestCloud_applyWithTarget(t *testing.T) { } } +// Applying with an exclude flag should error +func TestCloud_applyWithExclude(t *testing.T) { + b, bCleanup := testBackendWithName(t) + defer bCleanup() + + op, configCleanup, done := testOperationApply(t, "./testdata/apply") + defer configCleanup() + + addr, _ := addrs.ParseAbsResourceStr("null_resource.foo") + + op.Workspace = testBackendSingleWorkspaceName + op.Excludes = []addrs.Targetable{addr} + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + output := done(t) + if run.Result == backend.OperationSuccess { + t.Fatal("expected apply operation to fail") + } + if !run.PlanEmpty { + t.Fatalf("expected plan to be empty") + } + + errOutput := output.Stderr() + if !strings.Contains(errOutput, "-exclude option is not supported") { + t.Fatalf("expected -exclude option is not supported error, got: %v", errOutput) + } + + stateMgr, _ := b.StateMgr(testBackendSingleWorkspaceName) + // An error suggests that the state was not unlocked after apply + if _, err := stateMgr.Lock(statemgr.NewLockInfo()); err != nil { + t.Fatalf("unexpected error locking state after failed apply: %s", err.Error()) + } +} + func TestCloud_applyWithReplace(t *testing.T) { b, bCleanup := testBackendWithName(t) defer bCleanup() diff --git a/internal/cloud/backend_context.go b/internal/cloud/backend_context.go index d5082433f7..67ec5fe6d3 100644 --- a/internal/cloud/backend_context.go +++ b/internal/cloud/backend_context.go @@ -27,8 +27,9 @@ func (b *Cloud) LocalRun(op *backend.Operation) (*backend.LocalRun, statemgr.Ful var diags tfdiags.Diagnostics ret := &backend.LocalRun{ PlanOpts: &tofu.PlanOpts{ - Mode: op.PlanMode, - Targets: op.Targets, + Mode: op.PlanMode, + Targets: op.Targets, + Excludes: op.Excludes, }, } diff --git a/internal/cloud/backend_plan.go b/internal/cloud/backend_plan.go index f6f33d95a3..1eb985949e 100644 --- a/internal/cloud/backend_plan.go +++ b/internal/cloud/backend_plan.go @@ -79,6 +79,14 @@ func (b *Cloud) opPlan(stopCtx, cancelCtx context.Context, op *backend.Operation )) } + if len(op.Excludes) != 0 { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "-exclude option is not supported", + "The -exclude option is not currently supported for remote plans.", + )) + } + if len(op.GenerateConfigOut) > 0 { diags = diags.Append(genconfig.ValidateTargetFile(op.GenerateConfigOut)) } diff --git a/internal/cloud/backend_plan_test.go b/internal/cloud/backend_plan_test.go index 5128b9f33a..9ba2dab89d 100644 --- a/internal/cloud/backend_plan_test.go +++ b/internal/cloud/backend_plan_test.go @@ -565,6 +565,39 @@ func TestCloud_planWithTarget(t *testing.T) { } } +// Planning with an exclude flag should error +func TestCloud_planWithExclude(t *testing.T) { + b, bCleanup := testBackendWithName(t) + defer bCleanup() + + op, configCleanup, done := testOperationPlan(t, "./testdata/plan") + defer configCleanup() + + addr, _ := addrs.ParseAbsResourceStr("null_resource.foo") + + op.Workspace = testBackendSingleWorkspaceName + op.Excludes = []addrs.Targetable{addr} + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + output := done(t) + if run.Result == backend.OperationSuccess { + t.Fatal("expected apply operation to fail") + } + if !run.PlanEmpty { + t.Fatalf("expected plan to be empty") + } + + errOutput := output.Stderr() + if !strings.Contains(errOutput, "-exclude option is not supported") { + t.Fatalf("expected -exclude option is not supported error, got: %v", errOutput) + } +} + func TestCloud_planWithReplace(t *testing.T) { b, bCleanup := testBackendWithName(t) defer bCleanup() diff --git a/internal/command/apply.go b/internal/command/apply.go index 7789b3e18f..194ca59cd1 100644 --- a/internal/command/apply.go +++ b/internal/command/apply.go @@ -284,6 +284,7 @@ func (c *ApplyCommand) OperationRequest( opReq.PlanFile = planFile opReq.PlanRefresh = args.Refresh opReq.Targets = args.Targets + opReq.Excludes = args.Excludes opReq.ForceReplace = args.ForceReplace opReq.Type = backend.OperationTypeApply opReq.View = view.Operation() diff --git a/internal/command/apply_destroy_test.go b/internal/command/apply_destroy_test.go index 5c4ff7ca90..a7f1a06e03 100644 --- a/internal/command/apply_destroy_test.go +++ b/internal/command/apply_destroy_test.go @@ -7,6 +7,7 @@ package command import ( "os" + "path/filepath" "strings" "testing" @@ -386,290 +387,222 @@ func TestApply_destroyPath(t *testing.T) { } } -// Config with multiple resources with dependencies, targeting destroy of a -// root node, expecting all other resources to be destroyed due to -// dependencies. -func TestApply_destroyTargetedDependencies(t *testing.T) { - // Create a temporary working directory that is empty - td := t.TempDir() - testCopyDir(t, testFixturePath("apply-destroy-targeted"), td) - defer testChdir(t, td)() - - originalState := states.BuildState(func(s *states.SyncState) { - s.SetResourceInstanceCurrent( - addrs.Resource{ - Mode: addrs.ManagedResourceMode, - Type: "test_instance", - Name: "foo", - }.Instance(addrs.IntKey(0)).Absolute(addrs.RootModuleInstance), - &states.ResourceInstanceObjectSrc{ - AttrsJSON: []byte(`{"id":"i-ab123"}`), - Status: states.ObjectReady, - }, - addrs.AbsProviderConfig{ - Provider: addrs.NewDefaultProvider("test"), - Module: addrs.RootModule, - }, - ) - s.SetResourceInstanceCurrent( - addrs.Resource{ - Mode: addrs.ManagedResourceMode, - Type: "test_load_balancer", - Name: "foo", - }.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance), - &states.ResourceInstanceObjectSrc{ - AttrsJSON: []byte(`{"id":"i-abc123"}`), - Dependencies: []addrs.ConfigResource{mustResourceAddr("test_instance.foo")}, - Status: states.ObjectReady, - }, - addrs.AbsProviderConfig{ - Provider: addrs.NewDefaultProvider("test"), - Module: addrs.RootModule, - }, - ) - }) - statePath := testStateFile(t, originalState) - - p := testProvider() - p.GetProviderSchemaResponse = &providers.GetProviderSchemaResponse{ - ResourceTypes: map[string]providers.Schema{ - "test_instance": { - Block: &configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "id": {Type: cty.String, Computed: true}, +func TestApply_targetedDestroy(t *testing.T) { + testCases := []struct { + name string + flagName string + flagValue string + wantStatFunc func(s *states.SyncState) + }{ + { + // Config with multiple resources with dependencies, targeting destroy of a + // leaf node, expecting the other resources to remain. + name: "Targeted Destroy", + flagName: "-target", + flagValue: "test_load_balancer.foo", + wantStatFunc: func(s *states.SyncState) { + s.SetResourceInstanceCurrent( + addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "test_instance", + Name: "foo", + }.Instance(addrs.IntKey(0)).Absolute(addrs.RootModuleInstance), + &states.ResourceInstanceObjectSrc{ + AttrsJSON: []byte(`{"id":"i-ab123"}`), + Status: states.ObjectReady, }, - }, - }, - "test_load_balancer": { - Block: &configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "id": {Type: cty.String, Computed: true}, - "instances": {Type: cty.List(cty.String), Optional: true}, + addrs.AbsProviderConfig{ + Provider: addrs.NewDefaultProvider("test"), + Module: addrs.RootModule, }, - }, + ) + }, + }, + { + // Config with multiple resources with dependencies, targeting destroy of a + // root node, expecting all other resources to be destroyed due to + // dependencies. + name: "Targeted Destroy of root", + flagName: "-target", + flagValue: "test_instance.foo", + // No wantStatFunc, expecting empty state + }, + { + // Config with multiple resources with dependencies, destroy excluding a + // non-existent node, expecting all other resources to be destroyed. + name: "Targeted Destroy excluding non-existent resource", + flagName: "-exclude", + flagValue: "test_load_balancer.foo-nonexistent", + // No wantStatFunc, expecting empty state + }, + { + // Config with multiple resources with dependencies, destroy excluding the root node, + // expecting other resources to remain + name: "Targeted Destroy with exclude of root", + flagName: "-exclude", + flagValue: "test_instance.foo", + wantStatFunc: func(s *states.SyncState) { + s.SetResourceInstanceCurrent( + addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "test_instance", + Name: "foo", + }.Instance(addrs.IntKey(0)).Absolute(addrs.RootModuleInstance), + &states.ResourceInstanceObjectSrc{ + AttrsJSON: []byte(`{"id":"i-ab123"}`), + Status: states.ObjectReady, + }, + addrs.AbsProviderConfig{ + Provider: addrs.NewDefaultProvider("test"), + Module: addrs.RootModule, + }, + ) }, }, } - p.PlanResourceChangeFn = func(req providers.PlanResourceChangeRequest) providers.PlanResourceChangeResponse { - return providers.PlanResourceChangeResponse{ - PlannedState: req.ProposedNewState, - } - } - view, done := testView(t) - c := &ApplyCommand{ - Destroy: true, - Meta: Meta{ - testingOverrides: metaOverridesForProvider(p), - View: view, - }, - } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Create a temporary working directory that is empty + td := filepath.Join(t.TempDir(), t.Name()) + testCopyDir(t, testFixturePath("apply-destroy-targeted"), td) + defer testChdir(t, td)() - // Run the apply command pointing to our existing state - args := []string{ - "-auto-approve", - "-target", "test_instance.foo", - "-state", statePath, - } - code := c.Run(args) - output := done(t) - if code != 0 { - t.Log(output.Stdout()) - t.Fatalf("bad: %d\n\n%s", code, output.Stderr()) - } + originalState := states.BuildState(func(s *states.SyncState) { + s.SetResourceInstanceCurrent( + addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "test_instance", + Name: "foo", + }.Instance(addrs.IntKey(0)).Absolute(addrs.RootModuleInstance), + &states.ResourceInstanceObjectSrc{ + AttrsJSON: []byte(`{"id":"i-ab123"}`), + Status: states.ObjectReady, + }, + addrs.AbsProviderConfig{ + Provider: addrs.NewDefaultProvider("test"), + Module: addrs.RootModule, + }, + ) + s.SetResourceInstanceCurrent( + addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "test_load_balancer", + Name: "foo", + }.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance), + &states.ResourceInstanceObjectSrc{ + AttrsJSON: []byte(`{"id":"i-abc123"}`), + Dependencies: []addrs.ConfigResource{mustResourceAddr("test_instance.foo")}, + Status: states.ObjectReady, + }, + addrs.AbsProviderConfig{ + Provider: addrs.NewDefaultProvider("test"), + Module: addrs.RootModule, + }, + ) + }) - // Verify a new state exists - if _, err := os.Stat(statePath); err != nil { - t.Fatalf("err: %s", err) - } + statePath := testStateFile(t, originalState) - f, err := os.Open(statePath) - if err != nil { - t.Fatalf("err: %s", err) - } - defer f.Close() - - stateFile, err := statefile.Read(f, encryption.StateEncryptionDisabled()) - if err != nil { - t.Fatalf("err: %s", err) - } - if stateFile == nil || stateFile.State == nil { - t.Fatal("state should not be nil") - } - - spew.Config.DisableMethods = true - if !stateFile.State.Empty() { - t.Fatalf("unexpected final state\ngot: %s\nwant: empty state", spew.Sdump(stateFile.State)) - } - - // Should have a backup file - f, err = os.Open(statePath + DefaultBackupExtension) - if err != nil { - t.Fatalf("err: %s", err) - } - - backupStateFile, err := statefile.Read(f, encryption.StateEncryptionDisabled()) - f.Close() - if err != nil { - t.Fatalf("err: %s", err) - } - - actualStr := strings.TrimSpace(backupStateFile.State.String()) - expectedStr := strings.TrimSpace(originalState.String()) - if actualStr != expectedStr { - t.Fatalf("bad:\n\nactual:\n%s\n\nexpected:\nb%s", actualStr, expectedStr) - } -} - -// Config with multiple resources with dependencies, targeting destroy of a -// leaf node, expecting the other resources to remain. -func TestApply_destroyTargeted(t *testing.T) { - // Create a temporary working directory that is empty - td := t.TempDir() - testCopyDir(t, testFixturePath("apply-destroy-targeted"), td) - defer testChdir(t, td)() - - originalState := states.BuildState(func(s *states.SyncState) { - s.SetResourceInstanceCurrent( - addrs.Resource{ - Mode: addrs.ManagedResourceMode, - Type: "test_instance", - Name: "foo", - }.Instance(addrs.IntKey(0)).Absolute(addrs.RootModuleInstance), - &states.ResourceInstanceObjectSrc{ - AttrsJSON: []byte(`{"id":"i-ab123"}`), - Status: states.ObjectReady, - }, - addrs.AbsProviderConfig{ - Provider: addrs.NewDefaultProvider("test"), - Module: addrs.RootModule, - }, - ) - s.SetResourceInstanceCurrent( - addrs.Resource{ - Mode: addrs.ManagedResourceMode, - Type: "test_load_balancer", - Name: "foo", - }.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance), - &states.ResourceInstanceObjectSrc{ - AttrsJSON: []byte(`{"id":"i-abc123"}`), - Dependencies: []addrs.ConfigResource{mustResourceAddr("test_instance.foo")}, - Status: states.ObjectReady, - }, - addrs.AbsProviderConfig{ - Provider: addrs.NewDefaultProvider("test"), - Module: addrs.RootModule, - }, - ) - }) - wantState := states.BuildState(func(s *states.SyncState) { - s.SetResourceInstanceCurrent( - addrs.Resource{ - Mode: addrs.ManagedResourceMode, - Type: "test_instance", - Name: "foo", - }.Instance(addrs.IntKey(0)).Absolute(addrs.RootModuleInstance), - &states.ResourceInstanceObjectSrc{ - AttrsJSON: []byte(`{"id":"i-ab123"}`), - Status: states.ObjectReady, - }, - addrs.AbsProviderConfig{ - Provider: addrs.NewDefaultProvider("test"), - Module: addrs.RootModule, - }, - ) - }) - statePath := testStateFile(t, originalState) - - p := testProvider() - p.GetProviderSchemaResponse = &providers.GetProviderSchemaResponse{ - ResourceTypes: map[string]providers.Schema{ - "test_instance": { - Block: &configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "id": {Type: cty.String, Computed: true}, + p := testProvider() + p.GetProviderSchemaResponse = &providers.GetProviderSchemaResponse{ + ResourceTypes: map[string]providers.Schema{ + "test_instance": { + Block: &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "id": {Type: cty.String, Computed: true}, + }, + }, + }, + "test_load_balancer": { + Block: &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "id": {Type: cty.String, Computed: true}, + "instances": {Type: cty.List(cty.String), Optional: true}, + }, + }, }, }, - }, - "test_load_balancer": { - Block: &configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "id": {Type: cty.String, Computed: true}, - "instances": {Type: cty.List(cty.String), Optional: true}, - }, + } + + p.PlanResourceChangeFn = func(req providers.PlanResourceChangeRequest) providers.PlanResourceChangeResponse { + return providers.PlanResourceChangeResponse{ + PlannedState: req.ProposedNewState, + } + } + + view, done := testView(t) + c := &ApplyCommand{ + Destroy: true, + Meta: Meta{ + testingOverrides: metaOverridesForProvider(p), + View: view, }, - }, - }, - } - p.PlanResourceChangeFn = func(req providers.PlanResourceChangeRequest) providers.PlanResourceChangeResponse { - return providers.PlanResourceChangeResponse{ - PlannedState: req.ProposedNewState, - } - } + } - view, done := testView(t) - c := &ApplyCommand{ - Destroy: true, - Meta: Meta{ - testingOverrides: metaOverridesForProvider(p), - View: view, - }, - } + // Run the apply command pointing to our existing state + args := []string{ + "-auto-approve", + tc.flagName, tc.flagValue, + "-state", statePath, + } - // Run the apply command pointing to our existing state - args := []string{ - "-auto-approve", - "-target", "test_load_balancer.foo", - "-state", statePath, - } - code := c.Run(args) - output := done(t) - if code != 0 { - t.Log(output.Stdout()) - t.Fatalf("bad: %d\n\n%s", code, output.Stderr()) - } + code := c.Run(args) + output := done(t) + if code != 0 { + t.Log(output.Stdout()) + t.Fatalf("bad: %d\n\n%s", code, output.Stderr()) + } - // Verify a new state exists - if _, err := os.Stat(statePath); err != nil { - t.Fatalf("err: %s", err) - } + // Verify a new state exists + if _, err := os.Stat(statePath); err != nil { + t.Fatalf("err: %s", err) + } - f, err := os.Open(statePath) - if err != nil { - t.Fatalf("err: %s", err) - } - defer f.Close() + f, err := os.Open(statePath) + if err != nil { + t.Fatalf("err: %s", err) + } + defer f.Close() - stateFile, err := statefile.Read(f, encryption.StateEncryptionDisabled()) - if err != nil { - t.Fatalf("err: %s", err) - } - if stateFile == nil || stateFile.State == nil { - t.Fatal("state should not be nil") - } + stateFile, err := statefile.Read(f, encryption.StateEncryptionDisabled()) + if err != nil { + t.Fatalf("err: %s", err) + } + if stateFile == nil || stateFile.State == nil { + t.Fatal("state should not be nil") + } - actualStr := strings.TrimSpace(stateFile.State.String()) - expectedStr := strings.TrimSpace(wantState.String()) - if actualStr != expectedStr { - t.Fatalf("bad:\n\nactual:\n%s\n\nexpected:\nb%s", actualStr, expectedStr) - } + if tc.wantStatFunc != nil { + wantState := states.BuildState(tc.wantStatFunc) + actualStr := strings.TrimSpace(stateFile.State.String()) + expectedStr := strings.TrimSpace(wantState.String()) + if actualStr != expectedStr { + t.Fatalf("bad:\n\nactual:\n%s\n\nexpected:\nb%s", actualStr, expectedStr) + } + } else if !stateFile.State.Empty() { + // Missing wantStatFunc means expected empty state + t.Fatalf("unexpected final state\ngot: %s\nwant: empty state", spew.Sdump(stateFile.State)) + } - // Should have a backup file - f, err = os.Open(statePath + DefaultBackupExtension) - if err != nil { - t.Fatalf("err: %s", err) - } + // Should have a backup file + f, err = os.Open(statePath + DefaultBackupExtension) + if err != nil { + t.Fatalf("err: %s", err) + } - backupStateFile, err := statefile.Read(f, encryption.StateEncryptionDisabled()) - f.Close() - if err != nil { - t.Fatalf("err: %s", err) - } + backupStateFile, err := statefile.Read(f, encryption.StateEncryptionDisabled()) + f.Close() + if err != nil { + t.Fatalf("err: %s", err) + } - backupActualStr := strings.TrimSpace(backupStateFile.State.String()) - backupExpectedStr := strings.TrimSpace(originalState.String()) - if backupActualStr != backupExpectedStr { - t.Fatalf("bad:\n\nactual:\n%s\n\nexpected:\nb%s", backupActualStr, backupExpectedStr) + backupActualStr := strings.TrimSpace(backupStateFile.State.String()) + backupExpectedStr := strings.TrimSpace(originalState.String()) + if backupActualStr != backupExpectedStr { + t.Fatalf("bad:\n\nactual:\n%s\n\nexpected:\nb%s", backupActualStr, backupExpectedStr) + } + }) } } diff --git a/internal/command/apply_test.go b/internal/command/apply_test.go index 203df15672..0f573ebf82 100644 --- a/internal/command/apply_test.go +++ b/internal/command/apply_test.go @@ -2020,6 +2020,94 @@ func TestApply_targetFlagsDiags(t *testing.T) { } } +// Config with multiple resources, targeted apply with exclude +func TestApply_excluded(t *testing.T) { + td := t.TempDir() + testCopyDir(t, testFixturePath("apply-excluded"), td) + defer testChdir(t, td)() + + p := testProvider() + p.GetProviderSchemaResponse = &providers.GetProviderSchemaResponse{ + ResourceTypes: map[string]providers.Schema{ + "test_instance": { + Block: &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "id": {Type: cty.String, Computed: true}, + }, + }, + }, + }, + } + p.PlanResourceChangeFn = func(req providers.PlanResourceChangeRequest) providers.PlanResourceChangeResponse { + return providers.PlanResourceChangeResponse{ + PlannedState: req.ProposedNewState, + } + } + + view, done := testView(t) + c := &ApplyCommand{ + Meta: Meta{ + testingOverrides: metaOverridesForProvider(p), + View: view, + }, + } + + args := []string{ + "-auto-approve", + "-exclude", "test_instance.bar", + } + code := c.Run(args) + output := done(t) + if code != 0 { + t.Fatalf("bad: %d\n\n%s", code, output.Stderr()) + } + + if got, want := output.Stdout(), "3 added, 0 changed, 0 destroyed"; !strings.Contains(got, want) { + t.Fatalf("bad change summary, want %q, got:\n%s", want, got) + } +} + +// Diagnostics for invalid -exclude flags +func TestApply_excludeFlagsDiags(t *testing.T) { + testCases := map[string]string{ + "test_instance.": "Dot must be followed by attribute name.", + "test_instance": "Resource specification must include a resource type and name.", + } + + for exclude, wantDiag := range testCases { + t.Run(exclude, func(t *testing.T) { + td := testTempDir(t) + defer os.RemoveAll(td) + defer testChdir(t, td)() + + view, done := testView(t) + c := &ApplyCommand{ + Meta: Meta{ + View: view, + }, + } + + args := []string{ + "-auto-approve", + "-exclude", exclude, + } + code := c.Run(args) + output := done(t) + if code != 1 { + t.Fatalf("bad: %d\n\n%s", code, output.Stderr()) + } + + got := output.Stderr() + if !strings.Contains(got, exclude) { + t.Fatalf("bad error output, want %q, got:\n%s", exclude, got) + } + if !strings.Contains(got, wantDiag) { + t.Fatalf("bad error output, want %q, got:\n%s", wantDiag, got) + } + }) + } +} + func TestApply_replace(t *testing.T) { td := t.TempDir() testCopyDir(t, testFixturePath("apply-replace"), td) diff --git a/internal/command/arguments/apply_test.go b/internal/command/arguments/apply_test.go index 01e7c5315c..8a9e6c26b4 100644 --- a/internal/command/arguments/apply_test.go +++ b/internal/command/arguments/apply_test.go @@ -9,6 +9,8 @@ import ( "strings" "testing" + "github.com/opentofu/opentofu/internal/tfdiags" + "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/opentofu/opentofu/internal/addrs" @@ -202,9 +204,11 @@ func TestParseApply_targets(t *testing.T) { for name, tc := range testCases { t.Run(name, func(t *testing.T) { got, diags := ParseApply(tc.args) - if len(diags) > 0 { - if tc.wantErr == "" { - t.Fatalf("unexpected diags: %v", diags) + if tc.wantErr == "" && len(diags) > 0 { + t.Fatalf("unexpected diags: %v", diags) + } else if tc.wantErr != "" { + if len(diags) == 0 { + t.Fatalf("expected diags but got none") } else if got := diags.Err().Error(); !strings.Contains(got, tc.wantErr) { t.Fatalf("wrong diags\n got: %s\nwant: %s", got, tc.wantErr) } @@ -216,6 +220,81 @@ func TestParseApply_targets(t *testing.T) { } } +func TestParseApply_excludes(t *testing.T) { + foobarbaz, _ := addrs.ParseTargetStr("foo_bar.baz") + boop, _ := addrs.ParseTargetStr("module.boop") + testCases := map[string]struct { + args []string + want []addrs.Targetable + wantErr string + }{ + "no excludes by default": { + args: nil, + want: nil, + }, + "one exclude": { + args: []string{"-exclude=foo_bar.baz"}, + want: []addrs.Targetable{foobarbaz.Subject}, + }, + "two excludes": { + args: []string{"-exclude=foo_bar.baz", "-exclude", "module.boop"}, + want: []addrs.Targetable{foobarbaz.Subject, boop.Subject}, + }, + "invalid traversal": { + args: []string{"-exclude=foo."}, + want: nil, + wantErr: "Dot must be followed by attribute name", + }, + "invalid target": { + args: []string{"-exclude=data[0].foo"}, + want: nil, + wantErr: "A data source name is required", + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + got, diags := ParseApply(tc.args) + if tc.wantErr == "" && len(diags) > 0 { + t.Fatalf("unexpected diags: %v", diags) + } else if tc.wantErr != "" { + if len(diags) == 0 { + t.Fatalf("expected diags but got none") + } else if got := diags.Err().Error(); !strings.Contains(got, tc.wantErr) { + t.Fatalf("wrong diags\n got: %s\nwant: %s", got, tc.wantErr) + } + } + if !cmp.Equal(got.Operation.Excludes, tc.want) { + t.Fatalf("unexpected result\n%s", cmp.Diff(got.Operation.Targets, tc.want)) + } + }) + } +} + +func TestParseApply_excludeAndTarget(t *testing.T) { + got, gotDiags := ParseApply([]string{"-exclude=foo_bar.baz", "-target=foo_bar.bar"}) + if len(gotDiags) == 0 { + t.Fatalf("expected error, but there was none") + } + + wantDiags := tfdiags.Diagnostics{ + tfdiags.Sourceless( + tfdiags.Error, + "Invalid combination of arguments", + "-target and -exclude flags cannot be used together. Please remove one of the flags", + ), + } + if diff := cmp.Diff(wantDiags.ForRPC(), gotDiags.ForRPC()); diff != "" { + t.Errorf("wrong diagnostics\n%s", diff) + } + if len(got.Operation.Targets) > 0 { + t.Errorf("Did not expect operation to parse targets, but it parsed %d targets", len(got.Operation.Targets)) + } + if len(got.Operation.Excludes) > 0 { + t.Errorf("Did not expect operation to parse excludes, but it parsed %d targets", len(got.Operation.Excludes)) + } +} + func TestParseApply_replace(t *testing.T) { foobarbaz, _ := addrs.ParseAbsResourceInstanceStr("foo_bar.baz") foobarbeep, _ := addrs.ParseAbsResourceInstanceStr("foo_bar.beep") @@ -261,15 +340,17 @@ func TestParseApply_replace(t *testing.T) { for name, tc := range testCases { t.Run(name, func(t *testing.T) { got, diags := ParseApply(tc.args) - if len(diags) > 0 { - if tc.wantErr == "" { - t.Fatalf("unexpected diags: %v", diags) + if tc.wantErr == "" && len(diags) > 0 { + t.Fatalf("unexpected diags: %v", diags) + } else if tc.wantErr != "" { + if len(diags) == 0 { + t.Fatalf("expected diags but got none") } else if got := diags.Err().Error(); !strings.Contains(got, tc.wantErr) { t.Fatalf("wrong diags\n got: %s\nwant: %s", got, tc.wantErr) } } if !cmp.Equal(got.Operation.ForceReplace, tc.want) { - t.Fatalf("unexpected result\n%s", cmp.Diff(got.Operation.Targets, tc.want)) + t.Fatalf("unexpected result\n%s", cmp.Diff(got.Operation.ForceReplace, tc.want)) } }) } diff --git a/internal/command/arguments/extended.go b/internal/command/arguments/extended.go index b018a6970f..738a4695c1 100644 --- a/internal/command/arguments/extended.go +++ b/internal/command/arguments/extended.go @@ -69,6 +69,10 @@ type Operation struct { // their dependencies. Targets []addrs.Targetable + // Excludes allow limiting an operation to execute on all resources other + // than a set of excluded resource addresses and resources dependent on them. + Excludes []addrs.Targetable + // ForceReplace addresses cause OpenTofu to force a particular set of // resource instances to generate "replace" actions in any plan where they // would normally have generated "no-op" or "update" actions. @@ -85,25 +89,25 @@ type Operation struct { // method Parse to populate the exported fields from these, validating // the raw values in the process. targetsRaw []string + excludesRaw []string forceReplaceRaw []string destroyRaw bool refreshOnlyRaw bool } -// Parse must be called on Operation after initial flag parse. This processes -// the raw target flags into addrs.Targetable values, returning diagnostics if -// invalid. -func (o *Operation) Parse() tfdiags.Diagnostics { +// parseTargetables gets a list of strings, each representing a targetable object, and returns a list of +// addrs.Targetable +// This is used for parsing the input of -target and -exclude flags +func parseTargetables(rawTargetables []string, flag string) ([]addrs.Targetable, tfdiags.Diagnostics) { + var targetables []addrs.Targetable var diags tfdiags.Diagnostics - o.Targets = nil - - for _, tr := range o.targetsRaw { + for _, tr := range rawTargetables { traversal, syntaxDiags := hclsyntax.ParseTraversalAbs([]byte(tr), "", hcl.Pos{Line: 1, Column: 1}) if syntaxDiags.HasErrors() { diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, - fmt.Sprintf("Invalid target %q", tr), + fmt.Sprintf("Invalid %s %q", flag, tr), syntaxDiags[0].Detail, )) continue @@ -113,14 +117,50 @@ func (o *Operation) Parse() tfdiags.Diagnostics { if targetDiags.HasErrors() { diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, - fmt.Sprintf("Invalid target %q", tr), + fmt.Sprintf("Invalid %s %q", flag, tr), targetDiags[0].Description().Detail, )) continue } - o.Targets = append(o.Targets, target.Subject) + targetables = append(targetables, target.Subject) } + return targetables, diags +} + +func parseRawTargetsAndExcludes(targets []string, excludes []string) ([]addrs.Targetable, []addrs.Targetable, tfdiags.Diagnostics) { + var parsedTargets []addrs.Targetable + var parsedExcludes []addrs.Targetable + var diags tfdiags.Diagnostics + + if len(targets) > 0 && len(excludes) > 0 { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Invalid combination of arguments", + "-target and -exclude flags cannot be used together. Please remove one of the flags", + )) + return parsedTargets, parsedExcludes, diags + } + + var parseDiags tfdiags.Diagnostics + parsedTargets, parseDiags = parseTargetables(targets, "target") + diags = diags.Append(parseDiags) + + parsedExcludes, parseDiags = parseTargetables(excludes, "exclude") + diags = diags.Append(parseDiags) + + return parsedTargets, parsedExcludes, diags +} + +// Parse must be called on Operation after initial flag parse. This processes +// the raw target flags into addrs.Targetable values, returning diagnostics if +// invalid. +func (o *Operation) Parse() tfdiags.Diagnostics { + var diags tfdiags.Diagnostics + + var parseDiags tfdiags.Diagnostics + o.Targets, o.Excludes, parseDiags = parseRawTargetsAndExcludes(o.targetsRaw, o.excludesRaw) + diags = diags.Append(parseDiags) for _, raw := range o.forceReplaceRaw { traversal, syntaxDiags := hclsyntax.ParseTraversalAbs([]byte(raw), "", hcl.Pos{Line: 1, Column: 1}) @@ -230,6 +270,7 @@ func extendedFlagSet(name string, state *State, operation *Operation, vars *Vars f.BoolVar(&operation.destroyRaw, "destroy", false, "destroy") f.BoolVar(&operation.refreshOnlyRaw, "refresh-only", false, "refresh-only") f.Var((*flagStringSlice)(&operation.targetsRaw), "target", "target") + f.Var((*flagStringSlice)(&operation.excludesRaw), "exclude", "exclude") f.Var((*flagStringSlice)(&operation.forceReplaceRaw), "replace", "replace") } diff --git a/internal/command/arguments/plan_test.go b/internal/command/arguments/plan_test.go index 25af0ad871..2b79e6fab7 100644 --- a/internal/command/arguments/plan_test.go +++ b/internal/command/arguments/plan_test.go @@ -9,6 +9,8 @@ import ( "strings" "testing" + "github.com/opentofu/opentofu/internal/tfdiags" + "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/opentofu/opentofu/internal/addrs" @@ -134,25 +136,33 @@ func TestParsePlan_targets(t *testing.T) { "invalid traversal": { args: []string{"-target=foo."}, want: nil, - wantErr: "Dot must be followed by attribute name", + wantErr: "Invalid target \"foo.\": Dot must be followed by attribute name", }, "invalid target": { args: []string{"-target=data[0].foo"}, want: nil, - wantErr: "A data source name is required", + wantErr: "Invalid target \"data[0].foo\": A data source name is required", + }, + "empty target": { + args: []string{"-target="}, + want: nil, + wantErr: "Invalid target \"\": Must begin with a variable name.", // The error is `Invalid target "": Must begin with a variable name.` }, } for name, tc := range testCases { t.Run(name, func(t *testing.T) { got, diags := ParsePlan(tc.args) - if len(diags) > 0 { - if tc.wantErr == "" { - t.Fatalf("unexpected diags: %v", diags) + if tc.wantErr == "" && len(diags) > 0 { + t.Fatalf("unexpected diags: %v", diags) + } else if tc.wantErr != "" { + if len(diags) == 0 { + t.Fatalf("expected diags but got none") } else if got := diags.Err().Error(); !strings.Contains(got, tc.wantErr) { t.Fatalf("wrong diags\n got: %s\nwant: %s", got, tc.wantErr) } } + if !cmp.Equal(got.Operation.Targets, tc.want) { t.Fatalf("unexpected result\n%s", cmp.Diff(got.Operation.Targets, tc.want)) } @@ -160,6 +170,86 @@ func TestParsePlan_targets(t *testing.T) { } } +func TestParsePlan_excludes(t *testing.T) { + foobarbaz, _ := addrs.ParseTargetStr("foo_bar.baz") + boop, _ := addrs.ParseTargetStr("module.boop") + testCases := map[string]struct { + args []string + want []addrs.Targetable + wantErr string + }{ + "no excludes by default": { + args: nil, + want: nil, + }, + "one exclude": { + args: []string{"-exclude=foo_bar.baz"}, + want: []addrs.Targetable{foobarbaz.Subject}, + }, + "two excludes": { + args: []string{"-exclude=foo_bar.baz", "-exclude", "module.boop"}, + want: []addrs.Targetable{foobarbaz.Subject, boop.Subject}, + }, + "invalid traversal": { + args: []string{"-exclude=foo."}, + want: nil, + wantErr: "Invalid exclude \"foo.\": Dot must be followed by attribute name", + }, + "invalid exclude": { + args: []string{"-exclude=data[0].foo"}, + want: nil, + wantErr: "Invalid exclude \"data[0].foo\": A data source name is required", + }, + "empty exclude": { + args: []string{"-exclude="}, + want: nil, + wantErr: "Invalid exclude \"\": Must begin with a variable name.", + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + got, diags := ParsePlan(tc.args) + if tc.wantErr == "" && len(diags) > 0 { + t.Fatalf("unexpected diags: %v", diags) + } else if tc.wantErr != "" { + if len(diags) == 0 { + t.Fatalf("expected diags but got none") + } else if got := diags.Err().Error(); !strings.Contains(got, tc.wantErr) { + t.Fatalf("wrong diags\n got: %s\nwant: %s", got, tc.wantErr) + } + } + if !cmp.Equal(got.Operation.Excludes, tc.want) { + t.Fatalf("unexpected result\n%s", cmp.Diff(got.Operation.Excludes, tc.want)) + } + }) + } +} + +func TestParsePlan_excludeAndTarget(t *testing.T) { + got, gotDiags := ParsePlan([]string{"-exclude=foo_bar.baz", "-target=foo_bar.bar"}) + if len(gotDiags) == 0 { + t.Fatalf("expected error, but there was none") + } + + wantDiags := tfdiags.Diagnostics{ + tfdiags.Sourceless( + tfdiags.Error, + "Invalid combination of arguments", + "-target and -exclude flags cannot be used together. Please remove one of the flags", + ), + } + if diff := cmp.Diff(wantDiags.ForRPC(), gotDiags.ForRPC()); diff != "" { + t.Errorf("wrong diagnostics\n%s", diff) + } + if len(got.Operation.Targets) > 0 { + t.Errorf("Did not expect operation to parse targets, but it parsed %d targets", len(got.Operation.Targets)) + } + if len(got.Operation.Excludes) > 0 { + t.Errorf("Did not expect operation to parse excludes, but it parsed %d targets", len(got.Operation.Excludes)) + } +} + func TestParsePlan_vars(t *testing.T) { testCases := map[string]struct { args []string diff --git a/internal/command/arguments/refresh_test.go b/internal/command/arguments/refresh_test.go index 74b61e835f..3ebc11be89 100644 --- a/internal/command/arguments/refresh_test.go +++ b/internal/command/arguments/refresh_test.go @@ -9,6 +9,8 @@ import ( "strings" "testing" + "github.com/opentofu/opentofu/internal/tfdiags" + "github.com/google/go-cmp/cmp" "github.com/opentofu/opentofu/internal/addrs" ) @@ -119,13 +121,16 @@ func TestParseRefresh_targets(t *testing.T) { for name, tc := range testCases { t.Run(name, func(t *testing.T) { got, diags := ParseRefresh(tc.args) - if len(diags) > 0 { - if tc.wantErr == "" { - t.Fatalf("unexpected diags: %v", diags) + if tc.wantErr == "" && len(diags) > 0 { + t.Fatalf("unexpected diags: %v", diags) + } else if tc.wantErr != "" { + if len(diags) == 0 { + t.Fatalf("expected diags but got none") } else if got := diags.Err().Error(); !strings.Contains(got, tc.wantErr) { t.Fatalf("wrong diags\n got: %s\nwant: %s", got, tc.wantErr) } } + if !cmp.Equal(got.Operation.Targets, tc.want) { t.Fatalf("unexpected result\n%s", cmp.Diff(got.Operation.Targets, tc.want)) } @@ -133,6 +138,80 @@ func TestParseRefresh_targets(t *testing.T) { } } +func TestParseRefresh_excludes(t *testing.T) { + foobarbaz, _ := addrs.ParseTargetStr("foo_bar.baz") + boop, _ := addrs.ParseTargetStr("module.boop") + testCases := map[string]struct { + args []string + want []addrs.Targetable + wantErr string + }{ + "no excludes by default": { + args: nil, + want: nil, + }, + "one exclude": { + args: []string{"-exclude=foo_bar.baz"}, + want: []addrs.Targetable{foobarbaz.Subject}, + }, + "two excludes": { + args: []string{"-exclude=foo_bar.baz", "-exclude", "module.boop"}, + want: []addrs.Targetable{foobarbaz.Subject, boop.Subject}, + }, + "invalid traversal": { + args: []string{"-exclude=foo."}, + want: nil, + wantErr: "Dot must be followed by attribute name", + }, + "invalid target": { + args: []string{"-exclude=data[0].foo"}, + want: nil, + wantErr: "A data source name is required", + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + got, diags := ParseRefresh(tc.args) + if tc.wantErr == "" && len(diags) > 0 { + t.Fatalf("unexpected diags: %v", diags) + } else if tc.wantErr != "" { + if len(diags) == 0 { + t.Fatalf("expected diags but got none") + } else if got := diags.Err().Error(); !strings.Contains(got, tc.wantErr) { + t.Fatalf("wrong diags\n got: %s\nwant: %s", got, tc.wantErr) + } + } + if !cmp.Equal(got.Operation.Excludes, tc.want) { + t.Fatalf("unexpected result\n%s", cmp.Diff(got.Operation.Excludes, tc.want)) + } + }) + } +} + +func TestParseRefresh_excludeAndTarget(t *testing.T) { + got, gotDiags := ParseRefresh([]string{"-exclude=foo_bar.baz", "-target=foo_bar.bar"}) + if len(gotDiags) == 0 { + t.Fatalf("expected error, but there was none") + } + + wantDiags := tfdiags.Diagnostics{ + tfdiags.Sourceless( + tfdiags.Error, + "Invalid combination of arguments", + "-target and -exclude flags cannot be used together. Please remove one of the flags", + ), + } + if diff := cmp.Diff(wantDiags.ForRPC(), gotDiags.ForRPC()); diff != "" { + t.Errorf("wrong diagnostics\n%s", diff) + } + if len(got.Operation.Targets) > 0 { + t.Errorf("Did not expect operation to parse targets, but it parsed %d targets", len(got.Operation.Targets)) + } + if len(got.Operation.Excludes) > 0 { + t.Errorf("Did not expect operation to parse excludes, but it parsed %d targets", len(got.Operation.Excludes)) + } +} func TestParseRefresh_vars(t *testing.T) { testCases := map[string]struct { args []string diff --git a/internal/command/meta.go b/internal/command/meta.go index de0b92dcd6..ab4af57346 100644 --- a/internal/command/meta.go +++ b/internal/command/meta.go @@ -213,6 +213,10 @@ type Meta struct { targets []addrs.Targetable targetFlags []string + // Excludes for this context (private) + excludes []addrs.Targetable + excludeFlags []string + // Internal fields color bool oldUi cli.Ui @@ -618,6 +622,7 @@ func (m *Meta) extendedFlagSet(n string) *flag.FlagSet { f.BoolVar(&m.input, "input", true, "input") f.Var((*FlagStringSlice)(&m.targetFlags), "target", "resource to target") + f.Var((*FlagStringSlice)(&m.excludeFlags), "exclude", "resource to exclude") f.BoolVar(&m.compactWarnings, "compact-warnings", false, "use compact warnings") f.BoolVar(&m.consolidateWarnings, "consolidate-warnings", true, "consolidate warnings") f.BoolVar(&m.consolidateErrors, "consolidate-errors", false, "consolidate errors") diff --git a/internal/command/meta_backend.go b/internal/command/meta_backend.go index f780414b7d..c3a3561413 100644 --- a/internal/command/meta_backend.go +++ b/internal/command/meta_backend.go @@ -443,6 +443,7 @@ func (m *Meta) Operation(b backend.Backend, vt arguments.ViewType, enc encryptio Encryption: enc, PlanOutBackend: planOutBackend, Targets: m.targets, + Excludes: m.excludes, UIIn: m.UIInput(), UIOut: m.Ui, Workspace: workspace, diff --git a/internal/command/plan.go b/internal/command/plan.go index cf1089f19f..ecdab84fb0 100644 --- a/internal/command/plan.go +++ b/internal/command/plan.go @@ -166,6 +166,7 @@ func (c *PlanCommand) OperationRequest( opReq.PlanOutPath = planOutPath opReq.GenerateConfigOut = generateConfigOut opReq.Targets = args.Targets + opReq.Excludes = args.Excludes opReq.ForceReplace = args.ForceReplace opReq.Type = backend.OperationTypePlan opReq.View = view.Operation() @@ -238,7 +239,14 @@ Plan Customization Options: resource, or resource instance and all of its dependencies. You can use this option multiple times to include more than one object. This is for exceptional - use only. + use only. Cannot be used alongside the -exclude flag + + -exclude=resource Limit the planning operation to not operate on the given + module, resource, or resource instance and all of the + resources and modules that depend on it. You can use this + option multiple times to exclude more than one object. + This is for exceptional use only. Cannot be used alongside + the -target flag -var 'foo=bar' Set a value for one of the input variables in the root module of the configuration. Use this option more than diff --git a/internal/command/plan_test.go b/internal/command/plan_test.go index 15275e054e..2bccf78a23 100644 --- a/internal/command/plan_test.go +++ b/internal/command/plan_test.go @@ -1401,6 +1401,92 @@ func TestPlan_targetFlagsDiags(t *testing.T) { } } +// Config with multiple resources, targeted plan with exclude +func TestPlan_excluded(t *testing.T) { + td := t.TempDir() + testCopyDir(t, testFixturePath("apply-excluded"), td) + defer testChdir(t, td)() + + p := testProvider() + p.GetProviderSchemaResponse = &providers.GetProviderSchemaResponse{ + ResourceTypes: map[string]providers.Schema{ + "test_instance": { + Block: &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "id": {Type: cty.String, Computed: true}, + }, + }, + }, + }, + } + p.PlanResourceChangeFn = func(req providers.PlanResourceChangeRequest) providers.PlanResourceChangeResponse { + return providers.PlanResourceChangeResponse{ + PlannedState: req.ProposedNewState, + } + } + + view, done := testView(t) + c := &PlanCommand{ + Meta: Meta{ + testingOverrides: metaOverridesForProvider(p), + View: view, + }, + } + + args := []string{ + "-exclude", "test_instance.bar", + } + code := c.Run(args) + output := done(t) + if code != 0 { + t.Fatalf("bad: %d\n\n%s", code, output.Stderr()) + } + + if got, want := output.Stdout(), "3 to add, 0 to change, 0 to destroy"; !strings.Contains(got, want) { + t.Fatalf("bad change summary, want %q, got:\n%s", want, got) + } +} + +// Diagnostics for invalid -exclude flags +func TestPlan_excludeFlagsDiags(t *testing.T) { + testCases := map[string]string{ + "test_instance.": "Dot must be followed by attribute name.", + "test_instance": "Resource specification must include a resource type and name.", + } + + for exclude, wantDiag := range testCases { + t.Run(exclude, func(t *testing.T) { + td := testTempDir(t) + defer os.RemoveAll(td) + defer testChdir(t, td)() + + view, done := testView(t) + c := &PlanCommand{ + Meta: Meta{ + View: view, + }, + } + + args := []string{ + "-exclude", exclude, + } + code := c.Run(args) + output := done(t) + if code != 1 { + t.Fatalf("bad: %d\n\n%s", code, output.Stdout()) + } + + got := output.Stderr() + if !strings.Contains(got, exclude) { + t.Fatalf("bad error output, want %q, got:\n%s", exclude, got) + } + if !strings.Contains(got, wantDiag) { + t.Fatalf("bad error output, want %q, got:\n%s", wantDiag, got) + } + }) + } +} + func TestPlan_replace(t *testing.T) { td := t.TempDir() testCopyDir(t, testFixturePath("plan-replace"), td) diff --git a/internal/command/refresh.go b/internal/command/refresh.go index 4b15616b07..f5b651291d 100644 --- a/internal/command/refresh.go +++ b/internal/command/refresh.go @@ -150,6 +150,7 @@ func (c *RefreshCommand) OperationRequest(be backend.Enhanced, view views.Refres opReq.ConfigDir = "." opReq.Hooks = view.Hooks() opReq.Targets = args.Targets + opReq.Excludes = args.Excludes opReq.Type = backend.OperationTypeRefresh opReq.View = view.Operation() @@ -205,6 +206,11 @@ Options: will be performed. All locations, for all errors will be listed. Disabled by default + -exclude=resource Resource to exclude. Operation will be limited to all + resources that are not excluded or dependent on excluded + resources. This flag can be used multiple times. Cannot + be used alongside the -target flag. + -input=true Ask for input for variables if not directly set. -lock=false Don't hold a state lock during the operation. This is @@ -219,7 +225,8 @@ Options: -target=resource Resource to target. Operation will be limited to this resource and its dependencies. This flag can be used - multiple times. + multiple times. Cannot be used alongside the -exclude + flag. -var 'foo=bar' Set a variable in the OpenTofu configuration. This flag can be set multiple times. diff --git a/internal/command/refresh_test.go b/internal/command/refresh_test.go index 584b04633d..9667fba296 100644 --- a/internal/command/refresh_test.go +++ b/internal/command/refresh_test.go @@ -852,6 +852,100 @@ func TestRefresh_targetFlagsDiags(t *testing.T) { } } +// Config with multiple resources, targeted refresh with exclude +func TestRefresh_excluded(t *testing.T) { + td := t.TempDir() + testCopyDir(t, testFixturePath("refresh-targeted"), td) + defer testChdir(t, td)() + + state := testState() + statePath := testStateFile(t, state) + + p := testProvider() + p.GetProviderSchemaResponse = &providers.GetProviderSchemaResponse{ + ResourceTypes: map[string]providers.Schema{ + "test_instance": { + Block: &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "id": {Type: cty.String, Computed: true}, + }, + }, + }, + }, + } + p.PlanResourceChangeFn = func(req providers.PlanResourceChangeRequest) providers.PlanResourceChangeResponse { + return providers.PlanResourceChangeResponse{ + PlannedState: req.ProposedNewState, + } + } + + view, done := testView(t) + c := &RefreshCommand{ + Meta: Meta{ + testingOverrides: metaOverridesForProvider(p), + View: view, + }, + } + + args := []string{ + "-exclude", "test_instance.bar", + "-state", statePath, + } + code := c.Run(args) + output := done(t) + if code != 0 { + t.Fatalf("bad: %d\n\n%s", code, output.Stderr()) + } + + got := output.Stdout() + if want := "test_instance.foo: Refreshing"; !strings.Contains(got, want) { + t.Fatalf("expected output to contain %q, got:\n%s", want, got) + } + if doNotWant := "test_instance.bar: Refreshing"; strings.Contains(got, doNotWant) { + t.Fatalf("expected output not to contain %q, got:\n%s", doNotWant, got) + } +} + +// Diagnostics for invalid -exclude flags +func TestRefresh_excludeFlagsDiags(t *testing.T) { + testCases := map[string]string{ + "test_instance.": "Dot must be followed by attribute name.", + "test_instance": "Resource specification must include a resource type and name.", + } + + for exclude, wantDiag := range testCases { + t.Run(exclude, func(t *testing.T) { + td := testTempDir(t) + defer os.RemoveAll(td) + defer testChdir(t, td)() + + view, done := testView(t) + c := &RefreshCommand{ + Meta: Meta{ + View: view, + }, + } + + args := []string{ + "-exclude", exclude, + } + code := c.Run(args) + output := done(t) + if code != 1 { + t.Fatalf("bad: %d\n\n%s", code, output.Stderr()) + } + + got := output.Stderr() + if !strings.Contains(got, exclude) { + t.Fatalf("bad error output, want %q, got:\n%s", exclude, got) + } + if !strings.Contains(got, wantDiag) { + t.Fatalf("bad error output, want %q, got:\n%s", wantDiag, got) + } + }) + } +} + func TestRefresh_warnings(t *testing.T) { // Create a temporary working directory that is empty td := t.TempDir() diff --git a/internal/command/test_test.go b/internal/command/test_test.go index a85d9ad698..6c68e1c1d7 100644 --- a/internal/command/test_test.go +++ b/internal/command/test_test.go @@ -1024,25 +1024,26 @@ func TestTest_PartialUpdates(t *testing.T) { Warning: Resource targeting is in effect -You are creating a plan with the -target option, which means that the result -of this plan may not represent all of the changes requested by the current -configuration. +You are creating a plan with either the -target option or the -exclude +option, which means that the result of this plan may not represent all of the +changes requested by the current configuration. -The -target option is not for routine use, and is provided only for -exceptional situations such as recovering from errors or mistakes, or when -OpenTofu specifically suggests to use it as part of an error message. +The -target and -exclude options are not for routine use, and are provided +only for exceptional situations such as recovering from errors or mistakes, +or when OpenTofu specifically suggests to use it as part of an error message. Warning: Applied changes may be incomplete -The plan was created with the -target option in effect, so some changes -requested in the configuration may have been ignored and the output values -may not be fully updated. Run the following command to verify that no other -changes are pending: +The plan was created with the -target or the -exclude option in effect, so +some changes requested in the configuration may have been ignored and the +output values may not be fully updated. Run the following command to verify +that no other changes are pending: tofu plan -Note that the -target option is not suitable for routine use, and is provided -only for exceptional situations such as recovering from errors or mistakes, -or when OpenTofu specifically suggests to use it as part of an error message. +Note that the -target and -exclude options are not suitable for routine use, +and are provided only for exceptional situations such as recovering from +errors or mistakes, or when OpenTofu specifically suggests to use it as part +of an error message. run "second"... pass Success! 2 passed, 0 failed. @@ -1055,25 +1056,26 @@ Success! 2 passed, 0 failed. Warning: Resource targeting is in effect -You are creating a plan with the -target option, which means that the result -of this plan may not represent all of the changes requested by the current -configuration. +You are creating a plan with either the -target option or the -exclude +option, which means that the result of this plan may not represent all of the +changes requested by the current configuration. -The -target option is not for routine use, and is provided only for -exceptional situations such as recovering from errors or mistakes, or when -OpenTofu specifically suggests to use it as part of an error message. +The -target and -exclude options are not for routine use, and are provided +only for exceptional situations such as recovering from errors or mistakes, +or when OpenTofu specifically suggests to use it as part of an error message. Warning: Applied changes may be incomplete -The plan was created with the -target option in effect, so some changes -requested in the configuration may have been ignored and the output values -may not be fully updated. Run the following command to verify that no other -changes are pending: +The plan was created with the -target or the -exclude option in effect, so +some changes requested in the configuration may have been ignored and the +output values may not be fully updated. Run the following command to verify +that no other changes are pending: tofu plan -Note that the -target option is not suitable for routine use, and is provided -only for exceptional situations such as recovering from errors or mistakes, -or when OpenTofu specifically suggests to use it as part of an error message. +Note that the -target and -exclude options are not suitable for routine use, +and are provided only for exceptional situations such as recovering from +errors or mistakes, or when OpenTofu specifically suggests to use it as part +of an error message. Failure! 0 passed, 1 failed. `, diff --git a/internal/command/testdata/apply-excluded/main.tf b/internal/command/testdata/apply-excluded/main.tf new file mode 100644 index 0000000000..1b6c42450d --- /dev/null +++ b/internal/command/testdata/apply-excluded/main.tf @@ -0,0 +1,9 @@ +resource "test_instance" "foo" { + count = 2 +} + +resource "test_instance" "bar" { +} + +resource "test_instance" "baz" { +} diff --git a/internal/plans/internal/planproto/planfile.pb.go b/internal/plans/internal/planproto/planfile.pb.go index e8de94ca66..33ca91e1d1 100644 --- a/internal/plans/internal/planproto/planfile.pb.go +++ b/internal/plans/internal/planproto/planfile.pb.go @@ -387,6 +387,10 @@ type Plan struct { // target addresses are present, the plan applies to the whole // configuration. TargetAddrs []string `protobuf:"bytes,5,rep,name=target_addrs,json=targetAddrs,proto3" json:"target_addrs,omitempty"` + // An unordered set of exclude addresses to exclude when applying. If no + // target addresses are present, the plan applies to the whole + // configuration. + ExcludeAddrs []string `protobuf:"bytes,6,rep,name=exclude_addrs,json=excludeAddrs,proto3" json:"exclude_addrs,omitempty"` // An unordered set of force-replace addresses to include when applying. // This must match the set of addresses that was used when creating the // plan, or else applying the plan will fail when it reaches a different @@ -499,6 +503,13 @@ func (x *Plan) GetTargetAddrs() []string { return nil } +func (x *Plan) GetExcludeAddrs() []string { + if x != nil { + return x.ExcludeAddrs + } + return nil +} + func (x *Plan) GetForceReplaceAddrs() []string { if x != nil { return x.ForceReplaceAddrs @@ -1348,7 +1359,7 @@ var File_planfile_proto protoreflect.FileDescriptor var file_planfile_proto_rawDesc = []byte{ 0x0a, 0x0e, 0x70, 0x6c, 0x61, 0x6e, 0x66, 0x69, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, - 0x12, 0x06, 0x74, 0x66, 0x70, 0x6c, 0x61, 0x6e, 0x22, 0xdf, 0x06, 0x0a, 0x04, 0x50, 0x6c, 0x61, + 0x12, 0x06, 0x74, 0x66, 0x70, 0x6c, 0x61, 0x6e, 0x22, 0x84, 0x07, 0x0a, 0x04, 0x50, 0x6c, 0x61, 0x6e, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x04, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x25, 0x0a, 0x07, 0x75, 0x69, 0x5f, 0x6d, 0x6f, 0x64, 0x65, 0x18, 0x11, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x0c, 0x2e, 0x74, @@ -1377,178 +1388,181 @@ var file_planfile_proto_rawDesc = []byte{ 0x6c, 0x74, 0x73, 0x52, 0x0c, 0x63, 0x68, 0x65, 0x63, 0x6b, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, 0x12, 0x21, 0x0a, 0x0c, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0b, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x41, - 0x64, 0x64, 0x72, 0x73, 0x12, 0x2e, 0x0a, 0x13, 0x66, 0x6f, 0x72, 0x63, 0x65, 0x5f, 0x72, 0x65, - 0x70, 0x6c, 0x61, 0x63, 0x65, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x73, 0x18, 0x10, 0x20, 0x03, 0x28, - 0x09, 0x52, 0x11, 0x66, 0x6f, 0x72, 0x63, 0x65, 0x52, 0x65, 0x70, 0x6c, 0x61, 0x63, 0x65, 0x41, - 0x64, 0x64, 0x72, 0x73, 0x12, 0x2b, 0x0a, 0x11, 0x74, 0x65, 0x72, 0x72, 0x61, 0x66, 0x6f, 0x72, - 0x6d, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x10, 0x74, 0x65, 0x72, 0x72, 0x61, 0x66, 0x6f, 0x72, 0x6d, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, - 0x6e, 0x12, 0x29, 0x0a, 0x07, 0x62, 0x61, 0x63, 0x6b, 0x65, 0x6e, 0x64, 0x18, 0x0d, 0x20, 0x01, - 0x28, 0x0b, 0x32, 0x0f, 0x2e, 0x74, 0x66, 0x70, 0x6c, 0x61, 0x6e, 0x2e, 0x42, 0x61, 0x63, 0x6b, - 0x65, 0x6e, 0x64, 0x52, 0x07, 0x62, 0x61, 0x63, 0x6b, 0x65, 0x6e, 0x64, 0x12, 0x4b, 0x0a, 0x13, - 0x72, 0x65, 0x6c, 0x65, 0x76, 0x61, 0x6e, 0x74, 0x5f, 0x61, 0x74, 0x74, 0x72, 0x69, 0x62, 0x75, - 0x74, 0x65, 0x73, 0x18, 0x0f, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x74, 0x66, 0x70, 0x6c, - 0x61, 0x6e, 0x2e, 0x50, 0x6c, 0x61, 0x6e, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, - 0x5f, 0x61, 0x74, 0x74, 0x72, 0x52, 0x12, 0x72, 0x65, 0x6c, 0x65, 0x76, 0x61, 0x6e, 0x74, 0x41, - 0x74, 0x74, 0x72, 0x69, 0x62, 0x75, 0x74, 0x65, 0x73, 0x12, 0x1c, 0x0a, 0x09, 0x74, 0x69, 0x6d, - 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x18, 0x15, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x74, 0x69, - 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x1a, 0x52, 0x0a, 0x0e, 0x56, 0x61, 0x72, 0x69, 0x61, - 0x62, 0x6c, 0x65, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, - 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x2a, 0x0a, 0x05, 0x76, - 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x74, 0x66, 0x70, - 0x6c, 0x61, 0x6e, 0x2e, 0x44, 0x79, 0x6e, 0x61, 0x6d, 0x69, 0x63, 0x56, 0x61, 0x6c, 0x75, 0x65, - 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x1a, 0x4d, 0x0a, 0x0d, 0x72, - 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x61, 0x74, 0x74, 0x72, 0x12, 0x1a, 0x0a, 0x08, - 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, - 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x20, 0x0a, 0x04, 0x61, 0x74, 0x74, 0x72, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0c, 0x2e, 0x74, 0x66, 0x70, 0x6c, 0x61, 0x6e, 0x2e, - 0x50, 0x61, 0x74, 0x68, 0x52, 0x04, 0x61, 0x74, 0x74, 0x72, 0x22, 0x69, 0x0a, 0x07, 0x42, 0x61, - 0x63, 0x6b, 0x65, 0x6e, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x2c, 0x0a, 0x06, 0x63, 0x6f, 0x6e, - 0x66, 0x69, 0x67, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x74, 0x66, 0x70, 0x6c, - 0x61, 0x6e, 0x2e, 0x44, 0x79, 0x6e, 0x61, 0x6d, 0x69, 0x63, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, - 0x06, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x1c, 0x0a, 0x09, 0x77, 0x6f, 0x72, 0x6b, 0x73, - 0x70, 0x61, 0x63, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x77, 0x6f, 0x72, 0x6b, - 0x73, 0x70, 0x61, 0x63, 0x65, 0x22, 0xc0, 0x02, 0x0a, 0x06, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, - 0x12, 0x26, 0x0a, 0x06, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, - 0x32, 0x0e, 0x2e, 0x74, 0x66, 0x70, 0x6c, 0x61, 0x6e, 0x2e, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, - 0x52, 0x06, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x2c, 0x0a, 0x06, 0x76, 0x61, 0x6c, 0x75, - 0x65, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x74, 0x66, 0x70, 0x6c, 0x61, - 0x6e, 0x2e, 0x44, 0x79, 0x6e, 0x61, 0x6d, 0x69, 0x63, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x06, - 0x76, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x12, 0x42, 0x0a, 0x16, 0x62, 0x65, 0x66, 0x6f, 0x72, 0x65, - 0x5f, 0x73, 0x65, 0x6e, 0x73, 0x69, 0x74, 0x69, 0x76, 0x65, 0x5f, 0x70, 0x61, 0x74, 0x68, 0x73, - 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0c, 0x2e, 0x74, 0x66, 0x70, 0x6c, 0x61, 0x6e, 0x2e, - 0x50, 0x61, 0x74, 0x68, 0x52, 0x14, 0x62, 0x65, 0x66, 0x6f, 0x72, 0x65, 0x53, 0x65, 0x6e, 0x73, - 0x69, 0x74, 0x69, 0x76, 0x65, 0x50, 0x61, 0x74, 0x68, 0x73, 0x12, 0x40, 0x0a, 0x15, 0x61, 0x66, - 0x74, 0x65, 0x72, 0x5f, 0x73, 0x65, 0x6e, 0x73, 0x69, 0x74, 0x69, 0x76, 0x65, 0x5f, 0x70, 0x61, - 0x74, 0x68, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0c, 0x2e, 0x74, 0x66, 0x70, 0x6c, - 0x61, 0x6e, 0x2e, 0x50, 0x61, 0x74, 0x68, 0x52, 0x13, 0x61, 0x66, 0x74, 0x65, 0x72, 0x53, 0x65, - 0x6e, 0x73, 0x69, 0x74, 0x69, 0x76, 0x65, 0x50, 0x61, 0x74, 0x68, 0x73, 0x12, 0x2f, 0x0a, 0x09, - 0x69, 0x6d, 0x70, 0x6f, 0x72, 0x74, 0x69, 0x6e, 0x67, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, - 0x11, 0x2e, 0x74, 0x66, 0x70, 0x6c, 0x61, 0x6e, 0x2e, 0x49, 0x6d, 0x70, 0x6f, 0x72, 0x74, 0x69, - 0x6e, 0x67, 0x52, 0x09, 0x69, 0x6d, 0x70, 0x6f, 0x72, 0x74, 0x69, 0x6e, 0x67, 0x12, 0x29, 0x0a, - 0x10, 0x67, 0x65, 0x6e, 0x65, 0x72, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x63, 0x6f, 0x6e, 0x66, 0x69, - 0x67, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0f, 0x67, 0x65, 0x6e, 0x65, 0x72, 0x61, 0x74, - 0x65, 0x64, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x22, 0xd3, 0x02, 0x0a, 0x16, 0x52, 0x65, 0x73, - 0x6f, 0x75, 0x72, 0x63, 0x65, 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x43, 0x68, 0x61, - 0x6e, 0x67, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x61, 0x64, 0x64, 0x72, 0x18, 0x0d, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x04, 0x61, 0x64, 0x64, 0x72, 0x12, 0x22, 0x0a, 0x0d, 0x70, 0x72, 0x65, 0x76, 0x5f, - 0x72, 0x75, 0x6e, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, - 0x70, 0x72, 0x65, 0x76, 0x52, 0x75, 0x6e, 0x41, 0x64, 0x64, 0x72, 0x12, 0x1f, 0x0a, 0x0b, 0x64, - 0x65, 0x70, 0x6f, 0x73, 0x65, 0x64, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x0a, 0x64, 0x65, 0x70, 0x6f, 0x73, 0x65, 0x64, 0x4b, 0x65, 0x79, 0x12, 0x1a, 0x0a, 0x08, - 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, - 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x12, 0x26, 0x0a, 0x06, 0x63, 0x68, 0x61, 0x6e, - 0x67, 0x65, 0x18, 0x09, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x74, 0x66, 0x70, 0x6c, 0x61, - 0x6e, 0x2e, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x52, 0x06, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, - 0x12, 0x18, 0x0a, 0x07, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x18, 0x0a, 0x20, 0x01, 0x28, - 0x0c, 0x52, 0x07, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x12, 0x37, 0x0a, 0x10, 0x72, 0x65, - 0x71, 0x75, 0x69, 0x72, 0x65, 0x64, 0x5f, 0x72, 0x65, 0x70, 0x6c, 0x61, 0x63, 0x65, 0x18, 0x0b, - 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0c, 0x2e, 0x74, 0x66, 0x70, 0x6c, 0x61, 0x6e, 0x2e, 0x50, 0x61, - 0x74, 0x68, 0x52, 0x0f, 0x72, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x64, 0x52, 0x65, 0x70, 0x6c, - 0x61, 0x63, 0x65, 0x12, 0x49, 0x0a, 0x0d, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x72, 0x65, - 0x61, 0x73, 0x6f, 0x6e, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x24, 0x2e, 0x74, 0x66, 0x70, - 0x6c, 0x61, 0x6e, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x49, 0x6e, 0x73, 0x74, - 0x61, 0x6e, 0x63, 0x65, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x61, 0x73, 0x6f, 0x6e, - 0x52, 0x0c, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x22, 0x68, - 0x0a, 0x0c, 0x4f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x12, 0x12, - 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, - 0x6d, 0x65, 0x12, 0x26, 0x0a, 0x06, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, - 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x74, 0x66, 0x70, 0x6c, 0x61, 0x6e, 0x2e, 0x43, 0x68, 0x61, 0x6e, - 0x67, 0x65, 0x52, 0x06, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x12, 0x1c, 0x0a, 0x09, 0x73, 0x65, - 0x6e, 0x73, 0x69, 0x74, 0x69, 0x76, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x73, - 0x65, 0x6e, 0x73, 0x69, 0x74, 0x69, 0x76, 0x65, 0x22, 0xfc, 0x03, 0x0a, 0x0c, 0x43, 0x68, 0x65, - 0x63, 0x6b, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, 0x12, 0x33, 0x0a, 0x04, 0x6b, 0x69, 0x6e, - 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1f, 0x2e, 0x74, 0x66, 0x70, 0x6c, 0x61, 0x6e, - 0x2e, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, 0x2e, 0x4f, 0x62, - 0x6a, 0x65, 0x63, 0x74, 0x4b, 0x69, 0x6e, 0x64, 0x52, 0x04, 0x6b, 0x69, 0x6e, 0x64, 0x12, 0x1f, - 0x0a, 0x0b, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x18, 0x02, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x0a, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x41, 0x64, 0x64, 0x72, 0x12, - 0x33, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0e, 0x32, - 0x1b, 0x2e, 0x74, 0x66, 0x70, 0x6c, 0x61, 0x6e, 0x2e, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x52, 0x65, - 0x73, 0x75, 0x6c, 0x74, 0x73, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x06, 0x73, 0x74, - 0x61, 0x74, 0x75, 0x73, 0x12, 0x3b, 0x0a, 0x07, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x18, - 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x21, 0x2e, 0x74, 0x66, 0x70, 0x6c, 0x61, 0x6e, 0x2e, 0x43, - 0x68, 0x65, 0x63, 0x6b, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, 0x2e, 0x4f, 0x62, 0x6a, 0x65, - 0x63, 0x74, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x52, 0x07, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, - 0x73, 0x1a, 0x8f, 0x01, 0x0a, 0x0c, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, 0x73, 0x75, - 0x6c, 0x74, 0x12, 0x1f, 0x0a, 0x0b, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x5f, 0x61, 0x64, 0x64, - 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x41, - 0x64, 0x64, 0x72, 0x12, 0x33, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x02, 0x20, - 0x01, 0x28, 0x0e, 0x32, 0x1b, 0x2e, 0x74, 0x66, 0x70, 0x6c, 0x61, 0x6e, 0x2e, 0x43, 0x68, 0x65, - 0x63, 0x6b, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, - 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x29, 0x0a, 0x10, 0x66, 0x61, 0x69, 0x6c, - 0x75, 0x72, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, - 0x28, 0x09, 0x52, 0x0f, 0x66, 0x61, 0x69, 0x6c, 0x75, 0x72, 0x65, 0x4d, 0x65, 0x73, 0x73, 0x61, - 0x67, 0x65, 0x73, 0x22, 0x34, 0x0a, 0x06, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x0b, 0x0a, - 0x07, 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x00, 0x12, 0x08, 0x0a, 0x04, 0x50, 0x41, - 0x53, 0x53, 0x10, 0x01, 0x12, 0x08, 0x0a, 0x04, 0x46, 0x41, 0x49, 0x4c, 0x10, 0x02, 0x12, 0x09, - 0x0a, 0x05, 0x45, 0x52, 0x52, 0x4f, 0x52, 0x10, 0x03, 0x22, 0x5c, 0x0a, 0x0a, 0x4f, 0x62, 0x6a, - 0x65, 0x63, 0x74, 0x4b, 0x69, 0x6e, 0x64, 0x12, 0x0f, 0x0a, 0x0b, 0x55, 0x4e, 0x53, 0x50, 0x45, - 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x0c, 0x0a, 0x08, 0x52, 0x45, 0x53, 0x4f, - 0x55, 0x52, 0x43, 0x45, 0x10, 0x01, 0x12, 0x10, 0x0a, 0x0c, 0x4f, 0x55, 0x54, 0x50, 0x55, 0x54, - 0x5f, 0x56, 0x41, 0x4c, 0x55, 0x45, 0x10, 0x02, 0x12, 0x09, 0x0a, 0x05, 0x43, 0x48, 0x45, 0x43, - 0x4b, 0x10, 0x03, 0x12, 0x12, 0x0a, 0x0e, 0x49, 0x4e, 0x50, 0x55, 0x54, 0x5f, 0x56, 0x41, 0x52, - 0x49, 0x41, 0x42, 0x4c, 0x45, 0x10, 0x04, 0x22, 0x28, 0x0a, 0x0c, 0x44, 0x79, 0x6e, 0x61, 0x6d, - 0x69, 0x63, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x73, 0x67, 0x70, 0x61, - 0x63, 0x6b, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x07, 0x6d, 0x73, 0x67, 0x70, 0x61, 0x63, - 0x6b, 0x22, 0xa5, 0x01, 0x0a, 0x04, 0x50, 0x61, 0x74, 0x68, 0x12, 0x27, 0x0a, 0x05, 0x73, 0x74, - 0x65, 0x70, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x74, 0x66, 0x70, 0x6c, - 0x61, 0x6e, 0x2e, 0x50, 0x61, 0x74, 0x68, 0x2e, 0x53, 0x74, 0x65, 0x70, 0x52, 0x05, 0x73, 0x74, - 0x65, 0x70, 0x73, 0x1a, 0x74, 0x0a, 0x04, 0x53, 0x74, 0x65, 0x70, 0x12, 0x27, 0x0a, 0x0e, 0x61, - 0x74, 0x74, 0x72, 0x69, 0x62, 0x75, 0x74, 0x65, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, - 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x0d, 0x61, 0x74, 0x74, 0x72, 0x69, 0x62, 0x75, 0x74, 0x65, - 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x37, 0x0a, 0x0b, 0x65, 0x6c, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x5f, - 0x6b, 0x65, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x74, 0x66, 0x70, 0x6c, - 0x61, 0x6e, 0x2e, 0x44, 0x79, 0x6e, 0x61, 0x6d, 0x69, 0x63, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x48, - 0x00, 0x52, 0x0a, 0x65, 0x6c, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x4b, 0x65, 0x79, 0x42, 0x0a, 0x0a, - 0x08, 0x73, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x22, 0x1b, 0x0a, 0x09, 0x49, 0x6d, 0x70, - 0x6f, 0x72, 0x74, 0x69, 0x6e, 0x67, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x2a, 0x31, 0x0a, 0x04, 0x4d, 0x6f, 0x64, 0x65, 0x12, 0x0a, - 0x0a, 0x06, 0x4e, 0x4f, 0x52, 0x4d, 0x41, 0x4c, 0x10, 0x00, 0x12, 0x0b, 0x0a, 0x07, 0x44, 0x45, - 0x53, 0x54, 0x52, 0x4f, 0x59, 0x10, 0x01, 0x12, 0x10, 0x0a, 0x0c, 0x52, 0x45, 0x46, 0x52, 0x45, - 0x53, 0x48, 0x5f, 0x4f, 0x4e, 0x4c, 0x59, 0x10, 0x02, 0x2a, 0x7c, 0x0a, 0x06, 0x41, 0x63, 0x74, - 0x69, 0x6f, 0x6e, 0x12, 0x08, 0x0a, 0x04, 0x4e, 0x4f, 0x4f, 0x50, 0x10, 0x00, 0x12, 0x0a, 0x0a, - 0x06, 0x43, 0x52, 0x45, 0x41, 0x54, 0x45, 0x10, 0x01, 0x12, 0x08, 0x0a, 0x04, 0x52, 0x45, 0x41, - 0x44, 0x10, 0x02, 0x12, 0x0a, 0x0a, 0x06, 0x55, 0x50, 0x44, 0x41, 0x54, 0x45, 0x10, 0x03, 0x12, - 0x0a, 0x0a, 0x06, 0x44, 0x45, 0x4c, 0x45, 0x54, 0x45, 0x10, 0x05, 0x12, 0x16, 0x0a, 0x12, 0x44, - 0x45, 0x4c, 0x45, 0x54, 0x45, 0x5f, 0x54, 0x48, 0x45, 0x4e, 0x5f, 0x43, 0x52, 0x45, 0x41, 0x54, - 0x45, 0x10, 0x06, 0x12, 0x16, 0x0a, 0x12, 0x43, 0x52, 0x45, 0x41, 0x54, 0x45, 0x5f, 0x54, 0x48, - 0x45, 0x4e, 0x5f, 0x44, 0x45, 0x4c, 0x45, 0x54, 0x45, 0x10, 0x07, 0x12, 0x0a, 0x0a, 0x06, 0x46, - 0x4f, 0x52, 0x47, 0x45, 0x54, 0x10, 0x08, 0x2a, 0xc8, 0x03, 0x0a, 0x1c, 0x52, 0x65, 0x73, 0x6f, - 0x75, 0x72, 0x63, 0x65, 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x41, 0x63, 0x74, 0x69, - 0x6f, 0x6e, 0x52, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x12, 0x08, 0x0a, 0x04, 0x4e, 0x4f, 0x4e, 0x45, - 0x10, 0x00, 0x12, 0x1b, 0x0a, 0x17, 0x52, 0x45, 0x50, 0x4c, 0x41, 0x43, 0x45, 0x5f, 0x42, 0x45, - 0x43, 0x41, 0x55, 0x53, 0x45, 0x5f, 0x54, 0x41, 0x49, 0x4e, 0x54, 0x45, 0x44, 0x10, 0x01, 0x12, - 0x16, 0x0a, 0x12, 0x52, 0x45, 0x50, 0x4c, 0x41, 0x43, 0x45, 0x5f, 0x42, 0x59, 0x5f, 0x52, 0x45, - 0x51, 0x55, 0x45, 0x53, 0x54, 0x10, 0x02, 0x12, 0x21, 0x0a, 0x1d, 0x52, 0x45, 0x50, 0x4c, 0x41, - 0x43, 0x45, 0x5f, 0x42, 0x45, 0x43, 0x41, 0x55, 0x53, 0x45, 0x5f, 0x43, 0x41, 0x4e, 0x4e, 0x4f, - 0x54, 0x5f, 0x55, 0x50, 0x44, 0x41, 0x54, 0x45, 0x10, 0x03, 0x12, 0x25, 0x0a, 0x21, 0x44, 0x45, - 0x4c, 0x45, 0x54, 0x45, 0x5f, 0x42, 0x45, 0x43, 0x41, 0x55, 0x53, 0x45, 0x5f, 0x4e, 0x4f, 0x5f, - 0x52, 0x45, 0x53, 0x4f, 0x55, 0x52, 0x43, 0x45, 0x5f, 0x43, 0x4f, 0x4e, 0x46, 0x49, 0x47, 0x10, - 0x04, 0x12, 0x23, 0x0a, 0x1f, 0x44, 0x45, 0x4c, 0x45, 0x54, 0x45, 0x5f, 0x42, 0x45, 0x43, 0x41, - 0x55, 0x53, 0x45, 0x5f, 0x57, 0x52, 0x4f, 0x4e, 0x47, 0x5f, 0x52, 0x45, 0x50, 0x45, 0x54, 0x49, - 0x54, 0x49, 0x4f, 0x4e, 0x10, 0x05, 0x12, 0x1e, 0x0a, 0x1a, 0x44, 0x45, 0x4c, 0x45, 0x54, 0x45, - 0x5f, 0x42, 0x45, 0x43, 0x41, 0x55, 0x53, 0x45, 0x5f, 0x43, 0x4f, 0x55, 0x4e, 0x54, 0x5f, 0x49, - 0x4e, 0x44, 0x45, 0x58, 0x10, 0x06, 0x12, 0x1b, 0x0a, 0x17, 0x44, 0x45, 0x4c, 0x45, 0x54, 0x45, - 0x5f, 0x42, 0x45, 0x43, 0x41, 0x55, 0x53, 0x45, 0x5f, 0x45, 0x41, 0x43, 0x48, 0x5f, 0x4b, 0x45, - 0x59, 0x10, 0x07, 0x12, 0x1c, 0x0a, 0x18, 0x44, 0x45, 0x4c, 0x45, 0x54, 0x45, 0x5f, 0x42, 0x45, - 0x43, 0x41, 0x55, 0x53, 0x45, 0x5f, 0x4e, 0x4f, 0x5f, 0x4d, 0x4f, 0x44, 0x55, 0x4c, 0x45, 0x10, - 0x08, 0x12, 0x17, 0x0a, 0x13, 0x52, 0x45, 0x50, 0x4c, 0x41, 0x43, 0x45, 0x5f, 0x42, 0x59, 0x5f, - 0x54, 0x52, 0x49, 0x47, 0x47, 0x45, 0x52, 0x53, 0x10, 0x09, 0x12, 0x1f, 0x0a, 0x1b, 0x52, 0x45, - 0x41, 0x44, 0x5f, 0x42, 0x45, 0x43, 0x41, 0x55, 0x53, 0x45, 0x5f, 0x43, 0x4f, 0x4e, 0x46, 0x49, - 0x47, 0x5f, 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x0a, 0x12, 0x23, 0x0a, 0x1f, 0x52, - 0x45, 0x41, 0x44, 0x5f, 0x42, 0x45, 0x43, 0x41, 0x55, 0x53, 0x45, 0x5f, 0x44, 0x45, 0x50, 0x45, - 0x4e, 0x44, 0x45, 0x4e, 0x43, 0x59, 0x5f, 0x50, 0x45, 0x4e, 0x44, 0x49, 0x4e, 0x47, 0x10, 0x0b, - 0x12, 0x1d, 0x0a, 0x19, 0x52, 0x45, 0x41, 0x44, 0x5f, 0x42, 0x45, 0x43, 0x41, 0x55, 0x53, 0x45, - 0x5f, 0x43, 0x48, 0x45, 0x43, 0x4b, 0x5f, 0x4e, 0x45, 0x53, 0x54, 0x45, 0x44, 0x10, 0x0d, 0x12, - 0x21, 0x0a, 0x1d, 0x44, 0x45, 0x4c, 0x45, 0x54, 0x45, 0x5f, 0x42, 0x45, 0x43, 0x41, 0x55, 0x53, - 0x45, 0x5f, 0x4e, 0x4f, 0x5f, 0x4d, 0x4f, 0x56, 0x45, 0x5f, 0x54, 0x41, 0x52, 0x47, 0x45, 0x54, - 0x10, 0x0c, 0x42, 0x40, 0x5a, 0x3e, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, - 0x2f, 0x6f, 0x70, 0x65, 0x6e, 0x74, 0x6f, 0x66, 0x75, 0x2f, 0x6f, 0x70, 0x65, 0x6e, 0x74, 0x6f, - 0x66, 0x75, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x70, 0x6c, 0x61, 0x6e, - 0x73, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x70, 0x6c, 0x61, 0x6e, 0x70, - 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x64, 0x64, 0x72, 0x73, 0x12, 0x23, 0x0a, 0x0d, 0x65, 0x78, 0x63, 0x6c, 0x75, 0x64, 0x65, 0x5f, + 0x61, 0x64, 0x64, 0x72, 0x73, 0x18, 0x06, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x65, 0x78, 0x63, + 0x6c, 0x75, 0x64, 0x65, 0x41, 0x64, 0x64, 0x72, 0x73, 0x12, 0x2e, 0x0a, 0x13, 0x66, 0x6f, 0x72, + 0x63, 0x65, 0x5f, 0x72, 0x65, 0x70, 0x6c, 0x61, 0x63, 0x65, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x73, + 0x18, 0x10, 0x20, 0x03, 0x28, 0x09, 0x52, 0x11, 0x66, 0x6f, 0x72, 0x63, 0x65, 0x52, 0x65, 0x70, + 0x6c, 0x61, 0x63, 0x65, 0x41, 0x64, 0x64, 0x72, 0x73, 0x12, 0x2b, 0x0a, 0x11, 0x74, 0x65, 0x72, + 0x72, 0x61, 0x66, 0x6f, 0x72, 0x6d, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x0e, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x10, 0x74, 0x65, 0x72, 0x72, 0x61, 0x66, 0x6f, 0x72, 0x6d, 0x56, + 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x29, 0x0a, 0x07, 0x62, 0x61, 0x63, 0x6b, 0x65, 0x6e, + 0x64, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0f, 0x2e, 0x74, 0x66, 0x70, 0x6c, 0x61, 0x6e, + 0x2e, 0x42, 0x61, 0x63, 0x6b, 0x65, 0x6e, 0x64, 0x52, 0x07, 0x62, 0x61, 0x63, 0x6b, 0x65, 0x6e, + 0x64, 0x12, 0x4b, 0x0a, 0x13, 0x72, 0x65, 0x6c, 0x65, 0x76, 0x61, 0x6e, 0x74, 0x5f, 0x61, 0x74, + 0x74, 0x72, 0x69, 0x62, 0x75, 0x74, 0x65, 0x73, 0x18, 0x0f, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1a, + 0x2e, 0x74, 0x66, 0x70, 0x6c, 0x61, 0x6e, 0x2e, 0x50, 0x6c, 0x61, 0x6e, 0x2e, 0x72, 0x65, 0x73, + 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x61, 0x74, 0x74, 0x72, 0x52, 0x12, 0x72, 0x65, 0x6c, 0x65, + 0x76, 0x61, 0x6e, 0x74, 0x41, 0x74, 0x74, 0x72, 0x69, 0x62, 0x75, 0x74, 0x65, 0x73, 0x12, 0x1c, + 0x0a, 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x18, 0x15, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x1a, 0x52, 0x0a, 0x0e, + 0x56, 0x61, 0x72, 0x69, 0x61, 0x62, 0x6c, 0x65, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, + 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, + 0x12, 0x2a, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, + 0x14, 0x2e, 0x74, 0x66, 0x70, 0x6c, 0x61, 0x6e, 0x2e, 0x44, 0x79, 0x6e, 0x61, 0x6d, 0x69, 0x63, + 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, + 0x1a, 0x4d, 0x0a, 0x0d, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x61, 0x74, 0x74, + 0x72, 0x12, 0x1a, 0x0a, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x20, 0x0a, + 0x04, 0x61, 0x74, 0x74, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0c, 0x2e, 0x74, 0x66, + 0x70, 0x6c, 0x61, 0x6e, 0x2e, 0x50, 0x61, 0x74, 0x68, 0x52, 0x04, 0x61, 0x74, 0x74, 0x72, 0x22, + 0x69, 0x0a, 0x07, 0x42, 0x61, 0x63, 0x6b, 0x65, 0x6e, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x79, + 0x70, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x2c, + 0x0a, 0x06, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, + 0x2e, 0x74, 0x66, 0x70, 0x6c, 0x61, 0x6e, 0x2e, 0x44, 0x79, 0x6e, 0x61, 0x6d, 0x69, 0x63, 0x56, + 0x61, 0x6c, 0x75, 0x65, 0x52, 0x06, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x1c, 0x0a, 0x09, + 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x09, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x22, 0xc0, 0x02, 0x0a, 0x06, 0x43, + 0x68, 0x61, 0x6e, 0x67, 0x65, 0x12, 0x26, 0x0a, 0x06, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x0e, 0x2e, 0x74, 0x66, 0x70, 0x6c, 0x61, 0x6e, 0x2e, 0x41, + 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x06, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x2c, 0x0a, + 0x06, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x14, 0x2e, + 0x74, 0x66, 0x70, 0x6c, 0x61, 0x6e, 0x2e, 0x44, 0x79, 0x6e, 0x61, 0x6d, 0x69, 0x63, 0x56, 0x61, + 0x6c, 0x75, 0x65, 0x52, 0x06, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x12, 0x42, 0x0a, 0x16, 0x62, + 0x65, 0x66, 0x6f, 0x72, 0x65, 0x5f, 0x73, 0x65, 0x6e, 0x73, 0x69, 0x74, 0x69, 0x76, 0x65, 0x5f, + 0x70, 0x61, 0x74, 0x68, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0c, 0x2e, 0x74, 0x66, + 0x70, 0x6c, 0x61, 0x6e, 0x2e, 0x50, 0x61, 0x74, 0x68, 0x52, 0x14, 0x62, 0x65, 0x66, 0x6f, 0x72, + 0x65, 0x53, 0x65, 0x6e, 0x73, 0x69, 0x74, 0x69, 0x76, 0x65, 0x50, 0x61, 0x74, 0x68, 0x73, 0x12, + 0x40, 0x0a, 0x15, 0x61, 0x66, 0x74, 0x65, 0x72, 0x5f, 0x73, 0x65, 0x6e, 0x73, 0x69, 0x74, 0x69, + 0x76, 0x65, 0x5f, 0x70, 0x61, 0x74, 0x68, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0c, + 0x2e, 0x74, 0x66, 0x70, 0x6c, 0x61, 0x6e, 0x2e, 0x50, 0x61, 0x74, 0x68, 0x52, 0x13, 0x61, 0x66, + 0x74, 0x65, 0x72, 0x53, 0x65, 0x6e, 0x73, 0x69, 0x74, 0x69, 0x76, 0x65, 0x50, 0x61, 0x74, 0x68, + 0x73, 0x12, 0x2f, 0x0a, 0x09, 0x69, 0x6d, 0x70, 0x6f, 0x72, 0x74, 0x69, 0x6e, 0x67, 0x18, 0x05, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x74, 0x66, 0x70, 0x6c, 0x61, 0x6e, 0x2e, 0x49, 0x6d, + 0x70, 0x6f, 0x72, 0x74, 0x69, 0x6e, 0x67, 0x52, 0x09, 0x69, 0x6d, 0x70, 0x6f, 0x72, 0x74, 0x69, + 0x6e, 0x67, 0x12, 0x29, 0x0a, 0x10, 0x67, 0x65, 0x6e, 0x65, 0x72, 0x61, 0x74, 0x65, 0x64, 0x5f, + 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0f, 0x67, 0x65, + 0x6e, 0x65, 0x72, 0x61, 0x74, 0x65, 0x64, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x22, 0xd3, 0x02, + 0x0a, 0x16, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, + 0x63, 0x65, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x61, 0x64, 0x64, 0x72, + 0x18, 0x0d, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x61, 0x64, 0x64, 0x72, 0x12, 0x22, 0x0a, 0x0d, + 0x70, 0x72, 0x65, 0x76, 0x5f, 0x72, 0x75, 0x6e, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x18, 0x0e, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x0b, 0x70, 0x72, 0x65, 0x76, 0x52, 0x75, 0x6e, 0x41, 0x64, 0x64, 0x72, + 0x12, 0x1f, 0x0a, 0x0b, 0x64, 0x65, 0x70, 0x6f, 0x73, 0x65, 0x64, 0x5f, 0x6b, 0x65, 0x79, 0x18, + 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x64, 0x65, 0x70, 0x6f, 0x73, 0x65, 0x64, 0x4b, 0x65, + 0x79, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x18, 0x08, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x12, 0x26, 0x0a, + 0x06, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x18, 0x09, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0e, 0x2e, + 0x74, 0x66, 0x70, 0x6c, 0x61, 0x6e, 0x2e, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x52, 0x06, 0x63, + 0x68, 0x61, 0x6e, 0x67, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, + 0x18, 0x0a, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x07, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x12, + 0x37, 0x0a, 0x10, 0x72, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x64, 0x5f, 0x72, 0x65, 0x70, 0x6c, + 0x61, 0x63, 0x65, 0x18, 0x0b, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0c, 0x2e, 0x74, 0x66, 0x70, 0x6c, + 0x61, 0x6e, 0x2e, 0x50, 0x61, 0x74, 0x68, 0x52, 0x0f, 0x72, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, + 0x64, 0x52, 0x65, 0x70, 0x6c, 0x61, 0x63, 0x65, 0x12, 0x49, 0x0a, 0x0d, 0x61, 0x63, 0x74, 0x69, + 0x6f, 0x6e, 0x5f, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x0e, 0x32, + 0x24, 0x2e, 0x74, 0x66, 0x70, 0x6c, 0x61, 0x6e, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, + 0x65, 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x52, + 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x52, 0x0c, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x61, + 0x73, 0x6f, 0x6e, 0x22, 0x68, 0x0a, 0x0c, 0x4f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x43, 0x68, 0x61, + 0x6e, 0x67, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x26, 0x0a, 0x06, 0x63, 0x68, 0x61, 0x6e, 0x67, + 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x74, 0x66, 0x70, 0x6c, 0x61, 0x6e, + 0x2e, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x52, 0x06, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x12, + 0x1c, 0x0a, 0x09, 0x73, 0x65, 0x6e, 0x73, 0x69, 0x74, 0x69, 0x76, 0x65, 0x18, 0x03, 0x20, 0x01, + 0x28, 0x08, 0x52, 0x09, 0x73, 0x65, 0x6e, 0x73, 0x69, 0x74, 0x69, 0x76, 0x65, 0x22, 0xfc, 0x03, + 0x0a, 0x0c, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, 0x12, 0x33, + 0x0a, 0x04, 0x6b, 0x69, 0x6e, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1f, 0x2e, 0x74, + 0x66, 0x70, 0x6c, 0x61, 0x6e, 0x2e, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x52, 0x65, 0x73, 0x75, 0x6c, + 0x74, 0x73, 0x2e, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x4b, 0x69, 0x6e, 0x64, 0x52, 0x04, 0x6b, + 0x69, 0x6e, 0x64, 0x12, 0x1f, 0x0a, 0x0b, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x5f, 0x61, 0x64, + 0x64, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, + 0x41, 0x64, 0x64, 0x72, 0x12, 0x33, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x03, + 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1b, 0x2e, 0x74, 0x66, 0x70, 0x6c, 0x61, 0x6e, 0x2e, 0x43, 0x68, + 0x65, 0x63, 0x6b, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x75, + 0x73, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x3b, 0x0a, 0x07, 0x6f, 0x62, 0x6a, + 0x65, 0x63, 0x74, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x21, 0x2e, 0x74, 0x66, 0x70, + 0x6c, 0x61, 0x6e, 0x2e, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, + 0x2e, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x52, 0x07, 0x6f, + 0x62, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x1a, 0x8f, 0x01, 0x0a, 0x0c, 0x4f, 0x62, 0x6a, 0x65, 0x63, + 0x74, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x12, 0x1f, 0x0a, 0x0b, 0x6f, 0x62, 0x6a, 0x65, 0x63, + 0x74, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x6f, 0x62, + 0x6a, 0x65, 0x63, 0x74, 0x41, 0x64, 0x64, 0x72, 0x12, 0x33, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, + 0x75, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1b, 0x2e, 0x74, 0x66, 0x70, 0x6c, 0x61, + 0x6e, 0x2e, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, 0x2e, 0x53, + 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x29, 0x0a, + 0x10, 0x66, 0x61, 0x69, 0x6c, 0x75, 0x72, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, + 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0f, 0x66, 0x61, 0x69, 0x6c, 0x75, 0x72, 0x65, + 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x22, 0x34, 0x0a, 0x06, 0x53, 0x74, 0x61, 0x74, + 0x75, 0x73, 0x12, 0x0b, 0x0a, 0x07, 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x00, 0x12, + 0x08, 0x0a, 0x04, 0x50, 0x41, 0x53, 0x53, 0x10, 0x01, 0x12, 0x08, 0x0a, 0x04, 0x46, 0x41, 0x49, + 0x4c, 0x10, 0x02, 0x12, 0x09, 0x0a, 0x05, 0x45, 0x52, 0x52, 0x4f, 0x52, 0x10, 0x03, 0x22, 0x5c, + 0x0a, 0x0a, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x4b, 0x69, 0x6e, 0x64, 0x12, 0x0f, 0x0a, 0x0b, + 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x0c, 0x0a, + 0x08, 0x52, 0x45, 0x53, 0x4f, 0x55, 0x52, 0x43, 0x45, 0x10, 0x01, 0x12, 0x10, 0x0a, 0x0c, 0x4f, + 0x55, 0x54, 0x50, 0x55, 0x54, 0x5f, 0x56, 0x41, 0x4c, 0x55, 0x45, 0x10, 0x02, 0x12, 0x09, 0x0a, + 0x05, 0x43, 0x48, 0x45, 0x43, 0x4b, 0x10, 0x03, 0x12, 0x12, 0x0a, 0x0e, 0x49, 0x4e, 0x50, 0x55, + 0x54, 0x5f, 0x56, 0x41, 0x52, 0x49, 0x41, 0x42, 0x4c, 0x45, 0x10, 0x04, 0x22, 0x28, 0x0a, 0x0c, + 0x44, 0x79, 0x6e, 0x61, 0x6d, 0x69, 0x63, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x18, 0x0a, 0x07, + 0x6d, 0x73, 0x67, 0x70, 0x61, 0x63, 0x6b, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x07, 0x6d, + 0x73, 0x67, 0x70, 0x61, 0x63, 0x6b, 0x22, 0xa5, 0x01, 0x0a, 0x04, 0x50, 0x61, 0x74, 0x68, 0x12, + 0x27, 0x0a, 0x05, 0x73, 0x74, 0x65, 0x70, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x11, + 0x2e, 0x74, 0x66, 0x70, 0x6c, 0x61, 0x6e, 0x2e, 0x50, 0x61, 0x74, 0x68, 0x2e, 0x53, 0x74, 0x65, + 0x70, 0x52, 0x05, 0x73, 0x74, 0x65, 0x70, 0x73, 0x1a, 0x74, 0x0a, 0x04, 0x53, 0x74, 0x65, 0x70, + 0x12, 0x27, 0x0a, 0x0e, 0x61, 0x74, 0x74, 0x72, 0x69, 0x62, 0x75, 0x74, 0x65, 0x5f, 0x6e, 0x61, + 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x0d, 0x61, 0x74, 0x74, 0x72, + 0x69, 0x62, 0x75, 0x74, 0x65, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x37, 0x0a, 0x0b, 0x65, 0x6c, 0x65, + 0x6d, 0x65, 0x6e, 0x74, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, + 0x2e, 0x74, 0x66, 0x70, 0x6c, 0x61, 0x6e, 0x2e, 0x44, 0x79, 0x6e, 0x61, 0x6d, 0x69, 0x63, 0x56, + 0x61, 0x6c, 0x75, 0x65, 0x48, 0x00, 0x52, 0x0a, 0x65, 0x6c, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x4b, + 0x65, 0x79, 0x42, 0x0a, 0x0a, 0x08, 0x73, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x22, 0x1b, + 0x0a, 0x09, 0x49, 0x6d, 0x70, 0x6f, 0x72, 0x74, 0x69, 0x6e, 0x67, 0x12, 0x0e, 0x0a, 0x02, 0x69, + 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x2a, 0x31, 0x0a, 0x04, 0x4d, + 0x6f, 0x64, 0x65, 0x12, 0x0a, 0x0a, 0x06, 0x4e, 0x4f, 0x52, 0x4d, 0x41, 0x4c, 0x10, 0x00, 0x12, + 0x0b, 0x0a, 0x07, 0x44, 0x45, 0x53, 0x54, 0x52, 0x4f, 0x59, 0x10, 0x01, 0x12, 0x10, 0x0a, 0x0c, + 0x52, 0x45, 0x46, 0x52, 0x45, 0x53, 0x48, 0x5f, 0x4f, 0x4e, 0x4c, 0x59, 0x10, 0x02, 0x2a, 0x7c, + 0x0a, 0x06, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x08, 0x0a, 0x04, 0x4e, 0x4f, 0x4f, 0x50, + 0x10, 0x00, 0x12, 0x0a, 0x0a, 0x06, 0x43, 0x52, 0x45, 0x41, 0x54, 0x45, 0x10, 0x01, 0x12, 0x08, + 0x0a, 0x04, 0x52, 0x45, 0x41, 0x44, 0x10, 0x02, 0x12, 0x0a, 0x0a, 0x06, 0x55, 0x50, 0x44, 0x41, + 0x54, 0x45, 0x10, 0x03, 0x12, 0x0a, 0x0a, 0x06, 0x44, 0x45, 0x4c, 0x45, 0x54, 0x45, 0x10, 0x05, + 0x12, 0x16, 0x0a, 0x12, 0x44, 0x45, 0x4c, 0x45, 0x54, 0x45, 0x5f, 0x54, 0x48, 0x45, 0x4e, 0x5f, + 0x43, 0x52, 0x45, 0x41, 0x54, 0x45, 0x10, 0x06, 0x12, 0x16, 0x0a, 0x12, 0x43, 0x52, 0x45, 0x41, + 0x54, 0x45, 0x5f, 0x54, 0x48, 0x45, 0x4e, 0x5f, 0x44, 0x45, 0x4c, 0x45, 0x54, 0x45, 0x10, 0x07, + 0x12, 0x0a, 0x0a, 0x06, 0x46, 0x4f, 0x52, 0x47, 0x45, 0x54, 0x10, 0x08, 0x2a, 0xc8, 0x03, 0x0a, + 0x1c, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, + 0x65, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x12, 0x08, 0x0a, + 0x04, 0x4e, 0x4f, 0x4e, 0x45, 0x10, 0x00, 0x12, 0x1b, 0x0a, 0x17, 0x52, 0x45, 0x50, 0x4c, 0x41, + 0x43, 0x45, 0x5f, 0x42, 0x45, 0x43, 0x41, 0x55, 0x53, 0x45, 0x5f, 0x54, 0x41, 0x49, 0x4e, 0x54, + 0x45, 0x44, 0x10, 0x01, 0x12, 0x16, 0x0a, 0x12, 0x52, 0x45, 0x50, 0x4c, 0x41, 0x43, 0x45, 0x5f, + 0x42, 0x59, 0x5f, 0x52, 0x45, 0x51, 0x55, 0x45, 0x53, 0x54, 0x10, 0x02, 0x12, 0x21, 0x0a, 0x1d, + 0x52, 0x45, 0x50, 0x4c, 0x41, 0x43, 0x45, 0x5f, 0x42, 0x45, 0x43, 0x41, 0x55, 0x53, 0x45, 0x5f, + 0x43, 0x41, 0x4e, 0x4e, 0x4f, 0x54, 0x5f, 0x55, 0x50, 0x44, 0x41, 0x54, 0x45, 0x10, 0x03, 0x12, + 0x25, 0x0a, 0x21, 0x44, 0x45, 0x4c, 0x45, 0x54, 0x45, 0x5f, 0x42, 0x45, 0x43, 0x41, 0x55, 0x53, + 0x45, 0x5f, 0x4e, 0x4f, 0x5f, 0x52, 0x45, 0x53, 0x4f, 0x55, 0x52, 0x43, 0x45, 0x5f, 0x43, 0x4f, + 0x4e, 0x46, 0x49, 0x47, 0x10, 0x04, 0x12, 0x23, 0x0a, 0x1f, 0x44, 0x45, 0x4c, 0x45, 0x54, 0x45, + 0x5f, 0x42, 0x45, 0x43, 0x41, 0x55, 0x53, 0x45, 0x5f, 0x57, 0x52, 0x4f, 0x4e, 0x47, 0x5f, 0x52, + 0x45, 0x50, 0x45, 0x54, 0x49, 0x54, 0x49, 0x4f, 0x4e, 0x10, 0x05, 0x12, 0x1e, 0x0a, 0x1a, 0x44, + 0x45, 0x4c, 0x45, 0x54, 0x45, 0x5f, 0x42, 0x45, 0x43, 0x41, 0x55, 0x53, 0x45, 0x5f, 0x43, 0x4f, + 0x55, 0x4e, 0x54, 0x5f, 0x49, 0x4e, 0x44, 0x45, 0x58, 0x10, 0x06, 0x12, 0x1b, 0x0a, 0x17, 0x44, + 0x45, 0x4c, 0x45, 0x54, 0x45, 0x5f, 0x42, 0x45, 0x43, 0x41, 0x55, 0x53, 0x45, 0x5f, 0x45, 0x41, + 0x43, 0x48, 0x5f, 0x4b, 0x45, 0x59, 0x10, 0x07, 0x12, 0x1c, 0x0a, 0x18, 0x44, 0x45, 0x4c, 0x45, + 0x54, 0x45, 0x5f, 0x42, 0x45, 0x43, 0x41, 0x55, 0x53, 0x45, 0x5f, 0x4e, 0x4f, 0x5f, 0x4d, 0x4f, + 0x44, 0x55, 0x4c, 0x45, 0x10, 0x08, 0x12, 0x17, 0x0a, 0x13, 0x52, 0x45, 0x50, 0x4c, 0x41, 0x43, + 0x45, 0x5f, 0x42, 0x59, 0x5f, 0x54, 0x52, 0x49, 0x47, 0x47, 0x45, 0x52, 0x53, 0x10, 0x09, 0x12, + 0x1f, 0x0a, 0x1b, 0x52, 0x45, 0x41, 0x44, 0x5f, 0x42, 0x45, 0x43, 0x41, 0x55, 0x53, 0x45, 0x5f, + 0x43, 0x4f, 0x4e, 0x46, 0x49, 0x47, 0x5f, 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x0a, + 0x12, 0x23, 0x0a, 0x1f, 0x52, 0x45, 0x41, 0x44, 0x5f, 0x42, 0x45, 0x43, 0x41, 0x55, 0x53, 0x45, + 0x5f, 0x44, 0x45, 0x50, 0x45, 0x4e, 0x44, 0x45, 0x4e, 0x43, 0x59, 0x5f, 0x50, 0x45, 0x4e, 0x44, + 0x49, 0x4e, 0x47, 0x10, 0x0b, 0x12, 0x1d, 0x0a, 0x19, 0x52, 0x45, 0x41, 0x44, 0x5f, 0x42, 0x45, + 0x43, 0x41, 0x55, 0x53, 0x45, 0x5f, 0x43, 0x48, 0x45, 0x43, 0x4b, 0x5f, 0x4e, 0x45, 0x53, 0x54, + 0x45, 0x44, 0x10, 0x0d, 0x12, 0x21, 0x0a, 0x1d, 0x44, 0x45, 0x4c, 0x45, 0x54, 0x45, 0x5f, 0x42, + 0x45, 0x43, 0x41, 0x55, 0x53, 0x45, 0x5f, 0x4e, 0x4f, 0x5f, 0x4d, 0x4f, 0x56, 0x45, 0x5f, 0x54, + 0x41, 0x52, 0x47, 0x45, 0x54, 0x10, 0x0c, 0x42, 0x40, 0x5a, 0x3e, 0x67, 0x69, 0x74, 0x68, 0x75, + 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x6f, 0x70, 0x65, 0x6e, 0x74, 0x6f, 0x66, 0x75, 0x2f, 0x6f, + 0x70, 0x65, 0x6e, 0x74, 0x6f, 0x66, 0x75, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, + 0x2f, 0x70, 0x6c, 0x61, 0x6e, 0x73, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, + 0x70, 0x6c, 0x61, 0x6e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x33, } var ( diff --git a/internal/plans/internal/planproto/planfile.proto b/internal/plans/internal/planproto/planfile.proto index 657168618d..c0f1f82fc4 100644 --- a/internal/plans/internal/planproto/planfile.proto +++ b/internal/plans/internal/planproto/planfile.proto @@ -66,6 +66,11 @@ message Plan { // configuration. repeated string target_addrs = 5; + // An unordered set of exclude addresses to exclude when applying. If no + // target addresses are present, the plan applies to the whole + // configuration. + repeated string exclude_addrs = 6; + // An unordered set of force-replace addresses to include when applying. // This must match the set of addresses that was used when creating the // plan, or else applying the plan will fail when it reaches a different diff --git a/internal/plans/plan.go b/internal/plans/plan.go index 6043613e2f..de2ef88628 100644 --- a/internal/plans/plan.go +++ b/internal/plans/plan.go @@ -46,6 +46,7 @@ type Plan struct { Changes *Changes DriftedResources []*ResourceInstanceChangeSrc TargetAddrs []addrs.Targetable + ExcludeAddrs []addrs.Targetable ForceReplaceAddrs []addrs.AbsResourceInstance Backend Backend diff --git a/internal/plans/planfile/tfplan.go b/internal/plans/planfile/tfplan.go index e0469ae226..d15b2d3215 100644 --- a/internal/plans/planfile/tfplan.go +++ b/internal/plans/planfile/tfplan.go @@ -223,6 +223,14 @@ func readTfplan(r io.Reader) (*plans.Plan, error) { plan.TargetAddrs = append(plan.TargetAddrs, target.Subject) } + for _, rawExcludeAddr := range rawPlan.ExcludeAddrs { + exclude, diags := addrs.ParseTargetStr(rawExcludeAddr) + if diags.HasErrors() { + return nil, fmt.Errorf("plan contains invalid exclude address %q: %w", exclude, diags.Err()) + } + plan.ExcludeAddrs = append(plan.ExcludeAddrs, exclude.Subject) + } + for _, rawReplaceAddr := range rawPlan.ForceReplaceAddrs { addr, diags := addrs.ParseAbsResourceInstanceStr(rawReplaceAddr) if diags.HasErrors() { @@ -610,6 +618,10 @@ func writeTfplan(plan *plans.Plan, w io.Writer) error { rawPlan.TargetAddrs = append(rawPlan.TargetAddrs, targetAddr.String()) } + for _, excludeAddr := range plan.ExcludeAddrs { + rawPlan.ExcludeAddrs = append(rawPlan.ExcludeAddrs, excludeAddr.String()) + } + for _, replaceAddr := range plan.ForceReplaceAddrs { rawPlan.ForceReplaceAddrs = append(rawPlan.ForceReplaceAddrs, replaceAddr.String()) } diff --git a/internal/tofu/context_apply.go b/internal/tofu/context_apply.go index 883fa7a4eb..944bcf1949 100644 --- a/internal/tofu/context_apply.go +++ b/internal/tofu/context_apply.go @@ -93,14 +93,14 @@ func (c *Context) Apply(plan *plans.Plan, config *configs.Config) (*states.State newState.PruneResourceHusks() } - if len(plan.TargetAddrs) > 0 { + if len(plan.TargetAddrs) > 0 || len(plan.ExcludeAddrs) > 0 { diags = diags.Append(tfdiags.Sourceless( tfdiags.Warning, "Applied changes may be incomplete", - `The plan was created with the -target option in effect, so some changes requested in the configuration may have been ignored and the output values may not be fully updated. Run the following command to verify that no other changes are pending: + `The plan was created with the -target or the -exclude option in effect, so some changes requested in the configuration may have been ignored and the output values may not be fully updated. Run the following command to verify that no other changes are pending: tofu plan -Note that the -target option is not suitable for routine use, and is provided only for exceptional situations such as recovering from errors or mistakes, or when OpenTofu specifically suggests to use it as part of an error message.`, +Note that the -target and -exclude options are not suitable for routine use, and are provided only for exceptional situations such as recovering from errors or mistakes, or when OpenTofu specifically suggests to use it as part of an error message.`, )) } @@ -181,6 +181,7 @@ func (c *Context) applyGraph(plan *plans.Plan, config *configs.Config, validate RootVariableValues: variables, Plugins: c.plugins, Targets: plan.TargetAddrs, + Excludes: plan.ExcludeAddrs, ForceReplace: plan.ForceReplaceAddrs, Operation: operation, ExternalReferences: plan.ExternalReferences, diff --git a/internal/tofu/context_apply2_test.go b/internal/tofu/context_apply2_test.go index 25e98346d3..84d4661f54 100644 --- a/internal/tofu/context_apply2_test.go +++ b/internal/tofu/context_apply2_test.go @@ -691,6 +691,56 @@ resource "test_object" "s" { assertNoErrors(t, diags) } +// This test is inspired by the above test TestContext2Apply_targetedDestroyWithMoved +func TestContext2Apply_excludedDestroyWithMoved(t *testing.T) { + m := testModuleInline(t, map[string]string{ + "main.tf": ` +module "modb" { + source = "./mod" + for_each = toset(["a", "b"]) +} +`, + "./mod/main.tf": ` +resource "test_object" "a" { +} + +module "sub" { + for_each = toset(["a", "b"]) + source = "./sub" +} + +moved { + from = module.old + to = module.sub +} +`, + "./mod/sub/main.tf": ` +resource "test_object" "s" { +} +`}) + + p := simpleMockProvider() + + ctx := testContext2(t, &ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("test"): testProviderFuncFixed(p), + }, + }) + + plan, diags := ctx.Plan(m, states.NewState(), DefaultPlanOpts) + assertNoErrors(t, diags) + + state, diags := ctx.Apply(plan, m) + assertNoErrors(t, diags) + + // destroy excluding the module in the moved statements + _, diags = ctx.Plan(m, state, &PlanOpts{ + Mode: plans.DestroyMode, + Excludes: []addrs.Targetable{addrs.Module{"sub"}}, + }) + assertNoErrors(t, diags) +} + func TestContext2Apply_graphError(t *testing.T) { m := testModuleInline(t, map[string]string{ "main.tf": ` @@ -2307,3 +2357,1390 @@ func TestContext2Apply_forgetOrphanAndDeposed(t *testing.T) { t.Fatalf("PostApply hook should not be called as part of forget") } } + +// All exclude flag tests in this file, from here forward, are inspired by some counterpart target flag test +// either from this file or from context_apply_test.go +func TestContext2Apply_moduleProviderAliasExcludes(t *testing.T) { + m := testModule(t, "apply-module-provider-alias") + p := testProvider("aws") + p.PlanResourceChangeFn = testDiffFn + p.ApplyResourceChangeFn = testApplyFn + ctx := testContext2(t, &ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("aws"): testProviderFuncFixed(p), + }, + }) + + plan, diags := ctx.Plan(m, states.NewState(), &PlanOpts{ + Mode: plans.NormalMode, + Excludes: []addrs.Targetable{ + addrs.ConfigResource{ + Module: addrs.Module{"child"}, + Resource: addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "aws_instance", + Name: "foo", + }, + }, + }, + }) + assertNoErrors(t, diags) + + state, diags := ctx.Apply(plan, m) + if diags.HasErrors() { + t.Fatalf("diags: %s", diags.Err()) + } + + actual := strings.TrimSpace(state.String()) + expected := strings.TrimSpace(` + + `) + if actual != expected { + t.Fatalf("wrong result\n\ngot:\n%s\n\nwant:\n%s", actual, expected) + } +} + +func TestContext2Apply_moduleProviderAliasExcludesNonExistent(t *testing.T) { + m := testModule(t, "apply-module-provider-alias") + p := testProvider("aws") + p.PlanResourceChangeFn = testDiffFn + p.ApplyResourceChangeFn = testApplyFn + ctx := testContext2(t, &ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("aws"): testProviderFuncFixed(p), + }, + }) + + plan, diags := ctx.Plan(m, states.NewState(), &PlanOpts{ + Mode: plans.NormalMode, + Excludes: []addrs.Targetable{ + addrs.ConfigResource{ + Module: addrs.RootModule, + Resource: addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "nonexistent", + Name: "thing", + }, + }, + }, + }) + assertNoErrors(t, diags) + + state, diags := ctx.Apply(plan, m) + if diags.HasErrors() { + t.Fatalf("diags: %s", diags.Err()) + } + + actual := strings.TrimSpace(state.String()) + expected := strings.TrimSpace(testTofuApplyModuleProviderAliasStr) + if actual != expected { + t.Fatalf("wrong result\n\ngot:\n%s\n\nwant:\n%s", actual, expected) + } +} + +// Tests that a module can be excluded and everything is properly created. +// This adds to the plan test to also just verify that apply works. +func TestContext2Apply_moduleExclude(t *testing.T) { + m := testModule(t, "plan-targeted-cross-module") + p := testProvider("aws") + p.PlanResourceChangeFn = testDiffFn + p.ApplyResourceChangeFn = testApplyFn + ctx := testContext2(t, &ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("aws"): testProviderFuncFixed(p), + }, + }) + + plan, diags := ctx.Plan(m, states.NewState(), &PlanOpts{ + Mode: plans.NormalMode, + Excludes: []addrs.Targetable{ + addrs.RootModuleInstance.Child("B", addrs.NoKey), + }, + }) + assertNoErrors(t, diags) + + state, diags := ctx.Apply(plan, m) + if diags.HasErrors() { + t.Fatalf("diags: %s", diags.Err()) + } + + checkStateString(t, state, ` + +module.A: + aws_instance.foo: + ID = foo + provider = provider["registry.opentofu.org/hashicorp/aws"] + foo = bar + type = aws_instance`) +} + +// Tests that a module can be excluded, and dependent resources and modules are excluded as well +// This adds to the plan test to also just verify that apply works. +func TestContext2Apply_moduleExcludeDependent(t *testing.T) { + m := testModule(t, "plan-targeted-cross-module") + p := testProvider("aws") + p.PlanResourceChangeFn = testDiffFn + p.ApplyResourceChangeFn = testApplyFn + ctx := testContext2(t, &ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("aws"): testProviderFuncFixed(p), + }, + }) + + plan, diags := ctx.Plan(m, states.NewState(), &PlanOpts{ + Mode: plans.NormalMode, + Excludes: []addrs.Targetable{ + addrs.RootModuleInstance.Child("A", addrs.NoKey), + }, + }) + assertNoErrors(t, diags) + + state, diags := ctx.Apply(plan, m) + if diags.HasErrors() { + t.Fatalf("diags: %s", diags.Err()) + } + + checkStateString(t, state, ` + +`) +} + +// Tests that non-existent module can be excluded, and that the apply happens fully +// This adds to the plan test to also just verify that apply works. +func TestContext2Apply_moduleExcludeNonExistent(t *testing.T) { + m := testModule(t, "plan-targeted-cross-module") + p := testProvider("aws") + p.PlanResourceChangeFn = testDiffFn + p.ApplyResourceChangeFn = testApplyFn + ctx := testContext2(t, &ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("aws"): testProviderFuncFixed(p), + }, + }) + + plan, diags := ctx.Plan(m, states.NewState(), &PlanOpts{ + Mode: plans.NormalMode, + Excludes: []addrs.Targetable{ + addrs.RootModuleInstance.Child("C", addrs.NoKey), + }, + }) + assertNoErrors(t, diags) + + state, diags := ctx.Apply(plan, m) + if diags.HasErrors() { + t.Fatalf("diags: %s", diags.Err()) + } + + checkStateString(t, state, ` + +module.A: + aws_instance.foo: + ID = foo + provider = provider["registry.opentofu.org/hashicorp/aws"] + foo = bar + type = aws_instance + + Outputs: + + value = foo +module.B: + aws_instance.bar: + ID = foo + provider = provider["registry.opentofu.org/hashicorp/aws"] + foo = foo + type = aws_instance + + Dependencies: + module.A.aws_instance.foo + `) +} + +func TestContext2Apply_destroyExcludedNonExistentWithModuleVariableAndCount(t *testing.T) { + m := testModule(t, "apply-destroy-mod-var-and-count") + p := testProvider("aws") + p.PlanResourceChangeFn = testDiffFn + p.ApplyResourceChangeFn = testApplyFn + + var state *states.State + { + ctx := testContext2(t, &ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("aws"): testProviderFuncFixed(p), + }, + }) + + // First plan and apply a create operation + plan, diags := ctx.Plan(m, states.NewState(), DefaultPlanOpts) + assertNoErrors(t, diags) + + state, diags = ctx.Apply(plan, m) + if diags.HasErrors() { + t.Fatalf("apply err: %s", diags.Err()) + } + } + + { + ctx := testContext2(t, &ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("aws"): testProviderFuncFixed(p), + }, + }) + + plan, diags := ctx.Plan(m, state, &PlanOpts{ + Mode: plans.DestroyMode, + Excludes: []addrs.Targetable{ + addrs.RootModuleInstance.Child("child", addrs.NoKey), + }, + }) + if diags.HasErrors() { + t.Fatalf("plan err: %s", diags) + } + if len(diags) != 1 { + // Should have one warning that targeting is in effect. + t.Fatalf("got %d diagnostics in plan; want 1", len(diags)) + } + if got, want := diags[0].Severity(), tfdiags.Warning; got != want { + t.Errorf("wrong diagnostic severity %#v; want %#v", got, want) + } + if got, want := diags[0].Description().Summary, "Resource targeting is in effect"; got != want { + t.Errorf("wrong diagnostic summary %#v; want %#v", got, want) + } + + // Destroy, excluding the module explicitly + state, diags = ctx.Apply(plan, m) + if diags.HasErrors() { + t.Fatalf("destroy apply err: %s", diags) + } + if len(diags) != 1 { + t.Fatalf("got %d diagnostics; want 1", len(diags)) + } + if got, want := diags[0].Severity(), tfdiags.Warning; got != want { + t.Errorf("wrong diagnostic severity %#v; want %#v", got, want) + } + if got, want := diags[0].Description().Summary, "Applied changes may be incomplete"; got != want { + t.Errorf("wrong diagnostic summary %#v; want %#v", got, want) + } + } + + // Test that things were destroyed + actual := strings.TrimSpace(state.String()) + expected := strings.TrimSpace(` + +module.child: + aws_instance.foo.0: + ID = foo + provider = provider["registry.opentofu.org/hashicorp/aws"] + type = aws_instance + aws_instance.foo.1: + ID = foo + provider = provider["registry.opentofu.org/hashicorp/aws"] + type = aws_instance + aws_instance.foo.2: + ID = foo + provider = provider["registry.opentofu.org/hashicorp/aws"] + type = aws_instance`) + if actual != expected { + t.Fatalf("expected: \n%s\n\nbad: \n%s", expected, actual) + } +} + +func TestContext2Apply_destroyExcludedWithModuleVariableAndCount(t *testing.T) { + m := testModule(t, "apply-destroy-mod-var-and-count") + p := testProvider("aws") + p.PlanResourceChangeFn = testDiffFn + p.ApplyResourceChangeFn = testApplyFn + + var state *states.State + { + ctx := testContext2(t, &ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("aws"): testProviderFuncFixed(p), + }, + }) + + // First plan and apply a create operation + plan, diags := ctx.Plan(m, states.NewState(), DefaultPlanOpts) + assertNoErrors(t, diags) + + state, diags = ctx.Apply(plan, m) + if diags.HasErrors() { + t.Fatalf("apply err: %s", diags.Err()) + } + } + + { + ctx := testContext2(t, &ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("aws"): testProviderFuncFixed(p), + }, + }) + + plan, diags := ctx.Plan(m, state, &PlanOpts{ + Mode: plans.DestroyMode, + Excludes: []addrs.Targetable{ + addrs.RootModuleInstance.Child("non-existent-child", addrs.NoKey), + }, + }) + if diags.HasErrors() { + t.Fatalf("plan err: %s", diags) + } + if len(diags) != 1 { + // Should have one warning that targeting is in effect. + t.Fatalf("got %d diagnostics in plan; want 1", len(diags)) + } + if got, want := diags[0].Severity(), tfdiags.Warning; got != want { + t.Errorf("wrong diagnostic severity %#v; want %#v", got, want) + } + if got, want := diags[0].Description().Summary, "Resource targeting is in effect"; got != want { + t.Errorf("wrong diagnostic summary %#v; want %#v", got, want) + } + + // Destroy, excluding the module explicitly + state, diags = ctx.Apply(plan, m) + if diags.HasErrors() { + t.Fatalf("destroy apply err: %s", diags) + } + if len(diags) != 1 { + t.Fatalf("got %d diagnostics; want 1", len(diags)) + } + if got, want := diags[0].Severity(), tfdiags.Warning; got != want { + t.Errorf("wrong diagnostic severity %#v; want %#v", got, want) + } + if got, want := diags[0].Description().Summary, "Applied changes may be incomplete"; got != want { + t.Errorf("wrong diagnostic summary %#v; want %#v", got, want) + } + } + + // Test that things were destroyed + actual := strings.TrimSpace(state.String()) + expected := strings.TrimSpace(``) + if actual != expected { + t.Fatalf("expected: \n%s\n\nbad: \n%s", expected, actual) + } +} + +func TestContext2Apply_excluded(t *testing.T) { + m := testModule(t, "apply-targeted") + p := testProvider("aws") + p.PlanResourceChangeFn = testDiffFn + p.ApplyResourceChangeFn = testApplyFn + ctx := testContext2(t, &ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("aws"): testProviderFuncFixed(p), + }, + }) + + plan, diags := ctx.Plan(m, states.NewState(), &PlanOpts{ + Mode: plans.NormalMode, + Excludes: []addrs.Targetable{ + addrs.RootModuleInstance.Resource( + addrs.ManagedResourceMode, "aws_instance", "bar", + ), + }, + }) + assertNoErrors(t, diags) + + state, diags := ctx.Apply(plan, m) + if diags.HasErrors() { + t.Fatalf("diags: %s", diags.Err()) + } + + mod := state.RootModule() + if len(mod.Resources) != 1 { + t.Fatalf("expected 1 resource, got: %#v", mod.Resources) + } + + checkStateString(t, state, ` +aws_instance.foo: + ID = foo + provider = provider["registry.opentofu.org/hashicorp/aws"] + num = 2 + type = aws_instance + `) +} + +func TestContext2Apply_excludedCount(t *testing.T) { + m := testModule(t, "apply-targeted-count") + p := testProvider("aws") + p.PlanResourceChangeFn = testDiffFn + p.ApplyResourceChangeFn = testApplyFn + ctx := testContext2(t, &ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("aws"): testProviderFuncFixed(p), + }, + }) + + plan, diags := ctx.Plan(m, states.NewState(), &PlanOpts{ + Mode: plans.NormalMode, + Excludes: []addrs.Targetable{ + addrs.RootModuleInstance.Resource( + addrs.ManagedResourceMode, "aws_instance", "bar", + ), + }, + }) + assertNoErrors(t, diags) + + state, diags := ctx.Apply(plan, m) + if diags.HasErrors() { + t.Fatalf("diags: %s", diags.Err()) + } + + checkStateString(t, state, ` +aws_instance.foo.0: + ID = foo + provider = provider["registry.opentofu.org/hashicorp/aws"] + type = aws_instance +aws_instance.foo.1: + ID = foo + provider = provider["registry.opentofu.org/hashicorp/aws"] + type = aws_instance +aws_instance.foo.2: + ID = foo + provider = provider["registry.opentofu.org/hashicorp/aws"] + type = aws_instance + `) +} + +func TestContext2Apply_excludedCountIndex(t *testing.T) { + m := testModule(t, "apply-targeted-count") + p := testProvider("aws") + p.PlanResourceChangeFn = testDiffFn + p.ApplyResourceChangeFn = testApplyFn + ctx := testContext2(t, &ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("aws"): testProviderFuncFixed(p), + }, + }) + + plan, diags := ctx.Plan(m, states.NewState(), &PlanOpts{ + Mode: plans.NormalMode, + Excludes: []addrs.Targetable{ + addrs.RootModuleInstance.ResourceInstance( + addrs.ManagedResourceMode, "aws_instance", "foo", addrs.IntKey(1), + ), + }, + }) + assertNoErrors(t, diags) + + state, diags := ctx.Apply(plan, m) + if diags.HasErrors() { + t.Fatalf("diags: %s", diags.Err()) + } + + checkStateString(t, state, ` +aws_instance.bar.0: + ID = foo + provider = provider["registry.opentofu.org/hashicorp/aws"] + type = aws_instance +aws_instance.bar.1: + ID = foo + provider = provider["registry.opentofu.org/hashicorp/aws"] + type = aws_instance +aws_instance.bar.2: + ID = foo + provider = provider["registry.opentofu.org/hashicorp/aws"] + type = aws_instance +aws_instance.foo.0: + ID = foo + provider = provider["registry.opentofu.org/hashicorp/aws"] + type = aws_instance +aws_instance.foo.2: + ID = foo + provider = provider["registry.opentofu.org/hashicorp/aws"] + type = aws_instance`) +} + +func TestContext2Apply_excludedDestroy(t *testing.T) { + m := testModule(t, "destroy-targeted") + p := testProvider("aws") + p.PlanResourceChangeFn = testDiffFn + p.ApplyResourceChangeFn = testApplyFn + + var state *states.State + { + ctx := testContext2(t, &ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("aws"): testProviderFuncFixed(p), + }, + }) + + // First plan and apply a create operation + if diags := ctx.Validate(m); diags.HasErrors() { + t.Fatalf("validate errors: %s", diags.Err()) + } + + plan, diags := ctx.Plan(m, states.NewState(), DefaultPlanOpts) + assertNoErrors(t, diags) + + state, diags = ctx.Apply(plan, m) + if diags.HasErrors() { + t.Fatalf("apply err: %s", diags.Err()) + } + } + + { + ctx := testContext2(t, &ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("aws"): testProviderFuncFixed(p), + }, + }) + + plan, diags := ctx.Plan(m, state, &PlanOpts{ + Mode: plans.DestroyMode, + Excludes: []addrs.Targetable{ + addrs.RootModuleInstance.Resource( + addrs.ManagedResourceMode, "aws_instance", "a", + ), + }, + }) + assertNoErrors(t, diags) + + state, diags = ctx.Apply(plan, m) + if diags.HasErrors() { + t.Fatalf("diags: %s", diags.Err()) + } + } + + // The output should not be removed, as the aws_instance resource it relies on is excluded + checkStateString(t, state, ` +aws_instance.a: + ID = foo + provider = provider["registry.opentofu.org/hashicorp/aws"] + foo = bar + type = aws_instance + +Outputs: + +out = foo`) +} + +func TestContext2Apply_excludedDestroyDependent(t *testing.T) { + m := testModule(t, "destroy-targeted") + p := testProvider("aws") + p.PlanResourceChangeFn = testDiffFn + p.ApplyResourceChangeFn = testApplyFn + + var state *states.State + { + ctx := testContext2(t, &ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("aws"): testProviderFuncFixed(p), + }, + }) + + // First plan and apply a create operation + if diags := ctx.Validate(m); diags.HasErrors() { + t.Fatalf("validate errors: %s", diags.Err()) + } + + plan, diags := ctx.Plan(m, states.NewState(), DefaultPlanOpts) + assertNoErrors(t, diags) + + state, diags = ctx.Apply(plan, m) + if diags.HasErrors() { + t.Fatalf("apply err: %s", diags.Err()) + } + } + + { + ctx := testContext2(t, &ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("aws"): testProviderFuncFixed(p), + }, + }) + + plan, diags := ctx.Plan(m, state, &PlanOpts{ + Mode: plans.DestroyMode, + Excludes: []addrs.Targetable{ + addrs.RootModuleInstance.Child("child", addrs.NoKey), + }, + }) + assertNoErrors(t, diags) + + state, diags = ctx.Apply(plan, m) + if diags.HasErrors() { + t.Fatalf("diags: %s", diags.Err()) + } + } + + // The output should not be removed, as the aws_instance resource it relies on is excluded + checkStateString(t, state, ` +aws_instance.a: + ID = foo + provider = provider["registry.opentofu.org/hashicorp/aws"] + foo = bar + type = aws_instance + +Outputs: + +out = foo + +module.child: + aws_instance.b: + ID = foo + provider = provider["registry.opentofu.org/hashicorp/aws"] + foo = foo + type = aws_instance + + Dependencies: + aws_instance.a`) +} + +func TestContext2Apply_excludedDestroyCountDeps(t *testing.T) { + m := testModule(t, "apply-destroy-targeted-count") + p := testProvider("aws") + p.PlanResourceChangeFn = testDiffFn + + state := states.NewState() + root := state.EnsureModule(addrs.RootModuleInstance) + root.SetResourceInstanceCurrent( + mustResourceInstanceAddr("aws_instance.foo[0]").Resource, + &states.ResourceInstanceObjectSrc{ + Status: states.ObjectReady, + AttrsJSON: []byte(`{"id":"i-bcd345"}`), + }, + mustProviderConfig(`provider["registry.opentofu.org/hashicorp/aws"]`), + ) + root.SetResourceInstanceCurrent( + mustResourceInstanceAddr("aws_instance.foo[1]").Resource, + &states.ResourceInstanceObjectSrc{ + Status: states.ObjectReady, + AttrsJSON: []byte(`{"id":"i-cde345"}`), + }, + mustProviderConfig(`provider["registry.opentofu.org/hashicorp/aws"]`), + ) + root.SetResourceInstanceCurrent( + mustResourceInstanceAddr("aws_instance.foo[2]").Resource, + &states.ResourceInstanceObjectSrc{ + Status: states.ObjectReady, + AttrsJSON: []byte(`{"id":"i-def345"}`), + }, + mustProviderConfig(`provider["registry.opentofu.org/hashicorp/aws"]`), + ) + root.SetResourceInstanceCurrent( + mustResourceInstanceAddr("aws_instance.bar").Resource, + &states.ResourceInstanceObjectSrc{ + Status: states.ObjectReady, + AttrsJSON: []byte(`{"id":"i-abc123"}`), + Dependencies: []addrs.ConfigResource{mustConfigResourceAddr("aws_instance.foo")}, + }, + mustProviderConfig(`provider["registry.opentofu.org/hashicorp/aws"]`), + ) + + ctx := testContext2(t, &ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("aws"): testProviderFuncFixed(p), + }, + }) + + plan, diags := ctx.Plan(m, state, &PlanOpts{ + Mode: plans.DestroyMode, + Excludes: []addrs.Targetable{ + addrs.RootModuleInstance.Resource( + addrs.ManagedResourceMode, "aws_instance", "foo", + ), + }, + }) + assertNoErrors(t, diags) + + state, diags = ctx.Apply(plan, m) + if diags.HasErrors() { + t.Fatalf("diags: %s", diags.Err()) + } + + checkStateString(t, state, ` +aws_instance.foo.0: + ID = i-bcd345 + provider = provider["registry.opentofu.org/hashicorp/aws"] +aws_instance.foo.1: + ID = i-cde345 + provider = provider["registry.opentofu.org/hashicorp/aws"] +aws_instance.foo.2: + ID = i-def345 + provider = provider["registry.opentofu.org/hashicorp/aws"]`) +} + +func TestContext2Apply_excludedDependentDestroyCountDeps(t *testing.T) { + m := testModule(t, "apply-destroy-targeted-count") + p := testProvider("aws") + p.PlanResourceChangeFn = testDiffFn + + state := states.NewState() + root := state.EnsureModule(addrs.RootModuleInstance) + root.SetResourceInstanceCurrent( + mustResourceInstanceAddr("aws_instance.foo[0]").Resource, + &states.ResourceInstanceObjectSrc{ + Status: states.ObjectReady, + AttrsJSON: []byte(`{"id":"i-bcd345"}`), + }, + mustProviderConfig(`provider["registry.opentofu.org/hashicorp/aws"]`), + ) + root.SetResourceInstanceCurrent( + mustResourceInstanceAddr("aws_instance.foo[1]").Resource, + &states.ResourceInstanceObjectSrc{ + Status: states.ObjectReady, + AttrsJSON: []byte(`{"id":"i-cde345"}`), + }, + mustProviderConfig(`provider["registry.opentofu.org/hashicorp/aws"]`), + ) + root.SetResourceInstanceCurrent( + mustResourceInstanceAddr("aws_instance.foo[2]").Resource, + &states.ResourceInstanceObjectSrc{ + Status: states.ObjectReady, + AttrsJSON: []byte(`{"id":"i-def345"}`), + }, + mustProviderConfig(`provider["registry.opentofu.org/hashicorp/aws"]`), + ) + root.SetResourceInstanceCurrent( + mustResourceInstanceAddr("aws_instance.bar").Resource, + &states.ResourceInstanceObjectSrc{ + Status: states.ObjectReady, + AttrsJSON: []byte(`{"id":"i-abc123"}`), + Dependencies: []addrs.ConfigResource{mustConfigResourceAddr("aws_instance.foo")}, + }, + mustProviderConfig(`provider["registry.opentofu.org/hashicorp/aws"]`), + ) + + ctx := testContext2(t, &ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("aws"): testProviderFuncFixed(p), + }, + }) + + plan, diags := ctx.Plan(m, state, &PlanOpts{ + Mode: plans.DestroyMode, + Excludes: []addrs.Targetable{ + addrs.RootModuleInstance.Resource( + addrs.ManagedResourceMode, "aws_instance", "bar", + ), + }, + }) + assertNoErrors(t, diags) + + state, diags = ctx.Apply(plan, m) + if diags.HasErrors() { + t.Fatalf("diags: %s", diags.Err()) + } + + checkStateString(t, state, ` +aws_instance.bar: + ID = i-abc123 + provider = provider["registry.opentofu.org/hashicorp/aws"] + + Dependencies: + aws_instance.foo +aws_instance.foo.0: + ID = i-bcd345 + provider = provider["registry.opentofu.org/hashicorp/aws"] +aws_instance.foo.1: + ID = i-cde345 + provider = provider["registry.opentofu.org/hashicorp/aws"] +aws_instance.foo.2: + ID = i-def345 + provider = provider["registry.opentofu.org/hashicorp/aws"]`) +} + +func TestContext2Apply_excludedDestroyModule(t *testing.T) { + m := testModule(t, "apply-targeted-module") + p := testProvider("aws") + p.PlanResourceChangeFn = testDiffFn + + state := states.NewState() + root := state.EnsureModule(addrs.RootModuleInstance) + root.SetResourceInstanceCurrent( + mustResourceInstanceAddr("aws_instance.foo").Resource, + &states.ResourceInstanceObjectSrc{ + Status: states.ObjectReady, + AttrsJSON: []byte(`{"id":"i-bcd345"}`), + }, + mustProviderConfig(`provider["registry.opentofu.org/hashicorp/aws"]`), + ) + root.SetResourceInstanceCurrent( + mustResourceInstanceAddr("aws_instance.bar").Resource, + &states.ResourceInstanceObjectSrc{ + Status: states.ObjectReady, + AttrsJSON: []byte(`{"id":"i-abc123"}`), + }, + mustProviderConfig(`provider["registry.opentofu.org/hashicorp/aws"]`), + ) + child := state.EnsureModule(addrs.RootModuleInstance.Child("child", addrs.NoKey)) + child.SetResourceInstanceCurrent( + mustResourceInstanceAddr("aws_instance.foo").Resource, + &states.ResourceInstanceObjectSrc{ + Status: states.ObjectReady, + AttrsJSON: []byte(`{"id":"i-bcd345"}`), + }, + mustProviderConfig(`provider["registry.opentofu.org/hashicorp/aws"]`), + ) + child.SetResourceInstanceCurrent( + mustResourceInstanceAddr("aws_instance.bar").Resource, + &states.ResourceInstanceObjectSrc{ + Status: states.ObjectReady, + AttrsJSON: []byte(`{"id":"i-abc123"}`), + }, + mustProviderConfig(`provider["registry.opentofu.org/hashicorp/aws"]`), + ) + + ctx := testContext2(t, &ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("aws"): testProviderFuncFixed(p), + }, + }) + + plan, diags := ctx.Plan(m, state, &PlanOpts{ + Mode: plans.DestroyMode, + Excludes: []addrs.Targetable{ + addrs.RootModuleInstance.Child("child", addrs.NoKey).Resource( + addrs.ManagedResourceMode, "aws_instance", "foo", + ), + }, + }) + assertNoErrors(t, diags) + + state, diags = ctx.Apply(plan, m) + if diags.HasErrors() { + t.Fatalf("diags: %s", diags.Err()) + } + + checkStateString(t, state, ` + +module.child: + aws_instance.foo: + ID = i-bcd345 + provider = provider["registry.opentofu.org/hashicorp/aws"]`) +} + +func TestContext2Apply_excludedDestroyCountIndex(t *testing.T) { + m := testModule(t, "apply-targeted-count") + p := testProvider("aws") + p.PlanResourceChangeFn = testDiffFn + + foo := &states.ResourceInstanceObjectSrc{ + Status: states.ObjectReady, + AttrsJSON: []byte(`{"id":"i-bcd345"}`), + } + bar := &states.ResourceInstanceObjectSrc{ + Status: states.ObjectReady, + AttrsJSON: []byte(`{"id":"i-abc123"}`), + } + + state := states.NewState() + root := state.EnsureModule(addrs.RootModuleInstance) + root.SetResourceInstanceCurrent( + mustResourceInstanceAddr("aws_instance.foo[0]").Resource, + foo, + mustProviderConfig(`provider["registry.opentofu.org/hashicorp/aws"]`), + ) + root.SetResourceInstanceCurrent( + mustResourceInstanceAddr("aws_instance.foo[1]").Resource, + foo, + mustProviderConfig(`provider["registry.opentofu.org/hashicorp/aws"]`), + ) + root.SetResourceInstanceCurrent( + mustResourceInstanceAddr("aws_instance.foo[2]").Resource, + foo, + mustProviderConfig(`provider["registry.opentofu.org/hashicorp/aws"]`), + ) + root.SetResourceInstanceCurrent( + mustResourceInstanceAddr("aws_instance.bar[0]").Resource, + bar, + mustProviderConfig(`provider["registry.opentofu.org/hashicorp/aws"]`), + ) + root.SetResourceInstanceCurrent( + mustResourceInstanceAddr("aws_instance.bar[1]").Resource, + bar, + mustProviderConfig(`provider["registry.opentofu.org/hashicorp/aws"]`), + ) + root.SetResourceInstanceCurrent( + mustResourceInstanceAddr("aws_instance.bar[2]").Resource, + bar, + mustProviderConfig(`provider["registry.opentofu.org/hashicorp/aws"]`), + ) + + ctx := testContext2(t, &ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("aws"): testProviderFuncFixed(p), + }, + }) + + plan, diags := ctx.Plan(m, state, &PlanOpts{ + Mode: plans.DestroyMode, + Excludes: []addrs.Targetable{ + addrs.RootModuleInstance.ResourceInstance( + addrs.ManagedResourceMode, "aws_instance", "foo", addrs.IntKey(2), + ), + addrs.RootModuleInstance.ResourceInstance( + addrs.ManagedResourceMode, "aws_instance", "bar", addrs.IntKey(1), + ), + }, + }) + assertNoErrors(t, diags) + + state, diags = ctx.Apply(plan, m) + if diags.HasErrors() { + t.Fatalf("diags: %s", diags.Err()) + } + + checkStateString(t, state, ` +aws_instance.bar.1: + ID = i-abc123 + provider = provider["registry.opentofu.org/hashicorp/aws"] +aws_instance.foo.2: + ID = i-bcd345 + provider = provider["registry.opentofu.org/hashicorp/aws"] + `) +} + +func TestContext2Apply_excludedModule(t *testing.T) { + m := testModule(t, "apply-targeted-module") + p := testProvider("aws") + p.PlanResourceChangeFn = testDiffFn + p.ApplyResourceChangeFn = testApplyFn + ctx := testContext2(t, &ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("aws"): testProviderFuncFixed(p), + }, + }) + + plan, diags := ctx.Plan(m, states.NewState(), &PlanOpts{ + Mode: plans.NormalMode, + Excludes: []addrs.Targetable{ + addrs.RootModuleInstance.Child("child", addrs.NoKey), + }, + }) + assertNoErrors(t, diags) + + state, diags := ctx.Apply(plan, m) + if diags.HasErrors() { + t.Fatalf("diags: %s", diags.Err()) + } + + mod := state.Module(addrs.RootModuleInstance.Child("child", addrs.NoKey)) + if mod != nil { + t.Fatalf("child module should not be in state, but was found in the state!\n\n%#v", state) + } + + checkStateString(t, state, ` +aws_instance.bar: + ID = foo + provider = provider["registry.opentofu.org/hashicorp/aws"] + foo = bar + type = aws_instance +aws_instance.foo: + ID = foo + provider = provider["registry.opentofu.org/hashicorp/aws"] + foo = bar + type = aws_instance + `) +} + +func TestContext2Apply_excludedModuleResourceDep(t *testing.T) { + m := testModule(t, "apply-targeted-module-dep") + p := testProvider("aws") + p.PlanResourceChangeFn = testDiffFn + p.ApplyResourceChangeFn = testApplyFn + ctx := testContext2(t, &ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("aws"): testProviderFuncFixed(p), + }, + }) + + plan, diags := ctx.Plan(m, states.NewState(), &PlanOpts{ + Mode: plans.NormalMode, + Excludes: []addrs.Targetable{ + addrs.RootModuleInstance.Child("child", addrs.NoKey).Resource( + addrs.ManagedResourceMode, "aws_instance", "mod", + ), + }, + }) + if diags.HasErrors() { + t.Fatalf("diags: %s", diags.Err()) + } else { + t.Logf("Diff: %s", legacyDiffComparisonString(plan.Changes)) + } + + state, diags := ctx.Apply(plan, m) + if diags.HasErrors() { + t.Fatalf("diags: %s", diags.Err()) + } + + checkStateString(t, state, ` + +`) +} + +func TestContext2Apply_excludedResourceDependentOnModule(t *testing.T) { + m := testModule(t, "apply-targeted-module-dep") + p := testProvider("aws") + p.PlanResourceChangeFn = testDiffFn + p.ApplyResourceChangeFn = testApplyFn + ctx := testContext2(t, &ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("aws"): testProviderFuncFixed(p), + }, + }) + + plan, diags := ctx.Plan(m, states.NewState(), &PlanOpts{ + Mode: plans.NormalMode, + Excludes: []addrs.Targetable{ + addrs.RootModuleInstance.Resource(addrs.ManagedResourceMode, "aws_instance", "foo"), + }, + }) + if diags.HasErrors() { + t.Fatalf("diags: %s", diags.Err()) + } else { + t.Logf("Diff: %s", legacyDiffComparisonString(plan.Changes)) + } + + state, diags := ctx.Apply(plan, m) + if diags.HasErrors() { + t.Fatalf("diags: %s", diags.Err()) + } + + checkStateString(t, state, ` + +module.child: + aws_instance.mod: + ID = foo + provider = provider["registry.opentofu.org/hashicorp/aws"] + type = aws_instance +`) +} + +func TestContext2Apply_excludedModuleDep(t *testing.T) { + m := testModule(t, "apply-targeted-module-dep") + p := testProvider("aws") + p.PlanResourceChangeFn = testDiffFn + p.ApplyResourceChangeFn = testApplyFn + ctx := testContext2(t, &ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("aws"): testProviderFuncFixed(p), + }, + }) + + plan, diags := ctx.Plan(m, states.NewState(), &PlanOpts{ + Mode: plans.NormalMode, + Excludes: []addrs.Targetable{ + addrs.RootModuleInstance.Child("child", addrs.NoKey), + }, + }) + if diags.HasErrors() { + t.Fatalf("diags: %s", diags.Err()) + } else { + t.Logf("Diff: %s", legacyDiffComparisonString(plan.Changes)) + } + + state, diags := ctx.Apply(plan, m) + if diags.HasErrors() { + t.Fatalf("diags: %s", diags.Err()) + } + + checkStateString(t, state, ` + +`) +} + +func TestContext2Apply_excludedModuleUnrelatedOutputs(t *testing.T) { + m := testModule(t, "apply-targeted-module-unrelated-outputs") + p := testProvider("aws") + p.PlanResourceChangeFn = testDiffFn + p.ApplyResourceChangeFn = testApplyFn + + state := states.NewState() + + ctx := testContext2(t, &ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("aws"): testProviderFuncFixed(p), + }, + }) + + plan, diags := ctx.Plan(m, state, &PlanOpts{ + Mode: plans.NormalMode, + Excludes: []addrs.Targetable{ + // Excluding aws_instance.foo should also exclude module.child1, which is dependent on it + addrs.RootModuleInstance.Resource(addrs.ManagedResourceMode, "aws_instance", "foo"), + }, + }) + assertNoErrors(t, diags) + + s, diags := ctx.Apply(plan, m) + if diags.HasErrors() { + t.Fatalf("diags: %s", diags.Err()) + } + + // - module.child1's instance_id output is dropped because we don't preserve + // non-root module outputs between runs (they can be recalculated from config) + // - module.child2's instance_id is updated because its dependency is updated + // - child2_id is updated because if its transitive dependency via module.child2 + checkStateString(t, s, ` + +Outputs: + +child2_id = foo + +module.child2: + aws_instance.foo: + ID = foo + provider = provider["registry.opentofu.org/hashicorp/aws"] + type = aws_instance + + Outputs: + + instance_id = foo +`) +} + +func TestContext2Apply_excludedModuleResource(t *testing.T) { + m := testModule(t, "apply-targeted-module-resource") + p := testProvider("aws") + p.PlanResourceChangeFn = testDiffFn + p.ApplyResourceChangeFn = testApplyFn + ctx := testContext2(t, &ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("aws"): testProviderFuncFixed(p), + }, + }) + + plan, diags := ctx.Plan(m, states.NewState(), &PlanOpts{ + Mode: plans.NormalMode, + Excludes: []addrs.Targetable{ + addrs.RootModuleInstance.Child("child", addrs.NoKey).Resource( + addrs.ManagedResourceMode, "aws_instance", "foo", + ), + }, + }) + assertNoErrors(t, diags) + + state, diags := ctx.Apply(plan, m) + if diags.HasErrors() { + t.Fatalf("diags: %s", diags.Err()) + } + + mod := state.Module(addrs.RootModuleInstance.Child("child", addrs.NoKey)) + if mod == nil || len(mod.Resources) != 1 { + t.Fatalf("expected 1 resource, got: %#v", mod) + } + + checkStateString(t, state, ` +aws_instance.bar: + ID = foo + provider = provider["registry.opentofu.org/hashicorp/aws"] + foo = bar + type = aws_instance + +module.child: + aws_instance.bar: + ID = foo + provider = provider["registry.opentofu.org/hashicorp/aws"] + num = 2 + type = aws_instance + `) +} + +func TestContext2Apply_excludedResourceOrphanModule(t *testing.T) { + m := testModule(t, "apply-targeted-resource-orphan-module") + p := testProvider("aws") + p.PlanResourceChangeFn = testDiffFn + p.ApplyResourceChangeFn = testApplyFn + + state := states.NewState() + child := state.EnsureModule(addrs.RootModuleInstance.Child("parent", addrs.NoKey)) + child.SetResourceInstanceCurrent( + mustResourceInstanceAddr("aws_instance.bar").Resource, + &states.ResourceInstanceObjectSrc{ + Status: states.ObjectReady, + AttrsJSON: []byte(`{"id":"abc","type":"aws_instance"}`), + }, + mustProviderConfig(`provider["registry.opentofu.org/hashicorp/aws"]`), + ) + + ctx := testContext2(t, &ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("aws"): testProviderFuncFixed(p), + }, + }) + + plan, diags := ctx.Plan(m, state, &PlanOpts{ + Mode: plans.NormalMode, + Excludes: []addrs.Targetable{ + addrs.RootModuleInstance.Child("parent", addrs.NoKey).Resource( + addrs.ManagedResourceMode, "aws_instance", "bar", + ), + }, + }) + assertNoErrors(t, diags) + + state, diags = ctx.Apply(plan, m) + if diags.HasErrors() { + t.Fatalf("apply errors: %s", diags.Err()) + } + + checkStateString(t, state, ` +aws_instance.foo: + ID = foo + provider = provider["registry.opentofu.org/hashicorp/aws"] + type = aws_instance + +module.parent: + aws_instance.bar: + ID = abc + provider = provider["registry.opentofu.org/hashicorp/aws"] + type = aws_instance +`) +} + +func TestContext2Apply_excludedOrphanModule(t *testing.T) { + m := testModule(t, "apply-targeted-resource-orphan-module") + p := testProvider("aws") + p.PlanResourceChangeFn = testDiffFn + p.ApplyResourceChangeFn = testApplyFn + + state := states.NewState() + child := state.EnsureModule(addrs.RootModuleInstance.Child("parent", addrs.NoKey)) + child.SetResourceInstanceCurrent( + mustResourceInstanceAddr("aws_instance.bar").Resource, + &states.ResourceInstanceObjectSrc{ + Status: states.ObjectReady, + AttrsJSON: []byte(`{"id":"abc","type":"aws_instance"}`), + }, + mustProviderConfig(`provider["registry.opentofu.org/hashicorp/aws"]`), + ) + + ctx := testContext2(t, &ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("aws"): testProviderFuncFixed(p), + }, + }) + + plan, diags := ctx.Plan(m, state, &PlanOpts{ + Mode: plans.NormalMode, + Excludes: []addrs.Targetable{ + addrs.RootModuleInstance.Child("parent", addrs.NoKey), + }, + }) + assertNoErrors(t, diags) + + state, diags = ctx.Apply(plan, m) + if diags.HasErrors() { + t.Fatalf("apply errors: %s", diags.Err()) + } + + checkStateString(t, state, ` +aws_instance.foo: + ID = foo + provider = provider["registry.opentofu.org/hashicorp/aws"] + type = aws_instance + +module.parent: + aws_instance.bar: + ID = abc + provider = provider["registry.opentofu.org/hashicorp/aws"] + type = aws_instance +`) +} + +func TestContext2Apply_excludedWithTaintedInState(t *testing.T) { + p := testProvider("aws") + p.PlanResourceChangeFn = testDiffFn + p.ApplyResourceChangeFn = testApplyFn + m, snap := testModuleWithSnapshot(t, "apply-tainted-targets") + + state := states.NewState() + root := state.EnsureModule(addrs.RootModuleInstance) + root.SetResourceInstanceCurrent( + mustResourceInstanceAddr("aws_instance.ifailedprovisioners").Resource, + &states.ResourceInstanceObjectSrc{ + Status: states.ObjectTainted, + AttrsJSON: []byte(`{"id":"ifailedprovisioners"}`), + }, + mustProviderConfig(`provider["registry.opentofu.org/hashicorp/aws"]`), + ) + + ctx := testContext2(t, &ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("aws"): testProviderFuncFixed(p), + }, + }) + + plan, diags := ctx.Plan(m, state, &PlanOpts{ + Mode: plans.NormalMode, + Excludes: []addrs.Targetable{ + addrs.RootModuleInstance.Resource( + addrs.ManagedResourceMode, "aws_instance", "ifailedprovisioners", + ), + }, + }) + if diags.HasErrors() { + t.Fatalf("err: %s", diags.Err()) + } + + // Write / Read plan to simulate running it through a Plan file + ctxOpts, m, plan, err := contextOptsForPlanViaFile(t, snap, plan) + if err != nil { + t.Fatalf("failed to round-trip through planfile: %s", err) + } + + ctxOpts.Providers = map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("aws"): testProviderFuncFixed(p), + } + + ctx, diags = NewContext(ctxOpts) + if diags.HasErrors() { + t.Fatalf("err: %s", diags.Err()) + } + + s, diags := ctx.Apply(plan, m) + if diags.HasErrors() { + t.Fatalf("err: %s", diags.Err()) + } + + actual := strings.TrimSpace(s.String()) + expected := strings.TrimSpace(` +aws_instance.iambeingadded: + ID = foo + provider = provider["registry.opentofu.org/hashicorp/aws"] + type = aws_instance +aws_instance.ifailedprovisioners: (tainted) + ID = ifailedprovisioners + provider = provider["registry.opentofu.org/hashicorp/aws"] + `) + if actual != expected { + t.Fatalf("expected state: \n%s\ngot: \n%s", expected, actual) + } +} + +func TestContext2Apply_excludedModuleRecursive(t *testing.T) { + m := testModule(t, "apply-targeted-module-recursive") + p := testProvider("aws") + p.PlanResourceChangeFn = testDiffFn + p.ApplyResourceChangeFn = testApplyFn + ctx := testContext2(t, &ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("aws"): testProviderFuncFixed(p), + }, + }) + + plan, diags := ctx.Plan(m, states.NewState(), &PlanOpts{ + Mode: plans.NormalMode, + Excludes: []addrs.Targetable{ + addrs.RootModuleInstance.Child("child", addrs.NoKey), + }, + }) + assertNoErrors(t, diags) + + state, diags := ctx.Apply(plan, m) + if diags.HasErrors() { + t.Fatalf("err: %s", diags.Err()) + } + + mod := state.Module( + addrs.RootModuleInstance.Child("child", addrs.NoKey).Child("subchild", addrs.NoKey), + ) + if mod != nil { + t.Fatalf("subchild module should not exist in the state, but was found!\n\n%#v", state) + } + + checkStateString(t, state, ` + + `) +} diff --git a/internal/tofu/context_apply_test.go b/internal/tofu/context_apply_test.go index 3608fa9dd0..094894b66d 100644 --- a/internal/tofu/context_apply_test.go +++ b/internal/tofu/context_apply_test.go @@ -7149,52 +7149,51 @@ func TestContext2Apply_targetedDestroy(t *testing.T) { m := testModule(t, "destroy-targeted") p := testProvider("aws") p.PlanResourceChangeFn = testDiffFn + p.ApplyResourceChangeFn = testApplyFn - state := states.NewState() - root := state.EnsureModule(addrs.RootModuleInstance) - root.SetResourceInstanceCurrent( - mustResourceInstanceAddr("aws_instance.a").Resource, - &states.ResourceInstanceObjectSrc{ - Status: states.ObjectReady, - AttrsJSON: []byte(`{"id":"bar"}`), - }, - mustProviderConfig(`provider["registry.opentofu.org/hashicorp/aws"]`), - ) - root.SetOutputValue("out", cty.StringVal("bar"), false) + var state *states.State + { + ctx := testContext2(t, &ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("aws"): testProviderFuncFixed(p), + }, + }) - child := state.EnsureModule(addrs.RootModuleInstance.Child("child", addrs.NoKey)) - child.SetResourceInstanceCurrent( - mustResourceInstanceAddr("aws_instance.b").Resource, - &states.ResourceInstanceObjectSrc{ - Status: states.ObjectReady, - AttrsJSON: []byte(`{"id":"i-bcd345"}`), - }, - mustProviderConfig(`provider["registry.opentofu.org/hashicorp/aws"]`), - ) + // First plan and apply a create operation + if diags := ctx.Validate(m); diags.HasErrors() { + t.Fatalf("validate errors: %s", diags.Err()) + } - ctx := testContext2(t, &ContextOpts{ - Providers: map[addrs.Provider]providers.Factory{ - addrs.NewDefaultProvider("aws"): testProviderFuncFixed(p), - }, - }) + plan, diags := ctx.Plan(m, states.NewState(), DefaultPlanOpts) + assertNoErrors(t, diags) - if diags := ctx.Validate(m); diags.HasErrors() { - t.Fatalf("validate errors: %s", diags.Err()) + state, diags = ctx.Apply(plan, m) + if diags.HasErrors() { + t.Fatalf("apply err: %s", diags.Err()) + } } - plan, diags := ctx.Plan(m, state, &PlanOpts{ - Mode: plans.DestroyMode, - Targets: []addrs.Targetable{ - addrs.RootModuleInstance.Resource( - addrs.ManagedResourceMode, "aws_instance", "a", - ), - }, - }) - assertNoErrors(t, diags) + { + ctx := testContext2(t, &ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("aws"): testProviderFuncFixed(p), + }, + }) - state, diags = ctx.Apply(plan, m) - if diags.HasErrors() { - t.Fatalf("diags: %s", diags.Err()) + plan, diags := ctx.Plan(m, state, &PlanOpts{ + Mode: plans.DestroyMode, + Targets: []addrs.Targetable{ + addrs.RootModuleInstance.Resource( + addrs.ManagedResourceMode, "aws_instance", "a", + ), + }, + }) + assertNoErrors(t, diags) + + state, diags = ctx.Apply(plan, m) + if diags.HasErrors() { + t.Fatalf("diags: %s", diags.Err()) + } } mod := state.RootModule() @@ -7223,10 +7222,10 @@ func TestContext2Apply_targetedDestroy(t *testing.T) { t.Fatalf("expected 1 outputs, got: %#v", mod.OutputValues) } - // the module instance should remain + // the module instance should not remain mod = state.Module(addrs.RootModuleInstance.Child("child", addrs.NoKey)) - if len(mod.Resources) != 1 { - t.Fatalf("expected 1 resources, got: %#v", mod.Resources) + if mod != nil { + t.Fatalf("expected child module to not exist in state, but it does") } } @@ -7554,7 +7553,8 @@ func TestContext2Apply_targetedModuleUnrelatedOutputs(t *testing.T) { p.ApplyResourceChangeFn = testApplyFn state := states.NewState() - _ = state.EnsureModule(addrs.RootModuleInstance.Child("child2", addrs.NoKey)) + child1 := state.EnsureModule(addrs.RootModuleInstance.Child("child1", addrs.NoKey)) + child1.SetOutputValue("instance_id", cty.StringVal("something"), false) ctx := testContext2(t, &ContextOpts{ Providers: map[addrs.Provider]providers.Factory{ @@ -7643,6 +7643,7 @@ func TestContext2Apply_targetedResourceOrphanModule(t *testing.T) { m := testModule(t, "apply-targeted-resource-orphan-module") p := testProvider("aws") p.PlanResourceChangeFn = testDiffFn + p.ApplyResourceChangeFn = testApplyFn state := states.NewState() child := state.EnsureModule(addrs.RootModuleInstance.Child("parent", addrs.NoKey)) @@ -7650,7 +7651,7 @@ func TestContext2Apply_targetedResourceOrphanModule(t *testing.T) { mustResourceInstanceAddr("aws_instance.bar").Resource, &states.ResourceInstanceObjectSrc{ Status: states.ObjectReady, - AttrsJSON: []byte(`{"type":"aws_instance"}`), + AttrsJSON: []byte(`{"id":"abc","type":"aws_instance"}`), }, mustProviderConfig(`provider["registry.opentofu.org/hashicorp/aws"]`), ) @@ -7671,9 +7672,24 @@ func TestContext2Apply_targetedResourceOrphanModule(t *testing.T) { }) assertNoErrors(t, diags) - if _, diags := ctx.Apply(plan, m); diags.HasErrors() { + state, diags = ctx.Apply(plan, m) + if diags.HasErrors() { t.Fatalf("apply errors: %s", diags.Err()) + } + + checkStateString(t, state, ` +aws_instance.foo: + ID = foo + provider = provider["registry.opentofu.org/hashicorp/aws"] + type = aws_instance + +module.parent: + aws_instance.bar: + ID = abc + provider = provider["registry.opentofu.org/hashicorp/aws"] + type = aws_instance +`) } func TestContext2Apply_unknownAttribute(t *testing.T) { diff --git a/internal/tofu/context_plan.go b/internal/tofu/context_plan.go index 785eb23775..53801f6f46 100644 --- a/internal/tofu/context_plan.go +++ b/internal/tofu/context_plan.go @@ -67,6 +67,16 @@ type PlanOpts struct { // warnings as part of the planning result. Targets []addrs.Targetable + // If Excludes has a non-zero length then it activates targeted planning + // mode, where OpenTofu will take actions only for resource instances + // that are not mentioned in this set and are not dependent on targets + // mentioned in this set. + // + // Targeted planning mode is intended for exceptional use only, + // and so populating this field will cause OpenTofu to generate extra + // warnings as part of the planning result. + Excludes []addrs.Targetable + // ForceReplace is a set of resource instance addresses whose corresponding // objects should be forced planned for replacement if the provider's // plan would otherwise have been to either update the object in-place or @@ -186,13 +196,13 @@ func (c *Context) Plan(config *configs.Config, prevRunState *states.State, opts varDiags := checkInputVariables(config.Module.Variables, opts.SetVariables) diags = diags.Append(varDiags) - if len(opts.Targets) > 0 { + if len(opts.Targets) > 0 || len(opts.Excludes) > 0 { diags = diags.Append(tfdiags.Sourceless( tfdiags.Warning, "Resource targeting is in effect", - `You are creating a plan with the -target option, which means that the result of this plan may not represent all of the changes requested by the current configuration. + `You are creating a plan with either the -target option or the -exclude option, which means that the result of this plan may not represent all of the changes requested by the current configuration. -The -target option is not for routine use, and is provided only for exceptional situations such as recovering from errors or mistakes, or when OpenTofu specifically suggests to use it as part of an error message.`, +The -target and -exclude options are not for routine use, and are provided only for exceptional situations such as recovering from errors or mistakes, or when OpenTofu specifically suggests to use it as part of an error message.`, )) } @@ -241,6 +251,7 @@ The -target option is not for routine use, and is provided only for exceptional if plan != nil { plan.VariableValues = varVals plan.TargetAddrs = opts.Targets + plan.ExcludeAddrs = opts.Excludes } else if !diags.HasErrors() { panic("nil plan but no errors") } @@ -460,7 +471,7 @@ func (c *Context) destroyPlan(config *configs.Config, prevRunState *states.State return destroyPlan, diags } -func (c *Context) prePlanFindAndApplyMoves(config *configs.Config, prevRunState *states.State, targets []addrs.Targetable) ([]refactoring.MoveStatement, refactoring.MoveResults) { +func (c *Context) prePlanFindAndApplyMoves(config *configs.Config, prevRunState *states.State) ([]refactoring.MoveStatement, refactoring.MoveResults) { explicitMoveStmts := refactoring.FindMoveStatements(config) implicitMoveStmts := refactoring.ImpliedMoveStatements(config, prevRunState, explicitMoveStmts) var moveStmts []refactoring.MoveStatement @@ -473,11 +484,17 @@ func (c *Context) prePlanFindAndApplyMoves(config *configs.Config, prevRunState return moveStmts, moveResults } -func (c *Context) prePlanVerifyTargetedMoves(moveResults refactoring.MoveResults, targets []addrs.Targetable) tfdiags.Diagnostics { - if len(targets) < 1 { - return nil // the following only matters when targeting +func (c *Context) prePlanVerifyTargetedMoves(moveResults refactoring.MoveResults, targets []addrs.Targetable, excludes []addrs.Targetable) tfdiags.Diagnostics { + if len(targets) > 0 { + return c.prePlanVerifyMovesWithTargetFlag(moveResults, targets) } + if len(excludes) > 0 { + return c.prePlanVerifyMovesWithExcludeFlag(moveResults, excludes) + } + return nil +} +func (c *Context) prePlanVerifyMovesWithTargetFlag(moveResults refactoring.MoveResults, targets []addrs.Targetable) tfdiags.Diagnostics { var diags tfdiags.Diagnostics var excluded []addrs.AbsResourceInstance @@ -541,6 +558,70 @@ func (c *Context) prePlanVerifyTargetedMoves(moveResults refactoring.MoveResults return diags } +func (c *Context) prePlanVerifyMovesWithExcludeFlag(moveResults refactoring.MoveResults, excludes []addrs.Targetable) tfdiags.Diagnostics { + var diags tfdiags.Diagnostics + + var excluded []addrs.AbsResourceInstance + for _, result := range moveResults.Changes.Values() { + fromExcluded := false + toExcluded := false + for _, excludeAddr := range excludes { + if excludeAddr.TargetContains(result.From) { + fromExcluded = true + } + if excludeAddr.TargetContains(result.To) { + toExcluded = true + } + } + if fromExcluded { + excluded = append(excluded, result.From) + } + if toExcluded { + excluded = append(excluded, result.To) + } + } + if len(excluded) > 0 { + sort.Slice(excluded, func(i, j int) bool { + return excluded[i].Less(excluded[j]) + }) + + var listBuf strings.Builder + var prevResourceAddr addrs.AbsResource + for _, instAddr := range excluded { + // Targeting generally ends up selecting whole resources rather + // than individual instances, because we don't factor in + // individual instances until DynamicExpand, so we're going to + // always show whole resource addresses here, excluding any + // instance keys. (This also neatly avoids dealing with the + // different quoting styles required for string instance keys + // on different shells, which is handy.) + // + // To avoid showing duplicates when we have multiple instances + // of the same resource, we'll remember the most recent + // resource we rendered in prevResource, which is sufficient + // because we sorted the list of instance addresses above, and + // our sort order always groups together instances of the same + // resource. + resourceAddr := instAddr.ContainingResource() + if resourceAddr.Equal(prevResourceAddr) { + continue + } + fmt.Fprintf(&listBuf, "\n -exclude=%q", resourceAddr.String()) + prevResourceAddr = resourceAddr + } + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Moved resource instances excluded by targeting", + fmt.Sprintf( + "Resource instances in your current state have moved to new addresses in the latest configuration. OpenTofu must include those resource instances while planning in order to ensure a correct result, but your -exclude=... options exclude some of those resource instances.\n\nTo create a valid plan, either remove your -exclude=... options altogether or just specifically remove the following options:%s\n\nNote that removing these options may include further additional resource instances in your plan, in order to respect object dependencies.", + listBuf.String(), + ), + )) + } + + return diags +} + func (c *Context) postPlanValidateMoves(config *configs.Config, stmts []refactoring.MoveStatement, allInsts instances.Set) tfdiags.Diagnostics { return refactoring.ValidateMoves(stmts, config, allInsts) } @@ -662,11 +743,11 @@ func (c *Context) planWalk(config *configs.Config, prevRunState *states.State, o log.Printf("[DEBUG] Building and walking plan graph for %s", opts.Mode) prevRunState = prevRunState.DeepCopy() // don't modify the caller's object when we process the moves - moveStmts, moveResults := c.prePlanFindAndApplyMoves(config, prevRunState, opts.Targets) + moveStmts, moveResults := c.prePlanFindAndApplyMoves(config, prevRunState) // If resource targeting is in effect then it might conflict with the // move result. - diags = diags.Append(c.prePlanVerifyTargetedMoves(moveResults, opts.Targets)) + diags = diags.Append(c.prePlanVerifyTargetedMoves(moveResults, opts.Targets, opts.Excludes)) if diags.HasErrors() { // We'll return early here, because if we have any moved resource // instances excluded by targeting then planning is likely to encounter @@ -765,6 +846,7 @@ func (c *Context) planGraph(config *configs.Config, prevRunState *states.State, RootVariableValues: opts.SetVariables, Plugins: c.plugins, Targets: opts.Targets, + Excludes: opts.Excludes, ForceReplace: opts.ForceReplace, skipRefresh: opts.SkipRefresh, preDestroyRefresh: opts.PreDestroyRefresh, @@ -778,16 +860,16 @@ func (c *Context) planGraph(config *configs.Config, prevRunState *states.State, return graph, walkPlan, diags case plans.RefreshOnlyMode: graph, diags := (&PlanGraphBuilder{ - Config: config, - State: prevRunState, - RootVariableValues: opts.SetVariables, - Plugins: c.plugins, - Targets: opts.Targets, - skipRefresh: opts.SkipRefresh, - skipPlanChanges: true, // this activates "refresh only" mode. - Operation: walkPlan, - ExternalReferences: opts.ExternalReferences, - ProviderFunctionTracker: providerFunctionTracker, + Config: config, + State: prevRunState, + RootVariableValues: opts.SetVariables, + Plugins: c.plugins, + Targets: opts.Targets, + Excludes: opts.Excludes, + skipRefresh: opts.SkipRefresh, + skipPlanChanges: true, // this activates "refresh only" mode. + Operation: walkPlan, + ExternalReferences: opts.ExternalReferences, }).Build(addrs.RootModuleInstance) return graph, walkPlan, diags case plans.DestroyMode: @@ -797,6 +879,7 @@ func (c *Context) planGraph(config *configs.Config, prevRunState *states.State, RootVariableValues: opts.SetVariables, Plugins: c.plugins, Targets: opts.Targets, + Excludes: opts.Excludes, skipRefresh: opts.SkipRefresh, Operation: walkPlanDestroy, ProviderFunctionTracker: providerFunctionTracker, diff --git a/internal/tofu/context_plan2_test.go b/internal/tofu/context_plan2_test.go index fc00076369..39818fbd03 100644 --- a/internal/tofu/context_plan2_test.go +++ b/internal/tofu/context_plan2_test.go @@ -1574,9 +1574,9 @@ func TestContext2Plan_movedResourceUntargeted(t *testing.T) { tfdiags.Sourceless( tfdiags.Warning, "Resource targeting is in effect", - `You are creating a plan with the -target option, which means that the result of this plan may not represent all of the changes requested by the current configuration. + `You are creating a plan with either the -target option or the -exclude option, which means that the result of this plan may not represent all of the changes requested by the current configuration. -The -target option is not for routine use, and is provided only for exceptional situations such as recovering from errors or mistakes, or when OpenTofu specifically suggests to use it as part of an error message.`, +The -target and -exclude options are not for routine use, and are provided only for exceptional situations such as recovering from errors or mistakes, or when OpenTofu specifically suggests to use it as part of an error message.`, ), tfdiags.Sourceless( tfdiags.Error, @@ -1614,9 +1614,9 @@ Note that adding these options may include further additional resource instances tfdiags.Sourceless( tfdiags.Warning, "Resource targeting is in effect", - `You are creating a plan with the -target option, which means that the result of this plan may not represent all of the changes requested by the current configuration. + `You are creating a plan with either the -target option or the -exclude option, which means that the result of this plan may not represent all of the changes requested by the current configuration. -The -target option is not for routine use, and is provided only for exceptional situations such as recovering from errors or mistakes, or when OpenTofu specifically suggests to use it as part of an error message.`, +The -target and -exclude options are not for routine use, and are provided only for exceptional situations such as recovering from errors or mistakes, or when OpenTofu specifically suggests to use it as part of an error message.`, ), tfdiags.Sourceless( tfdiags.Error, @@ -1654,9 +1654,9 @@ Note that adding these options may include further additional resource instances tfdiags.Sourceless( tfdiags.Warning, "Resource targeting is in effect", - `You are creating a plan with the -target option, which means that the result of this plan may not represent all of the changes requested by the current configuration. + `You are creating a plan with either the -target option or the -exclude option, which means that the result of this plan may not represent all of the changes requested by the current configuration. -The -target option is not for routine use, and is provided only for exceptional situations such as recovering from errors or mistakes, or when OpenTofu specifically suggests to use it as part of an error message.`, +The -target and -exclude options are not for routine use, and are provided only for exceptional situations such as recovering from errors or mistakes, or when OpenTofu specifically suggests to use it as part of an error message.`, ), tfdiags.Sourceless( tfdiags.Error, @@ -1702,9 +1702,167 @@ Note that adding these options may include further additional resource instances tfdiags.Sourceless( tfdiags.Warning, "Resource targeting is in effect", - `You are creating a plan with the -target option, which means that the result of this plan may not represent all of the changes requested by the current configuration. + `You are creating a plan with either the -target option or the -exclude option, which means that the result of this plan may not represent all of the changes requested by the current configuration. -The -target option is not for routine use, and is provided only for exceptional situations such as recovering from errors or mistakes, or when OpenTofu specifically suggests to use it as part of an error message.`, +The -target and -exclude options are not for routine use, and are provided only for exceptional situations such as recovering from errors or mistakes, or when OpenTofu specifically suggests to use it as part of an error message.`, + ), + // ...but now we have no error about test_object.a + }.ForRPC() + + if diff := cmp.Diff(wantDiags, gotDiags); diff != "" { + t.Errorf("wrong diagnostics\n%s", diff) + } + }) + t.Run("excluding instance A", func(t *testing.T) { + _, diags := ctx.Plan(m, state, &PlanOpts{ + Mode: plans.NormalMode, + Excludes: []addrs.Targetable{ + // NOTE: addrA isn't excluded, and it's pending move to addrB + // and so this plan request is invalid. + addrA, + }, + }) + diags.Sort() + + // We're semi-abusing "ForRPC" here just to get diagnostics that are + // more easily comparable than the various different diagnostics types + // tfdiags uses internally. The RPC-friendly diagnostics are also + // comparison-friendly, by discarding all of the dynamic type information. + gotDiags := diags.ForRPC() + wantDiags := tfdiags.Diagnostics{ + tfdiags.Sourceless( + tfdiags.Warning, + "Resource targeting is in effect", + `You are creating a plan with either the -target option or the -exclude option, which means that the result of this plan may not represent all of the changes requested by the current configuration. + +The -target and -exclude options are not for routine use, and are provided only for exceptional situations such as recovering from errors or mistakes, or when OpenTofu specifically suggests to use it as part of an error message.`, + ), + tfdiags.Sourceless( + tfdiags.Error, + "Moved resource instances excluded by targeting", + `Resource instances in your current state have moved to new addresses in the latest configuration. OpenTofu must include those resource instances while planning in order to ensure a correct result, but your -exclude=... options exclude some of those resource instances. + +To create a valid plan, either remove your -exclude=... options altogether or just specifically remove the following options: + -exclude="test_object.a" + +Note that removing these options may include further additional resource instances in your plan, in order to respect object dependencies.`, + ), + }.ForRPC() + + if diff := cmp.Diff(wantDiags, gotDiags); diff != "" { + t.Errorf("wrong diagnostics\n%s", diff) + } + }) + t.Run("excluding instance B", func(t *testing.T) { + _, diags := ctx.Plan(m, state, &PlanOpts{ + Mode: plans.NormalMode, + Excludes: []addrs.Targetable{ + addrB, + // NOTE: addrB is excluded here, and it's pending move from + // addrA and so this plan request is invalid. + }, + }) + diags.Sort() + + // We're semi-abusing "ForRPC" here just to get diagnostics that are + // more easily comparable than the various different diagnostics types + // tfdiags uses internally. The RPC-friendly diagnostics are also + // comparison-friendly, by discarding all of the dynamic type information. + gotDiags := diags.ForRPC() + wantDiags := tfdiags.Diagnostics{ + tfdiags.Sourceless( + tfdiags.Warning, + "Resource targeting is in effect", + `You are creating a plan with either the -target option or the -exclude option, which means that the result of this plan may not represent all of the changes requested by the current configuration. + +The -target and -exclude options are not for routine use, and are provided only for exceptional situations such as recovering from errors or mistakes, or when OpenTofu specifically suggests to use it as part of an error message.`, + ), + tfdiags.Sourceless( + tfdiags.Error, + "Moved resource instances excluded by targeting", + `Resource instances in your current state have moved to new addresses in the latest configuration. OpenTofu must include those resource instances while planning in order to ensure a correct result, but your -exclude=... options exclude some of those resource instances. + +To create a valid plan, either remove your -exclude=... options altogether or just specifically remove the following options: + -exclude="test_object.b" + +Note that removing these options may include further additional resource instances in your plan, in order to respect object dependencies.`, + ), + }.ForRPC() + + if diff := cmp.Diff(wantDiags, gotDiags); diff != "" { + t.Errorf("wrong diagnostics\n%s", diff) + } + }) + t.Run("excluding both addresses", func(t *testing.T) { + _, diags := ctx.Plan(m, state, &PlanOpts{ + Mode: plans.NormalMode, + Excludes: []addrs.Targetable{ + // NOTE: both addrA nor addrB are excluded here, but there's + // a pending move between them and so this is invalid. + addrA, + addrB, + }, + }) + diags.Sort() + + // We're semi-abusing "ForRPC" here just to get diagnostics that are + // more easily comparable than the various different diagnostics types + // tfdiags uses internally. The RPC-friendly diagnostics are also + // comparison-friendly, by discarding all of the dynamic type information. + gotDiags := diags.ForRPC() + wantDiags := tfdiags.Diagnostics{ + tfdiags.Sourceless( + tfdiags.Warning, + "Resource targeting is in effect", + `You are creating a plan with either the -target option or the -exclude option, which means that the result of this plan may not represent all of the changes requested by the current configuration. + +The -target and -exclude options are not for routine use, and are provided only for exceptional situations such as recovering from errors or mistakes, or when OpenTofu specifically suggests to use it as part of an error message.`, + ), + tfdiags.Sourceless( + tfdiags.Error, + "Moved resource instances excluded by targeting", + `Resource instances in your current state have moved to new addresses in the latest configuration. OpenTofu must include those resource instances while planning in order to ensure a correct result, but your -exclude=... options exclude some of those resource instances. + +To create a valid plan, either remove your -exclude=... options altogether or just specifically remove the following options: + -exclude="test_object.a" + -exclude="test_object.b" + +Note that removing these options may include further additional resource instances in your plan, in order to respect object dependencies.`, + ), + }.ForRPC() + + if diff := cmp.Diff(wantDiags, gotDiags); diff != "" { + t.Errorf("wrong diagnostics\n%s", diff) + } + }) + t.Run("without excluding either instance", func(t *testing.T) { + // The error messages in the other subtests above suggest removing + // addresses to the set of excludes. This additional test makes sure that + // following that advice actually leads to a valid result. + _, diags := ctx.Plan(m, state, &PlanOpts{ + Mode: plans.NormalMode, + Excludes: []addrs.Targetable{ + mustResourceInstanceAddr("test_object.unrelated"), + // This time we're excluding neither address, + // to get the same effect an end-user would get if following + // the advice in our error message in the other subtests. + }, + }) + diags.Sort() + + // We're semi-abusing "ForRPC" here just to get diagnostics that are + // more easily comparable than the various different diagnostics types + // tfdiags uses internally. The RPC-friendly diagnostics are also + // comparison-friendly, by discarding all of the dynamic type information. + gotDiags := diags.ForRPC() + wantDiags := tfdiags.Diagnostics{ + // Still get the warning about the -target option... + tfdiags.Sourceless( + tfdiags.Warning, + "Resource targeting is in effect", + `You are creating a plan with either the -target option or the -exclude option, which means that the result of this plan may not represent all of the changes requested by the current configuration. + +The -target and -exclude options are not for routine use, and are provided only for exceptional situations such as recovering from errors or mistakes, or when OpenTofu specifically suggests to use it as part of an error message.`, ), // ...but now we have no error about test_object.a }.ForRPC() @@ -1759,7 +1917,7 @@ resource "test_object" "b" { }, }) - _, diags := ctx.Plan(m, state, &PlanOpts{ + plan, diags := ctx.Plan(m, state, &PlanOpts{ Mode: plans.NormalMode, Targets: []addrs.Targetable{ addrA, @@ -1767,6 +1925,79 @@ resource "test_object" "b" { }) // assertNoErrors(t, diags) + + if len(plan.Changes.Resources) != 1 { + t.Fatalf("expected 1 resource change, but got %d", len(plan.Changes.Resources)) + } + + instPlan := plan.Changes.ResourceInstance(addrA) + if instPlan == nil { + t.Fatalf("expected plan for %s; but got none", addrA) + } +} + +func TestContext2Plan_excludedResourceSchemaChange(t *testing.T) { + // an excluded resource which requires a schema migration should not + // block planning due external changes in the plan. + addrA := mustResourceInstanceAddr("test_object.a") + addrB := mustResourceInstanceAddr("test_object.b") + m := testModuleInline(t, map[string]string{ + "main.tf": ` +resource "test_object" "a" { +} +resource "test_object" "b" { +}`, + }) + + state := states.BuildState(func(s *states.SyncState) { + s.SetResourceInstanceCurrent(addrA, &states.ResourceInstanceObjectSrc{ + AttrsJSON: []byte(`{}`), + Status: states.ObjectReady, + }, mustProviderConfig(`provider["registry.opentofu.org/hashicorp/test"]`)) + s.SetResourceInstanceCurrent(addrB, &states.ResourceInstanceObjectSrc{ + // old_list is no longer in the schema + AttrsJSON: []byte(`{"old_list":["used to be","a list here"]}`), + Status: states.ObjectReady, + }, mustProviderConfig(`provider["registry.opentofu.org/hashicorp/test"]`)) + }) + + p := simpleMockProvider() + + // external changes trigger a "drift report", but because test_object.b was + // excluded, the state was not fixed to match the schema and cannot be + // deocded for the report. + p.ReadResourceFn = func(req providers.ReadResourceRequest) providers.ReadResourceResponse { + var resp providers.ReadResourceResponse + obj := req.PriorState.AsValueMap() + // test_number changed externally + obj["test_number"] = cty.NumberIntVal(1) + resp.NewState = cty.ObjectVal(obj) + return resp + } + + ctx := testContext2(t, &ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("test"): testProviderFuncFixed(p), + }, + }) + + plan, diags := ctx.Plan(m, state, &PlanOpts{ + Mode: plans.NormalMode, + Excludes: []addrs.Targetable{ + addrB, + }, + }) + // + assertNoErrors(t, diags) + + if len(plan.Changes.Resources) != 1 { + t.Fatalf("expected 1 resource change, but got %d", len(plan.Changes.Resources)) + } + + instPlan := plan.Changes.ResourceInstance(addrA) + if instPlan == nil { + t.Fatalf("expected plan for %s; but got none", addrA) + } } func TestContext2Plan_movedResourceRefreshOnly(t *testing.T) { diff --git a/internal/tofu/context_plan_test.go b/internal/tofu/context_plan_test.go index 73b72ccff8..dabf64bf41 100644 --- a/internal/tofu/context_plan_test.go +++ b/internal/tofu/context_plan_test.go @@ -4068,6 +4068,57 @@ func TestContext2Plan_targeted(t *testing.T) { } } +// All exclude flag tests in this file are inspired by a counterpart target flag test +// Usually that test exists right before the exclude flag test + +func TestContext2Plan_excluded(t *testing.T) { + m := testModule(t, "plan-targeted") + p := testProvider("aws") + p.PlanResourceChangeFn = testDiffFn + ctx := testContext2(t, &ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("aws"): testProviderFuncFixed(p), + }, + }) + + plan, diags := ctx.Plan(m, states.NewState(), &PlanOpts{ + Mode: plans.NormalMode, + Excludes: []addrs.Targetable{ + addrs.RootModuleInstance.Resource(addrs.ManagedResourceMode, "aws_instance", "foo"), + }, + }) + if diags.HasErrors() { + t.Fatalf("unexpected errors: %s", diags.Err()) + } + schema := p.GetProviderSchemaResponse.ResourceTypes["aws_instance"].Block + ty := schema.ImpliedType() + + if len(plan.Changes.Resources) != 1 { + t.Fatal("expected 1 changes, got", len(plan.Changes.Resources)) + } + + for _, res := range plan.Changes.Resources { + ric, err := res.Decode(ty) + if err != nil { + t.Fatal(err) + } + + switch i := ric.Addr.String(); i { + case "module.mod[0].aws_instance.foo": + if res.Action != plans.Create { + t.Fatalf("resource %s should be created", i) + } + checkVals(t, objectVal(t, schema, map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "num": cty.NumberIntVal(2), + "type": cty.UnknownVal(cty.String), + }), ric.After) + default: + t.Fatal("unknown instance:", i) + } + } +} + // Test that targeting a module properly plans any inputs that depend // on another module. func TestContext2Plan_targetedCrossModule(t *testing.T) { @@ -4123,6 +4174,33 @@ func TestContext2Plan_targetedCrossModule(t *testing.T) { } } +// Test that excluding a module properly plans and excludes any +// dependent modules. +func TestContext2Plan_excludedCrossModule(t *testing.T) { + m := testModule(t, "plan-targeted-cross-module") + p := testProvider("aws") + p.PlanResourceChangeFn = testDiffFn + ctx := testContext2(t, &ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("aws"): testProviderFuncFixed(p), + }, + }) + + plan, diags := ctx.Plan(m, states.NewState(), &PlanOpts{ + Mode: plans.NormalMode, + Excludes: []addrs.Targetable{ + addrs.RootModuleInstance.Child("A", addrs.NoKey), + }, + }) + if diags.HasErrors() { + t.Fatalf("unexpected errors: %s", diags.Err()) + } + + if len(plan.Changes.Resources) != 0 { + t.Fatal("expected 0 changes, got", len(plan.Changes.Resources)) + } +} + func TestContext2Plan_targetedModuleWithProvider(t *testing.T) { m := testModule(t, "plan-targeted-module-with-provider") p := testProvider("null") @@ -4173,6 +4251,56 @@ func TestContext2Plan_targetedModuleWithProvider(t *testing.T) { } } +func TestContext2Plan_excludedModuleWithProvider(t *testing.T) { + m := testModule(t, "plan-targeted-module-with-provider") + p := testProvider("null") + p.GetProviderSchemaResponse = getProviderSchemaResponseFromProviderSchema(&ProviderSchema{ + Provider: &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "key": {Type: cty.String, Optional: true}, + }, + }, + ResourceTypes: map[string]*configschema.Block{ + "null_resource": { + Attributes: map[string]*configschema.Attribute{}, + }, + }, + }) + + ctx := testContext2(t, &ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("null"): testProviderFuncFixed(p), + }, + }) + + plan, diags := ctx.Plan(m, states.NewState(), &PlanOpts{ + Mode: plans.NormalMode, + Excludes: []addrs.Targetable{ + addrs.RootModuleInstance.Child("child1", addrs.NoKey), + }, + }) + if diags.HasErrors() { + t.Fatalf("unexpected errors: %s", diags.Err()) + } + + schema := p.GetProviderSchemaResponse.ResourceTypes["null_resource"].Block + ty := schema.ImpliedType() + + if len(plan.Changes.Resources) != 1 { + t.Fatal("expected 1 changes, got", len(plan.Changes.Resources)) + } + + res := plan.Changes.Resources[0] + ric, err := res.Decode(ty) + if err != nil { + t.Fatal(err) + } + + if ric.Addr.String() != "module.child2.null_resource.foo" { + t.Fatalf("unexpected resource: %s", ric.Addr) + } +} + func TestContext2Plan_targetedOrphan(t *testing.T) { m := testModule(t, "plan-targeted-orphan") p := testProvider("aws") @@ -4238,6 +4366,71 @@ func TestContext2Plan_targetedOrphan(t *testing.T) { } } +func TestContext2Plan_excludedOrphan(t *testing.T) { + m := testModule(t, "plan-targeted-orphan") + p := testProvider("aws") + + state := states.NewState() + root := state.EnsureModule(addrs.RootModuleInstance) + root.SetResourceInstanceCurrent( + mustResourceInstanceAddr("aws_instance.orphan").Resource, + &states.ResourceInstanceObjectSrc{ + Status: states.ObjectReady, + AttrsJSON: []byte(`{"id":"i-789xyz"}`), + }, + mustProviderConfig(`provider["registry.opentofu.org/hashicorp/aws"]`), + ) + root.SetResourceInstanceCurrent( + mustResourceInstanceAddr("aws_instance.nottargeted").Resource, + &states.ResourceInstanceObjectSrc{ + Status: states.ObjectReady, + AttrsJSON: []byte(`{"id":"i-abc123"}`), + }, + mustProviderConfig(`provider["registry.opentofu.org/hashicorp/aws"]`), + ) + + ctx := testContext2(t, &ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("aws"): testProviderFuncFixed(p), + }, + }) + + plan, diags := ctx.Plan(m, state, &PlanOpts{ + Mode: plans.DestroyMode, + Excludes: []addrs.Targetable{ + addrs.RootModuleInstance.Resource( + addrs.ManagedResourceMode, "aws_instance", "nottargeted", + ), + }, + }) + if diags.HasErrors() { + t.Fatalf("unexpected errors: %s", diags.Err()) + } + + schema := p.GetProviderSchemaResponse.ResourceTypes["aws_instance"].Block + ty := schema.ImpliedType() + + if len(plan.Changes.Resources) != 1 { + t.Fatal("expected 1 changes, got", len(plan.Changes.Resources)) + } + + for _, res := range plan.Changes.Resources { + ric, err := res.Decode(ty) + if err != nil { + t.Fatal(err) + } + + switch i := ric.Addr.String(); i { + case "aws_instance.orphan": + if res.Action != plans.Delete { + t.Fatalf("resource %s should be destroyed", ric.Addr) + } + default: + t.Fatal("unknown instance:", i) + } + } +} + // https://github.com/hashicorp/terraform/issues/2538 func TestContext2Plan_targetedModuleOrphan(t *testing.T) { m := testModule(t, "plan-targeted-module-orphan") @@ -4301,6 +4494,68 @@ func TestContext2Plan_targetedModuleOrphan(t *testing.T) { } } +func TestContext2Plan_excludedModuleOrphan(t *testing.T) { + m := testModule(t, "plan-targeted-module-orphan") + p := testProvider("aws") + + state := states.NewState() + child := state.EnsureModule(addrs.RootModuleInstance.Child("child", addrs.NoKey)) + child.SetResourceInstanceCurrent( + mustResourceInstanceAddr("aws_instance.orphan").Resource, + &states.ResourceInstanceObjectSrc{ + Status: states.ObjectReady, + AttrsJSON: []byte(`{"id":"i-789xyz"}`), + }, + mustProviderConfig(`provider["registry.opentofu.org/hashicorp/aws"]`), + ) + child.SetResourceInstanceCurrent( + mustResourceInstanceAddr("aws_instance.nottargeted").Resource, + &states.ResourceInstanceObjectSrc{ + Status: states.ObjectReady, + AttrsJSON: []byte(`{"id":"i-abc123"}`), + }, + mustProviderConfig(`provider["registry.opentofu.org/hashicorp/aws"]`), + ) + + ctx := testContext2(t, &ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("aws"): testProviderFuncFixed(p), + }, + }) + + plan, diags := ctx.Plan(m, state, &PlanOpts{ + Mode: plans.DestroyMode, + Excludes: []addrs.Targetable{ + addrs.RootModuleInstance.Child("child", addrs.NoKey).Resource( + addrs.ManagedResourceMode, "aws_instance", "nottargeted", + ), + }, + }) + if diags.HasErrors() { + t.Fatalf("unexpected errors: %s", diags.Err()) + } + + schema := p.GetProviderSchemaResponse.ResourceTypes["aws_instance"].Block + ty := schema.ImpliedType() + + if len(plan.Changes.Resources) != 1 { + t.Fatal("expected 1 changes, got", len(plan.Changes.Resources)) + } + + res := plan.Changes.Resources[0] + ric, err := res.Decode(ty) + if err != nil { + t.Fatal(err) + } + + if ric.Addr.String() != "module.child.aws_instance.orphan" { + t.Fatalf("unexpected resource :%s", ric.Addr) + } + if res.Action != plans.Delete { + t.Fatalf("resource %s should be deleted", ric.Addr) + } +} + func TestContext2Plan_targetedModuleUntargetedVariable(t *testing.T) { m := testModule(t, "plan-targeted-module-untargeted-variable") p := testProvider("aws") @@ -4356,9 +4611,64 @@ func TestContext2Plan_targetedModuleUntargetedVariable(t *testing.T) { } } +func TestContext2Plan_excludedModuleUntargetedVariable(t *testing.T) { + m := testModule(t, "plan-targeted-module-untargeted-variable") + p := testProvider("aws") + p.PlanResourceChangeFn = testDiffFn + ctx := testContext2(t, &ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("aws"): testProviderFuncFixed(p), + }, + }) + + plan, diags := ctx.Plan(m, states.NewState(), &PlanOpts{ + Excludes: []addrs.Targetable{ + // Exclude green instance, which should also exclude the dependent green module + addrs.RootModuleInstance.Resource( + addrs.ManagedResourceMode, "aws_instance", "green", + ), + }, + }) + if diags.HasErrors() { + t.Fatalf("unexpected errors: %s", diags.Err()) + } + + schema := p.GetProviderSchemaResponse.ResourceTypes["aws_instance"].Block + ty := schema.ImpliedType() + + if len(plan.Changes.Resources) != 2 { + t.Fatal("expected 2 changes, got", len(plan.Changes.Resources)) + } + + for _, res := range plan.Changes.Resources { + ric, err := res.Decode(ty) + if err != nil { + t.Fatal(err) + } + if res.Action != plans.Create { + t.Fatalf("resource %s should be created", ric.Addr) + } + switch i := ric.Addr.String(); i { + case "aws_instance.blue": + checkVals(t, objectVal(t, schema, map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "type": cty.UnknownVal(cty.String), + }), ric.After) + case "module.blue_mod.aws_instance.mod": + checkVals(t, objectVal(t, schema, map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "value": cty.UnknownVal(cty.String), + "type": cty.UnknownVal(cty.String), + }), ric.After) + default: + t.Fatal("unknown instance:", i) + } + } +} + // ensure that outputs missing references due to targeting are removed from // the graph. -func TestContext2Plan_outputContainsTargetedResource(t *testing.T) { +func TestContext2Plan_outputContainsUntargetedResource(t *testing.T) { m := testModule(t, "plan-untargeted-resource-output") p := testProvider("aws") ctx := testContext2(t, &ContextOpts{ @@ -4367,13 +4677,14 @@ func TestContext2Plan_outputContainsTargetedResource(t *testing.T) { }, }) - _, diags := ctx.Plan(m, states.NewState(), &PlanOpts{ + plan, diags := ctx.Plan(m, states.NewState(), &PlanOpts{ Targets: []addrs.Targetable{ addrs.RootModuleInstance.Child("mod", addrs.NoKey).Resource( addrs.ManagedResourceMode, "aws_instance", "a", ), }, }) + if diags.HasErrors() { t.Fatalf("err: %s", diags) } @@ -4386,6 +4697,77 @@ func TestContext2Plan_outputContainsTargetedResource(t *testing.T) { if got, want := diags[0].Description().Summary, "Resource targeting is in effect"; got != want { t.Errorf("wrong diagnostic summary %#v; want %#v", got, want) } + + if len(plan.Changes.Outputs) != 0 { + t.Fatalf("expected 0 output changes, but got %d", len(plan.Changes.Outputs)) + } + + schema := p.GetProviderSchemaResponse.ResourceTypes["aws_instance"].Block + ty := schema.ImpliedType() + + res := plan.Changes.Resources[0] + ric, err := res.Decode(ty) + if err != nil { + t.Fatal(err) + } + + if ric.Addr.String() != "module.mod.aws_instance.a[0]" { + t.Fatalf("unexpected resource :%s", ric.Addr) + } + if res.Action != plans.Create { + t.Fatalf("resource %s should be deleted", ric.Addr) + } +} + +func TestContext2Plan_outputContainsExcludedResource(t *testing.T) { + m := testModule(t, "plan-untargeted-resource-output") + p := testProvider("aws") + ctx := testContext2(t, &ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("aws"): testProviderFuncFixed(p), + }, + }) + + plan, diags := ctx.Plan(m, states.NewState(), &PlanOpts{ + Excludes: []addrs.Targetable{ + addrs.RootModuleInstance.Child("mod", addrs.NoKey).Resource( + addrs.ManagedResourceMode, "aws_instance", "b", + ), + }, + }) + + if diags.HasErrors() { + t.Fatalf("err: %s", diags) + } + if len(diags) != 1 { + t.Fatalf("got %d diagnostics; want 1", diags) + } + if got, want := diags[0].Severity(), tfdiags.Warning; got != want { + t.Errorf("wrong diagnostic severity %#v; want %#v", got, want) + } + if got, want := diags[0].Description().Summary, "Resource targeting is in effect"; got != want { + t.Errorf("wrong diagnostic summary %#v; want %#v", got, want) + } + + if len(plan.Changes.Outputs) != 0 { + t.Fatalf("expected 0 output changes, but got %d", len(plan.Changes.Outputs)) + } + + schema := p.GetProviderSchemaResponse.ResourceTypes["aws_instance"].Block + ty := schema.ImpliedType() + + res := plan.Changes.Resources[0] + ric, err := res.Decode(ty) + if err != nil { + t.Fatal(err) + } + + if ric.Addr.String() != "module.mod.aws_instance.a[0]" { + t.Fatalf("unexpected resource :%s", ric.Addr) + } + if res.Action != plans.Create { + t.Fatalf("resource %s should be deleted", ric.Addr) + } } // https://github.com/hashicorp/terraform/issues/4515 @@ -4442,6 +4824,60 @@ func TestContext2Plan_targetedOverTen(t *testing.T) { } } +// https://github.com/hashicorp/terraform/issues/4515 - Making sure it doesn't happen with exclude flag +func TestContext2Plan_excludedOverTen(t *testing.T) { + m := testModule(t, "plan-targeted-over-ten") + p := testProvider("aws") + p.PlanResourceChangeFn = testDiffFn + + state := states.NewState() + root := state.EnsureModule(addrs.RootModuleInstance) + for i := 0; i < 13; i++ { + key := fmt.Sprintf("aws_instance.foo[%d]", i) + id := fmt.Sprintf("i-abc%d", i) + attrs := fmt.Sprintf(`{"id":"%s","type":"aws_instance"}`, id) + + root.SetResourceInstanceCurrent( + mustResourceInstanceAddr(key).Resource, + &states.ResourceInstanceObjectSrc{ + Status: states.ObjectReady, + AttrsJSON: []byte(attrs), + }, + mustProviderConfig(`provider["registry.opentofu.org/hashicorp/aws"]`), + ) + } + + ctx := testContext2(t, &ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("aws"): testProviderFuncFixed(p), + }, + }) + + plan, diags := ctx.Plan(m, state, &PlanOpts{ + Excludes: []addrs.Targetable{ + addrs.RootModuleInstance.ResourceInstance( + addrs.ManagedResourceMode, "aws_instance", "foo", addrs.IntKey(1), + ), + }, + }) + if diags.HasErrors() { + t.Fatalf("unexpected errors: %s", diags.Err()) + } + + schema := p.GetProviderSchemaResponse.ResourceTypes["aws_instance"].Block + ty := schema.ImpliedType() + + for _, res := range plan.Changes.Resources { + ric, err := res.Decode(ty) + if err != nil { + t.Fatal(err) + } + if res.Action != plans.NoOp { + t.Fatalf("unexpected action %s for %s", res.Action, ric.Addr) + } + } +} + func TestContext2Plan_provider(t *testing.T) { m := testModule(t, "plan-provider") p := testProvider("aws") @@ -5975,6 +6411,72 @@ resource "aws_instance" "foo" { } } +func TestContext2Plan_excludeExpandedAddress(t *testing.T) { + m := testModuleInline(t, map[string]string{ + "main.tf": ` +module "mod" { + count = 3 + source = "./mod" +} +`, + "mod/main.tf": ` +resource "aws_instance" "foo" { + count = 2 +} +`, + }) + + p := testProvider("aws") + + excludes := []addrs.Targetable{} + exclude, diags := addrs.ParseTargetStr("module.mod[1].aws_instance.foo[0]") + if diags.HasErrors() { + t.Fatal(diags.ErrWithWarnings()) + } + excludes = append(excludes, exclude.Subject) + + exclude, diags = addrs.ParseTargetStr("module.mod[2]") + if diags.HasErrors() { + t.Fatal(diags.ErrWithWarnings()) + } + excludes = append(excludes, exclude.Subject) + + ctx := testContext2(t, &ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("aws"): testProviderFuncFixed(p), + }, + }) + + plan, diags := ctx.Plan(m, states.NewState(), &PlanOpts{ + Mode: plans.NormalMode, + Excludes: excludes, + }) + if diags.HasErrors() { + t.Fatal(diags.ErrWithWarnings()) + } + + expected := map[string]plans.Action{ + // the whole mod[0], which was not excluded + `module.mod[0].aws_instance.foo[0]`: plans.Create, + `module.mod[0].aws_instance.foo[1]`: plans.Create, + // the unexcluded mod[1] instance + `module.mod[1].aws_instance.foo[1]`: plans.Create, + // the whole of mod[2] was excluded + } + + for _, res := range plan.Changes.Resources { + want := expected[res.Addr.String()] + if res.Action != want { + t.Fatalf("expected %s action, got: %q %s", want, res.Addr, res.Action) + } + delete(expected, res.Addr.String()) + } + + for res, action := range expected { + t.Errorf("missing %s change for %s", action, res) + } +} + func TestContext2Plan_targetResourceInModuleInstance(t *testing.T) { m := testModuleInline(t, map[string]string{ "main.tf": ` @@ -6030,6 +6532,62 @@ resource "aws_instance" "foo" { } } +func TestContext2Plan_excludeResourceInModuleInstance(t *testing.T) { + m := testModuleInline(t, map[string]string{ + "main.tf": ` +module "mod" { + count = 3 + source = "./mod" +} +`, + "mod/main.tf": ` +resource "aws_instance" "foo" { +} +`, + }) + + p := testProvider("aws") + + exclude, diags := addrs.ParseTargetStr("module.mod[1].aws_instance.foo") + if diags.HasErrors() { + t.Fatal(diags.ErrWithWarnings()) + } + + excludes := []addrs.Targetable{exclude.Subject} + + ctx := testContext2(t, &ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("aws"): testProviderFuncFixed(p), + }, + }) + + plan, diags := ctx.Plan(m, states.NewState(), &PlanOpts{ + Mode: plans.NormalMode, + Excludes: excludes, + }) + if diags.HasErrors() { + t.Fatal(diags.ErrWithWarnings()) + } + + expected := map[string]plans.Action{ + // the unexcluded instances from mod[0] and mod[2] + `module.mod[0].aws_instance.foo`: plans.Create, + `module.mod[2].aws_instance.foo`: plans.Create, + } + + for _, res := range plan.Changes.Resources { + want := expected[res.Addr.String()] + if res.Action != want { + t.Fatalf("expected %s action, got: %q %s", want, res.Addr, res.Action) + } + delete(expected, res.Addr.String()) + } + + for res, action := range expected { + t.Errorf("missing %s change for %s", action, res) + } +} + func TestContext2Plan_moduleRefIndex(t *testing.T) { m := testModuleInline(t, map[string]string{ "main.tf": ` @@ -6265,6 +6823,57 @@ func TestContext2Plan_targetedModuleInstance(t *testing.T) { } } +func TestContext2Plan_excludedModuleInstance(t *testing.T) { + m := testModule(t, "plan-targeted") + p := testProvider("aws") + p.PlanResourceChangeFn = testDiffFn + ctx := testContext2(t, &ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("aws"): testProviderFuncFixed(p), + }, + }) + + plan, diags := ctx.Plan(m, states.NewState(), &PlanOpts{ + Mode: plans.NormalMode, + Excludes: []addrs.Targetable{ + addrs.RootModuleInstance.Resource( + addrs.ManagedResourceMode, "aws_instance", "bar", + ), + addrs.RootModuleInstance.Child("mod", addrs.IntKey(0)), + }, + }) + if diags.HasErrors() { + t.Fatalf("unexpected errors: %s", diags.Err()) + } + schema := p.GetProviderSchemaResponse.ResourceTypes["aws_instance"].Block + ty := schema.ImpliedType() + + if len(plan.Changes.Resources) != 1 { + t.Fatal("expected 1 changes, got", len(plan.Changes.Resources)) + } + + for _, res := range plan.Changes.Resources { + ric, err := res.Decode(ty) + if err != nil { + t.Fatal(err) + } + + switch i := ric.Addr.String(); i { + case "aws_instance.foo": + if res.Action != plans.Create { + t.Fatalf("resource %s should be created", i) + } + checkVals(t, objectVal(t, schema, map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "num": cty.NumberIntVal(2), + "type": cty.UnknownVal(cty.String), + }), ric.After) + default: + t.Fatal("unknown instance:", i) + } + } +} + func TestContext2Plan_dataRefreshedInPlan(t *testing.T) { m := testModuleInline(t, map[string]string{ "main.tf": ` diff --git a/internal/tofu/context_refresh_test.go b/internal/tofu/context_refresh_test.go index 23a5e4f1fc..2b6bb78cd3 100644 --- a/internal/tofu/context_refresh_test.go +++ b/internal/tofu/context_refresh_test.go @@ -291,6 +291,95 @@ func TestContext2Refresh_targeted(t *testing.T) { } expected := []string{"vpc-abc123", "i-abc123"} + sort.Strings(expected) + sort.Strings(refreshedResources) + if !reflect.DeepEqual(refreshedResources, expected) { + t.Fatalf("expected: %#v, got: %#v", expected, refreshedResources) + } +} + +// All exclude flag tests in this file are inspired by a counterpart target flag test +// Usually that test exists right before the exclude flag test + +func TestContext2Refresh_excluded(t *testing.T) { + p := testProvider("aws") + p.GetProviderSchemaResponse = getProviderSchemaResponseFromProviderSchema(&ProviderSchema{ + Provider: &configschema.Block{}, + ResourceTypes: map[string]*configschema.Block{ + "aws_elb": { + Attributes: map[string]*configschema.Attribute{ + "id": { + Type: cty.String, + Computed: true, + }, + "instances": { + Type: cty.Set(cty.String), + Optional: true, + }, + }, + }, + "aws_instance": { + Attributes: map[string]*configschema.Attribute{ + "id": { + Type: cty.String, + Computed: true, + }, + "vpc_id": { + Type: cty.String, + Optional: true, + }, + }, + }, + "aws_vpc": { + Attributes: map[string]*configschema.Attribute{ + "id": { + Type: cty.String, + Computed: true, + }, + }, + }, + }, + }) + + // This test uses the same setup as TestContext2Refresh_targeted, but here the resources that should be refreshed + // are aws_vpc.metoo and aws_instance.notme. These are resources that are not aws_instance.me or dependent on it + state := states.NewState() + root := state.EnsureModule(addrs.RootModuleInstance) + testSetResourceInstanceCurrent(root, "aws_vpc.metoo", `{"id":"vpc-abc123"}`, `provider["registry.opentofu.org/hashicorp/aws"]`) + testSetResourceInstanceCurrent(root, "aws_instance.notme", `{"id":"i-bcd345"}`, `provider["registry.opentofu.org/hashicorp/aws"]`) + testSetResourceInstanceCurrent(root, "aws_instance.me", `{"id":"i-abc123"}`, `provider["registry.opentofu.org/hashicorp/aws"]`) + testSetResourceInstanceCurrent(root, "aws_elb.meneither", `{"id":"lb-abc123"}`, `provider["registry.opentofu.org/hashicorp/aws"]`) + + m := testModule(t, "refresh-targeted") + ctx := testContext2(t, &ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("aws"): testProviderFuncFixed(p), + }, + }) + + refreshedResources := make([]string, 0, 2) + p.ReadResourceFn = func(req providers.ReadResourceRequest) providers.ReadResourceResponse { + refreshedResources = append(refreshedResources, req.PriorState.GetAttr("id").AsString()) + return providers.ReadResourceResponse{ + NewState: req.PriorState, + } + } + + _, diags := ctx.Refresh(m, state, &PlanOpts{ + Mode: plans.NormalMode, + Excludes: []addrs.Targetable{ + addrs.RootModuleInstance.Resource( + addrs.ManagedResourceMode, "aws_instance", "me", + ), + }, + }) + if diags.HasErrors() { + t.Fatalf("refresh errors: %s", diags.Err()) + } + + expected := []string{"vpc-abc123", "i-bcd345"} + sort.Strings(expected) + sort.Strings(refreshedResources) if !reflect.DeepEqual(refreshedResources, expected) { t.Fatalf("expected: %#v, got: %#v", expected, refreshedResources) } @@ -386,6 +475,97 @@ func TestContext2Refresh_targetedCount(t *testing.T) { } } +func TestContext2Refresh_excludedCount(t *testing.T) { + p := testProvider("aws") + p.GetProviderSchemaResponse = getProviderSchemaResponseFromProviderSchema(&ProviderSchema{ + Provider: &configschema.Block{}, + ResourceTypes: map[string]*configschema.Block{ + "aws_elb": { + Attributes: map[string]*configschema.Attribute{ + "id": { + Type: cty.String, + Computed: true, + }, + "instances": { + Type: cty.Set(cty.String), + Optional: true, + }, + }, + }, + "aws_instance": { + Attributes: map[string]*configschema.Attribute{ + "id": { + Type: cty.String, + Computed: true, + }, + "vpc_id": { + Type: cty.String, + Optional: true, + }, + }, + }, + "aws_vpc": { + Attributes: map[string]*configschema.Attribute{ + "id": { + Type: cty.String, + Computed: true, + }, + }, + }, + }, + }) + + // This test uses the same setup as TestContext2Refresh_targetedCount, but here the resources that should be + // refreshed are aws_vpc.metoo and aws_instance.notme. These are resources that are not aws_instance.me or + // dependent on it + state := states.NewState() + root := state.EnsureModule(addrs.RootModuleInstance) + testSetResourceInstanceCurrent(root, "aws_vpc.metoo", `{"id":"vpc-abc123"}`, `provider["registry.opentofu.org/hashicorp/aws"]`) + testSetResourceInstanceCurrent(root, "aws_instance.notme", `{"id":"i-bcd345"}`, `provider["registry.opentofu.org/hashicorp/aws"]`) + testSetResourceInstanceCurrent(root, "aws_instance.me[0]", `{"id":"i-abc123"}`, `provider["registry.opentofu.org/hashicorp/aws"]`) + testSetResourceInstanceCurrent(root, "aws_instance.me[1]", `{"id":"i-cde567"}`, `provider["registry.opentofu.org/hashicorp/aws"]`) + testSetResourceInstanceCurrent(root, "aws_instance.me[2]", `{"id":"i-cde789"}`, `provider["registry.opentofu.org/hashicorp/aws"]`) + testSetResourceInstanceCurrent(root, "aws_elb.meneither", `{"id":"lb-abc123"}`, `provider["registry.opentofu.org/hashicorp/aws"]`) + + m := testModule(t, "refresh-targeted-count") + ctx := testContext2(t, &ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("aws"): testProviderFuncFixed(p), + }, + }) + + refreshedResources := make([]string, 0, 2) + p.ReadResourceFn = func(req providers.ReadResourceRequest) providers.ReadResourceResponse { + refreshedResources = append(refreshedResources, req.PriorState.GetAttr("id").AsString()) + return providers.ReadResourceResponse{ + NewState: req.PriorState, + } + } + + _, diags := ctx.Refresh(m, state, &PlanOpts{ + Mode: plans.NormalMode, + Excludes: []addrs.Targetable{ + addrs.RootModuleInstance.Resource( + addrs.ManagedResourceMode, "aws_instance", "me", + ), + }, + }) + if diags.HasErrors() { + t.Fatalf("refresh errors: %s", diags.Err()) + } + + // Target didn't specify index, so we should exclude all instances of aws_instance.me + expected := []string{ + "vpc-abc123", + "i-bcd345", + } + sort.Strings(expected) + sort.Strings(refreshedResources) + if !reflect.DeepEqual(refreshedResources, expected) { + t.Fatalf("wrong result\ngot: %#v\nwant: %#v", refreshedResources, expected) + } +} + func TestContext2Refresh_targetedCountIndex(t *testing.T) { p := testProvider("aws") p.GetProviderSchemaResponse = getProviderSchemaResponseFromProviderSchema(&ProviderSchema{ @@ -463,6 +643,95 @@ func TestContext2Refresh_targetedCountIndex(t *testing.T) { } expected := []string{"vpc-abc123", "i-abc123"} + sort.Strings(expected) + sort.Strings(refreshedResources) + if !reflect.DeepEqual(refreshedResources, expected) { + t.Fatalf("wrong result\ngot: %#v\nwant: %#v", refreshedResources, expected) + } +} + +func TestContext2Refresh_excludedCountIndex(t *testing.T) { + p := testProvider("aws") + p.GetProviderSchemaResponse = getProviderSchemaResponseFromProviderSchema(&ProviderSchema{ + Provider: &configschema.Block{}, + ResourceTypes: map[string]*configschema.Block{ + "aws_elb": { + Attributes: map[string]*configschema.Attribute{ + "id": { + Type: cty.String, + Computed: true, + }, + "instances": { + Type: cty.Set(cty.String), + Optional: true, + }, + }, + }, + "aws_instance": { + Attributes: map[string]*configschema.Attribute{ + "id": { + Type: cty.String, + Computed: true, + }, + "vpc_id": { + Type: cty.String, + Optional: true, + }, + }, + }, + "aws_vpc": { + Attributes: map[string]*configschema.Attribute{ + "id": { + Type: cty.String, + Computed: true, + }, + }, + }, + }, + }) + + // This test uses the same setup as TestContext2Refresh_targetedCountIndex, but here the resources that should be + // refreshed are aws_vpc.metoo, aws_instance.notme, aws_instance.me[1] and aws_instance.me[2]. These are resources + // that are not aws_instance.me[0] or dependent on it + state := states.NewState() + root := state.EnsureModule(addrs.RootModuleInstance) + testSetResourceInstanceCurrent(root, "aws_vpc.metoo", `{"id":"vpc-abc123"}`, `provider["registry.opentofu.org/hashicorp/aws"]`) + testSetResourceInstanceCurrent(root, "aws_instance.notme", `{"id":"i-bcd345"}`, `provider["registry.opentofu.org/hashicorp/aws"]`) + testSetResourceInstanceCurrent(root, "aws_instance.me[0]", `{"id":"i-abc123"}`, `provider["registry.opentofu.org/hashicorp/aws"]`) + testSetResourceInstanceCurrent(root, "aws_instance.me[1]", `{"id":"i-cde567"}`, `provider["registry.opentofu.org/hashicorp/aws"]`) + testSetResourceInstanceCurrent(root, "aws_instance.me[2]", `{"id":"i-cde789"}`, `provider["registry.opentofu.org/hashicorp/aws"]`) + testSetResourceInstanceCurrent(root, "aws_elb.meneither", `{"id":"lb-abc123"}`, `provider["registry.opentofu.org/hashicorp/aws"]`) + + m := testModule(t, "refresh-targeted-count") + ctx := testContext2(t, &ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("aws"): testProviderFuncFixed(p), + }, + }) + + refreshedResources := make([]string, 0, 2) + p.ReadResourceFn = func(req providers.ReadResourceRequest) providers.ReadResourceResponse { + refreshedResources = append(refreshedResources, req.PriorState.GetAttr("id").AsString()) + return providers.ReadResourceResponse{ + NewState: req.PriorState, + } + } + + _, diags := ctx.Refresh(m, state, &PlanOpts{ + Mode: plans.NormalMode, + Excludes: []addrs.Targetable{ + addrs.RootModuleInstance.ResourceInstance( + addrs.ManagedResourceMode, "aws_instance", "me", addrs.IntKey(0), + ), + }, + }) + if diags.HasErrors() { + t.Fatalf("refresh errors: %s", diags.Err()) + } + + expected := []string{"vpc-abc123", "i-bcd345", "i-cde567", "i-cde789"} + sort.Strings(expected) + sort.Strings(refreshedResources) if !reflect.DeepEqual(refreshedResources, expected) { t.Fatalf("wrong result\ngot: %#v\nwant: %#v", refreshedResources, expected) } diff --git a/internal/tofu/graph_builder_apply.go b/internal/tofu/graph_builder_apply.go index d3b12997f2..c78a50ec9f 100644 --- a/internal/tofu/graph_builder_apply.go +++ b/internal/tofu/graph_builder_apply.go @@ -46,6 +46,12 @@ type ApplyGraphBuilder struct { // outputs should go into the diff so that this is unnecessary. Targets []addrs.Targetable + // Excludes are resources to exclude. This is only required to make sure + // unnecessary outputs aren't included in the apply graph. The plan + // builder successfully handles targeting resources. In the future, + // outputs should go into the diff so that this is unnecessary. + Excludes []addrs.Targetable + // ForceReplace are the resource instance addresses that the user // requested to force replacement for when creating the plan, if any. // The apply step refers to these as part of verifying that the planned @@ -192,7 +198,7 @@ func (b *ApplyGraphBuilder) Steps() []GraphTransformer { &pruneUnusedNodesTransformer{}, // Target - &TargetsTransformer{Targets: b.Targets}, + &TargetingTransformer{Targets: b.Targets, Excludes: b.Excludes}, // Close opened plugin connections &CloseProviderTransformer{}, diff --git a/internal/tofu/graph_builder_apply_test.go b/internal/tofu/graph_builder_apply_test.go index 72a5394303..d800f1da5e 100644 --- a/internal/tofu/graph_builder_apply_test.go +++ b/internal/tofu/graph_builder_apply_test.go @@ -456,7 +456,42 @@ func TestApplyGraphBuilder_targetModule(t *testing.T) { t.Fatalf("err: %s", err) } - testGraphNotContains(t, g, "module.child1.output.instance_id") + testGraphNotContains(t, g, "test_object.foo") +} + +func TestApplyGraphBuilder_excludeModule(t *testing.T) { + changes := &plans.Changes{ + Resources: []*plans.ResourceInstanceChangeSrc{ + { + Addr: mustResourceInstanceAddr("test_object.foo"), + ChangeSrc: plans.ChangeSrc{ + Action: plans.Update, + }, + }, + { + Addr: mustResourceInstanceAddr("module.child2.test_object.foo"), + ChangeSrc: plans.ChangeSrc{ + Action: plans.Update, + }, + }, + }, + } + + b := &ApplyGraphBuilder{ + Config: testModule(t, "graph-builder-apply-target-module"), + Changes: changes, + Plugins: simpleMockPluginLibrary(), + Excludes: []addrs.Targetable{ + addrs.RootModuleInstance.Child("child2", addrs.NoKey), + }, + } + + g, err := b.Build(addrs.RootModuleInstance) + if err != nil { + t.Fatalf("err: %s", err) + } + + testGraphNotContains(t, g, "mod.child2.test_object.foo") } // Ensure that an update resulting from the removal of a resource happens after diff --git a/internal/tofu/graph_builder_plan.go b/internal/tofu/graph_builder_plan.go index 7eb3460a7f..b960d7a9c2 100644 --- a/internal/tofu/graph_builder_plan.go +++ b/internal/tofu/graph_builder_plan.go @@ -46,6 +46,9 @@ type PlanGraphBuilder struct { // Targets are resources to target Targets []addrs.Targetable + // Excludes are resources to exclude + Excludes []addrs.Targetable + // ForceReplace are resource instances where if we would normally have // generated a NoOp or Update action then we'll force generating a replace // action instead. Create and Delete actions are not affected. @@ -226,7 +229,7 @@ func (b *PlanGraphBuilder) Steps() []GraphTransformer { &attachDataResourceDependsOnTransformer{}, // DestroyEdgeTransformer is only required during a plan so that the - // TargetsTransformer can determine which nodes to keep in the graph. + // TargetingTransformer can determine which nodes to keep in the graph. &DestroyEdgeTransformer{ Operation: b.Operation, }, @@ -236,7 +239,7 @@ func (b *PlanGraphBuilder) Steps() []GraphTransformer { }, // Target - &TargetsTransformer{Targets: b.Targets}, + &TargetingTransformer{Targets: b.Targets, Excludes: b.Excludes}, // Detect when create_before_destroy must be forced on for a particular // node due to dependency edges, to avoid graph cycles during apply. diff --git a/internal/tofu/graph_builder_plan_test.go b/internal/tofu/graph_builder_plan_test.go index f8e1897fc0..04ba553828 100644 --- a/internal/tofu/graph_builder_plan_test.go +++ b/internal/tofu/graph_builder_plan_test.go @@ -195,6 +195,27 @@ func TestPlanGraphBuilder_targetModule(t *testing.T) { testGraphNotContains(t, g, "module.child1.test_object.foo") } +func TestPlanGraphBuilder_excludeModule(t *testing.T) { + b := &PlanGraphBuilder{ + Config: testModule(t, "graph-builder-plan-target-module-provider"), + Plugins: simpleMockPluginLibrary(), + Excludes: []addrs.Targetable{ + addrs.RootModuleInstance.Child("child1", addrs.NoKey), + }, + Operation: walkPlan, + } + + g, err := b.Build(addrs.RootModuleInstance) + if err != nil { + t.Fatalf("err: %s", err) + } + + t.Logf("Graph: %s", g.String()) + + testGraphNotContains(t, g, `module.child1.provider["registry.opentofu.org/hashicorp/test"]`) + testGraphNotContains(t, g, "module.child1.test_object.foo") +} + func TestPlanGraphBuilder_forEach(t *testing.T) { awsProvider := mockProviderWithResourceTypeSchema("aws_instance", simpleTestSchema()) diff --git a/internal/tofu/node_resource_abstract.go b/internal/tofu/node_resource_abstract.go index 28dd330b9c..398c67e84b 100644 --- a/internal/tofu/node_resource_abstract.go +++ b/internal/tofu/node_resource_abstract.go @@ -69,6 +69,9 @@ type NodeAbstractResource struct { // Set from GraphNodeTargetable Targets []addrs.Targetable + // Set from GraphNodeTargetable + Excludes []addrs.Targetable + // Set from AttachDataResourceDependsOn dependsOn []addrs.ConfigResource forceDependsOn bool @@ -394,6 +397,11 @@ func (n *NodeAbstractResource) SetTargets(targets []addrs.Targetable) { n.Targets = targets } +// GraphNodeTargetable +func (n *NodeAbstractResource) SetExcludes(excludes []addrs.Targetable) { + n.Excludes = excludes +} + // graphNodeAttachDataResourceDependsOn func (n *NodeAbstractResource) AttachDataResourceDependsOn(deps []addrs.ConfigResource, force bool) { n.dependsOn = deps diff --git a/internal/tofu/node_resource_plan.go b/internal/tofu/node_resource_plan.go index 676dbd91db..15f0ad8c29 100644 --- a/internal/tofu/node_resource_plan.go +++ b/internal/tofu/node_resource_plan.go @@ -413,7 +413,7 @@ func (n *nodeExpandPlannableResource) resourceInstanceSubgraph(ctx EvalContext, &AttachStateTransformer{State: state}, // Targeting - &TargetsTransformer{Targets: n.Targets}, + &TargetingTransformer{Targets: n.Targets, Excludes: n.Excludes}, // Connect references so ordering is correct &ReferenceTransformer{}, diff --git a/internal/tofu/testdata/plan-targeted-orphan/main.tf b/internal/tofu/testdata/plan-targeted-orphan/main.tf index f2020858b1..07a036121b 100644 --- a/internal/tofu/testdata/plan-targeted-orphan/main.tf +++ b/internal/tofu/testdata/plan-targeted-orphan/main.tf @@ -1,6 +1,10 @@ -# This resource was previously "created" and the fixture represents -# it being destroyed subsequently +# These resources were previously "created" and the fixture represents +# them being destroyed subsequently -/*resource "aws_instance" "orphan" {*/ - /*foo = "bar"*/ -/*}*/ +#resource "aws_instance" "orphan" { +# foo = "bar" +#} + +#resource "aws_instance" "nottargeted" { +# foo = "bar" +#} diff --git a/internal/tofu/testdata/plan-untargeted-resource-output/main.tf b/internal/tofu/testdata/plan-untargeted-resource-output/main.tf index 9d4a1c882d..fad1b64262 100644 --- a/internal/tofu/testdata/plan-untargeted-resource-output/main.tf +++ b/internal/tofu/testdata/plan-untargeted-resource-output/main.tf @@ -4,5 +4,5 @@ module "mod" { resource "aws_instance" "c" { - name = "${module.mod.output}" + foo = "${module.mod.output}" } diff --git a/internal/tofu/transform_provider_test.go b/internal/tofu/transform_provider_test.go index 28b63a8858..c1fb059b9c 100644 --- a/internal/tofu/transform_provider_test.go +++ b/internal/tofu/transform_provider_test.go @@ -144,7 +144,7 @@ func TestCloseProviderTransformer_withTargets(t *testing.T) { &MissingProviderTransformer{}, &ProviderTransformer{}, &CloseProviderTransformer{}, - &TargetsTransformer{ + &TargetingTransformer{ Targets: []addrs.Targetable{ addrs.RootModuleInstance.Resource( addrs.ManagedResourceMode, "something", "else", @@ -166,6 +166,36 @@ func TestCloseProviderTransformer_withTargets(t *testing.T) { } } +func TestCloseProviderTransformer_withExcludes(t *testing.T) { + mod := testModule(t, "transform-provider-basic") + + g := testProviderTransformerGraph(t, mod) + transforms := []GraphTransformer{ + &MissingProviderTransformer{}, + &ProviderTransformer{}, + &CloseProviderTransformer{}, + &TargetingTransformer{ + Excludes: []addrs.Targetable{ + addrs.RootModuleInstance.Resource( + addrs.ManagedResourceMode, "aws_instance", "web", + ), + }, + }, + } + + for _, tr := range transforms { + if err := tr.Transform(g); err != nil { + t.Fatalf("err: %s", err) + } + } + + actual := strings.TrimSpace(g.String()) + expected := strings.TrimSpace(``) + if actual != expected { + t.Fatalf("expected:%s\n\ngot:\n\n%s", expected, actual) + } +} + func TestMissingProviderTransformer(t *testing.T) { mod := testModule(t, "transform-provider-missing") diff --git a/internal/tofu/transform_targets.go b/internal/tofu/transform_targets.go index 3c744f96bd..e56ce1a057 100644 --- a/internal/tofu/transform_targets.go +++ b/internal/tofu/transform_targets.go @@ -13,44 +13,52 @@ import ( ) // GraphNodeTargetable is an interface for graph nodes to implement when they -// need to be told about incoming targets. This is useful for nodes that need -// to respect targets as they dynamically expand. Note that the list of targets -// provided will contain every target provided, and each implementing graph -// node must filter this list to targets considered relevant. +// need to be told about incoming targets or excluded targets. This is useful for +// nodes that need to respect targets and excludes as they dynamically expand. +// Note that the lists of targets and excludes provided will contain every target +// or every exclude provided, and each implementing graph node must filter this +// list to targets considered relevant. type GraphNodeTargetable interface { SetTargets([]addrs.Targetable) + SetExcludes([]addrs.Targetable) } -// TargetsTransformer is a GraphTransformer that, when the user specifies a -// list of resources to target, limits the graph to only those resources and -// their dependencies. -type TargetsTransformer struct { +// TargetingTransformer is a GraphTransformer that, when the user specifies a +// list of resources to target, or a list of resources to exclude, limits the +// graph to only those resources and their dependencies (or in the case of +// excludes - limits the graph to all resources that are not excluded or not +// dependent on excluded resources). +type TargetingTransformer struct { // List of targeted resource names specified by the user Targets []addrs.Targetable + // List of excluded resource names specified by the user + Excludes []addrs.Targetable } -func (t *TargetsTransformer) Transform(g *Graph) error { +func (t *TargetingTransformer) Transform(g *Graph) error { + var targetedNodes dag.Set if len(t.Targets) > 0 { - targetedNodes, err := t.selectTargetedNodes(g, t.Targets) - if err != nil { - return err - } + targetedNodes = t.selectTargetedNodes(g, t.Targets) + } else if len(t.Excludes) > 0 { + targetedNodes = t.removeExcludedNodes(g, t.Excludes) + } else { + return nil + } - for _, v := range g.Vertices() { - if !targetedNodes.Include(v) { - log.Printf("[DEBUG] Removing %q, filtered by targeting.", dag.VertexName(v)) - g.Remove(v) - } + for _, v := range g.Vertices() { + if !targetedNodes.Include(v) { + log.Printf("[DEBUG] Removing %q, filtered by targeting.", dag.VertexName(v)) + g.Remove(v) } } return nil } -// Returns a set of targeted nodes. A targeted node is either addressed -// directly, address indirectly via its container, or it's a dependency of a -// targeted node. -func (t *TargetsTransformer) selectTargetedNodes(g *Graph, addrs []addrs.Targetable) (dag.Set, error) { +// selectTargetedNodes goes over a list of resource and modules targeted with a -target flag, and returns a set of +// targeted nodes. A targeted node is either addressed directly, address indirectly via its container, or it's a +// dependency of a targeted node. +func (t *TargetingTransformer) selectTargetedNodes(g *Graph, addrs []addrs.Targetable) dag.Set { targetedNodes := make(dag.Set) vertices := g.Vertices() @@ -73,10 +81,118 @@ func (t *TargetsTransformer) selectTargetedNodes(g *Graph, addrs []addrs.Targeta } } + targetedOutputNodes := t.getTargetedOutputNodes(targetedNodes, g) + for _, outputNode := range targetedOutputNodes { + targetedNodes.Add(outputNode) + } + + return targetedNodes +} + +func (t *TargetingTransformer) getTargetableNodeResourceAddr(v dag.Vertex) addrs.Targetable { + switch r := v.(type) { + case GraphNodeResourceInstance: + return r.ResourceInstanceAddr() + case GraphNodeConfigResource: + return r.ResourceAddr() + default: + // Only resource and resource instance nodes can be targeted. + return nil + } +} + +// removeExcludedNodes goes over a list of excluded resources and modules, and returns a set of targeted nodes to be +// used for resource targeting. An excluded resource is either addressed directly, addressed indirectly via its +// container, or it's dependent on an excluded node. The rest are the targeted nodes used for resource targeting +func (t *TargetingTransformer) removeExcludedNodes(g *Graph, excludes []addrs.Targetable) dag.Set { + targetedNodes := make(dag.Set) + excludedNodes := make(dag.Set) + targetableNodes := make(dag.Set) + + vertices := g.Vertices() + + // Step 1: Find all excluded targetable nodes, and their descendants + for _, v := range vertices { + vertexAddr := t.getTargetableNodeResourceAddr(v) + if vertexAddr == nil { + continue + } + + targetableNodes.Add(v) + + nodeExcluded := t.nodeIsExcluded(vertexAddr, excludes) + if nodeExcluded { + excludedNodes.Add(v) + } + + if nodeExcluded || t.nodeDescendantsExcluded(vertexAddr, excludes) { + deps, _ := g.Descendents(v) + for _, d := range deps { + // In general, we'd like to exclude any descendant targetable node of the current node. + // We exclude any resource dependent on this resource (which is more general than resources dependent + // on the resource instance, but is in-line with how -target works). + // + // The exception to this is when excluding a specific instance of a resource that has multiple instances. + // During apply, the specific instance tofu.NodeApplyableResourceInstance would be dependent on the + // resource tofu.nodeExpandApplyableResource. + // Since we do not want to exclude all resource instances (other than the ones that we've explicitly + // excluded), we should only exclude dependents whose target is not contained in the current node. + depVertexAddr := t.getTargetableNodeResourceAddr(d) + if depVertexAddr != nil && !vertexAddr.TargetContains(depVertexAddr) { + excludedNodes.Add(d) + } + } + } + } + + // Step 2: Of the targetable nodes that were not excluded, build the graph similarly to -target + for _, v := range targetableNodes { + if !excludedNodes.Include(v) { + targetedNodes.Add(v) + + // We inform nodes that ask about the list of excludes - helps for nodes + // that need to dynamically expand. Note that this only occurs for nodes + // that are targetable and we didn't exclude + if tn, ok := v.(GraphNodeTargetable); ok { + tn.SetExcludes(excludes) + } + + deps, _ := g.Ancestors(v) + for _, d := range deps { + targetedNodes.Add(d) + } + } + } + + // Step 3: Add outputs + targetedOutputNodes := t.getTargetedOutputNodes(targetedNodes, g) + for _, outputNode := range targetedOutputNodes { + targetedNodes.Add(outputNode) + } + + return targetedNodes +} + +func (t *TargetingTransformer) getTargetedOutputNodes(targetedNodes dag.Set, graph *Graph) dag.Set { // It is expected that outputs which are only derived from targeted // resources are also updated. While we don't include any other possible // side effects from the targeted nodes, these are added because outputs // cannot be targeted on their own. + // + // Note: This behaviour has some quirks, as there are specific cases where + // you would think an output should not be updated, but it is + // For example, when there's a module call with an input that is dependent + // on a root resource, and only the root resource is targeted, any output + // that depends on a module output might be updated, if said module output + // does not depend on any resource of the module itself. + // Right now, we will not change this behaviour, as this has been the + // behaviour for quite a while. A possible fix could be a more detailed + // analysis of the outputs, and making sure that module outputs are only + // referenced if any of the targeted nodes is in said module + + targetedOutputNodes := make(dag.Set) + vertices := graph.Vertices() + // Start by finding the root module output nodes themselves for _, v := range vertices { // outputs are all temporary value types @@ -93,7 +209,7 @@ func (t *TargetsTransformer) selectTargetedNodes(g *Graph, addrs []addrs.Targeta // If this output is descended only from targeted resources, then we // will keep it - deps, _ := g.Ancestors(v) + deps, _ := graph.Ancestors(v) found := 0 for _, d := range deps { switch d.(type) { @@ -115,17 +231,65 @@ func (t *TargetsTransformer) selectTargetedNodes(g *Graph, addrs []addrs.Targeta if found > 0 { // we found an output we can keep; add it, and all it's dependencies - targetedNodes.Add(v) + targetedOutputNodes.Add(v) for _, d := range deps { - targetedNodes.Add(d) + targetedOutputNodes.Add(d) } } } - return targetedNodes, nil + return targetedOutputNodes } -func (t *TargetsTransformer) nodeIsTarget(v dag.Vertex, targets []addrs.Targetable) bool { +func (t *TargetingTransformer) nodeIsExcluded(vertexAddr addrs.Targetable, excludes []addrs.Targetable) bool { + for _, excludeAddr := range excludes { + if excludeAddr.TargetContains(vertexAddr) { + return true + } + } + + return false +} + +func (t *TargetingTransformer) nodeDescendantsExcluded(vertexAddr addrs.Targetable, excludes []addrs.Targetable) bool { + for _, excludeAddr := range excludes { + // The behaviour here is a bit different from targets. + // Before expansion - We'd like to only exclude resources that were excluded by module or resource. + // If the excluded target is an AbsResourceInstance, then we'd want to skip exclude until we expand the resource + // After expansion - We'd like to exclude any vertex that contains the exclude address + // Since before expansion the vertexAddr is without an index, then if the excludeAddr is an instance, it will + // only contain vertexAddr if its key is NoKey + // So - a simple TargetContains here should be enough, both before and after expansion + + if _, ok := vertexAddr.(addrs.ConfigResource); ok { + // Before expansion happens, we only have nodes that know their + // ConfigResource address. We need to take the more specific + // target addresses and generalize them in order to compare with a + // ConfigResource. + // + // If the excluded target, in is generalized form, contains the vertex address, then we know that we could remove the descendants + // even if we don't remove the node itself from the graph. However, this could cause cases where too many resources are excluded. + // For example, with -exclude=null_resource.a[1], and a null_resource.b[*] for which each instance depends on a single null_resource.a instance, + // all null_resource.b instances will be excluded. This is not accurate, but is in line with -target today, which over-targets dependencies + switch target := excludeAddr.(type) { + case addrs.AbsResourceInstance: + excludeAddr = target.ContainingResource().Config() + case addrs.AbsResource: + excludeAddr = target.Config() + case addrs.ModuleInstance: + excludeAddr = target.Module() + } + } + + if excludeAddr.TargetContains(vertexAddr) { + return true + } + } + + return false +} + +func (t *TargetingTransformer) nodeIsTarget(v dag.Vertex, targets []addrs.Targetable) bool { var vertexAddr addrs.Targetable switch r := v.(type) { case GraphNodeResourceInstance: diff --git a/internal/tofu/transform_targets_test.go b/internal/tofu/transform_targets_test.go index 1572797a19..4ebc80ffdc 100644 --- a/internal/tofu/transform_targets_test.go +++ b/internal/tofu/transform_targets_test.go @@ -38,7 +38,7 @@ func TestTargetsTransformer(t *testing.T) { } { - transform := &TargetsTransformer{ + transform := &TargetingTransformer{ Targets: []addrs.Targetable{ addrs.RootModuleInstance.Resource( addrs.ManagedResourceMode, "aws_instance", "me", @@ -63,6 +63,64 @@ aws_vpc.me } } +func TestTargetsTransformerExclude(t *testing.T) { + mod := testModule(t, "transform-targets-basic") + + g := Graph{Path: addrs.RootModuleInstance} + { + tf := &ConfigTransformer{Config: mod} + if err := tf.Transform(&g); err != nil { + t.Fatalf("err: %s", err) + } + } + + { + transform := &AttachResourceConfigTransformer{Config: mod} + if err := transform.Transform(&g); err != nil { + t.Fatalf("err: %s", err) + } + } + + { + transform := &ReferenceTransformer{} + if err := transform.Transform(&g); err != nil { + t.Fatalf("err: %s", err) + } + } + + { + transform := &TargetingTransformer{ + Excludes: []addrs.Targetable{ + addrs.RootModuleInstance.Resource( + addrs.ManagedResourceMode, "aws_instance", "me", + ), + addrs.RootModuleInstance.Resource( + addrs.ManagedResourceMode, "aws_vpc", "notme", + ), + addrs.RootModuleInstance.Resource( + addrs.ManagedResourceMode, "aws_subnet", "notme", + ), + addrs.RootModuleInstance.Resource( + addrs.ManagedResourceMode, "aws_instance", "notme", + ), + }, + } + if err := transform.Transform(&g); err != nil { + t.Fatalf("err: %s", err) + } + } + + actual := strings.TrimSpace(g.String()) + expected := strings.TrimSpace(` +aws_subnet.me + aws_vpc.me +aws_vpc.me + `) + if actual != expected { + t.Fatalf("bad:\n\nexpected:\n%s\n\ngot:\n%s\n", expected, actual) + } +} + func TestTargetsTransformer_downstream(t *testing.T) { mod := testModule(t, "transform-targets-downstream") @@ -103,7 +161,7 @@ func TestTargetsTransformer_downstream(t *testing.T) { } { - transform := &TargetsTransformer{ + transform := &TargetingTransformer{ Targets: []addrs.Targetable{ addrs.RootModuleInstance. Child("child", addrs.NoKey). @@ -135,7 +193,77 @@ output.grandchild_id (expand) } } -// This tests the TargetsTransformer targeting a whole module, +func TestTargetsTransformer_downstreamExclude(t *testing.T) { + mod := testModule(t, "transform-targets-downstream") + + g := Graph{Path: addrs.RootModuleInstance} + { + transform := &ConfigTransformer{Config: mod} + if err := transform.Transform(&g); err != nil { + t.Fatalf("%T failed: %s", transform, err) + } + } + + { + transform := &AttachResourceConfigTransformer{Config: mod} + if err := transform.Transform(&g); err != nil { + t.Fatalf("%T failed: %s", transform, err) + } + } + + { + transform := &AttachResourceConfigTransformer{Config: mod} + if err := transform.Transform(&g); err != nil { + t.Fatalf("%T failed: %s", transform, err) + } + } + + { + transform := &OutputTransformer{Config: mod} + if err := transform.Transform(&g); err != nil { + t.Fatalf("%T failed: %s", transform, err) + } + } + + { + transform := &ReferenceTransformer{} + if err := transform.Transform(&g); err != nil { + t.Fatalf("err: %s", err) + } + } + + { + transform := &TargetingTransformer{ + Excludes: []addrs.Targetable{ + addrs.RootModuleInstance.Resource(addrs.ManagedResourceMode, "aws_instance", "foo"), + addrs.RootModuleInstance. + Child("child", addrs.NoKey). + Resource(addrs.ManagedResourceMode, "aws_instance", "foo"), + }, + } + if err := transform.Transform(&g); err != nil { + t.Fatalf("%T failed: %s", transform, err) + } + } + + actual := strings.TrimSpace(g.String()) + // Even though we only asked to exclude all resources in root and child, only including the grandchild resource + // all of the outputs that descend from it are also targeted. + expected := strings.TrimSpace(` +module.child.module.grandchild.aws_instance.foo +module.child.module.grandchild.output.id (expand) + module.child.module.grandchild.aws_instance.foo +module.child.output.grandchild_id (expand) + module.child.module.grandchild.output.id (expand) +output.grandchild_id (expand) + module.child.output.grandchild_id (expand) + `) + if actual != expected { + t.Fatalf("bad:\n\nexpected:\n%s\n\ngot:\n%s\n", expected, actual) + } +} + +// This tests the TargetingTransformer targeting a whole module, // rather than a resource within a module instance. func TestTargetsTransformer_wholeModule(t *testing.T) { mod := testModule(t, "transform-targets-downstream") @@ -177,7 +305,7 @@ func TestTargetsTransformer_wholeModule(t *testing.T) { } { - transform := &TargetsTransformer{ + transform := &TargetingTransformer{ Targets: []addrs.Targetable{ addrs.RootModule. Child("child"). diff --git a/website/docs/cli/commands/plan.mdx b/website/docs/cli/commands/plan.mdx index a6eec71f5e..b8e99613d3 100644 --- a/website/docs/cli/commands/plan.mdx +++ b/website/docs/cli/commands/plan.mdx @@ -123,6 +123,14 @@ In addition to alternate [planning modes](#planning-modes), there are several op Use `-target=ADDRESS` in exceptional circumstances only, such as recovering from mistakes or working around OpenTofu limitations. Refer to [Resource Targeting](#resource-targeting) for more details. ::: +- `-exclude=ADDRESS` - Instructs OpenTofu to focus its planning efforts only + on resource instances which do not match the given excluded address, and that + do not depend on any such resources or modules that were excluded. + + :::note + Use `-exclude=ADDRESS` in exceptional circumstances only, such as recovering from mistakes or working around OpenTofu limitations. Refer to [Resource Targeting](#resource-targeting) for more details. + ::: + - `-var 'NAME=VALUE'` - Sets a value for a single [input variable](../../language/values/variables.mdx) declared in the root module of the configuration. Use this option multiple times to set @@ -217,8 +225,18 @@ input variables, see ### Resource Targeting -You can use the `-target` option to focus OpenTofu's attention on only a -subset of resources. +You can use the `-target` or the `-exclude` option to trigger resource targeting, +focusing OpenTofu's attention on only a subset of resources. +Using the `-target` option will focus OpenTofu's attention only on resources and +module that are directly targeted, or are dependencies of the target. +Using the `-exclude` option will focus OpenTofu's attention only on resources and +modules that are not directly excluded, and are not dependent on an excluded resource +or module. + +You can use multiple `-target` flags in order to target multiple resources and modules, +and you can use multiple `-exclude` flags in order to exclude multiple resource and +modules. You cannot use both `-target` and `-exclude` flags together. + You can use [resource address syntax](../../cli/state/resource-addressing.mdx) to specify the constraint. OpenTofu interprets the resource address as follows: @@ -238,18 +256,14 @@ to specify the constraint. OpenTofu interprets the resource address as follows: select all instances of all resources that belong to that module instance and all of its child module instances. -Once OpenTofu has selected one or more resource instances that you've directly -targeted, it will also then extend the selection to include all other objects -that those selections depend on either directly or indirectly. - This targeting capability is provided for exceptional circumstances, such as recovering from mistakes or working around OpenTofu limitations. It -is _not recommended_ to use `-target` for routine operations, since this can -lead to undetected configuration drift and confusion about how the true state -of resources relates to configuration. +is _not recommended_ to use `-target` or `-exclude` for routine operations, since +this can lead to undetected configuration drift and confusion about how the true +state of resources relates to configuration. -Instead of using `-target` as a means to operate on isolated portions of very -large configurations, prefer instead to break large configurations into +Instead of using `-target` or `-exclude` as a means to operate on isolated portions +of very large configurations, prefer instead to break large configurations into several smaller configurations that can each be independently applied. [Data sources](../../language/data-sources/index.mdx) can be used to access information about resources created in other configurations, allowing diff --git a/website/docs/language/meta-arguments/for_each.mdx b/website/docs/language/meta-arguments/for_each.mdx index 3a19b8eae4..71d233d106 100644 --- a/website/docs/language/meta-arguments/for_each.mdx +++ b/website/docs/language/meta-arguments/for_each.mdx @@ -98,7 +98,7 @@ This object has two attributes: The keys of the map (or all the values in the case of a set of strings) must be _known values_, or you will get an error message that `for_each` has dependencies -that cannot be determined before apply, and a `-target` may be needed. +that cannot be determined before apply, and a `-target`/`-exclude` may be needed. `for_each` keys cannot be the result (or rely on the result of) of impure functions, including `uuid`, `bcrypt`, or `timestamp`, as their evaluation is deferred during the diff --git a/website/docs/language/state/purpose.mdx b/website/docs/language/state/purpose.mdx index 5cef64c3c6..9448fbdfd1 100644 --- a/website/docs/language/state/purpose.mdx +++ b/website/docs/language/state/purpose.mdx @@ -90,7 +90,7 @@ round trip time for each resource is hundreds of milliseconds. On top of this, cloud providers almost always have API rate limiting so OpenTofu can only request a certain number of resources in a period of time. Larger users of OpenTofu make heavy use of the `-refresh=false` flag as well as the -`-target` flag in order to work around this. In these scenarios, the cached +`-target`/`-exclude` flags in order to work around this. In these scenarios, the cached state is treated as the record of truth. ## Syncing