diff --git a/pkg/iac/terraform/state/backend/tfcloud_config_reader.go b/pkg/iac/terraform/state/backend/tfcloud_config_reader.go new file mode 100644 index 00000000..6ca1c93c --- /dev/null +++ b/pkg/iac/terraform/state/backend/tfcloud_config_reader.go @@ -0,0 +1,56 @@ +package backend + +import ( + "encoding/json" + "errors" + "io" + "io/ioutil" + "path/filepath" + "runtime" + + "github.com/mitchellh/go-homedir" +) + +type container struct { + Credentials struct { + TerraformCloud struct { + Token string + } `json:"app.terraform.io"` + } +} + +type tfCloudConfigReader struct { + reader io.ReadCloser +} + +func NewTFCloudConfigReader(reader io.ReadCloser) *tfCloudConfigReader { + return &tfCloudConfigReader{reader} +} + +func (r *tfCloudConfigReader) GetToken() (string, error) { + b, err := ioutil.ReadAll(r.reader) + if err != nil { + return "", errors.New("unable to read file") + } + + var container container + if err := json.Unmarshal(b, &container); err != nil { + return "", err + } + if container.Credentials.TerraformCloud.Token == "" { + return "", errors.New("driftctl could not read your Terraform configuration file, please check that this is a valid Terraform credentials file") + } + return container.Credentials.TerraformCloud.Token, nil +} + +func getTerraformConfigFile() (string, error) { + homeDir, err := homedir.Dir() + if err != nil { + return "", err + } + tfConfigDir := ".terraform.d" + if runtime.GOOS == "windows" { + tfConfigDir = "terraform.d" + } + return filepath.Join(homeDir, tfConfigDir, "credentials.tfrc.json"), nil +} diff --git a/pkg/iac/terraform/state/backend/tfcloud_config_reader_test.go b/pkg/iac/terraform/state/backend/tfcloud_config_reader_test.go new file mode 100644 index 00000000..600fc534 --- /dev/null +++ b/pkg/iac/terraform/state/backend/tfcloud_config_reader_test.go @@ -0,0 +1,57 @@ +package backend + +import ( + "fmt" + "io/ioutil" + "strings" + "testing" +) + +func TestTFCloudConfigReader_GetToken(t *testing.T) { + tests := []struct { + name string + src string + want string + wantErr error + }{ + { + name: "get terraform cloud creds with config file", + src: `{"credentials": {"app.terraform.io": {"token": "token.creds.test"}}}`, + want: "token.creds.test", + wantErr: nil, + }, + { + name: "test with wrong credentials key in config file", + src: `{"test": {"app.terraform.io": {"token": "token.creds.test"}}}`, + want: "", + wantErr: fmt.Errorf("driftctl could not read your Terraform configuration file, please check that this is a valid Terraform credentials file"), + }, + { + name: "test with wrong terraform cloud hostname key in config file", + src: `{"credentials": {"test": {"token": "token.creds.test"}}}`, + want: "", + wantErr: fmt.Errorf("driftctl could not read your Terraform configuration file, please check that this is a valid Terraform credentials file"), + }, + { + name: "test with wrong terraform cloud token key in config file", + src: `{"credentials": {"app.terraform.io": {"test": "token.creds.test"}}}`, + want: "", + wantErr: fmt.Errorf("driftctl could not read your Terraform configuration file, please check that this is a valid Terraform credentials file"), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + readerCloser := ioutil.NopCloser(strings.NewReader(tt.src)) + defer readerCloser.Close() + r := NewTFCloudConfigReader(readerCloser) + got, err := r.GetToken() + if err != nil && err.Error() != tt.wantErr.Error() { + t.Errorf("GetToken() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("GetToken() got = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/pkg/iac/terraform/state/backend/tfcloud_reader.go b/pkg/iac/terraform/state/backend/tfcloud_reader.go index cd30b193..b04e9b20 100644 --- a/pkg/iac/terraform/state/backend/tfcloud_reader.go +++ b/pkg/iac/terraform/state/backend/tfcloud_reader.go @@ -3,8 +3,10 @@ package backend import ( "encoding/json" "fmt" + "io" "io/ioutil" "net/http" + "os" pkghttp "github.com/cloudskiff/driftctl/pkg/http" "github.com/pkg/errors" @@ -26,38 +28,80 @@ type TFCloudBody struct { Data TFCloudData `json:"data"` } -func NewTFCloudReader(client pkghttp.HTTPClient, workspaceId string, opts *Options) (*HTTPBackend, error) { - req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/workspaces/%s/current-state-version", TFCloudAPI, workspaceId), nil) - - if err != nil { - return nil, err - } - - req.Header.Add("Content-Type", "application/vnd.api+json") - req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", opts.TFCloudToken)) - - res, err := client.Do(req) - - if err != nil { - return nil, err - } - - if res.StatusCode < 200 || res.StatusCode >= 400 { - return nil, errors.Errorf("error requesting terraform cloud backend state: status code: %d", res.StatusCode) - } - - bodyBytes, _ := ioutil.ReadAll(res.Body) - - body := TFCloudBody{} - err = json.Unmarshal(bodyBytes, &body) - - if err != nil { - return nil, err - } - - rawURL := body.Data.Attributes.HostedStateDownloadUrl - logrus.WithFields(logrus.Fields{"hosted-state-download-url": rawURL}).Trace("Terraform Cloud backend response") - - opt := Options{} - return NewHTTPReader(client, rawURL, &opt) +type TFCloudBackend struct { + request *http.Request + client pkghttp.HTTPClient + reader io.ReadCloser + opts *Options +} + +func NewTFCloudReader(client pkghttp.HTTPClient, workspaceId string, opts *Options) (*TFCloudBackend, error) { + req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/workspaces/%s/current-state-version", TFCloudAPI, workspaceId), nil) + if err != nil { + return nil, err + } + req.Header.Add("Content-Type", "application/vnd.api+json") + return &TFCloudBackend{req, client, nil, opts}, nil +} + +func (t *TFCloudBackend) authorize() error { + token := t.opts.TFCloudToken + if token == "" { + tfConfigFile, err := getTerraformConfigFile() + if err != nil { + return err + } + file, err := os.Open(tfConfigFile) + if err != nil { + return err + } + defer file.Close() + reader := NewTFCloudConfigReader(file) + token, err = reader.GetToken() + if err != nil { + return err + } + } + t.request.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token)) + return nil +} + +func (t *TFCloudBackend) Read(p []byte) (n int, err error) { + if t.reader == nil { + if err := t.authorize(); err != nil { + return 0, err + } + res, err := t.client.Do(t.request) + if err != nil { + return 0, err + } + + if res.StatusCode < 200 || res.StatusCode >= 400 { + return 0, errors.Errorf("error requesting terraform cloud backend state: status code: %d", res.StatusCode) + } + + body := TFCloudBody{} + bodyBytes, _ := ioutil.ReadAll(res.Body) + err = json.Unmarshal(bodyBytes, &body) + if err != nil { + return 0, err + } + + rawURL := body.Data.Attributes.HostedStateDownloadUrl + logrus.WithFields(logrus.Fields{"hosted-state-download-url": rawURL}).Trace("Terraform Cloud backend response") + + h, err := NewHTTPReader(t.client, rawURL, &Options{}) + if err != nil { + return 0, err + } + t.reader = h + } + return t.reader.Read(p) +} + +func (t *TFCloudBackend) Close() error { + if t.reader != nil { + return t.reader.Close() + } + return errors.New("Unable to close reader as nothing was opened") } diff --git a/pkg/iac/terraform/state/backend/tfcloud_reader_test.go b/pkg/iac/terraform/state/backend/tfcloud_reader_test.go index 9de8fa90..66ad3143 100644 --- a/pkg/iac/terraform/state/backend/tfcloud_reader_test.go +++ b/pkg/iac/terraform/state/backend/tfcloud_reader_test.go @@ -9,7 +9,7 @@ import ( "github.com/stretchr/testify/assert" ) -func TestNewTFCloudReader(t *testing.T) { +func TestTFCloudBackend_Read(t *testing.T) { httpmock.Activate() defer httpmock.DeactivateAndReset() type args struct { @@ -17,12 +17,12 @@ func TestNewTFCloudReader(t *testing.T) { options *Options } tests := []struct { - name string - args args - url string - wantURL string - wantErr error - mock func() + name string + args args + url string + wantErr error + expected string + mock func() }{ { name: "Should fetch URL with auth header", @@ -32,9 +32,9 @@ func TestNewTFCloudReader(t *testing.T) { TFCloudToken: "TOKEN", }, }, - url: "https://app.terraform.io/api/v2/workspaces/workspaceId/current-state-version", - wantURL: "https://archivist.terraform.io/v1/object/test", - wantErr: nil, + url: "https://app.terraform.io/api/v2/workspaces/workspaceId/current-state-version", + wantErr: nil, + expected: "{}", mock: func() { httpmock.Reset() httpmock.RegisterResponder( @@ -57,8 +57,7 @@ func TestNewTFCloudReader(t *testing.T) { TFCloudToken: "TOKEN", }, }, - url: "https://app.terraform.io/api/v2/workspaces/wrong_workspaceId/current-state-version", - wantURL: "", + url: "https://app.terraform.io/api/v2/workspaces/wrong_workspaceId/current-state-version", mock: func() { httpmock.Reset() httpmock.RegisterResponder( @@ -77,8 +76,7 @@ func TestNewTFCloudReader(t *testing.T) { TFCloudToken: "TOKEN", }, }, - url: "https://app.terraform.io/api/v2/workspaces/workspaceId/current-state-version", - wantURL: "", + url: "https://app.terraform.io/api/v2/workspaces/workspaceId/current-state-version", mock: func() { httpmock.Reset() httpmock.RegisterResponder( @@ -93,7 +91,12 @@ func TestNewTFCloudReader(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { tt.mock() - got, err := NewTFCloudReader(&http.Client{}, tt.args.workspaceId, tt.args.options) + + reader, err := NewTFCloudReader(&http.Client{}, tt.args.workspaceId, tt.args.options) + assert.NoError(t, err) + + got := make([]byte, len(tt.expected)) + _, err = reader.Read(got) if tt.wantErr != nil { assert.EqualError(t, err, tt.wantErr.Error()) return @@ -101,7 +104,7 @@ func TestNewTFCloudReader(t *testing.T) { assert.NoError(t, err) } assert.NotNil(t, got) - assert.Equal(t, tt.wantURL, got.request.URL.String()) + assert.Equal(t, tt.expected, string(got)) }) } }