diff --git a/backend/atlas/backend.go b/backend/atlas/backend.go new file mode 100644 index 000000000..f6ce3ea51 --- /dev/null +++ b/backend/atlas/backend.go @@ -0,0 +1,163 @@ +package atlas + +import ( + "context" + "fmt" + "net/url" + "os" + "strings" + "sync" + + "github.com/hashicorp/terraform/backend" + "github.com/hashicorp/terraform/helper/schema" + "github.com/hashicorp/terraform/state" + "github.com/hashicorp/terraform/state/remote" + "github.com/hashicorp/terraform/terraform" + "github.com/mitchellh/cli" + "github.com/mitchellh/colorstring" +) + +// Backend is an implementation of EnhancedBackend that performs all operations +// in Atlas. State must currently also be stored in Atlas, although it is worth +// investigating in the future if state storage can be external as well. +type Backend struct { + // CLI and Colorize control the CLI output. If CLI is nil then no CLI + // output will be done. If CLIColor is nil then no coloring will be done. + CLI cli.Ui + CLIColor *colorstring.Colorize + + // ContextOpts are the base context options to set when initializing a + // Terraform context. Many of these will be overridden or merged by + // Operation. See Operation for more details. + ContextOpts *terraform.ContextOpts + + //--------------------------------------------------------------- + // Internal fields, do not set + //--------------------------------------------------------------- + // stateClient is the legacy state client, setup in Configure + stateClient *stateClient + + // schema is the schema for configuration, set by init + schema *schema.Backend + once sync.Once + + // opLock locks operations + opLock sync.Mutex +} + +func (b *Backend) Input( + ui terraform.UIInput, c *terraform.ResourceConfig) (*terraform.ResourceConfig, error) { + b.once.Do(b.init) + return b.schema.Input(ui, c) +} + +func (b *Backend) Validate(c *terraform.ResourceConfig) ([]string, []error) { + b.once.Do(b.init) + return b.schema.Validate(c) +} + +func (b *Backend) Configure(c *terraform.ResourceConfig) error { + b.once.Do(b.init) + return b.schema.Configure(c) +} + +func (b *Backend) States() ([]string, error) { + return nil, backend.ErrNamedStatesNotSupported +} + +func (b *Backend) DeleteState(name string) error { + return backend.ErrNamedStatesNotSupported +} + +func (b *Backend) State(name string) (state.State, error) { + if name != backend.DefaultStateName { + return nil, backend.ErrNamedStatesNotSupported + } + + return &remote.State{Client: b.stateClient}, nil +} + +// Colorize returns the Colorize structure that can be used for colorizing +// output. This is gauranteed to always return a non-nil value and so is useful +// as a helper to wrap any potentially colored strings. +func (b *Backend) Colorize() *colorstring.Colorize { + if b.CLIColor != nil { + return b.CLIColor + } + + return &colorstring.Colorize{ + Colors: colorstring.DefaultColors, + Disable: true, + } +} + +func (b *Backend) init() { + b.schema = &schema.Backend{ + Schema: map[string]*schema.Schema{ + "name": &schema.Schema{ + Type: schema.TypeString, + Required: true, + Description: schemaDescriptions["name"], + }, + + "access_token": &schema.Schema{ + Type: schema.TypeString, + Required: true, + Description: schemaDescriptions["access_token"], + DefaultFunc: schema.EnvDefaultFunc("ATLAS_TOKEN", nil), + }, + + "address": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + Default: defaultAtlasServer, + Description: schemaDescriptions["address"], + }, + }, + + ConfigureFunc: b.schemaConfigure, + } +} + +func (b *Backend) schemaConfigure(ctx context.Context) error { + d := schema.FromContextBackendConfig(ctx) + + // Parse the address + addr := d.Get("address").(string) + addrUrl, err := url.Parse(addr) + if err != nil { + return fmt.Errorf("Error parsing 'address': %s", err) + } + + // Parse the org/env + name := d.Get("name").(string) + parts := strings.Split(name, "/") + if len(parts) != 2 { + return fmt.Errorf("malformed name '%s', expected format '/'", name) + } + org := parts[0] + env := parts[1] + + // Setup the client + b.stateClient = &stateClient{ + Server: addr, + ServerURL: addrUrl, + AccessToken: d.Get("access_token").(string), + User: org, + Name: env, + + // This is optionally set during Atlas Terraform runs. + RunId: os.Getenv("ATLAS_RUN_ID"), + } + + return nil +} + +var schemaDescriptions = map[string]string{ + "name": "Full name of the environment in Atlas, such as 'hashicorp/myenv'", + "access_token": "Access token to use to access Atlas. If ATLAS_TOKEN is set then\n" + + "this will override any saved value for this.", + "address": "Address to your Atlas installation. This defaults to the publicly\n" + + "hosted version at 'https://atlas.hashicorp.com/'. This address\n" + + "should contain the full HTTP scheme to use.", +} diff --git a/backend/atlas/backend_test.go b/backend/atlas/backend_test.go new file mode 100644 index 000000000..14c8d1f9e --- /dev/null +++ b/backend/atlas/backend_test.go @@ -0,0 +1,12 @@ +package atlas + +import ( + "testing" + + "github.com/hashicorp/terraform/backend" +) + +func TestImpl(t *testing.T) { + var _ backend.Backend = new(Backend) + var _ backend.CLI = new(Backend) +} diff --git a/backend/atlas/cli.go b/backend/atlas/cli.go new file mode 100644 index 000000000..5b3656eff --- /dev/null +++ b/backend/atlas/cli.go @@ -0,0 +1,13 @@ +package atlas + +import ( + "github.com/hashicorp/terraform/backend" +) + +// backend.CLI impl. +func (b *Backend) CLIInit(opts *backend.CLIOpts) error { + b.CLI = opts.CLI + b.CLIColor = opts.CLIColor + b.ContextOpts = opts.ContextOpts + return nil +} diff --git a/state/remote/atlas.go b/backend/atlas/state_client.go similarity index 83% rename from state/remote/atlas.go rename to backend/atlas/state_client.go index ead0acbcb..e49cb7192 100644 --- a/state/remote/atlas.go +++ b/backend/atlas/state_client.go @@ -1,4 +1,4 @@ -package remote +package atlas import ( "bytes" @@ -13,11 +13,11 @@ import ( "net/url" "os" "path" - "strings" "github.com/hashicorp/go-cleanhttp" "github.com/hashicorp/go-retryablehttp" "github.com/hashicorp/go-rootcerts" + "github.com/hashicorp/terraform/state/remote" "github.com/hashicorp/terraform/terraform" ) @@ -27,55 +27,8 @@ const ( 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 { +type stateClient struct { Server string ServerURL *url.URL User string @@ -87,7 +40,7 @@ type AtlasClient struct { conflictHandlingAttempted bool } -func (c *AtlasClient) Get() (*Payload, error) { +func (c *stateClient) Get() (*remote.Payload, error) { // Make the HTTP request req, err := retryablehttp.NewRequest("GET", c.url().String(), nil) if err != nil { @@ -134,7 +87,7 @@ func (c *AtlasClient) Get() (*Payload, error) { } // Create the payload - payload := &Payload{ + payload := &remote.Payload{ Data: buf.Bytes(), } @@ -159,7 +112,7 @@ func (c *AtlasClient) Get() (*Payload, error) { return payload, nil } -func (c *AtlasClient) Put(state []byte) error { +func (c *stateClient) Put(state []byte) error { // Get the target URL base := c.url() @@ -203,7 +156,7 @@ func (c *AtlasClient) Put(state []byte) error { } } -func (c *AtlasClient) Delete() error { +func (c *stateClient) Delete() error { // Make the HTTP request req, err := retryablehttp.NewRequest("DELETE", c.url().String(), nil) if err != nil { @@ -237,7 +190,7 @@ func (c *AtlasClient) Delete() error { } } -func (c *AtlasClient) readBody(b io.Reader) string { +func (c *stateClient) 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) @@ -251,7 +204,7 @@ func (c *AtlasClient) readBody(b io.Reader) string { return result } -func (c *AtlasClient) url() *url.URL { +func (c *stateClient) url() *url.URL { values := url.Values{} values.Add("atlas_run_id", c.RunId) @@ -264,7 +217,7 @@ func (c *AtlasClient) url() *url.URL { } } -func (c *AtlasClient) http() (*retryablehttp.Client, error) { +func (c *stateClient) http() (*retryablehttp.Client, error) { if c.HTTPClient != nil { return c.HTTPClient, nil } @@ -314,7 +267,7 @@ func (c *AtlasClient) http() (*retryablehttp.Client, error) { // // 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 { +func (c *stateClient) handleConflict(msg string, state []byte) error { log.Printf("[DEBUG] Handling Atlas conflict response: %s", msg) if c.conflictHandlingAttempted { diff --git a/state/remote/atlas_test.go b/backend/atlas/state_client_test.go similarity index 87% rename from state/remote/atlas_test.go rename to backend/atlas/state_client_test.go index f5fe127d6..2fe85559d 100644 --- a/state/remote/atlas_test.go +++ b/backend/atlas/state_client_test.go @@ -1,4 +1,4 @@ -package remote +package atlas import ( "bytes" @@ -13,15 +13,28 @@ import ( "testing" "time" + "github.com/hashicorp/terraform/backend" "github.com/hashicorp/terraform/helper/acctest" + "github.com/hashicorp/terraform/state/remote" "github.com/hashicorp/terraform/terraform" ) -func TestAtlasClient_impl(t *testing.T) { - var _ Client = new(AtlasClient) +func testStateClient(t *testing.T, c map[string]interface{}) remote.Client { + b := backend.TestBackendConfig(t, &Backend{}, c) + raw, err := b.State(backend.DefaultStateName) + if err != nil { + t.Fatalf("err: %s", err) + } + + s := raw.(*remote.State) + return s.Client } -func TestAtlasClient(t *testing.T) { +func TestStateClient_impl(t *testing.T) { + var _ remote.Client = new(stateClient) +} + +func TestStateClient(t *testing.T) { acctest.RemoteTestPrecheck(t) token := os.Getenv("ATLAS_TOKEN") @@ -29,30 +42,24 @@ func TestAtlasClient(t *testing.T) { t.Skipf("skipping, ATLAS_TOKEN must be set") } - client, err := atlasFactory(map[string]string{ + client := testStateClient(t, map[string]interface{}{ "access_token": token, "name": "hashicorp/test-remote-state", }) - if err != nil { - t.Fatalf("bad: %s", err) - } - testClient(t, client) + remote.TestClient(t, client) } -func TestAtlasClient_noRetryOnBadCerts(t *testing.T) { +func TestStateClient_noRetryOnBadCerts(t *testing.T) { acctest.RemoteTestPrecheck(t) - client, err := atlasFactory(map[string]string{ + client := testStateClient(t, map[string]interface{}{ "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 + ac := client.(*stateClient) + // trigger the StateClient to build the http client and assign HTTPClient httpClient, err := ac.http() if err != nil { t.Fatal(err) @@ -87,18 +94,16 @@ func TestAtlasClient_noRetryOnBadCerts(t *testing.T) { t.Fatalf("expected x509.UnknownAuthorityError, got %v", err) } -func TestAtlasClient_ReportedConflictEqualStates(t *testing.T) { +func TestStateClient_ReportedConflictEqualStates(t *testing.T) { fakeAtlas := newFakeAtlas(t, testStateModuleOrderChange) srv := fakeAtlas.Server() defer srv.Close() - client, err := atlasFactory(map[string]string{ + + client := testStateClient(t, map[string]interface{}{ "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 { @@ -114,18 +119,16 @@ func TestAtlasClient_ReportedConflictEqualStates(t *testing.T) { } } -func TestAtlasClient_NoConflict(t *testing.T) { +func TestStateClient_NoConflict(t *testing.T) { fakeAtlas := newFakeAtlas(t, testStateSimple) srv := fakeAtlas.Server() defer srv.Close() - client, err := atlasFactory(map[string]string{ + + client := testStateClient(t, map[string]interface{}{ "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 { @@ -144,18 +147,16 @@ func TestAtlasClient_NoConflict(t *testing.T) { } } -func TestAtlasClient_LegitimateConflict(t *testing.T) { +func TestStateClient_LegitimateConflict(t *testing.T) { fakeAtlas := newFakeAtlas(t, testStateSimple) srv := fakeAtlas.Server() defer srv.Close() - client, err := atlasFactory(map[string]string{ + + client := testStateClient(t, map[string]interface{}{ "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 { @@ -181,7 +182,7 @@ func TestAtlasClient_LegitimateConflict(t *testing.T) { } } -func TestAtlasClient_UnresolvableConflict(t *testing.T) { +func TestStateClient_UnresolvableConflict(t *testing.T) { fakeAtlas := newFakeAtlas(t, testStateSimple) // Something unexpected causes Atlas to conflict in a way that we can't fix. @@ -189,14 +190,12 @@ func TestAtlasClient_UnresolvableConflict(t *testing.T) { srv := fakeAtlas.Server() defer srv.Close() - client, err := atlasFactory(map[string]string{ + + client := testStateClient(t, map[string]interface{}{ "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 { 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/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,