github.com/kevinklinger/open_terraform@v1.3.6/noninternal/states/remote/state_test.go (about) 1 package remote 2 3 import ( 4 "log" 5 "sync" 6 "testing" 7 8 "github.com/google/go-cmp/cmp" 9 "github.com/zclconf/go-cty/cty" 10 11 "github.com/kevinklinger/open_terraform/noninternal/states" 12 "github.com/kevinklinger/open_terraform/noninternal/states/statefile" 13 "github.com/kevinklinger/open_terraform/noninternal/states/statemgr" 14 "github.com/kevinklinger/open_terraform/version" 15 ) 16 17 func TestState_impl(t *testing.T) { 18 var _ statemgr.Reader = new(State) 19 var _ statemgr.Writer = new(State) 20 var _ statemgr.Persister = new(State) 21 var _ statemgr.Refresher = new(State) 22 var _ statemgr.OutputReader = new(State) 23 var _ statemgr.Locker = new(State) 24 } 25 26 func TestStateRace(t *testing.T) { 27 s := &State{ 28 Client: nilClient{}, 29 } 30 31 current := states.NewState() 32 33 var wg sync.WaitGroup 34 35 for i := 0; i < 100; i++ { 36 wg.Add(1) 37 go func() { 38 defer wg.Done() 39 s.WriteState(current) 40 s.PersistState(nil) 41 s.RefreshState() 42 }() 43 } 44 wg.Wait() 45 } 46 47 // testCase encapsulates a test state test 48 type testCase struct { 49 name string 50 // A function to mutate state and return a cleanup function 51 mutationFunc func(*State) (*states.State, func()) 52 // The expected request to have taken place 53 expectedRequest mockClientRequest 54 // Mark this case as not having a request 55 noRequest bool 56 } 57 58 // isRequested ensures a test that is specified as not having 59 // a request doesn't have one by checking if a method exists 60 // on the expectedRequest. 61 func (tc testCase) isRequested(t *testing.T) bool { 62 hasMethod := tc.expectedRequest.Method != "" 63 if tc.noRequest && hasMethod { 64 t.Fatalf("expected no content for %q but got: %v", tc.name, tc.expectedRequest) 65 } 66 return !tc.noRequest 67 } 68 69 func TestStatePersist(t *testing.T) { 70 testCases := []testCase{ 71 // Refreshing state before we run the test loop causes a GET 72 { 73 name: "refresh state", 74 mutationFunc: func(mgr *State) (*states.State, func()) { 75 return mgr.State(), func() {} 76 }, 77 expectedRequest: mockClientRequest{ 78 Method: "Get", 79 Content: map[string]interface{}{ 80 "version": 4.0, // encoding/json decodes this as float64 by default 81 "lineage": "mock-lineage", 82 "serial": 1.0, // encoding/json decodes this as float64 by default 83 "terraform_version": "0.0.0", 84 "outputs": map[string]interface{}{}, 85 "resources": []interface{}{}, 86 }, 87 }, 88 }, 89 { 90 name: "change lineage", 91 mutationFunc: func(mgr *State) (*states.State, func()) { 92 originalLineage := mgr.lineage 93 mgr.lineage = "some-new-lineage" 94 return mgr.State(), func() { 95 mgr.lineage = originalLineage 96 } 97 }, 98 expectedRequest: mockClientRequest{ 99 Method: "Put", 100 Content: map[string]interface{}{ 101 "version": 4.0, // encoding/json decodes this as float64 by default 102 "lineage": "some-new-lineage", 103 "serial": 2.0, // encoding/json decodes this as float64 by default 104 "terraform_version": version.Version, 105 "outputs": map[string]interface{}{}, 106 "resources": []interface{}{}, 107 "check_results": nil, 108 }, 109 }, 110 }, 111 { 112 name: "change serial", 113 mutationFunc: func(mgr *State) (*states.State, func()) { 114 originalSerial := mgr.serial 115 mgr.serial++ 116 return mgr.State(), func() { 117 mgr.serial = originalSerial 118 } 119 }, 120 expectedRequest: mockClientRequest{ 121 Method: "Put", 122 Content: map[string]interface{}{ 123 "version": 4.0, // encoding/json decodes this as float64 by default 124 "lineage": "mock-lineage", 125 "serial": 4.0, // encoding/json decodes this as float64 by default 126 "terraform_version": version.Version, 127 "outputs": map[string]interface{}{}, 128 "resources": []interface{}{}, 129 "check_results": nil, 130 }, 131 }, 132 }, 133 { 134 name: "add output to state", 135 mutationFunc: func(mgr *State) (*states.State, func()) { 136 s := mgr.State() 137 s.RootModule().SetOutputValue("foo", cty.StringVal("bar"), false) 138 return s, func() {} 139 }, 140 expectedRequest: mockClientRequest{ 141 Method: "Put", 142 Content: map[string]interface{}{ 143 "version": 4.0, // encoding/json decodes this as float64 by default 144 "lineage": "mock-lineage", 145 "serial": 3.0, // encoding/json decodes this as float64 by default 146 "terraform_version": version.Version, 147 "outputs": map[string]interface{}{ 148 "foo": map[string]interface{}{ 149 "type": "string", 150 "value": "bar", 151 }, 152 }, 153 "resources": []interface{}{}, 154 "check_results": nil, 155 }, 156 }, 157 }, 158 { 159 name: "mutate state bar -> baz", 160 mutationFunc: func(mgr *State) (*states.State, func()) { 161 s := mgr.State() 162 s.RootModule().SetOutputValue("foo", cty.StringVal("baz"), false) 163 return s, func() {} 164 }, 165 expectedRequest: mockClientRequest{ 166 Method: "Put", 167 Content: map[string]interface{}{ 168 "version": 4.0, // encoding/json decodes this as float64 by default 169 "lineage": "mock-lineage", 170 "serial": 4.0, // encoding/json decodes this as float64 by default 171 "terraform_version": version.Version, 172 "outputs": map[string]interface{}{ 173 "foo": map[string]interface{}{ 174 "type": "string", 175 "value": "baz", 176 }, 177 }, 178 "resources": []interface{}{}, 179 "check_results": nil, 180 }, 181 }, 182 }, 183 { 184 name: "nothing changed", 185 mutationFunc: func(mgr *State) (*states.State, func()) { 186 s := mgr.State() 187 return s, func() {} 188 }, 189 noRequest: true, 190 }, 191 { 192 name: "reset serial (force push style)", 193 mutationFunc: func(mgr *State) (*states.State, func()) { 194 mgr.serial = 2 195 return mgr.State(), func() {} 196 }, 197 expectedRequest: mockClientRequest{ 198 Method: "Put", 199 Content: map[string]interface{}{ 200 "version": 4.0, // encoding/json decodes this as float64 by default 201 "lineage": "mock-lineage", 202 "serial": 3.0, // encoding/json decodes this as float64 by default 203 "terraform_version": version.Version, 204 "outputs": map[string]interface{}{ 205 "foo": map[string]interface{}{ 206 "type": "string", 207 "value": "baz", 208 }, 209 }, 210 "resources": []interface{}{}, 211 "check_results": nil, 212 }, 213 }, 214 }, 215 } 216 217 // Initial setup of state just to give us a fixed starting point for our 218 // test assertions below, or else we'd need to deal with 219 // random lineage. 220 mgr := &State{ 221 Client: &mockClient{ 222 current: []byte(` 223 { 224 "version": 4, 225 "lineage": "mock-lineage", 226 "serial": 1, 227 "terraform_version":"0.0.0", 228 "outputs": {}, 229 "resources": [] 230 } 231 `), 232 }, 233 } 234 235 // In normal use (during a Terraform operation) we always refresh and read 236 // before any writes would happen, so we'll mimic that here for realism. 237 // NB This causes a GET to be logged so the first item in the test cases 238 // must account for this 239 if err := mgr.RefreshState(); err != nil { 240 t.Fatalf("failed to RefreshState: %s", err) 241 } 242 243 // Our client is a mockClient which has a log we 244 // use to check that operations generate expected requests 245 mockClient := mgr.Client.(*mockClient) 246 247 // logIdx tracks the current index of the log separate from 248 // the loop iteration so we can check operations that don't 249 // cause any requests to be generated 250 logIdx := 0 251 252 // Run tests in order. 253 for _, tc := range testCases { 254 t.Run(tc.name, func(t *testing.T) { 255 s, cleanup := tc.mutationFunc(mgr) 256 257 if err := mgr.WriteState(s); err != nil { 258 t.Fatalf("failed to WriteState for %q: %s", tc.name, err) 259 } 260 if err := mgr.PersistState(nil); err != nil { 261 t.Fatalf("failed to PersistState for %q: %s", tc.name, err) 262 } 263 264 if tc.isRequested(t) { 265 // Get captured request from the mock client log 266 // based on the index of the current test 267 if logIdx >= len(mockClient.log) { 268 t.Fatalf("request lock and index are out of sync on %q: idx=%d len=%d", tc.name, logIdx, len(mockClient.log)) 269 } 270 loggedRequest := mockClient.log[logIdx] 271 logIdx++ 272 if diff := cmp.Diff(tc.expectedRequest, loggedRequest); len(diff) > 0 { 273 t.Fatalf("incorrect client requests for %q:\n%s", tc.name, diff) 274 } 275 } 276 cleanup() 277 }) 278 } 279 logCnt := len(mockClient.log) 280 if logIdx != logCnt { 281 log.Fatalf("not all requests were read. Expected logIdx to be %d but got %d", logCnt, logIdx) 282 } 283 } 284 285 func TestState_GetRootOutputValues(t *testing.T) { 286 // Initial setup of state with outputs already defined 287 mgr := &State{ 288 Client: &mockClient{ 289 current: []byte(` 290 { 291 "version": 4, 292 "lineage": "mock-lineage", 293 "serial": 1, 294 "terraform_version":"0.0.0", 295 "outputs": {"foo": {"value":"bar", "type": "string"}}, 296 "resources": [] 297 } 298 `), 299 }, 300 } 301 302 outputs, err := mgr.GetRootOutputValues() 303 if err != nil { 304 t.Errorf("Expected GetRootOutputValues to not return an error, but it returned %v", err) 305 } 306 307 if len(outputs) != 1 { 308 t.Errorf("Expected %d outputs, but received %d", 1, len(outputs)) 309 } 310 } 311 312 type migrationTestCase struct { 313 name string 314 // A function to generate a statefile 315 stateFile func(*State) *statefile.File 316 // The expected request to have taken place 317 expectedRequest mockClientRequest 318 // Mark this case as not having a request 319 expectedError string 320 // force flag passed to client 321 force bool 322 } 323 324 func TestWriteStateForMigration(t *testing.T) { 325 mgr := &State{ 326 Client: &mockClient{ 327 current: []byte(` 328 { 329 "version": 4, 330 "lineage": "mock-lineage", 331 "serial": 3, 332 "terraform_version":"0.0.0", 333 "outputs": {"foo": {"value":"bar", "type": "string"}}, 334 "resources": [] 335 } 336 `), 337 }, 338 } 339 340 testCases := []migrationTestCase{ 341 // Refreshing state before we run the test loop causes a GET 342 { 343 name: "refresh state", 344 stateFile: func(mgr *State) *statefile.File { 345 return mgr.StateForMigration() 346 }, 347 expectedRequest: mockClientRequest{ 348 Method: "Get", 349 Content: map[string]interface{}{ 350 "version": 4.0, 351 "lineage": "mock-lineage", 352 "serial": 3.0, 353 "terraform_version": "0.0.0", 354 "outputs": map[string]interface{}{"foo": map[string]interface{}{"type": string("string"), "value": string("bar")}}, 355 "resources": []interface{}{}, 356 }, 357 }, 358 }, 359 { 360 name: "cannot import lesser serial without force", 361 stateFile: func(mgr *State) *statefile.File { 362 return statefile.New(mgr.state, mgr.lineage, 1) 363 }, 364 expectedError: "cannot import state with serial 1 over newer state with serial 3", 365 }, 366 { 367 name: "cannot import differing lineage without force", 368 stateFile: func(mgr *State) *statefile.File { 369 return statefile.New(mgr.state, "different-lineage", mgr.serial) 370 }, 371 expectedError: `cannot import state with lineage "different-lineage" over unrelated state with lineage "mock-lineage"`, 372 }, 373 { 374 name: "can import lesser serial with force", 375 stateFile: func(mgr *State) *statefile.File { 376 return statefile.New(mgr.state, mgr.lineage, 1) 377 }, 378 expectedRequest: mockClientRequest{ 379 Method: "Put", 380 Content: map[string]interface{}{ 381 "version": 4.0, 382 "lineage": "mock-lineage", 383 "serial": 2.0, 384 "terraform_version": version.Version, 385 "outputs": map[string]interface{}{"foo": map[string]interface{}{"type": string("string"), "value": string("bar")}}, 386 "resources": []interface{}{}, 387 "check_results": nil, 388 }, 389 }, 390 force: true, 391 }, 392 { 393 name: "cannot import differing lineage without force", 394 stateFile: func(mgr *State) *statefile.File { 395 return statefile.New(mgr.state, "different-lineage", mgr.serial) 396 }, 397 expectedRequest: mockClientRequest{ 398 Method: "Put", 399 Content: map[string]interface{}{ 400 "version": 4.0, 401 "lineage": "different-lineage", 402 "serial": 3.0, 403 "terraform_version": version.Version, 404 "outputs": map[string]interface{}{"foo": map[string]interface{}{"type": string("string"), "value": string("bar")}}, 405 "resources": []interface{}{}, 406 "check_results": nil, 407 }, 408 }, 409 force: true, 410 }, 411 } 412 413 // In normal use (during a Terraform operation) we always refresh and read 414 // before any writes would happen, so we'll mimic that here for realism. 415 // NB This causes a GET to be logged so the first item in the test cases 416 // must account for this 417 if err := mgr.RefreshState(); err != nil { 418 t.Fatalf("failed to RefreshState: %s", err) 419 } 420 421 if err := mgr.WriteState(mgr.State()); err != nil { 422 t.Fatalf("failed to write initial state: %s", err) 423 } 424 425 // Our client is a mockClient which has a log we 426 // use to check that operations generate expected requests 427 mockClient := mgr.Client.(*mockClient) 428 429 // logIdx tracks the current index of the log separate from 430 // the loop iteration so we can check operations that don't 431 // cause any requests to be generated 432 logIdx := 0 433 434 for _, tc := range testCases { 435 t.Run(tc.name, func(t *testing.T) { 436 sf := tc.stateFile(mgr) 437 err := mgr.WriteStateForMigration(sf, tc.force) 438 shouldError := tc.expectedError != "" 439 440 // If we are expecting and error check it and move on 441 if shouldError { 442 if err == nil { 443 t.Fatalf("test case %q should have failed with error %q", tc.name, tc.expectedError) 444 } else if err.Error() != tc.expectedError { 445 t.Fatalf("test case %q expected error %q but got %q", tc.name, tc.expectedError, err) 446 } 447 return 448 } 449 450 if err != nil { 451 t.Fatalf("test case %q failed: %v", tc.name, err) 452 } 453 454 // At this point we should just do a normal write and persist 455 // as would happen from the CLI 456 mgr.WriteState(mgr.State()) 457 mgr.PersistState(nil) 458 459 if logIdx >= len(mockClient.log) { 460 t.Fatalf("request lock and index are out of sync on %q: idx=%d len=%d", tc.name, logIdx, len(mockClient.log)) 461 } 462 loggedRequest := mockClient.log[logIdx] 463 logIdx++ 464 if diff := cmp.Diff(tc.expectedRequest, loggedRequest); len(diff) > 0 { 465 t.Fatalf("incorrect client requests for %q:\n%s", tc.name, diff) 466 } 467 }) 468 } 469 470 logCnt := len(mockClient.log) 471 if logIdx != logCnt { 472 log.Fatalf("not all requests were read. Expected logIdx to be %d but got %d", logCnt, logIdx) 473 } 474 } 475 476 // This test runs the same test cases as above, but with 477 // a client that implements EnableForcePush -- this allows 478 // us to test that -force continues to work for backends without 479 // this interface, but that this interface works for those that do. 480 func TestWriteStateForMigrationWithForcePushClient(t *testing.T) { 481 mgr := &State{ 482 Client: &mockClientForcePusher{ 483 current: []byte(` 484 { 485 "version": 4, 486 "lineage": "mock-lineage", 487 "serial": 3, 488 "terraform_version":"0.0.0", 489 "outputs": {"foo": {"value":"bar", "type": "string"}}, 490 "resources": [] 491 } 492 `), 493 }, 494 } 495 496 testCases := []migrationTestCase{ 497 // Refreshing state before we run the test loop causes a GET 498 { 499 name: "refresh state", 500 stateFile: func(mgr *State) *statefile.File { 501 return mgr.StateForMigration() 502 }, 503 expectedRequest: mockClientRequest{ 504 Method: "Get", 505 Content: map[string]interface{}{ 506 "version": 4.0, 507 "lineage": "mock-lineage", 508 "serial": 3.0, 509 "terraform_version": "0.0.0", 510 "outputs": map[string]interface{}{"foo": map[string]interface{}{"type": string("string"), "value": string("bar")}}, 511 "resources": []interface{}{}, 512 }, 513 }, 514 }, 515 { 516 name: "cannot import lesser serial without force", 517 stateFile: func(mgr *State) *statefile.File { 518 return statefile.New(mgr.state, mgr.lineage, 1) 519 }, 520 expectedError: "cannot import state with serial 1 over newer state with serial 3", 521 }, 522 { 523 name: "cannot import differing lineage without force", 524 stateFile: func(mgr *State) *statefile.File { 525 return statefile.New(mgr.state, "different-lineage", mgr.serial) 526 }, 527 expectedError: `cannot import state with lineage "different-lineage" over unrelated state with lineage "mock-lineage"`, 528 }, 529 { 530 name: "can import lesser serial with force", 531 stateFile: func(mgr *State) *statefile.File { 532 return statefile.New(mgr.state, mgr.lineage, 1) 533 }, 534 expectedRequest: mockClientRequest{ 535 Method: "Force Put", 536 Content: map[string]interface{}{ 537 "version": 4.0, 538 "lineage": "mock-lineage", 539 "serial": 2.0, 540 "terraform_version": version.Version, 541 "outputs": map[string]interface{}{"foo": map[string]interface{}{"type": string("string"), "value": string("bar")}}, 542 "resources": []interface{}{}, 543 "check_results": nil, 544 }, 545 }, 546 force: true, 547 }, 548 { 549 name: "cannot import differing lineage without force", 550 stateFile: func(mgr *State) *statefile.File { 551 return statefile.New(mgr.state, "different-lineage", mgr.serial) 552 }, 553 expectedRequest: mockClientRequest{ 554 Method: "Force Put", 555 Content: map[string]interface{}{ 556 "version": 4.0, 557 "lineage": "different-lineage", 558 "serial": 3.0, 559 "terraform_version": version.Version, 560 "outputs": map[string]interface{}{"foo": map[string]interface{}{"type": string("string"), "value": string("bar")}}, 561 "resources": []interface{}{}, 562 "check_results": nil, 563 }, 564 }, 565 force: true, 566 }, 567 } 568 569 // In normal use (during a Terraform operation) we always refresh and read 570 // before any writes would happen, so we'll mimic that here for realism. 571 // NB This causes a GET to be logged so the first item in the test cases 572 // must account for this 573 if err := mgr.RefreshState(); err != nil { 574 t.Fatalf("failed to RefreshState: %s", err) 575 } 576 577 if err := mgr.WriteState(mgr.State()); err != nil { 578 t.Fatalf("failed to write initial state: %s", err) 579 } 580 581 // Our client is a mockClientForcePusher which has a log we 582 // use to check that operations generate expected requests 583 mockClient := mgr.Client.(*mockClientForcePusher) 584 585 if mockClient.force { 586 t.Fatalf("client should not default to force") 587 } 588 589 // logIdx tracks the current index of the log separate from 590 // the loop iteration so we can check operations that don't 591 // cause any requests to be generated 592 logIdx := 0 593 594 for _, tc := range testCases { 595 t.Run(tc.name, func(t *testing.T) { 596 // Always reset client to not be force pushing 597 mockClient.force = false 598 sf := tc.stateFile(mgr) 599 err := mgr.WriteStateForMigration(sf, tc.force) 600 shouldError := tc.expectedError != "" 601 602 // If we are expecting and error check it and move on 603 if shouldError { 604 if err == nil { 605 t.Fatalf("test case %q should have failed with error %q", tc.name, tc.expectedError) 606 } else if err.Error() != tc.expectedError { 607 t.Fatalf("test case %q expected error %q but got %q", tc.name, tc.expectedError, err) 608 } 609 return 610 } 611 612 if err != nil { 613 t.Fatalf("test case %q failed: %v", tc.name, err) 614 } 615 616 if tc.force && !mockClient.force { 617 t.Fatalf("test case %q should have enabled force push", tc.name) 618 } 619 620 // At this point we should just do a normal write and persist 621 // as would happen from the CLI 622 mgr.WriteState(mgr.State()) 623 mgr.PersistState(nil) 624 625 if logIdx >= len(mockClient.log) { 626 t.Fatalf("request lock and index are out of sync on %q: idx=%d len=%d", tc.name, logIdx, len(mockClient.log)) 627 } 628 loggedRequest := mockClient.log[logIdx] 629 logIdx++ 630 if diff := cmp.Diff(tc.expectedRequest, loggedRequest); len(diff) > 0 { 631 t.Fatalf("incorrect client requests for %q:\n%s", tc.name, diff) 632 } 633 }) 634 } 635 636 logCnt := len(mockClient.log) 637 if logIdx != logCnt { 638 log.Fatalf("not all requests were read. Expected logIdx to be %d but got %d", logCnt, logIdx) 639 } 640 }