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  }