From 40f08764cbadbdefa16882f155317bc7ebe63bcf Mon Sep 17 00:00:00 2001 From: Sebastian Rivera Date: Tue, 24 May 2022 16:32:11 -0400 Subject: [PATCH] Added support for GPG Key API --- CHANGELOG.md | 2 + errors.go | 6 ++ generate_mocks.sh | 1 + gpg_key.go | 200 ++++++++++++++++++++++++++++++++++++ gpg_key_integration_test.go | 197 +++++++++++++++++++++++++++++++++++ helper_test.go | 99 ++++++++++++++++++ mocks/gpg_key_mocks.go | 95 +++++++++++++++++ tfe.go | 41 +++++++- tfe_test.go | 17 +++ 9 files changed, 653 insertions(+), 5 deletions(-) create mode 100644 gpg_key.go create mode 100644 gpg_key_integration_test.go create mode 100644 mocks/gpg_key_mocks.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ce6d9beb..f19c8d10a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,9 @@ ## Enhancements * Adds `RetryServerErrors` field to the `Config` object by @sebasslash [#439](https://github.com/hashicorp/go-tfe/pull/439) +* Adds support for the GPG Keys API by @sebasslash [#429](https://github.com/hashicorp/go-tfe/pull/429) * [beta] Renames the optional StateVersion field `ExtState` to `JSONState` and changes to string for base64 encoding by @annawinkler [#444](https://github.com/hashicorp/go-tfe/pull/444) + # v1.3.0 ## Enhancements diff --git a/errors.go b/errors.go index 226d256e4..a04a4d17e 100644 --- a/errors.go +++ b/errors.go @@ -14,6 +14,10 @@ var ( // ErrMissingDirectory is returned when the path does not have an existing directory. ErrMissingDirectory = errors.New("path needs to be an existing directory") + + // ErrNamespaceNotAuthorized is returned when a user attempts to perform an action + // on a namespace (organization) they do not have access to. + ErrNamespaceNotAuthorized = errors.New("namespace not authorized") ) // Options/fields that cannot be defined @@ -288,4 +292,6 @@ var ( ErrRequiredShasum = errors.New("shasum is required") ErrRequiredFilename = errors.New("filename is required") + + ErrInvalidAsciiArmor = errors.New("ascii armor is invalid") ) diff --git a/generate_mocks.sh b/generate_mocks.sh index 0e933ab56..924ea62ce 100755 --- a/generate_mocks.sh +++ b/generate_mocks.sh @@ -20,6 +20,7 @@ mockgen -source=apply.go -destination=mocks/apply_mocks.go -package=mocks mockgen -source=audit_trail.go -destination=mocks/audit_trail.go -package=mocks mockgen -source=configuration_version.go -destination=mocks/configuration_version_mocks.go -package=mocks mockgen -source=cost_estimate.go -destination=mocks/cost_estimate_mocks.go -package=mocks +mockgen -source=gpg_key.go -destination=mocks/gpg_key_mocks.go -package=mocks mockgen -source=ip_ranges.go -destination=mocks/ip_ranges_mocks.go -package=mocks mockgen -source=logreader.go -destination=mocks/logreader_mocks.go -package=mocks mockgen -source=notification_configuration.go -destination=mocks/notification_configuration_mocks.go -package=mocks diff --git a/gpg_key.go b/gpg_key.go new file mode 100644 index 000000000..a56b11156 --- /dev/null +++ b/gpg_key.go @@ -0,0 +1,200 @@ +package tfe + +import ( + "context" + "fmt" + "net/url" + "strings" + "time" +) + +// Compile-time proof of interface implementation +var _ GPGKeys = (*gpgKeys)(nil) + +// GPGKeys describes all the GPG key related methods that the Terraform Private Registry API supports. +// +// TFE API Docs: https://www.terraform.io/cloud-docs/api-docs/private-registry/gpg-keys +type GPGKeys interface { + // Uploads a GPG Key to a private registry scoped with a namespace. + Create(ctx context.Context, registryName RegistryName, options GPGKeyCreateOptions) (*GPGKey, error) + + // Read a GPG key. + Read(ctx context.Context, keyID GPGKeyID) (*GPGKey, error) + + // Update a GPG key. + Update(ctx context.Context, keyID GPGKeyID, options GPGKeyUpdateOptions) (*GPGKey, error) + + // Delete a GPG key. + Delete(ctx context.Context, keyID GPGKeyID) error +} + +// gpgKeys implements GPGKeys +type gpgKeys struct { + client *Client +} + +// GPGKey represents a signed GPG key for a TFC/E private provider. +type GPGKey struct { + ID string `jsonapi:"primary,gpg-keys"` + AsciiArmor string `jsonapi:"attr,ascii-armor"` + CreatedAt time.Time `jsonapi:"attr,created-at,iso8601"` + KeyID string `jsonapi:"attr,key-id"` + Namespace string `jsonapi:"attr,namespace"` + Source string `jsonapi:"attr,source"` + SourceURL *string `jsonapi:"attr,source-url"` + TrustSignature string `jsonapi:"attr,trust-signature"` + UpdatedAt time.Time `jsonapi:"attr,updated-at,iso8601"` +} + +// GPGKeyID represents the set of identifiers used to fetch a GPG key. +type GPGKeyID struct { + RegistryName RegistryName + Namespace string + KeyID string +} + +// GPGKeyCreateOptions represents all the available options used to create a GPG key. +type GPGKeyCreateOptions struct { + Type string `jsonapi:"primary,gpg-keys"` + Namespace string `jsonapi:"attr,namespace"` + AsciiArmor string `jsonapi:"attr,ascii-armor"` +} + +// GPGKeyCreateOptions represents all the available options used to update a GPG key. +type GPGKeyUpdateOptions struct { + Type string `jsonapi:"primary,gpg-keys"` + Namespace string `jsonapi:"attr,namespace"` +} + +func (s *gpgKeys) Create(ctx context.Context, registryName RegistryName, options GPGKeyCreateOptions) (*GPGKey, error) { + if err := options.valid(); err != nil { + return nil, err + } + + if registryName != PrivateRegistry { + return nil, ErrInvalidRegistryName + } + + u := fmt.Sprintf("/api/registry/%s/v2/gpg-keys", url.QueryEscape(string(registryName))) + req, err := s.client.newRequest("POST", u, &options) + if err != nil { + return nil, err + } + + g := &GPGKey{} + err = s.client.do(ctx, req, g) + if err != nil { + return nil, err + } + + return g, nil +} + +func (s *gpgKeys) Read(ctx context.Context, keyID GPGKeyID) (*GPGKey, error) { + if err := keyID.valid(); err != nil { + return nil, err + } + + u := fmt.Sprintf("/api/registry/%s/v2/gpg-keys/%s/%s", + url.QueryEscape(string(keyID.RegistryName)), + url.QueryEscape(keyID.Namespace), + url.QueryEscape(keyID.KeyID), + ) + req, err := s.client.newRequest("GET", u, nil) + if err != nil { + return nil, err + } + + g := &GPGKey{} + err = s.client.do(ctx, req, g) + if err != nil { + return nil, err + } + + return g, nil +} + +func (s *gpgKeys) Update(ctx context.Context, keyID GPGKeyID, options GPGKeyUpdateOptions) (*GPGKey, error) { + if err := options.valid(); err != nil { + return nil, err + } + + if err := keyID.valid(); err != nil { + return nil, err + } + + u := fmt.Sprintf("/api/registry/%s/v2/gpg-keys/%s/%s", + url.QueryEscape(string(keyID.RegistryName)), + url.QueryEscape(keyID.Namespace), + url.QueryEscape(keyID.KeyID), + ) + req, err := s.client.newRequest("PATCH", u, &options) + if err != nil { + return nil, err + } + + g := &GPGKey{} + err = s.client.do(ctx, req, g) + if err != nil { + if strings.Contains(err.Error(), "namespace not authorized") { + return nil, ErrNamespaceNotAuthorized + } + return nil, err + } + + return g, nil +} + +func (s *gpgKeys) Delete(ctx context.Context, keyID GPGKeyID) error { + if err := keyID.valid(); err != nil { + return err + } + + u := fmt.Sprintf("/api/registry/%s/v2/gpg-keys/%s/%s", + url.QueryEscape(string(keyID.RegistryName)), + url.QueryEscape(keyID.Namespace), + url.QueryEscape(keyID.KeyID), + ) + req, err := s.client.newRequest("DELETE", u, nil) + if err != nil { + return err + } + + return s.client.do(ctx, req, nil) +} + +func (o GPGKeyID) valid() error { + if o.RegistryName != PrivateRegistry { + return ErrInvalidRegistryName + } + + if !validString(&o.Namespace) { + return ErrInvalidNamespace + } + + if !validString(&o.KeyID) { + return ErrInvalidKeyID + } + + return nil +} + +func (o GPGKeyCreateOptions) valid() error { + if !validString(&o.Namespace) { + return ErrInvalidNamespace + } + + if !validString(&o.AsciiArmor) { + return ErrInvalidAsciiArmor + } + + return nil +} + +func (o GPGKeyUpdateOptions) valid() error { + if !validString(&o.Namespace) { + return ErrInvalidNamespace + } + + return nil +} diff --git a/gpg_key_integration_test.go b/gpg_key_integration_test.go new file mode 100644 index 000000000..a0eb9ed56 --- /dev/null +++ b/gpg_key_integration_test.go @@ -0,0 +1,197 @@ +//go:build integration +// +build integration + +package tfe + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGPGKeyCreate(t *testing.T) { + client := testClient(t) + ctx := context.Background() + + org, orgCleanup := createOrganization(t, client) + t.Cleanup(orgCleanup) + + upgradeOrganizationSubscription(t, client, org) + + provider, providerCleanup := createRegistryProvider(t, client, org, PrivateRegistry) + t.Cleanup(providerCleanup) + + t.Run("with valid options", func(t *testing.T) { + opts := GPGKeyCreateOptions{ + Namespace: provider.Organization.Name, + AsciiArmor: testGpgArmor, + } + + gpgKey, err := client.GPGKeys.Create(ctx, PrivateRegistry, opts) + require.NoError(t, err) + + assert.NotEmpty(t, gpgKey.ID) + assert.Equal(t, gpgKey.AsciiArmor, opts.AsciiArmor) + assert.Equal(t, gpgKey.Namespace, opts.Namespace) + assert.NotEmpty(t, gpgKey.CreatedAt) + assert.NotEmpty(t, gpgKey.UpdatedAt) + + // The default value for these two fields is an empty string + assert.Empty(t, gpgKey.Source) + assert.Empty(t, gpgKey.TrustSignature) + }) + + t.Run("with invalid registry name", func(t *testing.T) { + opts := GPGKeyCreateOptions{ + Namespace: provider.Organization.Name, + AsciiArmor: testGpgArmor, + } + + _, err := client.GPGKeys.Create(ctx, "foobar", opts) + assert.ErrorIs(t, err, ErrInvalidRegistryName) + }) + + t.Run("with invalid options", func(t *testing.T) { + missingNamespaceOpts := GPGKeyCreateOptions{ + Namespace: "", + AsciiArmor: testGpgArmor, + } + _, err := client.GPGKeys.Create(ctx, PrivateRegistry, missingNamespaceOpts) + assert.ErrorIs(t, err, ErrInvalidNamespace) + + missingAsciiArmorOpts := GPGKeyCreateOptions{ + Namespace: provider.Organization.Name, + AsciiArmor: "", + } + _, err = client.GPGKeys.Create(ctx, PrivateRegistry, missingAsciiArmorOpts) + assert.ErrorIs(t, err, ErrInvalidAsciiArmor) + }) +} + +func TestGPGKeyRead(t *testing.T) { + client := testClient(t) + ctx := context.Background() + + org, orgCleanup := createOrganization(t, client) + t.Cleanup(orgCleanup) + + upgradeOrganizationSubscription(t, client, org) + + provider, providerCleanup := createRegistryProvider(t, client, org, PrivateRegistry) + t.Cleanup(providerCleanup) + + gpgKey, gpgKeyCleanup := createGPGKey(t, client, org, provider) + t.Cleanup(gpgKeyCleanup) + + t.Run("when the gpg key exists", func(t *testing.T) { + fetched, err := client.GPGKeys.Read(ctx, GPGKeyID{ + RegistryName: PrivateRegistry, + Namespace: provider.Organization.Name, + KeyID: gpgKey.KeyID, + }) + require.NoError(t, err) + + assert.NotEmpty(t, gpgKey.ID) + assert.NotEmpty(t, gpgKey.KeyID) + assert.Greater(t, len(gpgKey.AsciiArmor), 0) + assert.Equal(t, fetched.Namespace, provider.Organization.Name) + }) + + t.Run("when the key does not exist", func(t *testing.T) { + _, err := client.GPGKeys.Read(ctx, GPGKeyID{ + RegistryName: PrivateRegistry, + Namespace: provider.Organization.Name, + KeyID: "foobar", + }) + assert.ErrorIs(t, err, ErrResourceNotFound) + }) +} + +func TestGPGKeyUpdate(t *testing.T) { + client := testClient(t) + ctx := context.Background() + + org, orgCleanup := createOrganization(t, client) + t.Cleanup(orgCleanup) + + upgradeOrganizationSubscription(t, client, org) + + provider, providerCleanup := createRegistryProvider(t, client, org, PrivateRegistry) + t.Cleanup(providerCleanup) + + // We won't use the cleanup method here as the namespace + // is used to identify a key and that will change due to the update + // call. We'll need to manually delete the key. + gpgKey, _ := createGPGKey(t, client, org, provider) + + t.Run("when using an invalid namespace", func(t *testing.T) { + keyID := GPGKeyID{ + RegistryName: PrivateRegistry, + Namespace: provider.Organization.Name, + KeyID: gpgKey.KeyID, + } + opts := GPGKeyUpdateOptions{ + Namespace: "invalid_namespace_org", + } + _, err := client.GPGKeys.Update(ctx, keyID, opts) + assert.ErrorIs(t, err, ErrNamespaceNotAuthorized) + }) + + t.Run("when updating to a valid namespace", func(t *testing.T) { + // Create a new namespace to update the key with + org2, org2Cleanup := createOrganization(t, client) + t.Cleanup(org2Cleanup) + + provider2, provider2Cleanup := createRegistryProvider(t, client, org2, PrivateRegistry) + t.Cleanup(provider2Cleanup) + + keyID := GPGKeyID{ + RegistryName: PrivateRegistry, + Namespace: provider.Organization.Name, + KeyID: gpgKey.KeyID, + } + opts := GPGKeyUpdateOptions{ + Namespace: provider2.Organization.Name, + } + + updatedKey, err := client.GPGKeys.Update(ctx, keyID, opts) + require.NoError(t, err) + + assert.Equal(t, gpgKey.KeyID, updatedKey.KeyID) + assert.Equal(t, updatedKey.Namespace, provider2.Organization.Name) + + // Cleanup + err = client.GPGKeys.Delete(ctx, GPGKeyID{ + RegistryName: PrivateRegistry, + Namespace: provider2.Organization.Name, + KeyID: updatedKey.KeyID, + }) + require.NoError(t, err) + }) +} + +func TestGPGKeyDelete(t *testing.T) { + client := testClient(t) + ctx := context.Background() + + org, orgCleanup := createOrganization(t, client) + t.Cleanup(orgCleanup) + + upgradeOrganizationSubscription(t, client, org) + + provider, providerCleanup := createRegistryProvider(t, client, org, PrivateRegistry) + t.Cleanup(providerCleanup) + + gpgKey, _ := createGPGKey(t, client, org, provider) + + t.Run("when a key exists", func(t *testing.T) { + err := client.GPGKeys.Delete(ctx, GPGKeyID{ + RegistryName: PrivateRegistry, + Namespace: provider.Organization.Name, + KeyID: gpgKey.KeyID, + }) + require.NoError(t, err) + }) +} diff --git a/helper_test.go b/helper_test.go index cb3b8c2c6..227796696 100644 --- a/helper_test.go +++ b/helper_test.go @@ -196,6 +196,50 @@ func createUploadedConfigurationVersion(t *testing.T, client *Client, w *Workspa return cv, cvCleanup } +func createGPGKey(t *testing.T, client *Client, org *Organization, provider *RegistryProvider) (*GPGKey, func()) { + var orgCleanup func() + var providerCleanup func() + + ctx := context.Background() + + if org == nil { + org, orgCleanup = createOrganization(t, client) + upgradeOrganizationSubscription(t, client, org) + } + + if provider == nil { + provider, providerCleanup = createRegistryProvider(t, client, org, PrivateRegistry) + } + + gpgKey, err := client.GPGKeys.Create(ctx, PrivateRegistry, GPGKeyCreateOptions{ + Namespace: provider.Organization.Name, + AsciiArmor: testGpgArmor, + }) + if err != nil { + t.Fatal(err) + } + + return gpgKey, func() { + if err := client.GPGKeys.Delete(ctx, GPGKeyID{ + RegistryName: PrivateRegistry, + Namespace: provider.Organization.Name, + KeyID: gpgKey.KeyID, + }); err != nil { + t.Errorf("Error removing GPG key! WARNING: Dangling resources\n"+ + "may exist! The full error is shown below.\n\n"+ + "GPGKey: %s\nError: %s", gpgKey.KeyID, err) + } + + if providerCleanup != nil { + providerCleanup() + } + + if orgCleanup != nil { + orgCleanup() + } + } +} + func createNotificationConfiguration(t *testing.T, client *Client, w *Workspace, options *NotificationConfigurationCreateOptions) (*NotificationConfiguration, func()) { var wCleanup func() @@ -1587,3 +1631,58 @@ func paidFeaturesDisabled() bool { func betaFeaturesEnabled() bool { return os.Getenv("ENABLE_BETA") == "1" } + +// Useless key but enough to pass validation in the API +const testGpgArmor string = ` +-----BEGIN PGP PUBLIC KEY BLOCK----- + +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 +=bBSS +-----END PGP PUBLIC KEY BLOCK----- +` diff --git a/mocks/gpg_key_mocks.go b/mocks/gpg_key_mocks.go new file mode 100644 index 000000000..34e7c2714 --- /dev/null +++ b/mocks/gpg_key_mocks.go @@ -0,0 +1,95 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: gpg_key.go + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + gomock "github.com/golang/mock/gomock" + tfe "github.com/hashicorp/go-tfe" +) + +// MockGPGKeys is a mock of GPGKeys interface. +type MockGPGKeys struct { + ctrl *gomock.Controller + recorder *MockGPGKeysMockRecorder +} + +// MockGPGKeysMockRecorder is the mock recorder for MockGPGKeys. +type MockGPGKeysMockRecorder struct { + mock *MockGPGKeys +} + +// NewMockGPGKeys creates a new mock instance. +func NewMockGPGKeys(ctrl *gomock.Controller) *MockGPGKeys { + mock := &MockGPGKeys{ctrl: ctrl} + mock.recorder = &MockGPGKeysMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockGPGKeys) EXPECT() *MockGPGKeysMockRecorder { + return m.recorder +} + +// Create mocks base method. +func (m *MockGPGKeys) Create(ctx context.Context, registryName tfe.RegistryName, options tfe.GPGKeyCreateOptions) (*tfe.GPGKey, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Create", ctx, registryName, options) + ret0, _ := ret[0].(*tfe.GPGKey) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Create indicates an expected call of Create. +func (mr *MockGPGKeysMockRecorder) Create(ctx, registryName, options interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockGPGKeys)(nil).Create), ctx, registryName, options) +} + +// Delete mocks base method. +func (m *MockGPGKeys) Delete(ctx context.Context, keyID tfe.GPGKeyID) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Delete", ctx, keyID) + ret0, _ := ret[0].(error) + return ret0 +} + +// Delete indicates an expected call of Delete. +func (mr *MockGPGKeysMockRecorder) Delete(ctx, keyID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Delete", reflect.TypeOf((*MockGPGKeys)(nil).Delete), ctx, keyID) +} + +// Read mocks base method. +func (m *MockGPGKeys) Read(ctx context.Context, keyID tfe.GPGKeyID) (*tfe.GPGKey, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Read", ctx, keyID) + ret0, _ := ret[0].(*tfe.GPGKey) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Read indicates an expected call of Read. +func (mr *MockGPGKeysMockRecorder) Read(ctx, keyID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Read", reflect.TypeOf((*MockGPGKeys)(nil).Read), ctx, keyID) +} + +// Update mocks base method. +func (m *MockGPGKeys) Update(ctx context.Context, keyID tfe.GPGKeyID, options tfe.GPGKeyUpdateOptions) (*tfe.GPGKey, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Update", ctx, keyID, options) + ret0, _ := ret[0].(*tfe.GPGKey) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Update indicates an expected call of Update. +func (mr *MockGPGKeysMockRecorder) Update(ctx, keyID, options interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockGPGKeys)(nil).Update), ctx, keyID, options) +} diff --git a/tfe.go b/tfe.go index 9f8a5c293..953ac6bfc 100644 --- a/tfe.go +++ b/tfe.go @@ -36,8 +36,9 @@ const ( _headerAPIVersion = "TFP-API-Version" _includeQueryParam = "include" - DefaultAddress = "https://app.terraform.io" - DefaultBasePath = "/api/v2/" + DefaultAddress = "https://app.terraform.io" + DefaultBasePath = "/api/v2/" + DefaultRegistryPath = "/api/registry/" // PingEndpoint is a no-op API endpoint used to configure the rate limiter PingEndpoint = "ping" ) @@ -55,6 +56,9 @@ type Config struct { // The base path on which the API is served. BasePath string + // The base path for the Registry API + RegistryBasePath string + // API token used to access the Terraform Enterprise API. Token string @@ -77,6 +81,7 @@ func DefaultConfig() *Config { config := &Config{ Address: os.Getenv("TFE_ADDRESS"), BasePath: DefaultBasePath, + RegistryBasePath: DefaultRegistryPath, Token: os.Getenv("TFE_TOKEN"), Headers: make(http.Header), HTTPClient: cleanhttp.DefaultPooledClient(), @@ -102,6 +107,7 @@ func DefaultConfig() *Config { // connectivity and configuration for accessing the TFE API type Client struct { baseURL *url.URL + registryBaseURL *url.URL token string headers http.Header http *retryablehttp.Client @@ -118,6 +124,7 @@ type Client struct { Comments Comments ConfigurationVersions ConfigurationVersions CostEstimates CostEstimates + GPGKeys GPGKeys NotificationConfigurations NotificationConfigurations OAuthClients OAuthClients OAuthTokens OAuthTokens @@ -188,6 +195,9 @@ func NewClient(cfg *Config) (*Client, error) { if cfg.BasePath != "" { config.BasePath = cfg.BasePath } + if cfg.RegistryBasePath != "" { + config.RegistryBasePath = cfg.RegistryBasePath + } if cfg.Token != "" { config.Token = cfg.Token } @@ -214,6 +224,16 @@ func NewClient(cfg *Config) (*Client, error) { baseURL.Path += "/" } + registryURL, err := url.Parse(config.Address) + if err != nil { + return nil, fmt.Errorf("invalid address: %w", err) + } + + registryURL.Path = config.RegistryBasePath + if !strings.HasSuffix(registryURL.Path, "/") { + registryURL.Path += "/" + } + // This value must be provided by the user. if config.Token == "" { return nil, fmt.Errorf("missing API token") @@ -222,6 +242,7 @@ func NewClient(cfg *Config) (*Client, error) { // Create the client. client := &Client{ baseURL: baseURL, + registryBaseURL: registryURL, token: config.Token, headers: config.Headers, retryLogHook: config.RetryLogHook, @@ -268,6 +289,7 @@ func NewClient(cfg *Config) (*Client, error) { client.Comments = &comments{client: client} client.ConfigurationVersions = &configurationVersions{client: client} client.CostEstimates = &costEstimates{client: client} + client.GPGKeys = &gpgKeys{client: client} client.NotificationConfigurations = ¬ificationConfigurations{client: client} client.OAuthClients = &oAuthClients{client: client} client.OAuthTokens = &oAuthTokens{client: client} @@ -496,9 +518,18 @@ func (c *Client) configureLimiter(rawLimit string) { // request body. If the method is GET, the value will be parsed and added as // query parameters. func (c *Client) newRequest(method, path string, v interface{}) (*retryablehttp.Request, error) { - u, err := c.baseURL.Parse(path) - if err != nil { - return nil, err + var u *url.URL + var err error + if strings.Contains(path, "/api/registry/") { + u, err = c.registryBaseURL.Parse(path) + if err != nil { + return nil, err + } + } else { + u, err = c.baseURL.Parse(path) + if err != nil { + return nil, err + } } // Create a request specific headers map. diff --git a/tfe_test.go b/tfe_test.go index 62090f970..eef419463 100644 --- a/tfe_test.go +++ b/tfe_test.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/json" "fmt" + "os" "testing" "time" @@ -131,3 +132,19 @@ func Test_EncodeQueryParams(t *testing.T) { assert.Equal(t, requestURLquery, "include=workspace%2Ccost_estimate") }) } + +func Test_RegistryBasePath(t *testing.T) { + client, err := NewClient(&Config{ + Token: "foo", + }) + require.NoError(t, err) + + t.Run("ensures client creates a request with registry base path", func(t *testing.T) { + path := "/api/registry/some/path/to/resource" + req, err := client.newRequest("GET", path, nil) + require.NoError(t, err) + + expected := os.Getenv("TFE_ADDRESS") + path + assert.Equal(t, req.URL.String(), expected) + }) +}