// Copyright (c) The OpenTofu Authors // SPDX-License-Identifier: MPL-2.0 // Copyright (c) 2023 HashiCorp, Inc. // SPDX-License-Identifier: MPL-2.0 package cloud import ( "bytes" "context" "maps" "os" "slices" "testing" "time" tfe "github.com/hashicorp/go-tfe" "github.com/opentofu/opentofu/internal/configs/configschema" "github.com/opentofu/opentofu/internal/providers" "github.com/opentofu/opentofu/internal/tofu" "github.com/zclconf/go-cty/cty" "github.com/opentofu/opentofu/internal/addrs" "github.com/opentofu/opentofu/internal/backend/local" "github.com/opentofu/opentofu/internal/encryption" "github.com/opentofu/opentofu/internal/states" "github.com/opentofu/opentofu/internal/states/statefile" "github.com/opentofu/opentofu/internal/states/statemgr" ) func TestState_impl(t *testing.T) { var _ statemgr.Reader = new(State) var _ statemgr.Writer = new(State) var _ statemgr.Persister = new(State) var _ statemgr.Refresher = new(State) var _ statemgr.OutputReader = new(State) var _ statemgr.Locker = new(State) } type ExpectedOutput struct { Name string Sensitive bool IsNull bool } func TestState_GetRootOutputValues(t *testing.T) { b, bCleanup := testBackendWithOutputs(t) defer bCleanup() state := &State{tfeClient: b.client, organization: b.organization, workspace: &tfe.Workspace{ ID: "ws-abcd", }, encryption: encryption.StateEncryptionDisabled()} outputs, err := state.GetRootOutputValues(t.Context()) if err != nil { t.Fatalf("error returned from GetRootOutputValues: %s", err) } cases := []ExpectedOutput{ { Name: "sensitive_output", Sensitive: true, IsNull: false, }, { Name: "nonsensitive_output", Sensitive: false, IsNull: false, }, { Name: "object_output", Sensitive: false, IsNull: false, }, { Name: "list_output", Sensitive: false, IsNull: false, }, } if len(outputs) != len(cases) { t.Errorf("Expected %d item but %d were returned", len(cases), len(outputs)) } for _, testCase := range cases { so, ok := outputs[testCase.Name] if !ok { t.Fatalf("Expected key %s but it was not found", testCase.Name) } if so.Value.IsNull() != testCase.IsNull { t.Errorf("Key %s does not match null expectation %v", testCase.Name, testCase.IsNull) } if so.Sensitive != testCase.Sensitive { t.Errorf("Key %s does not match sensitive expectation %v", testCase.Name, testCase.Sensitive) } } } func TestState(t *testing.T) { var buf bytes.Buffer s := statemgr.TestFullInitialState() sf := statefile.New(s, "stub-lineage", 2) err := statefile.Write(sf, &buf, encryption.StateEncryptionDisabled()) if err != nil { t.Fatalf("err: %s", err) } data := buf.Bytes() state := testCloudState(t) jsonState, err := os.ReadFile("../command/testdata/show-json-state/sensitive-variables/output.json") if err != nil { t.Fatal(err) } jsonStateOutputs := []byte(` { "outputs": { "foo": { "type": "string", "value": "bar" } } }`) if err := state.uploadState(t.Context(), state.lineage, state.serial, state.forcePush, data, jsonState, jsonStateOutputs); err != nil { t.Fatalf("put: %s", err) } payload, err := state.getStatePayload() if err != nil { t.Fatalf("get: %s", err) } if !bytes.Equal(payload.Data, data) { t.Fatalf("expected full state %q\n\ngot: %q", string(payload.Data), string(data)) } if err := state.Delete(true); err != nil { t.Fatalf("delete: %s", err) } p, err := state.getStatePayload() if err != nil { t.Fatalf("get: %s", err) } if p != nil { t.Fatalf("expected empty state, got: %q", string(p.Data)) } } func TestCloudLocks(t *testing.T) { back, bCleanup := testBackendWithName(t) defer bCleanup() a, err := back.StateMgr(t.Context(), testBackendSingleWorkspaceName) if err != nil { t.Fatalf("expected no error, got %v", err) } b, err := back.StateMgr(t.Context(), testBackendSingleWorkspaceName) if err != nil { t.Fatalf("expected no error, got %v", err) } lockerA, ok := a.(statemgr.Locker) if !ok { t.Fatal("client A not a statemgr.Locker") } lockerB, ok := b.(statemgr.Locker) if !ok { t.Fatal("client B not a statemgr.Locker") } infoA := statemgr.NewLockInfo() infoA.Operation = "test" infoA.Who = "clientA" infoB := statemgr.NewLockInfo() infoB.Operation = "test" infoB.Who = "clientB" lockIDA, err := lockerA.Lock(t.Context(), infoA) if err != nil { t.Fatal("unable to get initial lock:", err) } _, err = lockerB.Lock(t.Context(), infoB) if err == nil { _ = lockerA.Unlock(t.Context(), lockIDA) // test already failed, no need to check err further t.Fatal("client B obtained lock while held by client A") } if _, ok := err.(*statemgr.LockError); !ok { t.Errorf("expected a LockError, but was %t: %s", err, err) } if err := lockerA.Unlock(t.Context(), lockIDA); err != nil { t.Fatal("error unlocking client A", err) } lockIDB, err := lockerB.Lock(t.Context(), infoB) if err != nil { t.Fatal("unable to obtain lock from client B") } if lockIDB == lockIDA { t.Fatalf("duplicate lock IDs: %q", lockIDB) } if err = lockerB.Unlock(t.Context(), lockIDB); err != nil { t.Fatal("error unlocking client B:", err) } } func TestDelete_SafeDeleteNotSupported(t *testing.T) { state := testCloudState(t) workspaceId := state.workspace.ID state.workspace.Permissions.CanForceDelete = nil state.workspace.ResourceCount = 5 // Typically delete(false) should safe-delete a cloud workspace, which should fail on this workspace with resources // However, since we have set the workspace canForceDelete permission to nil, we should fall back to force delete if err := state.Delete(false); err != nil { t.Fatalf("delete: %s", err) } workspace, err := state.tfeClient.Workspaces.ReadByID(context.Background(), workspaceId) if workspace != nil || err != tfe.ErrResourceNotFound { t.Fatalf("workspace %s not deleted", workspaceId) } } func TestDelete_ForceDelete(t *testing.T) { state := testCloudState(t) workspaceId := state.workspace.ID state.workspace.Permissions.CanForceDelete = tfe.Bool(true) state.workspace.ResourceCount = 5 if err := state.Delete(true); err != nil { t.Fatalf("delete: %s", err) } workspace, err := state.tfeClient.Workspaces.ReadByID(context.Background(), workspaceId) if workspace != nil || err != tfe.ErrResourceNotFound { t.Fatalf("workspace %s not deleted", workspaceId) } } func TestDelete_SafeDelete(t *testing.T) { state := testCloudState(t) workspaceId := state.workspace.ID state.workspace.Permissions.CanForceDelete = tfe.Bool(false) state.workspace.ResourceCount = 5 // safe-deleting a workspace with resources should fail err := state.Delete(false) if err == nil { t.Fatalf("workspace should have failed to safe delete") } // safe-deleting a workspace with resources should succeed once it has no resources state.workspace.ResourceCount = 0 if err = state.Delete(false); err != nil { t.Fatalf("workspace safe-delete err: %s", err) } workspace, err := state.tfeClient.Workspaces.ReadByID(context.Background(), workspaceId) if workspace != nil || err != tfe.ErrResourceNotFound { t.Fatalf("workspace %s not deleted", workspaceId) } } func TestState_PersistState(t *testing.T) { t.Run("Initial PersistState", func(t *testing.T) { cloudState := testCloudState(t) if cloudState.readState != nil { t.Fatal("expected nil initial readState") } err := cloudState.PersistState(t.Context(), nil) if err != nil { t.Fatalf("expected no error, got %q", err) } var expectedSerial uint64 = 1 if cloudState.readSerial != expectedSerial { t.Fatalf("expected initial state readSerial to be %d, got %d", expectedSerial, cloudState.readSerial) } }) t.Run("Snapshot Interval Backpressure Header", func(t *testing.T) { // The "Create a State Version" API is allowed to return a special // HTTP response header X-Terraform-Snapshot-Interval, in which case // we should remember the number of seconds it specifies and delay // creating any more intermediate state snapshots for that many seconds. cloudState := testCloudState(t) if cloudState.stateSnapshotInterval != 0 { t.Error("state manager already has a nonzero snapshot interval") } if cloudState.enableIntermediateSnapshots { t.Error("expected state manager to have disabled snapshots") } // For this test we'll use a real client talking to a fake server, // since HTTP-level concerns like headers are out of scope for the // mock client we typically use in other tests in this package, which // aim to abstract away HTTP altogether. // Didn't want to repeat myself here for _, testCase := range []struct { expectedInterval time.Duration snapshotsEnabled bool }{ { expectedInterval: 300 * time.Second, snapshotsEnabled: true, }, { expectedInterval: 0 * time.Second, snapshotsEnabled: false, }, } { server := testServerWithSnapshotsEnabled(t, testCase.snapshotsEnabled) defer server.Close() cfg := &tfe.Config{ Address: server.URL, BasePath: "api", Token: "placeholder", } client, err := tfe.NewClient(cfg) if err != nil { t.Fatal(err) } cloudState.tfeClient = client err = cloudState.RefreshState(t.Context()) if err != nil { t.Fatal(err) } err = cloudState.WriteState(states.BuildState(func(s *states.SyncState) { s.SetOutputValue( addrs.OutputValue{Name: "boop"}.Absolute(addrs.RootModuleInstance), cty.StringVal("beep"), false, "", ) })) if err != nil { t.Fatal(err) } err = cloudState.PersistState(t.Context(), nil) if err != nil { t.Fatal(err) } // The PersistState call above should have sent a request to the test // server and got back the x-terraform-snapshot-interval header, whose // value should therefore now be recorded in the relevant field. if got := cloudState.stateSnapshotInterval; got != testCase.expectedInterval { t.Errorf("wrong state snapshot interval after PersistState\ngot: %s\nwant: %s", got, testCase.expectedInterval) } if got, want := cloudState.enableIntermediateSnapshots, testCase.snapshotsEnabled; got != want { t.Errorf("expected disable intermediate snapshots to be\ngot: %t\nwant: %t", got, want) } } }) } func TestState_ShouldPersistIntermediateState(t *testing.T) { cloudState := testCloudState(t) testCases := []struct { Enabled bool LastPersist time.Time Interval time.Duration Expected bool Force bool Description string }{ { Interval: 20 * time.Second, Enabled: true, Expected: true, Description: "Not persisted yet", }, { Interval: 20 * time.Second, Enabled: false, Expected: false, Description: "Intermediate snapshots not enabled", }, { Interval: 20 * time.Second, Enabled: false, Force: true, Expected: true, Description: "Force persist", }, { Interval: 20 * time.Second, LastPersist: time.Now().Add(-15 * time.Second), Enabled: true, Expected: false, Description: "Last persisted 15s ago", }, { Interval: 20 * time.Second, LastPersist: time.Now().Add(-25 * time.Second), Enabled: true, Expected: true, Description: "Last persisted 25s ago", }, { Interval: 5 * time.Second, LastPersist: time.Now().Add(-15 * time.Second), Enabled: true, Expected: true, Description: "Last persisted 15s ago, but interval is 5s", }, } for _, testCase := range testCases { cloudState.enableIntermediateSnapshots = testCase.Enabled cloudState.stateSnapshotInterval = testCase.Interval actual := cloudState.ShouldPersistIntermediateState(&local.IntermediateStatePersistInfo{ LastPersist: testCase.LastPersist, ForcePersist: testCase.Force, }) if actual != testCase.Expected { t.Errorf("%s: expected %v but got %v", testCase.Description, testCase.Expected, actual) } } } func TestState_PersistStateCorrectlyWithoutEphemeral(t *testing.T) { // This test is meant to test that a state containing ephemeral resources can be marshalled correctly // by filtering out the ephemeral ones from the content. remoteClientMock := &MockStateVersions{ states: map[string][]byte{}, outputStates: map[string][]byte{}, stateVersions: map[string]*tfe.StateVersion{}, workspaces: map[string][]string{}, } cloudState := &State{ state: states.NewState(), readState: states.NewState(), workspace: &tfe.Workspace{ ID: "test", }, tfeClient: &tfe.Client{ StateVersions: remoteClientMock, }, encryption: encryption.Disabled().State(), } // prepare addresses provAddr := addrs.MustParseProviderSourceString("test") ephAddr, diags := addrs.ParseAbsResourceInstanceStr("ephemeral.test_resource.test") if diags != nil { t.Fatalf("could not parse ephemeral resource address: %s", diags) } resAddr, diags := addrs.ParseAbsResourceInstanceStr("test_resource.test") if diags != nil { t.Fatalf("could not parse ephemeral resource address: %s", diags) } resSchema := configschema.Block{ Attributes: map[string]*configschema.Attribute{ "id": { Type: cty.String, Optional: true, }, }, } schemas := &tofu.Schemas{ Providers: map[addrs.Provider]providers.ProviderSchema{ provAddr: { EphemeralResources: map[string]providers.Schema{ "test_resource": { Block: &resSchema, }, }, ResourceTypes: map[string]providers.Schema{ "test_resource": { Block: &resSchema, }, }, }, }, } // prepare new state cloudState.state.SyncWrapper().SetResourceInstanceCurrent(ephAddr, &states.ResourceInstanceObjectSrc{ AttrsJSON: []byte(`{"id":"test"}`), Status: states.ObjectReady, }, addrs.AbsProviderConfig{ Provider: provAddr, }, addrs.NoKey) cloudState.state.SyncWrapper().SetResourceInstanceCurrent(resAddr, &states.ResourceInstanceObjectSrc{ AttrsJSON: []byte(`{"id":"test"}`), Status: states.ObjectReady, }, addrs.AbsProviderConfig{ Provider: provAddr, }, addrs.NoKey) if err := cloudState.PersistState(t.Context(), schemas); err != nil { t.Fatalf("expected no error, got %q", err) } var expectedSerial uint64 = 1 if cloudState.readSerial != expectedSerial { t.Fatalf("expected initial state readSerial to be %d, got %d", expectedSerial, cloudState.readSerial) } if got, want := len(remoteClientMock.states), 1; got != want { t.Fatalf("expected to have %d state objects but got %d", want, got) } // since the key is an url with randomly generated keys, we just get the values to work with dat := slices.Collect(maps.Values(remoteClientMock.states))[0] unmarshalledState, err := statefile.Read(bytes.NewReader(dat), encryption.Disabled().State()) if err != nil { t.Fatalf("failed to unmarshal the generated state: %s", err) } moduleState := unmarshalledState.State.Modules[addrs.RootModule.String()] if got, want := len(moduleState.Resources), 1; got != want { t.Fatalf("expected to have %d resources in the module state but got %d: %#v", want, got, moduleState.Resources) } resState, ok := moduleState.Resources[resAddr.String()] if !ok { t.Fatalf("expected to have in the state a resource for %s but is not there: %#v", resAddr.String(), moduleState.Resources) } if got, want := resState.Addr.Resource.Mode, addrs.ManagedResourceMode; got != want { t.Fatalf("expected the resource to be %s but it's %s", want, got) } }