From 942572b574007d2df94a1c4d9cfeb76bacb465f4 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 27 Feb 2017 13:37:09 -0800 Subject: [PATCH] backend/init: add atlas, remove legacy atlas remote state --- backend/init/init.go | 2 + state/remote/atlas.go | 366 ----------------------------------- state/remote/atlas_test.go | 385 ------------------------------------- state/remote/remote.go | 1 - 4 files changed, 2 insertions(+), 752 deletions(-) delete mode 100644 state/remote/atlas.go delete mode 100644 state/remote/atlas_test.go diff --git a/backend/init/init.go b/backend/init/init.go index 6c057c32b..7297904b0 100644 --- a/backend/init/init.go +++ b/backend/init/init.go @@ -7,6 +7,7 @@ import ( "github.com/hashicorp/terraform/backend" + backendatlas "github.com/hashicorp/terraform/backend/atlas" backendlegacy "github.com/hashicorp/terraform/backend/legacy" backendlocal "github.com/hashicorp/terraform/backend/local" backendconsul "github.com/hashicorp/terraform/backend/remote-state/consul" @@ -31,6 +32,7 @@ func init() { // Our hardcoded backends. We don't need to acquire a lock here // since init() code is serial and can't spawn goroutines. backends = map[string]func() backend.Backend{ + "atlas": func() backend.Backend { return &backendatlas.Backend{} }, "local": func() backend.Backend { return &backendlocal.Local{} }, "consul": func() backend.Backend { return backendconsul.New() }, "inmem": func() backend.Backend { return backendinmem.New() }, diff --git a/state/remote/atlas.go b/state/remote/atlas.go deleted file mode 100644 index ead0acbcb..000000000 --- a/state/remote/atlas.go +++ /dev/null @@ -1,366 +0,0 @@ -package remote - -import ( - "bytes" - "crypto/md5" - "crypto/tls" - "crypto/x509" - "encoding/base64" - "fmt" - "io" - "log" - "net/http" - "net/url" - "os" - "path" - "strings" - - "github.com/hashicorp/go-cleanhttp" - "github.com/hashicorp/go-retryablehttp" - "github.com/hashicorp/go-rootcerts" - "github.com/hashicorp/terraform/terraform" -) - -const ( - // defaultAtlasServer is used when no address is given - defaultAtlasServer = "https://atlas.hashicorp.com/" - atlasTokenHeader = "X-Atlas-Token" -) - -func atlasFactory(conf map[string]string) (Client, error) { - var client AtlasClient - - server, ok := conf["address"] - if !ok || server == "" { - server = defaultAtlasServer - } - - url, err := url.Parse(server) - if err != nil { - return nil, err - } - - token, ok := conf["access_token"] - if token == "" { - token = os.Getenv("ATLAS_TOKEN") - ok = true - } - if !ok || token == "" { - return nil, fmt.Errorf( - "missing 'access_token' configuration or ATLAS_TOKEN environmental variable") - } - - name, ok := conf["name"] - if !ok || name == "" { - return nil, fmt.Errorf("missing 'name' configuration") - } - - parts := strings.Split(name, "/") - if len(parts) != 2 { - return nil, fmt.Errorf("malformed name '%s', expected format '/'", name) - } - - // If it exists, add the `ATLAS_RUN_ID` environment - // variable as a param, which is injected during Atlas Terraform - // runs. This is completely optional. - client.RunId = os.Getenv("ATLAS_RUN_ID") - - client.Server = server - client.ServerURL = url - client.AccessToken = token - client.User = parts[0] - client.Name = parts[1] - - return &client, nil -} - -// AtlasClient implements the Client interface for an Atlas compatible server. -type AtlasClient struct { - Server string - ServerURL *url.URL - User string - Name string - AccessToken string - RunId string - HTTPClient *retryablehttp.Client - - conflictHandlingAttempted bool -} - -func (c *AtlasClient) Get() (*Payload, error) { - // Make the HTTP request - req, err := retryablehttp.NewRequest("GET", c.url().String(), nil) - if err != nil { - return nil, fmt.Errorf("Failed to make HTTP request: %v", err) - } - - req.Header.Set(atlasTokenHeader, c.AccessToken) - - // Request the url - client, err := c.http() - if err != nil { - return nil, err - } - resp, err := client.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - // Handle the common status codes - switch resp.StatusCode { - case http.StatusOK: - // Handled after - case http.StatusNoContent: - return nil, nil - case http.StatusNotFound: - return nil, nil - case http.StatusUnauthorized: - return nil, fmt.Errorf("HTTP remote state endpoint requires auth") - case http.StatusForbidden: - return nil, fmt.Errorf("HTTP remote state endpoint invalid auth") - case http.StatusInternalServerError: - return nil, fmt.Errorf("HTTP remote state internal server error") - default: - return nil, fmt.Errorf( - "Unexpected HTTP response code: %d\n\nBody: %s", - resp.StatusCode, c.readBody(resp.Body)) - } - - // Read in the body - buf := bytes.NewBuffer(nil) - if _, err := io.Copy(buf, resp.Body); err != nil { - return nil, fmt.Errorf("Failed to read remote state: %v", err) - } - - // Create the payload - payload := &Payload{ - Data: buf.Bytes(), - } - - if len(payload.Data) == 0 { - return nil, nil - } - - // Check for the MD5 - if raw := resp.Header.Get("Content-MD5"); raw != "" { - md5, err := base64.StdEncoding.DecodeString(raw) - if err != nil { - return nil, fmt.Errorf("Failed to decode Content-MD5 '%s': %v", raw, err) - } - - payload.MD5 = md5 - } else { - // Generate the MD5 - hash := md5.Sum(payload.Data) - payload.MD5 = hash[:] - } - - return payload, nil -} - -func (c *AtlasClient) Put(state []byte) error { - // Get the target URL - base := c.url() - - // Generate the MD5 - hash := md5.Sum(state) - b64 := base64.StdEncoding.EncodeToString(hash[:]) - - // Make the HTTP client and request - req, err := retryablehttp.NewRequest("PUT", base.String(), bytes.NewReader(state)) - if err != nil { - return fmt.Errorf("Failed to make HTTP request: %v", err) - } - - // Prepare the request - req.Header.Set(atlasTokenHeader, c.AccessToken) - req.Header.Set("Content-MD5", b64) - req.Header.Set("Content-Type", "application/json") - req.ContentLength = int64(len(state)) - - // Make the request - client, err := c.http() - if err != nil { - return err - } - resp, err := client.Do(req) - if err != nil { - return fmt.Errorf("Failed to upload state: %v", err) - } - defer resp.Body.Close() - - // Handle the error codes - switch resp.StatusCode { - case http.StatusOK: - return nil - case http.StatusConflict: - return c.handleConflict(c.readBody(resp.Body), state) - default: - return fmt.Errorf( - "HTTP error: %d\n\nBody: %s", - resp.StatusCode, c.readBody(resp.Body)) - } -} - -func (c *AtlasClient) Delete() error { - // Make the HTTP request - req, err := retryablehttp.NewRequest("DELETE", c.url().String(), nil) - if err != nil { - return fmt.Errorf("Failed to make HTTP request: %v", err) - } - req.Header.Set(atlasTokenHeader, c.AccessToken) - - // Make the request - client, err := c.http() - if err != nil { - return err - } - resp, err := client.Do(req) - if err != nil { - return fmt.Errorf("Failed to delete state: %v", err) - } - defer resp.Body.Close() - - // Handle the error codes - switch resp.StatusCode { - case http.StatusOK: - return nil - case http.StatusNoContent: - return nil - case http.StatusNotFound: - return nil - default: - return fmt.Errorf( - "HTTP error: %d\n\nBody: %s", - resp.StatusCode, c.readBody(resp.Body)) - } -} - -func (c *AtlasClient) readBody(b io.Reader) string { - var buf bytes.Buffer - if _, err := io.Copy(&buf, b); err != nil { - return fmt.Sprintf("Error reading body: %s", err) - } - - result := buf.String() - if result == "" { - result = "" - } - - return result -} - -func (c *AtlasClient) url() *url.URL { - values := url.Values{} - - values.Add("atlas_run_id", c.RunId) - - return &url.URL{ - Scheme: c.ServerURL.Scheme, - Host: c.ServerURL.Host, - Path: path.Join("api/v1/terraform/state", c.User, c.Name), - RawQuery: values.Encode(), - } -} - -func (c *AtlasClient) http() (*retryablehttp.Client, error) { - if c.HTTPClient != nil { - return c.HTTPClient, nil - } - tlsConfig := &tls.Config{} - err := rootcerts.ConfigureTLS(tlsConfig, &rootcerts.Config{ - CAFile: os.Getenv("ATLAS_CAFILE"), - CAPath: os.Getenv("ATLAS_CAPATH"), - }) - if err != nil { - return nil, err - } - rc := retryablehttp.NewClient() - - rc.CheckRetry = func(resp *http.Response, err error) (bool, error) { - if err != nil { - // don't bother retrying if the certs don't match - if err, ok := err.(*url.Error); ok { - if _, ok := err.Err.(x509.UnknownAuthorityError); ok { - return false, nil - } - } - // continue retrying - return true, nil - } - return retryablehttp.DefaultRetryPolicy(resp, err) - } - - t := cleanhttp.DefaultTransport() - t.TLSClientConfig = tlsConfig - rc.HTTPClient.Transport = t - - c.HTTPClient = rc - return rc, nil -} - -// Atlas returns an HTTP 409 - Conflict if the pushed state reports the same -// Serial number but the checksum of the raw content differs. This can -// sometimes happen when Terraform changes state representation internally -// between versions in a way that's semantically neutral but affects the JSON -// output and therefore the checksum. -// -// Here we detect and handle this situation by ticking the serial and retrying -// iff for the previous state and the proposed state: -// -// * the serials match -// * the parsed states are Equal (semantically equivalent) -// -// In other words, in this situation Terraform can override Atlas's detected -// conflict by asserting that the state it is pushing is indeed correct. -func (c *AtlasClient) handleConflict(msg string, state []byte) error { - log.Printf("[DEBUG] Handling Atlas conflict response: %s", msg) - - if c.conflictHandlingAttempted { - log.Printf("[DEBUG] Already attempted conflict resolution; returning conflict.") - } else { - c.conflictHandlingAttempted = true - log.Printf("[DEBUG] Atlas reported conflict, checking for equivalent states.") - - payload, err := c.Get() - if err != nil { - return conflictHandlingError(err) - } - - currentState, err := terraform.ReadState(bytes.NewReader(payload.Data)) - if err != nil { - return conflictHandlingError(err) - } - - proposedState, err := terraform.ReadState(bytes.NewReader(state)) - if err != nil { - return conflictHandlingError(err) - } - - if statesAreEquivalent(currentState, proposedState) { - log.Printf("[DEBUG] States are equivalent, incrementing serial and retrying.") - proposedState.Serial++ - var buf bytes.Buffer - if err := terraform.WriteState(proposedState, &buf); err != nil { - return conflictHandlingError(err) - - } - return c.Put(buf.Bytes()) - } else { - log.Printf("[DEBUG] States are not equivalent, returning conflict.") - } - } - - return fmt.Errorf( - "Atlas detected a remote state conflict.\n\nMessage: %s", msg) -} - -func conflictHandlingError(err error) error { - return fmt.Errorf( - "Error while handling a conflict response from Atlas: %s", err) -} - -func statesAreEquivalent(current, proposed *terraform.State) bool { - return current.Serial == proposed.Serial && current.Equal(proposed) -} diff --git a/state/remote/atlas_test.go b/state/remote/atlas_test.go deleted file mode 100644 index f5fe127d6..000000000 --- a/state/remote/atlas_test.go +++ /dev/null @@ -1,385 +0,0 @@ -package remote - -import ( - "bytes" - "crypto/md5" - "crypto/tls" - "crypto/x509" - "encoding/json" - "net/http" - "net/http/httptest" - "net/url" - "os" - "testing" - "time" - - "github.com/hashicorp/terraform/helper/acctest" - "github.com/hashicorp/terraform/terraform" -) - -func TestAtlasClient_impl(t *testing.T) { - var _ Client = new(AtlasClient) -} - -func TestAtlasClient(t *testing.T) { - acctest.RemoteTestPrecheck(t) - - token := os.Getenv("ATLAS_TOKEN") - if token == "" { - t.Skipf("skipping, ATLAS_TOKEN must be set") - } - - client, err := atlasFactory(map[string]string{ - "access_token": token, - "name": "hashicorp/test-remote-state", - }) - if err != nil { - t.Fatalf("bad: %s", err) - } - - testClient(t, client) -} - -func TestAtlasClient_noRetryOnBadCerts(t *testing.T) { - acctest.RemoteTestPrecheck(t) - - client, err := atlasFactory(map[string]string{ - "access_token": "NOT_REQUIRED", - "name": "hashicorp/test-remote-state", - }) - if err != nil { - t.Fatalf("bad: %s", err) - } - - ac := client.(*AtlasClient) - // trigger the AtlasClient to build the http client and assign HTTPClient - httpClient, err := ac.http() - if err != nil { - t.Fatal(err) - } - - // remove the CA certs from the client - brokenCfg := &tls.Config{ - RootCAs: new(x509.CertPool), - } - httpClient.HTTPClient.Transport.(*http.Transport).TLSClientConfig = brokenCfg - - // Instrument CheckRetry to make sure we didn't retry - retries := 0 - oldCheck := httpClient.CheckRetry - httpClient.CheckRetry = func(resp *http.Response, err error) (bool, error) { - if retries > 0 { - t.Fatal("retried after certificate error") - } - retries++ - return oldCheck(resp, err) - } - - _, err = client.Get() - if err != nil { - if err, ok := err.(*url.Error); ok { - if _, ok := err.Err.(x509.UnknownAuthorityError); ok { - return - } - } - } - - t.Fatalf("expected x509.UnknownAuthorityError, got %v", err) -} - -func TestAtlasClient_ReportedConflictEqualStates(t *testing.T) { - fakeAtlas := newFakeAtlas(t, testStateModuleOrderChange) - srv := fakeAtlas.Server() - defer srv.Close() - client, err := atlasFactory(map[string]string{ - "access_token": "sometoken", - "name": "someuser/some-test-remote-state", - "address": srv.URL, - }) - if err != nil { - t.Fatalf("err: %s", err) - } - - state, err := terraform.ReadState(bytes.NewReader(testStateModuleOrderChange)) - if err != nil { - t.Fatalf("err: %s", err) - } - - var stateJson bytes.Buffer - if err := terraform.WriteState(state, &stateJson); err != nil { - t.Fatalf("err: %s", err) - } - if err := client.Put(stateJson.Bytes()); err != nil { - t.Fatalf("err: %s", err) - } -} - -func TestAtlasClient_NoConflict(t *testing.T) { - fakeAtlas := newFakeAtlas(t, testStateSimple) - srv := fakeAtlas.Server() - defer srv.Close() - client, err := atlasFactory(map[string]string{ - "access_token": "sometoken", - "name": "someuser/some-test-remote-state", - "address": srv.URL, - }) - if err != nil { - t.Fatalf("err: %s", err) - } - - state, err := terraform.ReadState(bytes.NewReader(testStateSimple)) - if err != nil { - t.Fatalf("err: %s", err) - } - - fakeAtlas.NoConflictAllowed(true) - - var stateJson bytes.Buffer - if err := terraform.WriteState(state, &stateJson); err != nil { - t.Fatalf("err: %s", err) - } - - if err := client.Put(stateJson.Bytes()); err != nil { - t.Fatalf("err: %s", err) - } -} - -func TestAtlasClient_LegitimateConflict(t *testing.T) { - fakeAtlas := newFakeAtlas(t, testStateSimple) - srv := fakeAtlas.Server() - defer srv.Close() - client, err := atlasFactory(map[string]string{ - "access_token": "sometoken", - "name": "someuser/some-test-remote-state", - "address": srv.URL, - }) - if err != nil { - t.Fatalf("err: %s", err) - } - - state, err := terraform.ReadState(bytes.NewReader(testStateSimple)) - if err != nil { - t.Fatalf("err: %s", err) - } - - var buf bytes.Buffer - terraform.WriteState(state, &buf) - - // Changing the state but not the serial. Should generate a conflict. - state.RootModule().Outputs["drift"] = &terraform.OutputState{ - Type: "string", - Sensitive: false, - Value: "happens", - } - - var stateJson bytes.Buffer - if err := terraform.WriteState(state, &stateJson); err != nil { - t.Fatalf("err: %s", err) - } - if err := client.Put(stateJson.Bytes()); err == nil { - t.Fatal("Expected error from state conflict, got none.") - } -} - -func TestAtlasClient_UnresolvableConflict(t *testing.T) { - fakeAtlas := newFakeAtlas(t, testStateSimple) - - // Something unexpected causes Atlas to conflict in a way that we can't fix. - fakeAtlas.AlwaysConflict(true) - - srv := fakeAtlas.Server() - defer srv.Close() - client, err := atlasFactory(map[string]string{ - "access_token": "sometoken", - "name": "someuser/some-test-remote-state", - "address": srv.URL, - }) - if err != nil { - t.Fatalf("err: %s", err) - } - - state, err := terraform.ReadState(bytes.NewReader(testStateSimple)) - if err != nil { - t.Fatalf("err: %s", err) - } - - var stateJson bytes.Buffer - if err := terraform.WriteState(state, &stateJson); err != nil { - t.Fatalf("err: %s", err) - } - doneCh := make(chan struct{}) - go func() { - defer close(doneCh) - if err := client.Put(stateJson.Bytes()); err == nil { - t.Fatal("Expected error from state conflict, got none.") - } - }() - - select { - case <-doneCh: - // OK - case <-time.After(500 * time.Millisecond): - t.Fatalf("Timed out after 500ms, probably because retrying infinitely.") - } -} - -// Stub Atlas HTTP API for a given state JSON string; does checksum-based -// conflict detection equivalent to Atlas's. -type fakeAtlas struct { - state []byte - t *testing.T - - // Used to test that we only do the special conflict handling retry once. - alwaysConflict bool - - // Used to fail the test immediately if a conflict happens. - noConflictAllowed bool -} - -func newFakeAtlas(t *testing.T, state []byte) *fakeAtlas { - return &fakeAtlas{ - state: state, - t: t, - } -} - -func (f *fakeAtlas) Server() *httptest.Server { - return httptest.NewServer(http.HandlerFunc(f.handler)) -} - -func (f *fakeAtlas) CurrentState() *terraform.State { - // we read the state manually here, because terraform may alter state - // during read - currentState := &terraform.State{} - err := json.Unmarshal(f.state, currentState) - if err != nil { - f.t.Fatalf("err: %s", err) - } - return currentState -} - -func (f *fakeAtlas) CurrentSerial() int64 { - return f.CurrentState().Serial -} - -func (f *fakeAtlas) CurrentSum() [md5.Size]byte { - return md5.Sum(f.state) -} - -func (f *fakeAtlas) AlwaysConflict(b bool) { - f.alwaysConflict = b -} - -func (f *fakeAtlas) NoConflictAllowed(b bool) { - f.noConflictAllowed = b -} - -func (f *fakeAtlas) handler(resp http.ResponseWriter, req *http.Request) { - // access tokens should only be sent as a header - if req.FormValue("access_token") != "" { - http.Error(resp, "access_token in request params", http.StatusBadRequest) - return - } - - if req.Header.Get(atlasTokenHeader) == "" { - http.Error(resp, "missing access token", http.StatusBadRequest) - return - } - - switch req.Method { - case "GET": - // Respond with the current stored state. - resp.Header().Set("Content-Type", "application/json") - resp.Write(f.state) - case "PUT": - var buf bytes.Buffer - buf.ReadFrom(req.Body) - sum := md5.Sum(buf.Bytes()) - - // we read the state manually here, because terraform may alter state - // during read - state := &terraform.State{} - err := json.Unmarshal(buf.Bytes(), state) - if err != nil { - f.t.Fatalf("err: %s", err) - } - - conflict := f.CurrentSerial() == state.Serial && f.CurrentSum() != sum - conflict = conflict || f.alwaysConflict - if conflict { - if f.noConflictAllowed { - f.t.Fatal("Got conflict when NoConflictAllowed was set.") - } - http.Error(resp, "Conflict", 409) - } else { - f.state = buf.Bytes() - resp.WriteHeader(200) - } - } -} - -// This is a tfstate file with the module order changed, which is a structural -// but not a semantic difference. Terraform will sort these modules as it -// loads the state. -var testStateModuleOrderChange = []byte( - `{ - "version": 3, - "serial": 1, - "modules": [ - { - "path": [ - "root", - "child2", - "grandchild" - ], - "outputs": { - "foo": { - "sensitive": false, - "type": "string", - "value": "bar" - } - }, - "resources": null - }, - { - "path": [ - "root", - "child1", - "grandchild" - ], - "outputs": { - "foo": { - "sensitive": false, - "type": "string", - "value": "bar" - } - }, - "resources": null - } - ] -} -`) - -var testStateSimple = []byte( - `{ - "version": 3, - "serial": 2, - "lineage": "c00ad9ac-9b35-42fe-846e-b06f0ef877e9", - "modules": [ - { - "path": [ - "root" - ], - "outputs": { - "foo": { - "sensitive": false, - "type": "string", - "value": "bar" - } - }, - "resources": {}, - "depends_on": [] - } - ] -} -`) diff --git a/state/remote/remote.go b/state/remote/remote.go index 414c115e9..0b1ee5f7c 100644 --- a/state/remote/remote.go +++ b/state/remote/remote.go @@ -46,7 +46,6 @@ func NewClient(t string, conf map[string]string) (Client, error) { // NewClient. var BuiltinClients = map[string]Factory{ "artifactory": artifactoryFactory, - "atlas": atlasFactory, "azure": azureFactory, "etcd": etcdFactory, "gcs": gcsFactory,