Skip to content

Commit 9d90bd6

Browse files
committed
Fix bug for force push for backends besides the remote backend
In refactoring the force push code when implementing force push for the Terraform remote backend, a bug was introduced that meant that backends that don't implement the EnableForcePush method would still have their state validated. This commit fixes that, and adds test coverage such that there is a separate mockRemoteClient that has this method implemented.
1 parent 069f379 commit 9d90bd6

File tree

3 files changed

+214
-20
lines changed

3 files changed

+214
-20
lines changed

states/remote/remote_test.go

Lines changed: 52 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,6 @@ func (c nilClient) Delete() error { return nil }
6969
type mockClient struct {
7070
current []byte
7171
log []mockClientRequest
72-
force bool
7372
}
7473

7574
type mockClientRequest struct {
@@ -90,11 +89,7 @@ func (c *mockClient) Get() (*Payload, error) {
9089
}
9190

9291
func (c *mockClient) Put(data []byte) error {
93-
if c.force {
94-
c.appendLog("Force Put", data)
95-
} else {
96-
c.appendLog("Put", data)
97-
}
92+
c.appendLog("Put", data)
9893
c.current = data
9994
return nil
10095
}
@@ -105,11 +100,6 @@ func (c *mockClient) Delete() error {
105100
return nil
106101
}
107102

108-
// Implements remote.ClientForcePusher
109-
func (c *mockClient) EnableForcePush() {
110-
c.force = true
111-
}
112-
113103
func (c *mockClient) appendLog(method string, content []byte) {
114104
// For easier test assertions, we actually log the result of decoding
115105
// the content JSON rather than the raw bytes. Callers are in principle
@@ -126,3 +116,54 @@ func (c *mockClient) appendLog(method string, content []byte) {
126116
}
127117
c.log = append(c.log, mockClientRequest{method, contentVal})
128118
}
119+
120+
// mockRemoteClient is like mockClient, but also implements
121+
// EnableForcePush, allowing testing for this behavior
122+
type mockRemoteClient struct {
123+
current []byte
124+
force bool
125+
log []mockClientRequest
126+
}
127+
128+
func (c *mockRemoteClient) Get() (*Payload, error) {
129+
c.appendLog("Get", c.current)
130+
if c.current == nil {
131+
return nil, nil
132+
}
133+
checksum := md5.Sum(c.current)
134+
return &Payload{
135+
Data: c.current,
136+
MD5: checksum[:],
137+
}, nil
138+
}
139+
140+
func (c *mockRemoteClient) Put(data []byte) error {
141+
if c.force {
142+
c.appendLog("Force Put", data)
143+
} else {
144+
c.appendLog("Put", data)
145+
}
146+
c.current = data
147+
return nil
148+
}
149+
150+
// Implements remote.ClientForcePusher
151+
func (c *mockRemoteClient) EnableForcePush() {
152+
c.force = true
153+
}
154+
155+
func (c *mockRemoteClient) Delete() error {
156+
c.appendLog("Delete", c.current)
157+
c.current = nil
158+
return nil
159+
}
160+
func (c *mockRemoteClient) appendLog(method string, content []byte) {
161+
var contentVal map[string]interface{}
162+
if content != nil {
163+
err := json.Unmarshal(content, &contentVal)
164+
if err != nil {
165+
panic(err) // should never happen because our tests control this input
166+
}
167+
}
168+
c.log = append(c.log, mockClientRequest{method, contentVal})
169+
}

states/remote/state.go

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -72,20 +72,21 @@ func (s *State) WriteStateForMigration(f *statefile.File, force bool) error {
7272
s.mu.Lock()
7373
defer s.mu.Unlock()
7474

75-
// `force` is passed down from the CLI flag and terminates here. Actual
76-
// force pushing with the remote backend happens when Put()'ing the contents
77-
// in the backend. If force is specified we skip verifications and hand the
78-
// context off to the client to use when persitence operations actually take place.
79-
c, isForcePusher := s.Client.(ClientForcePusher)
80-
if force && isForcePusher {
81-
c.EnableForcePush()
82-
} else {
75+
if !force {
8376
checkFile := statefile.New(s.state, s.lineage, s.serial)
8477
if err := statemgr.CheckValidImport(f, checkFile); err != nil {
8578
return err
8679
}
8780
}
8881

82+
// The remote backend needs to pass the `force` flag through to its client.
83+
// For backends that support such operations, inform the client
84+
// that a force push has been requested
85+
c, isForcePusher := s.Client.(ClientForcePusher)
86+
if force && isForcePusher {
87+
c.EnableForcePush()
88+
}
89+
8990
// We create a deep copy of the state here, because the caller also has
9091
// a reference to the given object and can potentially go on to mutate
9192
// it after we return, but we want the snapshot at this point in time.

states/remote/state_test.go

Lines changed: 153 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,158 @@ func TestWriteStateForMigration(t *testing.T) {
302302
},
303303
}
304304

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: &mockRemoteClient{
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+
305457
testCases := []migrationTestCase{
306458
// Refreshing state before we run the test loop causes a GET
307459
{
@@ -387,7 +539,7 @@ func TestWriteStateForMigration(t *testing.T) {
387539

388540
// Our client is a mockClient which has a log we
389541
// use to check that operations generate expected requests
390-
mockClient := mgr.Client.(*mockClient)
542+
mockClient := mgr.Client.(*mockRemoteClient)
391543

392544
if mockClient.force {
393545
t.Fatalf("client should not default to force")

0 commit comments

Comments
 (0)