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