From bec1dfcd6830db1c1e90601f35d919b1e5694107 Mon Sep 17 00:00:00 2001 From: Armon Dadgar Date: Wed, 8 Oct 2014 10:28:47 -0700 Subject: [PATCH] remote: Supporting push state --- remote/client.go | 6 +++ remote/client_test.go | 7 +++ remote/remote.go | 57 ++++++++++++++++++++++++ remote/remote_test.go | 101 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 171 insertions(+) diff --git a/remote/client.go b/remote/client.go index c290f3bb1..812c24122 100644 --- a/remote/client.go +++ b/remote/client.go @@ -19,6 +19,10 @@ var ( // due to a conflict on the state ErrConflict = fmt.Errorf("Conflicting state file") + // ErrServerNewer is used to indicate the serial number of + // the state is newer on the server side + ErrServerNewer = fmt.Errorf("Server-side Serial is newer") + // ErrRequireAuth is used if the remote server requires // authentication and none is provided ErrRequireAuth = fmt.Errorf("Remote server requires authentication") @@ -187,6 +191,8 @@ func (r *remoteStateClient) PutState(state []byte, force bool) error { return nil case http.StatusConflict: return ErrConflict + case http.StatusPreconditionFailed: + return ErrServerNewer case http.StatusUnauthorized: return ErrRequireAuth case http.StatusForbidden: diff --git a/remote/client_test.go b/remote/client_test.go index c81cab410..04060cfb2 100644 --- a/remote/client_test.go +++ b/remote/client_test.go @@ -193,6 +193,13 @@ func TestPutState(t *testing.T) { ExpectMD5: hash, ExpectErr: ErrConflict.Error(), }, + &tcase{ + Code: http.StatusPreconditionFailed, + Path: "/foobar", + Body: inp, + ExpectMD5: hash, + ExpectErr: ErrServerNewer.Error(), + }, &tcase{ Code: http.StatusUnauthorized, Path: "/foobar", diff --git a/remote/remote.go b/remote/remote.go index 259bdab52..447125693 100644 --- a/remote/remote.go +++ b/remote/remote.go @@ -115,6 +115,24 @@ func (sc StateChangeResult) SuccessfulPull() bool { } } +// SuccessfulPush is used to clasify the StateChangeResult for +// a push operation. This is different by operation, but can be used +// to determine a proper exit code +func (sc StateChangeResult) SuccessfulPush() bool { + switch sc { + case StateChangeNoop: + return true + case StateChangeUpdateRemote: + return true + case StateChangeRemoteNewer: + return false + case StateChangeConflict: + return false + default: + return false + } +} + // EnsureDirectory is used to make sure the local storage // directory exists func EnsureDirectory() error { @@ -312,6 +330,45 @@ func RefreshState(conf *terraform.RemoteState) (StateChangeResult, error) { panic("Unhandled remote update case") } +// PushState is used to read the local state and +// update the remote state if necessary. The state push +// can be 'forced' to override any conflict detection +// on the server-side. +func PushState(conf *terraform.RemoteState, force bool) (StateChangeResult, error) { + // Get the path to the state file + path, err := HiddenStatePath() + if err != nil { + return StateChangeNoop, err + } + + // Get the existing state file + raw, err := ioutil.ReadFile(path) + if err != nil && !os.IsNotExist(err) { + return StateChangeNoop, fmt.Errorf("Failed to read local state: %v", err) + } + + // Check if there is no local state + if raw == nil { + return StateChangeNoop, fmt.Errorf("No local state to push") + } + + // Push the state to the server + client := &remoteStateClient{conf: conf} + err = client.PutState(raw, force) + + // Handle the various edge cases + switch err { + case nil: + return StateChangeUpdateRemote, nil + case ErrServerNewer: + return StateChangeRemoteNewer, nil + case ErrConflict: + return StateChangeConflict, nil + default: + return StateChangeNoop, err + } +} + // blankState is used to return a serialized form of a blank state // with only the remote info. func blankState(conf *terraform.RemoteState) ([]byte, error) { diff --git a/remote/remote_test.go b/remote/remote_test.go index 009c1f15d..ee872c778 100644 --- a/remote/remote_test.go +++ b/remote/remote_test.go @@ -241,6 +241,93 @@ func TestRefreshState_Conflict(t *testing.T) { } } +func TestPushState_NoState(t *testing.T) { + defer fixDir(testDir(t)) + + remote, srv := testRemotePush(t, 200) + defer srv.Close() + + sc, err := PushState(remote, false) + if err.Error() != "No local state to push" { + t.Fatalf("err: %v", err) + } + if sc != StateChangeNoop { + t.Fatalf("Bad: %v", sc) + } +} + +func TestPushState_Update(t *testing.T) { + defer fixDir(testDir(t)) + + remote, srv := testRemotePush(t, 200) + defer srv.Close() + + local := terraform.NewState() + testWriteLocal(t, local) + + sc, err := PushState(remote, false) + if err != nil { + t.Fatalf("err: %v", err) + } + if sc != StateChangeUpdateRemote { + t.Fatalf("Bad: %v", sc) + } +} + +func TestPushState_RemoteNewer(t *testing.T) { + defer fixDir(testDir(t)) + + remote, srv := testRemotePush(t, 412) + defer srv.Close() + + local := terraform.NewState() + testWriteLocal(t, local) + + sc, err := PushState(remote, false) + if err != nil { + t.Fatalf("err: %v", err) + } + if sc != StateChangeRemoteNewer { + t.Fatalf("Bad: %v", sc) + } +} + +func TestPushState_Conflict(t *testing.T) { + defer fixDir(testDir(t)) + + remote, srv := testRemotePush(t, 409) + defer srv.Close() + + local := terraform.NewState() + testWriteLocal(t, local) + + sc, err := PushState(remote, false) + if err != nil { + t.Fatalf("err: %v", err) + } + if sc != StateChangeConflict { + t.Fatalf("Bad: %v", sc) + } +} + +func TestPushState_Error(t *testing.T) { + defer fixDir(testDir(t)) + + remote, srv := testRemotePush(t, 500) + defer srv.Close() + + local := terraform.NewState() + testWriteLocal(t, local) + + sc, err := PushState(remote, false) + if err != ErrRemoteInternal { + t.Fatalf("err: %v", err) + } + if sc != StateChangeNoop { + t.Fatalf("Bad: %v", sc) + } +} + func TestBlankState(t *testing.T) { remote := &terraform.RemoteState{ Name: "foo", @@ -337,6 +424,20 @@ func testRemote(t *testing.T, s *terraform.State) (*terraform.RemoteState, *http return remote, srv } +// testRemotePush is used to make a test HTTP server to +// return a given status code on push +func testRemotePush(t *testing.T, c int) (*terraform.RemoteState, *httptest.Server) { + cb := func(resp http.ResponseWriter, req *http.Request) { + resp.WriteHeader(c) + } + srv := httptest.NewServer(http.HandlerFunc(cb)) + remote := &terraform.RemoteState{ + Name: "foo", + Server: srv.URL, + } + return remote, srv +} + // testDir is used to change the current working directory // into a test directory that should be remoted after func testDir(t *testing.T) (string, string) {