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  }