github.com/opentofu/opentofu@v1.7.1/internal/cloud/state_test.go (about)

     1  // Copyright (c) The OpenTofu Authors
     2  // SPDX-License-Identifier: MPL-2.0
     3  // Copyright (c) 2023 HashiCorp, Inc.
     4  // SPDX-License-Identifier: MPL-2.0
     5  
     6  package cloud
     7  
     8  import (
     9  	"bytes"
    10  	"context"
    11  	"os"
    12  	"testing"
    13  	"time"
    14  
    15  	tfe "github.com/hashicorp/go-tfe"
    16  	"github.com/opentofu/opentofu/internal/addrs"
    17  	"github.com/opentofu/opentofu/internal/backend/local"
    18  	"github.com/opentofu/opentofu/internal/encryption"
    19  	"github.com/opentofu/opentofu/internal/states"
    20  	"github.com/opentofu/opentofu/internal/states/statefile"
    21  	"github.com/opentofu/opentofu/internal/states/statemgr"
    22  	"github.com/zclconf/go-cty/cty"
    23  )
    24  
    25  func TestState_impl(t *testing.T) {
    26  	var _ statemgr.Reader = new(State)
    27  	var _ statemgr.Writer = new(State)
    28  	var _ statemgr.Persister = new(State)
    29  	var _ statemgr.Refresher = new(State)
    30  	var _ statemgr.OutputReader = new(State)
    31  	var _ statemgr.Locker = new(State)
    32  }
    33  
    34  type ExpectedOutput struct {
    35  	Name      string
    36  	Sensitive bool
    37  	IsNull    bool
    38  }
    39  
    40  func TestState_GetRootOutputValues(t *testing.T) {
    41  	b, bCleanup := testBackendWithOutputs(t)
    42  	defer bCleanup()
    43  
    44  	state := &State{tfeClient: b.client, organization: b.organization, workspace: &tfe.Workspace{
    45  		ID: "ws-abcd",
    46  	}, encryption: encryption.StateEncryptionDisabled()}
    47  	outputs, err := state.GetRootOutputValues()
    48  
    49  	if err != nil {
    50  		t.Fatalf("error returned from GetRootOutputValues: %s", err)
    51  	}
    52  
    53  	cases := []ExpectedOutput{
    54  		{
    55  			Name:      "sensitive_output",
    56  			Sensitive: true,
    57  			IsNull:    false,
    58  		},
    59  		{
    60  			Name:      "nonsensitive_output",
    61  			Sensitive: false,
    62  			IsNull:    false,
    63  		},
    64  		{
    65  			Name:      "object_output",
    66  			Sensitive: false,
    67  			IsNull:    false,
    68  		},
    69  		{
    70  			Name:      "list_output",
    71  			Sensitive: false,
    72  			IsNull:    false,
    73  		},
    74  	}
    75  
    76  	if len(outputs) != len(cases) {
    77  		t.Errorf("Expected %d item but %d were returned", len(cases), len(outputs))
    78  	}
    79  
    80  	for _, testCase := range cases {
    81  		so, ok := outputs[testCase.Name]
    82  		if !ok {
    83  			t.Fatalf("Expected key %s but it was not found", testCase.Name)
    84  		}
    85  		if so.Value.IsNull() != testCase.IsNull {
    86  			t.Errorf("Key %s does not match null expectation %v", testCase.Name, testCase.IsNull)
    87  		}
    88  		if so.Sensitive != testCase.Sensitive {
    89  			t.Errorf("Key %s does not match sensitive expectation %v", testCase.Name, testCase.Sensitive)
    90  		}
    91  	}
    92  }
    93  
    94  func TestState(t *testing.T) {
    95  	var buf bytes.Buffer
    96  	s := statemgr.TestFullInitialState()
    97  	sf := statefile.New(s, "stub-lineage", 2)
    98  	err := statefile.Write(sf, &buf, encryption.StateEncryptionDisabled())
    99  	if err != nil {
   100  		t.Fatalf("err: %s", err)
   101  	}
   102  	data := buf.Bytes()
   103  
   104  	state := testCloudState(t)
   105  
   106  	jsonState, err := os.ReadFile("../command/testdata/show-json-state/sensitive-variables/output.json")
   107  	if err != nil {
   108  		t.Fatal(err)
   109  	}
   110  
   111  	jsonStateOutputs := []byte(`
   112  {
   113  	"outputs": {
   114  			"foo": {
   115  					"type": "string",
   116  					"value": "bar"
   117  			}
   118  	}
   119  }`)
   120  
   121  	if err := state.uploadState(state.lineage, state.serial, state.forcePush, data, jsonState, jsonStateOutputs); err != nil {
   122  		t.Fatalf("put: %s", err)
   123  	}
   124  
   125  	payload, err := state.getStatePayload()
   126  	if err != nil {
   127  		t.Fatalf("get: %s", err)
   128  	}
   129  	if !bytes.Equal(payload.Data, data) {
   130  		t.Fatalf("expected full state %q\n\ngot: %q", string(payload.Data), string(data))
   131  	}
   132  
   133  	if err := state.Delete(true); err != nil {
   134  		t.Fatalf("delete: %s", err)
   135  	}
   136  
   137  	p, err := state.getStatePayload()
   138  	if err != nil {
   139  		t.Fatalf("get: %s", err)
   140  	}
   141  	if p != nil {
   142  		t.Fatalf("expected empty state, got: %q", string(p.Data))
   143  	}
   144  }
   145  
   146  func TestCloudLocks(t *testing.T) {
   147  	back, bCleanup := testBackendWithName(t)
   148  	defer bCleanup()
   149  
   150  	a, err := back.StateMgr(testBackendSingleWorkspaceName)
   151  	if err != nil {
   152  		t.Fatalf("expected no error, got %v", err)
   153  	}
   154  	b, err := back.StateMgr(testBackendSingleWorkspaceName)
   155  	if err != nil {
   156  		t.Fatalf("expected no error, got %v", err)
   157  	}
   158  
   159  	lockerA, ok := a.(statemgr.Locker)
   160  	if !ok {
   161  		t.Fatal("client A not a statemgr.Locker")
   162  	}
   163  
   164  	lockerB, ok := b.(statemgr.Locker)
   165  	if !ok {
   166  		t.Fatal("client B not a statemgr.Locker")
   167  	}
   168  
   169  	infoA := statemgr.NewLockInfo()
   170  	infoA.Operation = "test"
   171  	infoA.Who = "clientA"
   172  
   173  	infoB := statemgr.NewLockInfo()
   174  	infoB.Operation = "test"
   175  	infoB.Who = "clientB"
   176  
   177  	lockIDA, err := lockerA.Lock(infoA)
   178  	if err != nil {
   179  		t.Fatal("unable to get initial lock:", err)
   180  	}
   181  
   182  	_, err = lockerB.Lock(infoB)
   183  	if err == nil {
   184  		lockerA.Unlock(lockIDA)
   185  		t.Fatal("client B obtained lock while held by client A")
   186  	}
   187  	if _, ok := err.(*statemgr.LockError); !ok {
   188  		t.Errorf("expected a LockError, but was %t: %s", err, err)
   189  	}
   190  
   191  	if err := lockerA.Unlock(lockIDA); err != nil {
   192  		t.Fatal("error unlocking client A", err)
   193  	}
   194  
   195  	lockIDB, err := lockerB.Lock(infoB)
   196  	if err != nil {
   197  		t.Fatal("unable to obtain lock from client B")
   198  	}
   199  
   200  	if lockIDB == lockIDA {
   201  		t.Fatalf("duplicate lock IDs: %q", lockIDB)
   202  	}
   203  
   204  	if err = lockerB.Unlock(lockIDB); err != nil {
   205  		t.Fatal("error unlocking client B:", err)
   206  	}
   207  }
   208  
   209  func TestDelete_SafeDeleteNotSupported(t *testing.T) {
   210  	state := testCloudState(t)
   211  	workspaceId := state.workspace.ID
   212  	state.workspace.Permissions.CanForceDelete = nil
   213  	state.workspace.ResourceCount = 5
   214  
   215  	// Typically delete(false) should safe-delete a cloud workspace, which should fail on this workspace with resources
   216  	// However, since we have set the workspace canForceDelete permission to nil, we should fall back to force delete
   217  	if err := state.Delete(false); err != nil {
   218  		t.Fatalf("delete: %s", err)
   219  	}
   220  	workspace, err := state.tfeClient.Workspaces.ReadByID(context.Background(), workspaceId)
   221  	if workspace != nil || err != tfe.ErrResourceNotFound {
   222  		t.Fatalf("workspace %s not deleted", workspaceId)
   223  	}
   224  }
   225  
   226  func TestDelete_ForceDelete(t *testing.T) {
   227  	state := testCloudState(t)
   228  	workspaceId := state.workspace.ID
   229  	state.workspace.Permissions.CanForceDelete = tfe.Bool(true)
   230  	state.workspace.ResourceCount = 5
   231  
   232  	if err := state.Delete(true); err != nil {
   233  		t.Fatalf("delete: %s", err)
   234  	}
   235  	workspace, err := state.tfeClient.Workspaces.ReadByID(context.Background(), workspaceId)
   236  	if workspace != nil || err != tfe.ErrResourceNotFound {
   237  		t.Fatalf("workspace %s not deleted", workspaceId)
   238  	}
   239  }
   240  
   241  func TestDelete_SafeDelete(t *testing.T) {
   242  	state := testCloudState(t)
   243  	workspaceId := state.workspace.ID
   244  	state.workspace.Permissions.CanForceDelete = tfe.Bool(false)
   245  	state.workspace.ResourceCount = 5
   246  
   247  	// safe-deleting a workspace with resources should fail
   248  	err := state.Delete(false)
   249  	if err == nil {
   250  		t.Fatalf("workspace should have failed to safe delete")
   251  	}
   252  
   253  	// safe-deleting a workspace with resources should succeed once it has no resources
   254  	state.workspace.ResourceCount = 0
   255  	if err = state.Delete(false); err != nil {
   256  		t.Fatalf("workspace safe-delete err: %s", err)
   257  	}
   258  
   259  	workspace, err := state.tfeClient.Workspaces.ReadByID(context.Background(), workspaceId)
   260  	if workspace != nil || err != tfe.ErrResourceNotFound {
   261  		t.Fatalf("workspace %s not deleted", workspaceId)
   262  	}
   263  }
   264  
   265  func TestState_PersistState(t *testing.T) {
   266  	t.Run("Initial PersistState", func(t *testing.T) {
   267  		cloudState := testCloudState(t)
   268  
   269  		if cloudState.readState != nil {
   270  			t.Fatal("expected nil initial readState")
   271  		}
   272  
   273  		err := cloudState.PersistState(nil)
   274  		if err != nil {
   275  			t.Fatalf("expected no error, got %q", err)
   276  		}
   277  
   278  		var expectedSerial uint64 = 1
   279  		if cloudState.readSerial != expectedSerial {
   280  			t.Fatalf("expected initial state readSerial to be %d, got %d", expectedSerial, cloudState.readSerial)
   281  		}
   282  	})
   283  
   284  	t.Run("Snapshot Interval Backpressure Header", func(t *testing.T) {
   285  		// The "Create a State Version" API is allowed to return a special
   286  		// HTTP response header X-Terraform-Snapshot-Interval, in which case
   287  		// we should remember the number of seconds it specifies and delay
   288  		// creating any more intermediate state snapshots for that many seconds.
   289  
   290  		cloudState := testCloudState(t)
   291  
   292  		if cloudState.stateSnapshotInterval != 0 {
   293  			t.Error("state manager already has a nonzero snapshot interval")
   294  		}
   295  
   296  		if cloudState.enableIntermediateSnapshots {
   297  			t.Error("expected state manager to have disabled snapshots")
   298  		}
   299  
   300  		// For this test we'll use a real client talking to a fake server,
   301  		// since HTTP-level concerns like headers are out of scope for the
   302  		// mock client we typically use in other tests in this package, which
   303  		// aim to abstract away HTTP altogether.
   304  
   305  		// Didn't want to repeat myself here
   306  		for _, testCase := range []struct {
   307  			expectedInterval time.Duration
   308  			snapshotsEnabled bool
   309  		}{
   310  			{
   311  				expectedInterval: 300 * time.Second,
   312  				snapshotsEnabled: true,
   313  			},
   314  			{
   315  				expectedInterval: 0 * time.Second,
   316  				snapshotsEnabled: false,
   317  			},
   318  		} {
   319  			server := testServerWithSnapshotsEnabled(t, testCase.snapshotsEnabled)
   320  
   321  			defer server.Close()
   322  			cfg := &tfe.Config{
   323  				Address:  server.URL,
   324  				BasePath: "api",
   325  				Token:    "placeholder",
   326  			}
   327  			client, err := tfe.NewClient(cfg)
   328  			if err != nil {
   329  				t.Fatal(err)
   330  			}
   331  			cloudState.tfeClient = client
   332  
   333  			err = cloudState.RefreshState()
   334  			if err != nil {
   335  				t.Fatal(err)
   336  			}
   337  			cloudState.WriteState(states.BuildState(func(s *states.SyncState) {
   338  				s.SetOutputValue(
   339  					addrs.OutputValue{Name: "boop"}.Absolute(addrs.RootModuleInstance),
   340  					cty.StringVal("beep"), false,
   341  				)
   342  			}))
   343  
   344  			err = cloudState.PersistState(nil)
   345  			if err != nil {
   346  				t.Fatal(err)
   347  			}
   348  
   349  			// The PersistState call above should have sent a request to the test
   350  			// server and got back the x-terraform-snapshot-interval header, whose
   351  			// value should therefore now be recorded in the relevant field.
   352  			if got := cloudState.stateSnapshotInterval; got != testCase.expectedInterval {
   353  				t.Errorf("wrong state snapshot interval after PersistState\ngot:  %s\nwant: %s", got, testCase.expectedInterval)
   354  			}
   355  
   356  			if got, want := cloudState.enableIntermediateSnapshots, testCase.snapshotsEnabled; got != want {
   357  				t.Errorf("expected disable intermediate snapshots to be\ngot: %t\nwant: %t", got, want)
   358  			}
   359  		}
   360  	})
   361  }
   362  
   363  func TestState_ShouldPersistIntermediateState(t *testing.T) {
   364  	cloudState := testCloudState(t)
   365  
   366  	testCases := []struct {
   367  		Enabled     bool
   368  		LastPersist time.Time
   369  		Interval    time.Duration
   370  		Expected    bool
   371  		Force       bool
   372  		Description string
   373  	}{
   374  		{
   375  			Interval:    20 * time.Second,
   376  			Enabled:     true,
   377  			Expected:    true,
   378  			Description: "Not persisted yet",
   379  		},
   380  		{
   381  			Interval:    20 * time.Second,
   382  			Enabled:     false,
   383  			Expected:    false,
   384  			Description: "Intermediate snapshots not enabled",
   385  		},
   386  		{
   387  			Interval:    20 * time.Second,
   388  			Enabled:     false,
   389  			Force:       true,
   390  			Expected:    true,
   391  			Description: "Force persist",
   392  		},
   393  		{
   394  			Interval:    20 * time.Second,
   395  			LastPersist: time.Now().Add(-15 * time.Second),
   396  			Enabled:     true,
   397  			Expected:    false,
   398  			Description: "Last persisted 15s ago",
   399  		},
   400  		{
   401  			Interval:    20 * time.Second,
   402  			LastPersist: time.Now().Add(-25 * time.Second),
   403  			Enabled:     true,
   404  			Expected:    true,
   405  			Description: "Last persisted 25s ago",
   406  		},
   407  		{
   408  			Interval:    5 * time.Second,
   409  			LastPersist: time.Now().Add(-15 * time.Second),
   410  			Enabled:     true,
   411  			Expected:    true,
   412  			Description: "Last persisted 15s ago, but interval is 5s",
   413  		},
   414  	}
   415  
   416  	for _, testCase := range testCases {
   417  		cloudState.enableIntermediateSnapshots = testCase.Enabled
   418  		cloudState.stateSnapshotInterval = testCase.Interval
   419  
   420  		actual := cloudState.ShouldPersistIntermediateState(&local.IntermediateStatePersistInfo{
   421  			LastPersist:  testCase.LastPersist,
   422  			ForcePersist: testCase.Force,
   423  		})
   424  		if actual != testCase.Expected {
   425  			t.Errorf("%s: expected %v but got %v", testCase.Description, testCase.Expected, actual)
   426  		}
   427  	}
   428  }