diff --git a/internal/backend/backendrun/operation.go b/internal/backend/backendrun/operation.go index af208c64fb7e..164f9ef25c87 100644 --- a/internal/backend/backendrun/operation.go +++ b/internal/backend/backendrun/operation.go @@ -73,14 +73,20 @@ type Operation struct { // PlanId is an opaque value that backends can use to execute a specific // plan for an apply operation. - // + PlanId string + PlanRefresh bool // PlanRefresh will do a refresh before a plan + PlanOutPath string // PlanOutPath is the path to save the plan + // PlanOutBackend is the backend to store with the plan. This is the // backend that will be used when applying the plan. - PlanId string - PlanRefresh bool // PlanRefresh will do a refresh before a plan - PlanOutPath string // PlanOutPath is the path to save the plan + // Only one of PlanOutBackend or PlanOutStateStore may be set. PlanOutBackend *plans.Backend + // PlanOutStateStore is the state_store to store with the plan. This is the + // state store that will be used when applying the plan. + // Only one of PlanOutBackend or PlanOutStateStore may be set + PlanOutStateStore *plans.StateStore + // ConfigDir is the path to the directory containing the configuration's // root module. ConfigDir string diff --git a/internal/backend/local/backend_local_test.go b/internal/backend/local/backend_local_test.go index 2e84d01f6a64..83c0c17cbd9b 100644 --- a/internal/backend/local/backend_local_test.go +++ b/internal/backend/local/backend_local_test.go @@ -161,8 +161,9 @@ func TestLocalRun_stalePlan(t *testing.T) { UIMode: plans.NormalMode, Changes: plans.NewChangesSrc(), Backend: &plans.Backend{ - Type: "local", - Config: backendConfigRaw, + Type: "local", + Config: backendConfigRaw, + Workspace: "default", }, PrevRunState: states.NewState(), PriorState: states.NewState(), diff --git a/internal/backend/local/backend_plan.go b/internal/backend/local/backend_plan.go index 0fa256fd3e00..5635510d422a 100644 --- a/internal/backend/local/backend_plan.go +++ b/internal/backend/local/backend_plan.go @@ -149,16 +149,22 @@ func (b *Local) opPlan( // Save the plan to disk if path := op.PlanOutPath; path != "" { - if op.PlanOutBackend == nil { + switch { + case op.PlanOutStateStore != nil: + plan.StateStore = op.PlanOutStateStore + case op.PlanOutBackend != nil: + plan.Backend = op.PlanOutBackend + default: // This is always a bug in the operation caller; it's not valid - // to set PlanOutPath without also setting PlanOutBackend. + // to set PlanOutPath without also setting PlanOutStateStore or PlanOutBackend. + // Even when there is no state_store or backend block in the configuration, there should be a PlanOutBackend + // describing the implied local backend. diags = diags.Append(fmt.Errorf( - "PlanOutPath set without also setting PlanOutBackend (this is a bug in Terraform)"), + "PlanOutPath set without also setting PlanOutStateStore or PlanOutBackend (this is a bug in Terraform)"), ) op.ReportResult(runningOp, diags) return } - plan.Backend = op.PlanOutBackend // We may have updated the state in the refresh step above, but we // will freeze that updated state in the plan file for now and diff --git a/internal/backend/local/backend_plan_test.go b/internal/backend/local/backend_plan_test.go index ace870fe5e29..f7ecb0f6f4bd 100644 --- a/internal/backend/local/backend_plan_test.go +++ b/internal/backend/local/backend_plan_test.go @@ -12,6 +12,7 @@ import ( "github.com/zclconf/go-cty/cty" + "github.com/hashicorp/go-version" "github.com/hashicorp/terraform/internal/addrs" "github.com/hashicorp/terraform/internal/backend/backendrun" "github.com/hashicorp/terraform/internal/command/arguments" @@ -206,8 +207,9 @@ func TestLocal_planOutputsChanged(t *testing.T) { } op.PlanOutBackend = &plans.Backend{ // Just a placeholder so that we can generate a valid plan file. - Type: "local", - Config: cfgRaw, + Type: "local", + Config: cfgRaw, + Workspace: "default", } run, err := b.Operation(context.Background(), op) if err != nil { @@ -262,8 +264,9 @@ func TestLocal_planModuleOutputsChanged(t *testing.T) { t.Fatal(err) } op.PlanOutBackend = &plans.Backend{ - Type: "local", - Config: cfgRaw, + Type: "local", + Config: cfgRaw, + Workspace: "default", } run, err := b.Operation(context.Background(), op) if err != nil { @@ -304,8 +307,9 @@ func TestLocal_planTainted(t *testing.T) { } op.PlanOutBackend = &plans.Backend{ // Just a placeholder so that we can generate a valid plan file. - Type: "local", - Config: cfgRaw, + Type: "local", + Config: cfgRaw, + Workspace: "default", } run, err := b.Operation(context.Background(), op) if err != nil { @@ -383,8 +387,9 @@ func TestLocal_planDeposedOnly(t *testing.T) { } op.PlanOutBackend = &plans.Backend{ // Just a placeholder so that we can generate a valid plan file. - Type: "local", - Config: cfgRaw, + Type: "local", + Config: cfgRaw, + Workspace: "default", } run, err := b.Operation(context.Background(), op) if err != nil { @@ -474,8 +479,9 @@ func TestLocal_planTainted_createBeforeDestroy(t *testing.T) { } op.PlanOutBackend = &plans.Backend{ // Just a placeholder so that we can generate a valid plan file. - Type: "local", - Config: cfgRaw, + Type: "local", + Config: cfgRaw, + Workspace: "default", } run, err := b.Operation(context.Background(), op) if err != nil { @@ -565,8 +571,9 @@ func TestLocal_planDestroy(t *testing.T) { } op.PlanOutBackend = &plans.Backend{ // Just a placeholder so that we can generate a valid plan file. - Type: "local", - Config: cfgRaw, + Type: "local", + Config: cfgRaw, + Workspace: "default", } run, err := b.Operation(context.Background(), op) @@ -617,8 +624,9 @@ func TestLocal_planDestroy_withDataSources(t *testing.T) { } op.PlanOutBackend = &plans.Backend{ // Just a placeholder so that we can generate a valid plan file. - Type: "local", - Config: cfgRaw, + Type: "local", + Config: cfgRaw, + Workspace: "default", } run, err := b.Operation(context.Background(), op) @@ -689,8 +697,9 @@ func TestLocal_planOutPathNoChange(t *testing.T) { } op.PlanOutBackend = &plans.Backend{ // Just a placeholder so that we can generate a valid plan file. - Type: "local", - Config: cfgRaw, + Type: "local", + Config: cfgRaw, + Workspace: "default", } op.PlanRefresh = true @@ -913,3 +922,155 @@ func TestLocal_invalidOptions(t *testing.T) { t.Fatal("expected error output") } } + +// Checks if the state store info set on an Operation makes it into the resulting Plan +func TestLocal_plan_withStateStore(t *testing.T) { + b := TestLocal(t) + + // Note: the mock provider doesn't include an implementation of + // pluggable state storage, but that's not needed for this test. + TestLocalProvider(t, b, "test", planFixtureSchema()) + mockAddr := addrs.NewDefaultProvider("test") + providerVersion := version.Must(version.NewSemver("0.0.1")) + storeType := "test_foobar" + defaultWorkspace := "default" + + testStateFile(t, b.StatePath, testPlanState_withDataSource()) + + outDir := t.TempDir() + planPath := filepath.Join(outDir, "plan.tfplan") + + // Note: the config doesn't include a state_store block. Instead, + // that data is provided below when assigning a value to op.PlanOutStateStore. + // Usually that data is set as a result of parsing configuration. + op, configCleanup, _ := testOperationPlan(t, "./testdata/plan") + defer configCleanup() + op.PlanMode = plans.NormalMode + op.PlanRefresh = true + op.PlanOutPath = planPath + storeCfg := cty.ObjectVal(map[string]cty.Value{ + "path": cty.StringVal(b.StatePath), + }) + storeCfgRaw, err := plans.NewDynamicValue(storeCfg, storeCfg.Type()) + if err != nil { + t.Fatal(err) + } + providerCfg := cty.ObjectVal(map[string]cty.Value{}) // Empty as the mock provider has no schema for the provider + providerCfgRaw, err := plans.NewDynamicValue(providerCfg, providerCfg.Type()) + if err != nil { + t.Fatal(err) + } + op.PlanOutStateStore = &plans.StateStore{ + Type: storeType, + Config: storeCfgRaw, + Provider: &plans.Provider{ + Source: &mockAddr, + Version: providerVersion, + Config: providerCfgRaw, + }, + Workspace: defaultWorkspace, + } + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("bad: %s", err) + } + <-run.Done() + if run.Result != backendrun.OperationSuccess { + t.Fatalf("plan operation failed") + } + + if run.PlanEmpty { + t.Fatal("plan should not be empty") + } + + plan := testReadPlan(t, planPath) + + // The plan should contain details about the state store + if plan.StateStore == nil { + t.Fatalf("Expected plan to describe a state store, but data was missing") + } + // The plan should NOT contain details about a backend + if plan.Backend != nil { + t.Errorf("Expected plan to not describe a backend because a state store is in use, but data was present:\n plan.Backend = %v", plan.Backend) + } + + if plan.StateStore.Type != storeType { + t.Errorf("Expected plan to describe a state store with type %s, but got %s", storeType, plan.StateStore.Type) + } + if plan.StateStore.Workspace != defaultWorkspace { + t.Errorf("Expected plan to describe a state store with workspace %s, but got %s", defaultWorkspace, plan.StateStore.Workspace) + } + if !plan.StateStore.Provider.Source.Equals(mockAddr) { + t.Errorf("Expected plan to describe a state store with provider address %s, but got %s", mockAddr, plan.StateStore.Provider.Source) + } + if !plan.StateStore.Provider.Version.Equal(providerVersion) { + t.Errorf("Expected plan to describe a state store with provider version %s, but got %s", providerVersion, plan.StateStore.Provider.Version) + } +} + +// Checks if the backend info set on an Operation makes it into the resulting Plan +func TestLocal_plan_withBackend(t *testing.T) { + b := TestLocal(t) + + TestLocalProvider(t, b, "test", planFixtureSchema()) + + testStateFile(t, b.StatePath, testPlanState_withDataSource()) + + outDir := t.TempDir() + planPath := filepath.Join(outDir, "plan.tfplan") + + // Note: the config doesn't include a backend block. Instead, + // that data is provided below when assigning a value to op.PlanOutBackend. + // Usually that data is set as a result of parsing configuration. + op, configCleanup, _ := testOperationPlan(t, "./testdata/plan") + defer configCleanup() + op.PlanMode = plans.NormalMode + op.PlanRefresh = true + op.PlanOutPath = planPath + cfg := cty.ObjectVal(map[string]cty.Value{ + "path": cty.StringVal(b.StatePath), + }) + cfgRaw, err := plans.NewDynamicValue(cfg, cfg.Type()) + if err != nil { + t.Fatal(err) + } + backendType := "foobar" + defaultWorkspace := "default" + op.PlanOutBackend = &plans.Backend{ + Type: backendType, + Config: cfgRaw, + Workspace: defaultWorkspace, + } + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("bad: %s", err) + } + <-run.Done() + if run.Result != backendrun.OperationSuccess { + t.Fatalf("plan operation failed") + } + + if run.PlanEmpty { + t.Fatal("plan should not be empty") + } + + plan := testReadPlan(t, planPath) + + // The plan should contain details about the backend + if plan.Backend == nil { + t.Fatalf("Expected plan to describe a backend, but data was missing") + } + // The plan should NOT contain details about a state store + if plan.StateStore != nil { + t.Errorf("Expected plan to not describe a state store because a backend is in use, but data was present:\n plan.StateStore = %v", plan.StateStore) + } + + if plan.Backend.Type != backendType { + t.Errorf("Expected plan to describe a backend with type %s, but got %s", backendType, plan.Backend.Type) + } + if plan.Backend.Workspace != defaultWorkspace { + t.Errorf("Expected plan to describe a backend with workspace %s, but got %s", defaultWorkspace, plan.Backend.Workspace) + } +} diff --git a/internal/command/apply.go b/internal/command/apply.go index 74e8e0378eea..1c10cca357f1 100644 --- a/internal/command/apply.go +++ b/internal/command/apply.go @@ -210,17 +210,17 @@ func (c *ApplyCommand) PrepareBackend(planFile *planfile.WrappedPlanFile, args * )) return nil, diags } - if plan.Backend == nil { + + if plan.Backend == nil && plan.StateStore == nil { // Should never happen; always indicates a bug in the creation of the plan file diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, "Failed to read plan from plan file", - "The given plan file does not have a valid backend configuration. This is a bug in the Terraform command that generated this plan file.", + "The given plan file does not have either a valid backend or state store configuration. This is a bug in the Terraform command that generated this plan file.", )) return nil, diags } - // TODO: Update BackendForLocalPlan to use state storage, and plan to be able to contain State Store config details - be, beDiags = c.BackendForLocalPlan(*plan.Backend) + be, beDiags = c.BackendForLocalPlan(plan) } else { // Load the backend diff --git a/internal/command/apply_test.go b/internal/command/apply_test.go index 38b416f711f2..21df2aeffd1c 100644 --- a/internal/command/apply_test.go +++ b/internal/command/apply_test.go @@ -19,6 +19,8 @@ import ( "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/hashicorp/cli" + version "github.com/hashicorp/go-version" + tfaddr "github.com/hashicorp/terraform-registry-address" "github.com/zclconf/go-cty/cty" "github.com/hashicorp/terraform/internal/addrs" @@ -701,6 +703,96 @@ func TestApply_plan(t *testing.T) { } } +// Test the ability to apply a plan file with a state store. +// +// The state store's details (provider, config, etc) are supplied by the plan, +// which allows this test to not use any configuration. +func TestApply_plan_stateStore(t *testing.T) { + // Disable test mode so input would be asked + test = false + defer func() { test = true }() + + // Set some default reader/writers for the inputs + defaultInputReader = new(bytes.Buffer) + defaultInputWriter = new(bytes.Buffer) + + // Create the plan file that includes a state store + ver := version.Must(version.NewVersion("1.2.3")) + providerCfg := cty.ObjectVal(map[string]cty.Value{ + "region": cty.StringVal("spain"), + }) + providerCfgRaw, err := plans.NewDynamicValue(providerCfg, providerCfg.Type()) + if err != nil { + t.Fatal(err) + } + storeCfg := cty.ObjectVal(map[string]cty.Value{ + "value": cty.StringVal("foobar"), + }) + storeCfgRaw, err := plans.NewDynamicValue(storeCfg, storeCfg.Type()) + if err != nil { + t.Fatal(err) + } + + plan := &plans.Plan{ + Changes: plans.NewChangesSrc(), + + // We'll default to the fake plan being both applyable and complete, + // since that's what most tests expect. Tests can override these + // back to false again afterwards if they need to. + Applyable: true, + Complete: true, + + StateStore: &plans.StateStore{ + Type: "test_store", + Provider: &plans.Provider{ + Version: ver, + Source: &tfaddr.Provider{ + Hostname: tfaddr.DefaultProviderRegistryHost, + Namespace: "hashicorp", + Type: "test", + }, + Config: providerCfgRaw, + }, + Config: storeCfgRaw, + Workspace: "default", + }, + } + + // Create a plan file on disk + // + // In this process we create a plan file describing the creation of a test_instance.foo resource. + state := testState() // State describes + _, snap := testModuleWithSnapshot(t, "apply") + planPath := testPlanFile(t, snap, state, plan) + + // Create a mock, to be used as the pluggable state store described in the planfile + mock := testStateStoreMockWithChunkNegotiation(t, 1000) + view, done := testView(t) + c := &ApplyCommand{ + Meta: Meta{ + testingOverrides: &testingOverrides{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("test"): providers.FactoryFixed(mock), + }, + }, + View: view, + }, + } + + args := []string{ + planPath, + } + code := c.Run(args) + output := done(t) + if code != 0 { + t.Fatalf("bad: %d\n\n%s", code, output.Stderr()) + } + + if !mock.WriteStateBytesCalled { + t.Fatal("expected the test to write new state when applying the plan, but WriteStateBytesCalled is false on the mock provider.") + } +} + func TestApply_plan_backup(t *testing.T) { statePath := testTempFile(t) backupPath := testTempFile(t) @@ -822,8 +914,9 @@ func TestApply_plan_remoteState(t *testing.T) { } planPath := testPlanFile(t, snap, state, &plans.Plan{ Backend: &plans.Backend{ - Type: "http", - Config: backendConfigRaw, + Type: "http", + Config: backendConfigRaw, + Workspace: "default", }, Changes: plans.NewChangesSrc(), }) diff --git a/internal/command/command_test.go b/internal/command/command_test.go index 2c0486dca33c..7c0609388b45 100644 --- a/internal/command/command_test.go +++ b/internal/command/command_test.go @@ -192,8 +192,9 @@ func testPlan(t *testing.T) *plans.Plan { // This is just a placeholder so that the plan file can be written // out. Caller may wish to override it to something more "real" // where the plan will actually be subsequently applied. - Type: "local", - Config: backendConfigRaw, + Type: "local", + Config: backendConfigRaw, + Workspace: "default", }, Changes: plans.NewChangesSrc(), diff --git a/internal/command/e2etest/primary_test.go b/internal/command/e2etest/primary_test.go index ebffa9c37d75..0da21067c0c3 100644 --- a/internal/command/e2etest/primary_test.go +++ b/internal/command/e2etest/primary_test.go @@ -314,6 +314,88 @@ func TestPrimary_stateStore(t *testing.T) { } } +func TestPrimary_stateStore_planFile(t *testing.T) { + + if !canRunGoBuild { + // We're running in a separate-build-then-run context, so we can't + // currently execute this test which depends on being able to build + // new executable at runtime. + // + // (See the comment on canRunGoBuild's declaration for more information.) + t.Skip("can't run without building a new provider executable") + } + + t.Setenv(e2e.TestExperimentFlag, "true") + terraformBin := e2e.GoBuild("github.com/hashicorp/terraform", "terraform") + + fixturePath := filepath.Join("testdata", "full-workflow-with-state-store-fs") + tf := e2e.NewBinary(t, terraformBin, fixturePath) + + // In order to test integration with PSS we need a provider plugin implementing a state store. + // Here will build the simple6 (built with protocol v6) provider, which implements PSS. + simple6Provider := filepath.Join(tf.WorkDir(), "terraform-provider-simple6") + simple6ProviderExe := e2e.GoBuild("github.com/hashicorp/terraform/internal/provider-simple-v6/main", simple6Provider) + + // Move the provider binaries into a directory that we will point terraform + // to using the -plugin-dir cli flag. + platform := getproviders.CurrentPlatform.String() + hashiDir := "cache/registry.terraform.io/hashicorp/" + if err := os.MkdirAll(tf.Path(hashiDir, "simple6/0.0.1/", platform), os.ModePerm); err != nil { + t.Fatal(err) + } + if err := os.Rename(simple6ProviderExe, tf.Path(hashiDir, "simple6/0.0.1/", platform, "terraform-provider-simple6")); err != nil { + t.Fatal(err) + } + + //// INIT + stdout, stderr, err := tf.Run("init", "-enable-pluggable-state-storage-experiment=true", "-plugin-dir=cache", "-no-color") + if err != nil { + t.Fatalf("unexpected init error: %s\nstderr:\n%s", err, stderr) + } + + if !strings.Contains(stdout, "Terraform created an empty state file for the default workspace") { + t.Errorf("notice about creating the default workspace is missing from init output:\n%s", stdout) + } + + //// PLAN + planFile := "testplan" + _, stderr, err = tf.Run("plan", "-out="+planFile, "-no-color") + if err != nil { + t.Fatalf("unexpected apply error: %s\nstderr:\n%s", err, stderr) + } + + //// APPLY + stdout, stderr, err = tf.Run("apply", "-auto-approve", "-no-color", planFile) + if err != nil { + t.Fatalf("unexpected apply error: %s\nstderr:\n%s", err, stderr) + } + + if !strings.Contains(stdout, "Resources: 1 added, 0 changed, 0 destroyed") { + t.Errorf("incorrect apply tally; want 1 added:\n%s", stdout) + } + + // Check the statefile saved by the fs state store. + path := "states/default/terraform.tfstate" + f, err := tf.OpenFile(path) + if err != nil { + t.Fatalf("unexpected error opening state file %s: %s\nstderr:\n%s", path, err, stderr) + } + defer f.Close() + + stateFile, err := statefile.Read(f) + if err != nil { + t.Fatalf("unexpected error reading statefile %s: %s\nstderr:\n%s", path, err, stderr) + } + + r := stateFile.State.RootModule().Resources + if len(r) != 1 { + t.Fatalf("expected state to include one resource, but got %d", len(r)) + } + if _, ok := r["terraform_data.my-data"]; !ok { + t.Fatalf("expected state to include terraform_data.my-data but it's missing") + } +} + func TestPrimary_stateStore_inMem(t *testing.T) { if !canRunGoBuild { // We're running in a separate-build-then-run context, so we can't diff --git a/internal/command/graph_test.go b/internal/command/graph_test.go index dc98f74c71f7..8193614329de 100644 --- a/internal/command/graph_test.go +++ b/internal/command/graph_test.go @@ -329,8 +329,9 @@ func TestGraph_applyPhaseSavedPlan(t *testing.T) { // Doesn't actually matter since we aren't going to activate the backend // for this command anyway, but we need something here for the plan // file writer to succeed. - Type: "placeholder", - Config: emptyObj, + Type: "placeholder", + Config: emptyObj, + Workspace: "default", } _, configSnap := testModuleWithSnapshot(t, "graph") diff --git a/internal/command/meta.go b/internal/command/meta.go index b7e60acc1c6e..d085bc4d9b5c 100644 --- a/internal/command/meta.go +++ b/internal/command/meta.go @@ -208,8 +208,12 @@ type Meta struct { // It is initialized on first use. configLoader *configload.Loader - // backendState is the currently active backend state - backendState *workdir.BackendConfigState + // backendConfigState is the currently active backend state. + // This is used when creating plan files. + backendConfigState *workdir.BackendConfigState + // stateStoreConfigState is the currently active state_store state. + // This is used when creating plan files. + stateStoreConfigState *workdir.StateStoreConfigState // Variables for the context (private) variableArgs arguments.FlagNameValueSlice diff --git a/internal/command/meta_backend.go b/internal/command/meta_backend.go index 61e11dc6b35c..b0208e9bac7f 100644 --- a/internal/command/meta_backend.go +++ b/internal/command/meta_backend.go @@ -29,6 +29,7 @@ import ( "github.com/hashicorp/terraform/internal/backend" "github.com/hashicorp/terraform/internal/backend/backendrun" backendInit "github.com/hashicorp/terraform/internal/backend/init" + "github.com/hashicorp/terraform/internal/backend/local" backendLocal "github.com/hashicorp/terraform/internal/backend/local" backendPluggable "github.com/hashicorp/terraform/internal/backend/pluggable" "github.com/hashicorp/terraform/internal/cloud" @@ -37,6 +38,7 @@ import ( "github.com/hashicorp/terraform/internal/command/views" "github.com/hashicorp/terraform/internal/command/workdir" "github.com/hashicorp/terraform/internal/configs" + "github.com/hashicorp/terraform/internal/configs/configschema" "github.com/hashicorp/terraform/internal/depsfile" "github.com/hashicorp/terraform/internal/didyoumean" "github.com/hashicorp/terraform/internal/getproviders/providerreqs" @@ -221,13 +223,14 @@ func (m *Meta) Backend(opts *BackendOpts) (backendrun.OperationsBackend, tfdiags // the user, since the local backend should only be used when learning or // in exceptional cases and so it's better to help the user learn that // by introducing it as a concept. - if m.backendState == nil { + backendInUse := opts.StateStoreConfig == nil + if backendInUse && m.backendConfigState == nil { // NOTE: This synthetic object is intentionally _not_ retained in the // on-disk record of the backend configuration, which was already dealt // with inside backendFromConfig, because we still need that codepath // to be able to recognize the lack of a config as distinct from // explicitly setting local until we do some more refactoring here. - m.backendState = &workdir.BackendConfigState{ + m.backendConfigState = &workdir.BackendConfigState{ Type: "local", ConfigRaw: json.RawMessage("{}"), } @@ -324,40 +327,213 @@ func (m *Meta) selectWorkspace(b backend.Backend) error { return m.SetWorkspace(workspace) } -// BackendForLocalPlan is similar to Backend, but uses backend settings that were -// stored in a plan. +// BackendForLocalPlan is similar to Backend, but uses settings that were +// stored in a plan when preparing the returned operations backend. +// The plan's data may describe `backend` or `state_store` configuration. // // The current workspace name is also stored as part of the plan, and so this // method will check that it matches the currently-selected workspace name // and produce error diagnostics if not. -func (m *Meta) BackendForLocalPlan(settings plans.Backend) (backendrun.OperationsBackend, tfdiags.Diagnostics) { +func (m *Meta) BackendForLocalPlan(plan *plans.Plan) (backendrun.OperationsBackend, tfdiags.Diagnostics) { var diags tfdiags.Diagnostics - f := backendInit.Backend(settings.Type) - if f == nil { - diags = diags.Append(errBackendSavedUnknown{settings.Type}) - return nil, diags - } - b := f() - log.Printf("[TRACE] Meta.BackendForLocalPlan: instantiated backend of type %T", b) + var b backend.Backend + switch { + case plan.StateStore != nil: + settings := plan.StateStore + + // BackendForLocalPlan is used in the context of an apply command using a plan file, + // so we can read locks directly from the lock file and trust it contains what we need. + locks, lockDiags := m.lockedDependencies() + diags = diags.Append(lockDiags) + if lockDiags.HasErrors() { + return nil, diags + } - schema := b.ConfigSchema() - configVal, err := settings.Config.Decode(schema.ImpliedType()) - if err != nil { - diags = diags.Append(fmt.Errorf("saved backend configuration is invalid: %w", err)) - return nil, diags - } + factories, err := m.ProviderFactoriesFromLocks(locks) + if err != nil { + // This may happen if the provider isn't present in the provider cache. + // This should be caught earlier by logic that diffs the config against the backend state file. + return nil, diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Provider unavailable", + Detail: fmt.Sprintf("Terraform experienced an error when trying to use provider %s (%q) to initialize the %q state store: %s", + settings.Provider.Source.Type, + settings.Provider.Source, + settings.Type, + err), + }) + } - newVal, validateDiags := b.PrepareConfig(configVal) - diags = diags.Append(validateDiags) - if validateDiags.HasErrors() { - return nil, diags - } + factory, exists := factories[*settings.Provider.Source] + if !exists { + return nil, diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Provider unavailable", + Detail: fmt.Sprintf("The provider %s (%q) is required to initialize the %q state store, but the matching provider factory is missing. This is a bug in Terraform and should be reported.", + settings.Provider.Source.Type, + settings.Provider.Source, + settings.Type, + ), + }) + } - configureDiags := b.Configure(newVal) - diags = diags.Append(configureDiags) - if configureDiags.HasErrors() { - return nil, diags + provider, err := factory() + if err != nil { + diags = diags.Append(fmt.Errorf("error when obtaining provider instance during state store initialization: %w", err)) + return nil, diags + } + + // We purposefully don't have a deferred call to the provider's Close method here because the calling code needs a + // running provider instance inside the returned backend.Backend instance. + // Stopping the provider process is the responsibility of the calling code. + + resp := provider.GetProviderSchema() + + if len(resp.StateStores) == 0 { + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Provider does not support pluggable state storage", + Detail: fmt.Sprintf("There are no state stores implemented by provider %s (%q)", + settings.Provider.Source.Type, + settings.Provider.Source), + }) + return nil, diags + } + + stateStoreSchema, exists := resp.StateStores[settings.Type] + if !exists { + suggestions := slices.Sorted(maps.Keys(resp.StateStores)) + suggestion := didyoumean.NameSuggestion(settings.Type, suggestions) + if suggestion != "" { + suggestion = fmt.Sprintf(" Did you mean %q?", suggestion) + } + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "State store not implemented by the provider", + Detail: fmt.Sprintf("State store %q is not implemented by provider %s (%q)%s", + settings.Type, + settings.Provider.Source.Type, + settings.Provider.Source, + suggestion), + }) + return nil, diags + } + + // Get the provider config from the backend state file. + providerConfigVal, err := settings.Provider.Config.Decode(resp.Provider.Body.ImpliedType()) + if err != nil { + diags = diags.Append( + &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Error reading provider configuration state", + Detail: fmt.Sprintf("Terraform experienced an error reading provider configuration for provider %s (%q) while configuring state store %s: %s", + settings.Provider.Source.Type, + settings.Provider.Source, + settings.Type, + err, + ), + }, + ) + return nil, diags + } + + // Get the state store config from the backend state file. + stateStoreConfigVal, err := settings.Config.Decode(stateStoreSchema.Body.ImpliedType()) + if err != nil { + diags = diags.Append( + &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Error reading state store configuration state", + Detail: fmt.Sprintf("Terraform experienced an error reading state store configuration for state store %s in provider %s (%q): %s", + settings.Type, + settings.Provider.Source.Type, + settings.Provider.Source, + err, + ), + }, + ) + return nil, diags + } + + // Validate and configure the provider + // + // NOTE: there are no marks we need to remove at this point. + // We haven't added marks since the provider config from the backend state was used + // because the state-storage provider's config isn't going to be presented to the user via terminal output or diags. + validateResp := provider.ValidateProviderConfig(providers.ValidateProviderConfigRequest{ + Config: providerConfigVal, + }) + diags = diags.Append(validateResp.Diagnostics) + if diags.HasErrors() { + return nil, diags + } + + configureResp := provider.ConfigureProvider(providers.ConfigureProviderRequest{ + TerraformVersion: tfversion.SemVer.String(), + Config: providerConfigVal, + }) + diags = diags.Append(configureResp.Diagnostics) + if diags.HasErrors() { + return nil, diags + } + + // Now that the provider is configured we can begin using the state store through + // the backend.Backend interface. + p, err := backendPluggable.NewPluggable(provider, settings.Type) + if err != nil { + diags = diags.Append(err) + return nil, diags + } + + // Validate and configure the state store + // + // Note: we do not use the value returned from PrepareConfig for state stores, + // however that old approach is still used with backends for compatibility reasons. + _, validateDiags := p.PrepareConfig(stateStoreConfigVal) + diags = diags.Append(validateDiags) + if validateDiags.HasErrors() { + return nil, diags + } + + configureDiags := p.Configure(stateStoreConfigVal) + diags = diags.Append(configureDiags) + if configureDiags.HasErrors() { + return nil, diags + } + + // The fully configured Pluggable is used as the instance of backend.Backend + b = p + + default: + settings := plan.Backend + + f := backendInit.Backend(settings.Type) + if f == nil { + diags = diags.Append(errBackendSavedUnknown{settings.Type}) + return nil, diags + } + b = f() + log.Printf("[TRACE] Meta.BackendForLocalPlan: instantiated backend of type %T", b) + + schema := b.ConfigSchema() + configVal, err := settings.Config.Decode(schema.ImpliedType()) + if err != nil { + diags = diags.Append(fmt.Errorf("saved backend configuration is invalid: %w", err)) + return nil, diags + } + + newVal, validateDiags := b.PrepareConfig(configVal) + diags = diags.Append(validateDiags) + if validateDiags.HasErrors() { + return nil, diags + } + + configureDiags := b.Configure(newVal) + diags = diags.Append(configureDiags) + if configureDiags.HasErrors() { + return nil, diags + } } // If the backend supports CLI initialization, do it. @@ -440,13 +616,42 @@ func (m *Meta) Operation(b backend.Backend, vt arguments.ViewType) *backendrun.O // here first is a bug, so panic. panic(fmt.Sprintf("invalid workspace: %s", err)) } - planOutBackend, err := m.backendState.PlanData(schema, nil, workspace) - if err != nil { - // Always indicates an implementation error in practice, because - // errors here indicate invalid encoding of the backend configuration - // in memory, and we should always have validated that by the time - // we get here. - panic(fmt.Sprintf("failed to encode backend configuration for plan: %s", err)) + + var planOutBackend *plans.Backend + var planOutStateStore *plans.StateStore + switch { + case m.backendConfigState != nil && m.stateStoreConfigState != nil: + // Both set + panic("failed to encode backend configuration for plan: both backend and state_store data present but they are mutually exclusive") + case m.stateStoreConfigState != nil: + // To access the provider schema, we need to access the underlying backends + var providerSchema *configschema.Block + if lb, ok := b.(*local.Local); ok { + if p, ok := lb.Backend.(*backendPluggable.Pluggable); ok { + providerSchema = p.ProviderSchema() + } + } + + // TODO: do we need to protect against a nil provider schema? When a provider has an empty schema does that present as nil? + + planOutStateStore, err = m.stateStoreConfigState.PlanData(schema, providerSchema, workspace) + if err != nil { + // Always indicates an implementation error in practice, because + // errors here indicate invalid encoding of the state_store configuration + // in memory, and we should always have validated that by the time + // we get here. + panic(fmt.Sprintf("failed to encode state_store configuration for plan: %s", err)) + } + default: + // Either backendConfigState is set, or it's nil; PlanData method can handle either. + planOutBackend, err = m.backendConfigState.PlanData(schema, nil, workspace) + if err != nil { + // Always indicates an implementation error in practice, because + // errors here indicate invalid encoding of the backend configuration + // in memory, and we should always have validated that by the time + // we get here. + panic(fmt.Sprintf("failed to encode backend configuration for plan: %s", err)) + } } stateLocker := clistate.NewNoopLocker() @@ -465,8 +670,11 @@ func (m *Meta) Operation(b backend.Backend, vt arguments.ViewType) *backendrun.O log.Printf("[WARN] Failed to load dependency locks while preparing backend operation (ignored): %s", diags.Err().Error()) } - return &backendrun.Operation{ - PlanOutBackend: planOutBackend, + op := &backendrun.Operation{ + // These two fields are mutually exclusive; one is being assigned a nil value below. + PlanOutBackend: planOutBackend, + PlanOutStateStore: planOutStateStore, + Targets: m.targets, UIIn: m.UIInput(), UIOut: m.Ui, @@ -474,6 +682,12 @@ func (m *Meta) Operation(b backend.Backend, vt arguments.ViewType) *backendrun.O StateLocker: stateLocker, DependencyLocks: depLocks, } + + if op.PlanOutBackend != nil && op.PlanOutStateStore != nil { + panic("failed to prepare operation: both backend and state_store configurations are present") + } + + return op } // backendConfig returns the local configuration for the backend @@ -727,10 +941,21 @@ func (m *Meta) backendFromConfig(opts *BackendOpts) (backend.Backend, tfdiags.Di // Upon return, we want to set the state we're using in-memory so that // we can access it for commands. - m.backendState = nil + m.backendConfigState = nil + m.stateStoreConfigState = nil defer func() { - if s := sMgr.State(); s != nil && !s.Backend.Empty() { - m.backendState = s.Backend + s := sMgr.State() + switch { + case s == nil: + // Do nothing + + // TODO: Should we add a synthetic object here, + // as part of addressing actions described in this FIXME? + // https://github.com/hashicorp/terraform/blob/053738fbf08d50261eccb463580525b88f461d8e/internal/command/meta_backend.go#L222-L243 + case !s.Backend.Empty(): + m.backendConfigState = s.Backend + case !s.StateStore.Empty(): + m.stateStoreConfigState = s.StateStore } }() diff --git a/internal/command/meta_backend_test.go b/internal/command/meta_backend_test.go index 05803622cbb1..9911309c4586 100644 --- a/internal/command/meta_backend_test.go +++ b/internal/command/meta_backend_test.go @@ -1560,7 +1560,7 @@ func TestMetaBackend_configuredBackendUnsetCopy(t *testing.T) { } } -// A plan that has uses the local backend +// A plan that has uses the local backend and local state storage func TestMetaBackend_planLocal(t *testing.T) { // Create a temporary working directory that is empty td := t.TempDir() @@ -1575,17 +1575,19 @@ func TestMetaBackend_planLocal(t *testing.T) { if err != nil { t.Fatal(err) } - backendConfig := plans.Backend{ - Type: "local", - Config: backendConfigRaw, - Workspace: "default", + plan := &plans.Plan{ + Backend: &plans.Backend{ + Type: "local", + Config: backendConfigRaw, + Workspace: "default", + }, } // Setup the meta m := testMetaBackend(t, nil) // Get the backend - b, diags := m.BackendForLocalPlan(backendConfig) + b, diags := m.BackendForLocalPlan(plan) if diags.HasErrors() { t.Fatal(diags.Err()) } @@ -1649,6 +1651,71 @@ func TestMetaBackend_planLocal(t *testing.T) { } } +// A plan that has uses the local backend and pluggable state storage +func TestMetaBackend_planLocal_stateStore(t *testing.T) { + // Create a temporary working directory + td := t.TempDir() + testCopyDir(t, testFixturePath("state-store-unchanged"), td) + t.Chdir(td) + + stateStoreConfigBlock := cty.ObjectVal(map[string]cty.Value{ + "value": cty.StringVal("foobar"), + }) + stateStoreConfigRaw, err := plans.NewDynamicValue(stateStoreConfigBlock, stateStoreConfigBlock.Type()) + if err != nil { + t.Fatal(err) + } + providerAddr := addrs.MustParseProviderSourceString("registry.terraform.io/hashicorp/test") + + plan := &plans.Plan{ + StateStore: &plans.StateStore{ + Type: "test_store", + Config: stateStoreConfigRaw, + Workspace: backend.DefaultStateName, + Provider: &plans.Provider{ + Version: version.Must(version.NewVersion("1.2.3")), // Matches lock file in the test fixtures + Source: &providerAddr, + Config: nil, + }, + }, + } + + // Setup the meta, including a mock provider set up to mock PSS + m := testMetaBackend(t, nil) + mock := testStateStoreMockWithChunkNegotiation(t, 1000) + m.testingOverrides = &testingOverrides{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("test"): providers.FactoryFixed(mock), + }, + } + + // Get the backend + b, diags := m.BackendForLocalPlan(plan) + if diags.HasErrors() { + t.Fatal(diags.Err()) + } + + // Check the state + s, sDiags := b.StateMgr(backend.DefaultStateName) + if sDiags.HasErrors() { + t.Fatalf("unexpected error: %s", sDiags.Err()) + } + if err := s.RefreshState(); err != nil { + t.Fatalf("unexpected error: %s", err) + } + state := s.State() + if state != nil { + t.Fatalf("state should be nil: %#v", state) + } + + // Write some state + state = states.NewState() + s.WriteState(state) + if err := s.PersistState(nil); err != nil { + t.Fatalf("unexpected error: %s", err) + } +} + // A plan with a custom state save path func TestMetaBackend_planLocalStatePath(t *testing.T) { td := t.TempDir() @@ -1666,10 +1733,12 @@ func TestMetaBackend_planLocalStatePath(t *testing.T) { if err != nil { t.Fatal(err) } - plannedBackend := plans.Backend{ - Type: "local", - Config: backendConfigRaw, - Workspace: "default", + plan := &plans.Plan{ + Backend: &plans.Backend{ + Type: "local", + Config: backendConfigRaw, + Workspace: "default", + }, } // Create an alternate output path @@ -1686,7 +1755,7 @@ func TestMetaBackend_planLocalStatePath(t *testing.T) { m.stateOutPath = statePath // Get the backend - b, diags := m.BackendForLocalPlan(plannedBackend) + b, diags := m.BackendForLocalPlan(plan) if diags.HasErrors() { t.Fatal(diags.Err()) } @@ -1765,17 +1834,19 @@ func TestMetaBackend_planLocalMatch(t *testing.T) { if err != nil { t.Fatal(err) } - backendConfig := plans.Backend{ - Type: "local", - Config: backendConfigRaw, - Workspace: "default", + plan := &plans.Plan{ + Backend: &plans.Backend{ + Type: "local", + Config: backendConfigRaw, + Workspace: "default", + }, } // Setup the meta m := testMetaBackend(t, nil) // Get the backend - b, diags := m.BackendForLocalPlan(backendConfig) + b, diags := m.BackendForLocalPlan(plan) if diags.HasErrors() { t.Fatal(diags.Err()) } diff --git a/internal/command/plan_test.go b/internal/command/plan_test.go index 710dfef29efd..ac75461527bd 100644 --- a/internal/command/plan_test.go +++ b/internal/command/plan_test.go @@ -22,11 +22,13 @@ import ( "github.com/hashicorp/terraform/internal/addrs" backendinit "github.com/hashicorp/terraform/internal/backend/init" "github.com/hashicorp/terraform/internal/checks" + "github.com/hashicorp/terraform/internal/command/clistate" "github.com/hashicorp/terraform/internal/configs/configschema" "github.com/hashicorp/terraform/internal/plans" "github.com/hashicorp/terraform/internal/providers" testing_provider "github.com/hashicorp/terraform/internal/providers/testing" "github.com/hashicorp/terraform/internal/states" + "github.com/hashicorp/terraform/internal/states/statefile" "github.com/hashicorp/terraform/internal/tfdiags" ) @@ -473,6 +475,156 @@ func TestPlan_outBackend(t *testing.T) { } } +// When using "-out" with a state store, the plan should encode the state store config +func TestPlan_outStateStore(t *testing.T) { + // Create a temporary working directory with state_store config + td := t.TempDir() + testCopyDir(t, testFixturePath("plan-out-state-store"), td) + t.Chdir(td) + + // Make state that resembles the resource defined in the test fixture + originalState := states.BuildState(func(s *states.SyncState) { + s.SetResourceInstanceCurrent( + addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "test_instance", + Name: "foo", + }.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance), + &states.ResourceInstanceObjectSrc{ + AttrsJSON: []byte(`{"id":"bar","ami":"bar"}`), + Status: states.ObjectReady, + }, + addrs.AbsProviderConfig{ + Provider: addrs.NewDefaultProvider("test"), + Module: addrs.RootModule, + }, + ) + }) + var stateBuf bytes.Buffer + if err := statefile.Write(statefile.New(originalState, "", 1), &stateBuf); err != nil { + t.Fatalf("error during test setup: %s", err) + } + stateBytes := stateBuf.Bytes() + + // Make a mock provider that: + // 1) will return the state defined above. + // 2) has a schema for the resource being managed in this test. + mock := mockPluggableStateStorageProvider() + mock.MockStates = map[string]interface{}{ + "default": stateBytes, + } + mock.GetProviderSchemaResponse.ResourceTypes = map[string]providers.Schema{ + "test_instance": { + Body: &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "id": { + Type: cty.String, + Computed: true, + }, + "ami": { + Type: cty.String, + Optional: true, + }, + }, + }, + }, + } + mock.PlanResourceChangeFn = func(req providers.PlanResourceChangeRequest) providers.PlanResourceChangeResponse { + return providers.PlanResourceChangeResponse{ + PlannedState: req.ProposedNewState, + } + } + view, done := testView(t) + c := &PlanCommand{ + Meta: Meta{ + AllowExperimentalFeatures: true, + testingOverrides: metaOverridesForProvider(mock), + View: view, + }, + } + + outPath := "foo" + args := []string{ + "-out", outPath, + "-no-color", + } + code := c.Run(args) + output := done(t) + if code != 0 { + t.Logf("stdout: %s", output.Stdout()) + t.Fatalf("plan command failed with exit code %d\n\n%s", code, output.Stderr()) + } + + plan := testReadPlan(t, outPath) + if !plan.Changes.Empty() { + t.Fatalf("Expected empty plan to be written to plan file, got: %s", spew.Sdump(plan)) + } + + if plan.Backend != nil { + t.Errorf("expected the planfile to not describe a backend, but got %#v", plan.Backend) + } + if plan.StateStore == nil { + t.Errorf("expected the planfile to describe a state store, but it's empty: %#v", plan.StateStore) + } + if got, want := plan.StateStore.Workspace, "default"; got != want { + t.Errorf("wrong workspace %q; want %q", got, want) + } + { + // Comparing the plan's description of the state store + // to the backend state file's description of the state store: + statePath := ".terraform/terraform.tfstate" + sMgr := &clistate.LocalState{Path: statePath} + if err := sMgr.RefreshState(); err != nil { + t.Fatal(err) + } + s := sMgr.State() // The plan should resemble this. + + if !plan.StateStore.Provider.Version.Equal(s.StateStore.Provider.Version) { + t.Fatalf("wrong provider version, got %q; want %q", + plan.StateStore.Provider.Version, + s.StateStore.Provider.Version, + ) + } + if !plan.StateStore.Provider.Source.Equals(*s.StateStore.Provider.Source) { + t.Fatalf("wrong provider source, got %q; want %q", + plan.StateStore.Provider.Source, + s.StateStore.Provider.Source, + ) + } + + // Is the provider config data correct? + providerSchema := mock.GetProviderSchemaResponse.Provider + providerTy := providerSchema.Body.ImpliedType() + pGot, err := plan.StateStore.Provider.Config.Decode(providerTy) + if err != nil { + t.Fatalf("failed to decode provider config in plan: %s", err) + } + pWant, err := s.StateStore.Provider.Config(providerSchema.Body) + if err != nil { + t.Fatalf("failed to decode cached provider config: %s", err) + } + if !pWant.RawEquals(pGot) { + t.Errorf("wrong provider config\ngot: %#v\nwant: %#v", pGot, pWant) + } + + // Is the store config data correct? + storeSchema := mock.GetProviderSchemaResponse.StateStores["test_store"] + ty := storeSchema.Body.ImpliedType() + sGot, err := plan.StateStore.Config.Decode(ty) + if err != nil { + t.Fatalf("failed to decode state store config in plan: %s", err) + } + + sWant, err := s.StateStore.Config(storeSchema.Body) + if err != nil { + t.Fatalf("failed to decode cached state store config: %s", err) + } + if !sWant.RawEquals(sGot) { + t.Errorf("wrong state store config\ngot: %#v\nwant: %#v", sGot, sWant) + } + } +} + func TestPlan_refreshFalse(t *testing.T) { // Create a temporary working directory that is empty td := t.TempDir() diff --git a/internal/command/testdata/plan-out-state-store/.terraform.lock.hcl b/internal/command/testdata/plan-out-state-store/.terraform.lock.hcl new file mode 100644 index 000000000000..e5c03757a7fa --- /dev/null +++ b/internal/command/testdata/plan-out-state-store/.terraform.lock.hcl @@ -0,0 +1,6 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/test" { + version = "1.2.3" +} diff --git a/internal/command/testdata/plan-out-state-store/.terraform/terraform.tfstate b/internal/command/testdata/plan-out-state-store/.terraform/terraform.tfstate new file mode 100644 index 000000000000..4f96aa73ee7d --- /dev/null +++ b/internal/command/testdata/plan-out-state-store/.terraform/terraform.tfstate @@ -0,0 +1,19 @@ +{ + "version": 3, + "serial": 0, + "lineage": "666f9301-7e65-4b19-ae23-71184bb19b03", + "state_store": { + "type": "test_store", + "config": { + "value": "foobar" + }, + "provider": { + "version": "1.2.3", + "source": "registry.terraform.io/hashicorp/test", + "config": { + "region": null + } + }, + "hash": 4158988729 + } +} \ No newline at end of file diff --git a/internal/command/testdata/plan-out-state-store/main.tf b/internal/command/testdata/plan-out-state-store/main.tf new file mode 100644 index 000000000000..d38a6c30025e --- /dev/null +++ b/internal/command/testdata/plan-out-state-store/main.tf @@ -0,0 +1,17 @@ +terraform { + required_providers { + test = { + source = "hashicorp/test" + version = "1.2.3" + } + } + state_store "test_store" { + provider "test" {} + + value = "foobar" + } +} + +resource "test_instance" "foo" { + ami = "bar" +} diff --git a/internal/command/workdir/statestore_config_state.go b/internal/command/workdir/statestore_config_state.go index 5fa2a2fe77ca..9d6bf904e19b 100644 --- a/internal/command/workdir/statestore_config_state.go +++ b/internal/command/workdir/statestore_config_state.go @@ -114,7 +114,7 @@ func (s *StateStoreConfigState) SetConfig(val cty.Value, schema *configschema.Bl // encode the state store-specific configuration settings. func (s *StateStoreConfigState) PlanData(storeSchema *configschema.Block, providerSchema *configschema.Block, workspaceName string) (*plans.StateStore, error) { if s == nil { - return nil, nil + panic("PlanData called on a nil *StateStoreConfigState receiver. This is a bug in Terraform and should be reported.") } if err := s.Validate(); err != nil { diff --git a/internal/plans/plan.go b/internal/plans/plan.go index 53b18c951509..9fe4f0c84731 100644 --- a/internal/plans/plan.go +++ b/internal/plans/plan.go @@ -4,6 +4,8 @@ package plans import ( + "errors" + "fmt" "sort" "time" @@ -181,9 +183,30 @@ func (p *Plan) ProviderAddrs() []addrs.AbsProviderConfig { } m := map[string]addrs.AbsProviderConfig{} + + // Get all provider requirements from resources. for _, rc := range p.Changes.Resources { m[rc.ProviderAddr.String()] = rc.ProviderAddr } + + // Get the provider required for pluggable state storage, if that's in use. + // + // This check should be redundant as: + // 1) Any provider used for state storage would be in required_providers, which is checked separately elsewhere. + // 2) An apply operation that uses the planfile only checks the providers needed for the plan _after_ the operations backend + // for the operation is set up, and that process will detect if the provider needed for state storage is missing. + // + // However, for completeness when describing the providers needed by a plan, it is included here. + if p.StateStore != nil { + address := addrs.AbsProviderConfig{ + Module: addrs.RootModule, // A state_store block is only ever in the root module + Provider: *p.StateStore.Provider.Source, + // Alias: aliases are not permitted when using a provider for PSS. + } + + m[p.StateStore.Provider.Source.String()] = address + } + if len(m) == 0 { return nil } @@ -235,6 +258,22 @@ func NewBackend(typeName string, config cty.Value, configSchema *configschema.Bl }, nil } +func (b *Backend) Validate() error { + if b == nil { + return errors.New("plan contains a nil Backend") + } + if b.Type == "" { + return fmt.Errorf("plan's description of a backend has an unset Type: %#v", b) + } + if b.Workspace == "" { + return fmt.Errorf("plan's description of a backend has the Workspace unset: %#v", b) + } + if len(b.Config) == 0 { + return fmt.Errorf("plan's description of a backend includes no Config: %#v", b) + } + return nil +} + // StateStore represents the state store-related configuration and other data as it // existed when a plan was created. type StateStore struct { @@ -243,8 +282,8 @@ type StateStore struct { Provider *Provider - // Config is the configuration of the state store, whose schema is obtained - // from the host provider's GetProviderSchema response. + // Config is the configuration of the state store, excluding the nested provider block. + // The schema is determined by the state store's type and data received via GetProviderSchema RPC. Config DynamicValue // Workspace is the name of the workspace that was active when the plan @@ -256,15 +295,50 @@ type StateStore struct { Workspace string } +func (s *StateStore) Validate() error { + if s == nil { + return errors.New("plan contains a nil StateStore") + } + if s.Type == "" { + return fmt.Errorf("plan's description of a state store has an unset Type: %#v", s) + } + if len(s.Config) == 0 { + return fmt.Errorf("plan's description of a state store includes no Config: %#v", s) + } + if err := s.Provider.Validate(); err != nil { + return err + } + if s.Workspace == "" { + return fmt.Errorf("plan's description of a state store has an unset Workspace: %#v", s) + } + return nil +} + type Provider struct { Version *version.Version // The specific provider version used for the state store. Should be set using a getproviders.Version, etc. Source *tfaddr.Provider // The FQN/fully-qualified name of the provider. - // Config is the configuration of the state store, whose schema is obtained - // from the host provider's GetProviderSchema response. + // Config is the configuration of the provider block nested within state_store. + // The schema is determined by data received via GetProviderSchema RPC. Config DynamicValue } +func (p *Provider) Validate() error { + if p == nil { + return errors.New("plan's description of a state store contains a nil Provider") + } + if p.Version == nil { + return fmt.Errorf("plan's description of a state store contains a nil provider Version: %#v", p) + } + if p.Source == nil { + return fmt.Errorf("plan's description of a state store contains a nil provider Source: %#v", p) + } + if len(p.Config) == 0 { + return fmt.Errorf("plan's description of a state store includes no provider Config: %#v", p) + } + return nil +} + func NewStateStore(typeName string, ver *version.Version, source *tfaddr.Provider, storeConfig cty.Value, storeSchema *configschema.Block, providerConfig cty.Value, providerSchema *configschema.Block, workspaceName string) (*StateStore, error) { sdv, err := NewDynamicValue(storeConfig, storeSchema.ImpliedType()) if err != nil { diff --git a/internal/plans/plan_test.go b/internal/plans/plan_test.go index eca7e64d8327..0d2ee401f012 100644 --- a/internal/plans/plan_test.go +++ b/internal/plans/plan_test.go @@ -7,14 +7,42 @@ import ( "testing" "github.com/go-test/deep" + "github.com/zclconf/go-cty/cty" + version "github.com/hashicorp/go-version" "github.com/hashicorp/terraform/internal/addrs" + "github.com/hashicorp/terraform/internal/configs/configschema" ) func TestProviderAddrs(t *testing.T) { + // Inputs for plan + provider := &Provider{} + err := provider.SetSource("registry.terraform.io/hashicorp/pluggable") + if err != nil { + panic(err) + } + err = provider.SetVersion("9.9.9") + if err != nil { + panic(err) + } + config, err := NewDynamicValue(cty.ObjectVal(map[string]cty.Value{ + "foo": cty.StringVal("bar"), + }), cty.Object(map[string]cty.Type{ + "foo": cty.String, + })) + if err != nil { + panic(err) + } + provider.Config = config // Prepare plan plan := &Plan{ + StateStore: &StateStore{ + Type: "pluggable_foobar", + Provider: provider, + Config: config, + Workspace: "default", + }, VariableValues: map[string]DynamicValue{}, Changes: &ChangesSrc{ Resources: []*ResourceInstanceChangeSrc{ @@ -67,6 +95,11 @@ func TestProviderAddrs(t *testing.T) { Module: addrs.RootModule, Provider: addrs.NewDefaultProvider("test"), }, + // Provider used for pluggable state storage + { + Module: addrs.RootModule, + Provider: addrs.NewDefaultProvider("pluggable"), + }, } for _, problem := range deep.Equal(got, want) { @@ -74,6 +107,188 @@ func TestProviderAddrs(t *testing.T) { } } +func TestBackend_Validate(t *testing.T) { + + typeName := "foobar" + workspace := "default" + config := cty.ObjectVal(map[string]cty.Value{ + "bool": cty.BoolVal(true), + }) + schema := &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "bool": { + Type: cty.Bool, + }, + }, + } + + // Not-empty cases + t.Run("backend is not valid if all values are set", func(t *testing.T) { + b, err := NewBackend(typeName, config, schema, workspace) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + if err := b.Validate(); err != nil { + t.Fatalf("expected the Backend to be valid, given all input values were provided: %s", err) + } + }) + t.Run("backend is not empty if the schema contains no attributes or blocks", func(t *testing.T) { + emptyConfig := cty.ObjectVal(map[string]cty.Value{}) + emptySchema := &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + // No attributes + }, + } + b, err := NewBackend(typeName, emptyConfig, emptySchema, workspace) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + if err := b.Validate(); err != nil { + t.Fatalf("expected the Backend to be valid, as empty schemas should be tolerated: %s", err) + } + }) + + // Empty cases + t.Run("backend is empty if type name is missing", func(t *testing.T) { + b, err := NewBackend("", config, schema, workspace) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + if err := b.Validate(); err == nil { + t.Fatalf("expected the Backend to be invalid, given the type being unset: %#v", b) + } + }) + t.Run("backend is empty if workspace name is missing", func(t *testing.T) { + b, err := NewBackend(typeName, config, schema, "") + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + if err := b.Validate(); err == nil { + t.Fatalf("expected the Backend to be invalid, given the type being unset: %#v", b) + } + }) +} + +func TestStateStore_Validate(t *testing.T) { + typeName := "test_store" + providerVersion := version.Must(version.NewSemver("1.2.3")) + source := addrs.MustParseProviderSourceString("hashicorp/test") + workspace := "default" + config := cty.ObjectVal(map[string]cty.Value{ + "bool": cty.BoolVal(true), + }) + schema := &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "bool": { + Type: cty.Bool, + }, + }, + } + + // Not-empty cases + t.Run("state store is not empty if all values are set", func(t *testing.T) { + s, err := NewStateStore(typeName, providerVersion, &source, config, schema, config, schema, workspace) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + if err := s.Validate(); err != nil { + t.Fatalf("expected the StateStore to be valid, given all input values were provided: %s", err) + } + }) + t.Run("state store is not empty if the state store config is present but contains all null values", func(t *testing.T) { + nullConfig := cty.ObjectVal(map[string]cty.Value{ + "bool": cty.NullVal(cty.Bool), + }) + s, err := NewStateStore(typeName, providerVersion, &source, nullConfig, schema, config, schema, workspace) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + if err := s.Validate(); err != nil { + t.Fatalf("expected the StateStore to be valid, despite the state store config containing only null values: %s", err) + } + }) + t.Run("state store is not empty if the provider config is present but contains all null values", func(t *testing.T) { + nullConfig := cty.ObjectVal(map[string]cty.Value{ + "bool": cty.NullVal(cty.Bool), + }) + s, err := NewStateStore(typeName, providerVersion, &source, nullConfig, schema, config, schema, workspace) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + if err := s.Validate(); err != nil { + t.Fatalf("expected the StateStore to be valid, despite the provider config containing only null values: %s", err) + } + }) + t.Run("state store is not incorrectly identified as empty if the state store's schema contains no attributes or blocks", func(t *testing.T) { + emptyConfig := cty.ObjectVal(map[string]cty.Value{}) + emptySchema := &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + // No attributes + }, + } + s, err := NewStateStore(typeName, providerVersion, &source, emptyConfig, emptySchema, config, schema, workspace) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + if err := s.Validate(); err != nil { + t.Fatalf("expected the StateStore to be valid, as empty schemas should be tolerated: %s", err) + } + }) + t.Run("state store is not incorrectly identified as empty if the provider's schema contains no attributes or blocks", func(t *testing.T) { + emptyConfig := cty.ObjectVal(map[string]cty.Value{}) + emptySchema := &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + // No attributes + }, + } + s, err := NewStateStore(typeName, providerVersion, &source, config, schema, emptyConfig, emptySchema, workspace) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + if err := s.Validate(); err != nil { + t.Fatalf("expected the StateStore to be valid, as empty schemas should be tolerated: %s", err) + } + }) + + // Empty cases + t.Run("state store is empty if the type is missing", func(t *testing.T) { + s, err := NewStateStore("", providerVersion, &source, config, schema, config, schema, workspace) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + if err := s.Validate(); err == nil { + t.Fatalf("expected the StateStore to be invalid, given the type name is missing: %s", err) + } + }) + t.Run("state store is empty if the provider version is missing", func(t *testing.T) { + s, err := NewStateStore(typeName, nil, &source, config, schema, config, schema, workspace) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + if err := s.Validate(); err == nil { + t.Fatalf("expected the StateStore to be invalid, given the version is missing: %s", err) + } + }) + t.Run("state store is empty if the provider source is missing", func(t *testing.T) { + s, err := NewStateStore(typeName, providerVersion, nil, config, schema, config, schema, workspace) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + if err := s.Validate(); err == nil { + t.Fatalf("expected the StateStore to be invalid, given the version is missing: %s", err) + } + }) + t.Run("state store is empty if the workspace name is missing", func(t *testing.T) { + s, err := NewStateStore(typeName, providerVersion, &source, cty.NilVal, schema, config, schema, "") + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + if err := s.Validate(); err == nil { + t.Fatalf("expected the StateStore to be invalid, given the workspace name is missing: %s", err) + } + }) +} + // Module outputs should not effect the result of Empty func TestModuleOutputChangesEmpty(t *testing.T) { changes := &ChangesSrc{ diff --git a/internal/plans/planfile/tfplan.go b/internal/plans/planfile/tfplan.go index b89ed65e6661..ae872a066f77 100644 --- a/internal/plans/planfile/tfplan.go +++ b/internal/plans/planfile/tfplan.go @@ -227,12 +227,13 @@ func readTfplan(r io.Reader) (*plans.Plan, error) { Config: config, Workspace: rawBackend.Workspace, } - case rawPlan.StateStore != nil: - rawStateStore := rawPlan.StateStore - config, err := valueFromTfplan(rawStateStore.Config) + err = plan.Backend.Validate() if err != nil { - return nil, fmt.Errorf("plan file has invalid state_store configuration: %s", err) + return nil, fmt.Errorf("plan describes an invalid backend: %w", err) } + case rawPlan.StateStore != nil: + rawStateStore := rawPlan.StateStore + provider := &plans.Provider{} err = provider.SetSource(rawStateStore.Provider.Source) if err != nil { @@ -242,13 +243,27 @@ func readTfplan(r io.Reader) (*plans.Plan, error) { if err != nil { return nil, fmt.Errorf("plan file has invalid state_store provider version: %s", err) } + providerConfig, err := valueFromTfplan(rawStateStore.Provider.Config) + if err != nil { + return nil, fmt.Errorf("plan file has invalid state_store configuration: %s", err) + } + provider.Config = providerConfig + + storeConfig, err := valueFromTfplan(rawStateStore.Config) + if err != nil { + return nil, fmt.Errorf("plan file has invalid state_store configuration: %s", err) + } plan.StateStore = &plans.StateStore{ Type: rawStateStore.Type, Provider: provider, - Config: config, + Config: storeConfig, Workspace: rawStateStore.Workspace, } + err = plan.StateStore.Validate() + if err != nil { + return nil, fmt.Errorf("plan describes an invalid state store: %w", err) + } } if plan.Timestamp, err = time.Parse(time.RFC3339, rawPlan.Timestamp); err != nil { @@ -748,17 +763,26 @@ func writeTfplan(plan *plans.Plan, w io.Writer) error { // should never have both a backend and state_store populated. return fmt.Errorf("plan contains both backend and state_store configurations, only one is expected") case plan.Backend != nil: + err := plan.Backend.Validate() + if err != nil { + return fmt.Errorf("plan describes an invalid backend: %w", err) + } rawPlan.Backend = &planproto.Backend{ Type: plan.Backend.Type, Config: valueToTfplan(plan.Backend.Config), Workspace: plan.Backend.Workspace, } case plan.StateStore != nil: + err := plan.StateStore.Validate() + if err != nil { + return fmt.Errorf("plan describes an invalid state store: %w", err) + } rawPlan.StateStore = &planproto.StateStore{ Type: plan.StateStore.Type, Provider: &planproto.Provider{ Version: plan.StateStore.Provider.Version.String(), Source: plan.StateStore.Provider.Source.String(), + Config: valueToTfplan(plan.StateStore.Provider.Config), }, Config: valueToTfplan(plan.StateStore.Config), Workspace: plan.StateStore.Workspace, diff --git a/internal/plans/planfile/tfplan_test.go b/internal/plans/planfile/tfplan_test.go index 0541f37052f1..6942a592a96f 100644 --- a/internal/plans/planfile/tfplan_test.go +++ b/internal/plans/planfile/tfplan_test.go @@ -5,6 +5,7 @@ package planfile import ( "bytes" + "strings" "testing" "github.com/go-test/deep" @@ -57,7 +58,13 @@ func TestTFPlanRoundTrip(t *testing.T) { Namespace: "foobar", Type: "foo", }, + // Imagining a provider that has nothing in its schema + Config: mustNewDynamicValue( + cty.EmptyObjectVal, + cty.Object(nil), + ), }, + // Imagining a state store with a field called `foo` in its schema Config: mustNewDynamicValue( cty.ObjectVal(map[string]cty.Value{ "foo": cty.StringVal("bar"), @@ -136,6 +143,14 @@ func Test_writeTfplan_validation(t *testing.T) { Namespace: "foobar", Type: "foo", }, + Config: mustNewDynamicValue( + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.StringVal("bar"), + }), + cty.Object(map[string]cty.Type{ + "foo": cty.String, + }), + ), }, Config: mustNewDynamicValue( cty.ObjectVal(map[string]cty.Value{ @@ -151,6 +166,234 @@ func Test_writeTfplan_validation(t *testing.T) { }(), wantWriteErrMsg: "plan contains both backend and state_store configurations, only one is expected", }, + "error when state store lacks a provider source": { + plan: func() *plans.Plan { + rawPlan := examplePlanForTest(t) + // remove backend from example plan + rawPlan.Backend = nil + + // Add state store with missing data + ver, err := version.NewVersion("9.9.9") + if err != nil { + t.Fatalf("error encountered during test setup: %s", err) + } + rawPlan.StateStore = &plans.StateStore{ + Type: "foo_bar", + Provider: &plans.Provider{ + Version: ver, + // Source: omitted + Config: mustNewDynamicValue( + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.StringVal("bar"), + }), + cty.Object(map[string]cty.Type{ + "foo": cty.String, + }), + ), + }, + Config: mustNewDynamicValue( + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.StringVal("bar"), + }), + cty.Object(map[string]cty.Type{ + "foo": cty.String, + }), + ), + Workspace: "default", + } + return rawPlan + }(), + wantWriteErrMsg: "contains a nil provider Source", + }, + "error when state store lacks a provider version": { + plan: func() *plans.Plan { + rawPlan := examplePlanForTest(t) + // remove backend from example plan + rawPlan.Backend = nil + + // Add state store with missing data + rawPlan.StateStore = &plans.StateStore{ + Type: "foo_bar", + Provider: &plans.Provider{ + // Version: omitted + Source: &tfaddr.Provider{ + Hostname: tfaddr.DefaultProviderRegistryHost, + Namespace: "foobar", + Type: "foo", + }, + Config: mustNewDynamicValue( + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.StringVal("bar"), + }), + cty.Object(map[string]cty.Type{ + "foo": cty.String, + }), + ), + }, + Config: mustNewDynamicValue( + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.StringVal("bar"), + }), + cty.Object(map[string]cty.Type{ + "foo": cty.String, + }), + ), + Workspace: "default", + } + return rawPlan + }(), + wantWriteErrMsg: "contains a nil provider Version", + }, + "error when state store lacks provider config": { + plan: func() *plans.Plan { + rawPlan := examplePlanForTest(t) + // remove backend from example plan + rawPlan.Backend = nil + + // Add state store with missing data + ver, err := version.NewVersion("9.9.9") + if err != nil { + t.Fatalf("error encountered during test setup: %s", err) + } + rawPlan.StateStore = &plans.StateStore{ + Type: "foo_bar", + Provider: &plans.Provider{ + Version: ver, + Source: &tfaddr.Provider{ + Hostname: tfaddr.DefaultProviderRegistryHost, + Namespace: "foobar", + Type: "foo", + }, + // Config: omitted + }, + Config: mustNewDynamicValue( + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.StringVal("bar"), + }), + cty.Object(map[string]cty.Type{ + "foo": cty.String, + }), + ), + Workspace: "default", + } + return rawPlan + }(), + wantWriteErrMsg: "includes no provider Config", + }, + "error when state store lacks a type": { + plan: func() *plans.Plan { + rawPlan := examplePlanForTest(t) + // remove backend from example plan + rawPlan.Backend = nil + + // Add state store with missing data + ver, err := version.NewVersion("9.9.9") + if err != nil { + t.Fatalf("error encountered during test setup: %s", err) + } + rawPlan.StateStore = &plans.StateStore{ + // Type: omitted + Provider: &plans.Provider{ + Version: ver, + Source: &tfaddr.Provider{ + Hostname: tfaddr.DefaultProviderRegistryHost, + Namespace: "foobar", + Type: "foo", + }, + }, + Config: mustNewDynamicValue( + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.StringVal("bar"), + }), + cty.Object(map[string]cty.Type{ + "foo": cty.String, + }), + ), + Workspace: "default", + } + return rawPlan + }(), + wantWriteErrMsg: "state store has an unset Type", + }, + "error when state store lacks config": { + plan: func() *plans.Plan { + rawPlan := examplePlanForTest(t) + // remove backend from example plan + rawPlan.Backend = nil + + // Add state store with missing data + ver, err := version.NewVersion("9.9.9") + if err != nil { + t.Fatalf("error encountered during test setup: %s", err) + } + rawPlan.StateStore = &plans.StateStore{ + Type: "foo_bar", + Provider: &plans.Provider{ + Version: ver, + Source: &tfaddr.Provider{ + Hostname: tfaddr.DefaultProviderRegistryHost, + Namespace: "foobar", + Type: "foo", + }, + Config: mustNewDynamicValue( + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.StringVal("bar"), + }), + cty.Object(map[string]cty.Type{ + "foo": cty.String, + }), + ), + }, + // Config: omitted + Workspace: "default", + } + return rawPlan + }(), + wantWriteErrMsg: "state store includes no Config", + }, + "error when state store lacks a workspace": { + plan: func() *plans.Plan { + rawPlan := examplePlanForTest(t) + // remove backend from example plan + rawPlan.Backend = nil + + // Add state store with missing data + ver, err := version.NewVersion("9.9.9") + if err != nil { + t.Fatalf("error encountered during test setup: %s", err) + } + rawPlan.StateStore = &plans.StateStore{ + Type: "foo_bar", + Provider: &plans.Provider{ + Version: ver, + Source: &tfaddr.Provider{ + Hostname: tfaddr.DefaultProviderRegistryHost, + Namespace: "foobar", + Type: "foo", + }, + Config: mustNewDynamicValue( + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.StringVal("bar"), + }), + cty.Object(map[string]cty.Type{ + "foo": cty.String, + }), + ), + }, + Config: mustNewDynamicValue( + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.StringVal("bar"), + }), + cty.Object(map[string]cty.Type{ + "foo": cty.String, + }), + ), + // Workspace: omitted + } + return rawPlan + }(), + wantWriteErrMsg: "state store has an unset Workspace", + }, } for tn, tc := range cases { @@ -160,7 +403,7 @@ func Test_writeTfplan_validation(t *testing.T) { if err == nil { t.Fatal("this test expects an error but got none") } - if err.Error() != tc.wantWriteErrMsg { + if !strings.Contains(err.Error(), tc.wantWriteErrMsg) { t.Fatalf("unexpected error message: wanted %q, got %q", tc.wantWriteErrMsg, err) } }) diff --git a/internal/plans/planproto/planfile.pb.go b/internal/plans/planproto/planfile.pb.go index ada0316eea92..0d3e0c58ebae 100644 --- a/internal/plans/planproto/planfile.pb.go +++ b/internal/plans/planproto/planfile.pb.go @@ -900,6 +900,7 @@ type Provider struct { state protoimpl.MessageState `protogen:"open.v1"` Source string `protobuf:"bytes,1,opt,name=source,proto3" json:"source,omitempty"` Version string `protobuf:"bytes,2,opt,name=version,proto3" json:"version,omitempty"` + Config *DynamicValue `protobuf:"bytes,3,opt,name=config,proto3" json:"config,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -948,6 +949,13 @@ func (x *Provider) GetVersion() string { return "" } +func (x *Provider) GetConfig() *DynamicValue { + if x != nil { + return x.Config + } + return nil +} + // Change represents a change made to some object, transforming it from an old // state to a new state. type Change struct { @@ -2265,10 +2273,11 @@ const file_planfile_proto_rawDesc = "" + "\x04type\x18\x01 \x01(\tR\x04type\x12,\n" + "\x06config\x18\x02 \x01(\v2\x14.tfplan.DynamicValueR\x06config\x12\x1c\n" + "\tworkspace\x18\x03 \x01(\tR\tworkspace\x12,\n" + - "\bprovider\x18\x04 \x01(\v2\x10.tfplan.ProviderR\bprovider\"<\n" + + "\bprovider\x18\x04 \x01(\v2\x10.tfplan.ProviderR\bprovider\"j\n" + "\bProvider\x12\x16\n" + "\x06source\x18\x01 \x01(\tR\x06source\x12\x18\n" + - "\aversion\x18\x02 \x01(\tR\aversion\"\xbc\x03\n" + + "\aversion\x18\x02 \x01(\tR\aversion\x12,\n" + + "\x06config\x18\x03 \x01(\v2\x14.tfplan.DynamicValueR\x06config\"\xbc\x03\n" + "\x06Change\x12&\n" + "\x06action\x18\x01 \x01(\x0e2\x0e.tfplan.ActionR\x06action\x12,\n" + "\x06values\x18\x02 \x03(\v2\x14.tfplan.DynamicValueR\x06values\x12B\n" + @@ -2476,42 +2485,43 @@ var file_planfile_proto_depIdxs = []int32{ 18, // 13: tfplan.Backend.config:type_name -> tfplan.DynamicValue 18, // 14: tfplan.StateStore.config:type_name -> tfplan.DynamicValue 10, // 15: tfplan.StateStore.provider:type_name -> tfplan.Provider - 1, // 16: tfplan.Change.action:type_name -> tfplan.Action - 18, // 17: tfplan.Change.values:type_name -> tfplan.DynamicValue - 19, // 18: tfplan.Change.before_sensitive_paths:type_name -> tfplan.Path - 19, // 19: tfplan.Change.after_sensitive_paths:type_name -> tfplan.Path - 20, // 20: tfplan.Change.importing:type_name -> tfplan.Importing - 18, // 21: tfplan.Change.before_identity:type_name -> tfplan.DynamicValue - 18, // 22: tfplan.Change.after_identity:type_name -> tfplan.DynamicValue - 11, // 23: tfplan.ResourceInstanceChange.change:type_name -> tfplan.Change - 19, // 24: tfplan.ResourceInstanceChange.required_replace:type_name -> tfplan.Path - 2, // 25: tfplan.ResourceInstanceChange.action_reason:type_name -> tfplan.ResourceInstanceActionReason - 21, // 26: tfplan.DeferredResourceInstanceChange.deferred:type_name -> tfplan.Deferred - 12, // 27: tfplan.DeferredResourceInstanceChange.change:type_name -> tfplan.ResourceInstanceChange - 21, // 28: tfplan.DeferredActionInvocation.deferred:type_name -> tfplan.Deferred - 22, // 29: tfplan.DeferredActionInvocation.action_invocation:type_name -> tfplan.ActionInvocationInstance - 11, // 30: tfplan.OutputChange.change:type_name -> tfplan.Change - 6, // 31: tfplan.CheckResults.kind:type_name -> tfplan.CheckResults.ObjectKind - 5, // 32: tfplan.CheckResults.status:type_name -> tfplan.CheckResults.Status - 28, // 33: tfplan.CheckResults.objects:type_name -> tfplan.CheckResults.ObjectResult - 29, // 34: tfplan.Path.steps:type_name -> tfplan.Path.Step - 18, // 35: tfplan.Importing.identity:type_name -> tfplan.DynamicValue - 3, // 36: tfplan.Deferred.reason:type_name -> tfplan.DeferredReason - 18, // 37: tfplan.ActionInvocationInstance.config_value:type_name -> tfplan.DynamicValue - 19, // 38: tfplan.ActionInvocationInstance.sensitive_config_paths:type_name -> tfplan.Path - 23, // 39: tfplan.ActionInvocationInstance.lifecycle_action_trigger:type_name -> tfplan.LifecycleActionTrigger - 24, // 40: tfplan.ActionInvocationInstance.invoke_action_trigger:type_name -> tfplan.InvokeActionTrigger - 4, // 41: tfplan.LifecycleActionTrigger.trigger_event:type_name -> tfplan.ActionTriggerEvent - 11, // 42: tfplan.ResourceInstanceActionChange.change:type_name -> tfplan.Change - 18, // 43: tfplan.Plan.VariablesEntry.value:type_name -> tfplan.DynamicValue - 19, // 44: tfplan.Plan.resource_attr.attr:type_name -> tfplan.Path - 5, // 45: tfplan.CheckResults.ObjectResult.status:type_name -> tfplan.CheckResults.Status - 18, // 46: tfplan.Path.Step.element_key:type_name -> tfplan.DynamicValue - 47, // [47:47] is the sub-list for method output_type - 47, // [47:47] is the sub-list for method input_type - 47, // [47:47] is the sub-list for extension type_name - 47, // [47:47] is the sub-list for extension extendee - 0, // [0:47] is the sub-list for field type_name + 18, // 16: tfplan.Provider.config:type_name -> tfplan.DynamicValue + 1, // 17: tfplan.Change.action:type_name -> tfplan.Action + 18, // 18: tfplan.Change.values:type_name -> tfplan.DynamicValue + 19, // 19: tfplan.Change.before_sensitive_paths:type_name -> tfplan.Path + 19, // 20: tfplan.Change.after_sensitive_paths:type_name -> tfplan.Path + 20, // 21: tfplan.Change.importing:type_name -> tfplan.Importing + 18, // 22: tfplan.Change.before_identity:type_name -> tfplan.DynamicValue + 18, // 23: tfplan.Change.after_identity:type_name -> tfplan.DynamicValue + 11, // 24: tfplan.ResourceInstanceChange.change:type_name -> tfplan.Change + 19, // 25: tfplan.ResourceInstanceChange.required_replace:type_name -> tfplan.Path + 2, // 26: tfplan.ResourceInstanceChange.action_reason:type_name -> tfplan.ResourceInstanceActionReason + 21, // 27: tfplan.DeferredResourceInstanceChange.deferred:type_name -> tfplan.Deferred + 12, // 28: tfplan.DeferredResourceInstanceChange.change:type_name -> tfplan.ResourceInstanceChange + 21, // 29: tfplan.DeferredActionInvocation.deferred:type_name -> tfplan.Deferred + 22, // 30: tfplan.DeferredActionInvocation.action_invocation:type_name -> tfplan.ActionInvocationInstance + 11, // 31: tfplan.OutputChange.change:type_name -> tfplan.Change + 6, // 32: tfplan.CheckResults.kind:type_name -> tfplan.CheckResults.ObjectKind + 5, // 33: tfplan.CheckResults.status:type_name -> tfplan.CheckResults.Status + 28, // 34: tfplan.CheckResults.objects:type_name -> tfplan.CheckResults.ObjectResult + 29, // 35: tfplan.Path.steps:type_name -> tfplan.Path.Step + 18, // 36: tfplan.Importing.identity:type_name -> tfplan.DynamicValue + 3, // 37: tfplan.Deferred.reason:type_name -> tfplan.DeferredReason + 18, // 38: tfplan.ActionInvocationInstance.config_value:type_name -> tfplan.DynamicValue + 19, // 39: tfplan.ActionInvocationInstance.sensitive_config_paths:type_name -> tfplan.Path + 23, // 40: tfplan.ActionInvocationInstance.lifecycle_action_trigger:type_name -> tfplan.LifecycleActionTrigger + 24, // 41: tfplan.ActionInvocationInstance.invoke_action_trigger:type_name -> tfplan.InvokeActionTrigger + 4, // 42: tfplan.LifecycleActionTrigger.trigger_event:type_name -> tfplan.ActionTriggerEvent + 11, // 43: tfplan.ResourceInstanceActionChange.change:type_name -> tfplan.Change + 18, // 44: tfplan.Plan.VariablesEntry.value:type_name -> tfplan.DynamicValue + 19, // 45: tfplan.Plan.resource_attr.attr:type_name -> tfplan.Path + 5, // 46: tfplan.CheckResults.ObjectResult.status:type_name -> tfplan.CheckResults.Status + 18, // 47: tfplan.Path.Step.element_key:type_name -> tfplan.DynamicValue + 48, // [48:48] is the sub-list for method output_type + 48, // [48:48] is the sub-list for method input_type + 48, // [48:48] is the sub-list for extension type_name + 48, // [48:48] is the sub-list for extension extendee + 0, // [0:48] is the sub-list for field type_name } func init() { file_planfile_proto_init() } diff --git a/internal/plans/planproto/planfile.proto b/internal/plans/planproto/planfile.proto index 71dbe6bf08f1..a2f0a179b51c 100644 --- a/internal/plans/planproto/planfile.proto +++ b/internal/plans/planproto/planfile.proto @@ -169,6 +169,7 @@ message StateStore { message Provider { string source = 1; string version = 2; + DynamicValue config = 3; } // Action describes the type of action planned for an object.