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