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 }