diff --git a/internal/config/client.go b/internal/config/client.go index 0faab31242..f62e553557 100644 --- a/internal/config/client.go +++ b/internal/config/client.go @@ -97,13 +97,20 @@ type UAMetadata struct { } func (c *Config) NewClient(ctx context.Context) (any, error) { - // Network Logging transport is before Digest transport so it can log the first Digest requests with 401 Unauthorized. - // Terraform logging transport is after Digest transport so the Unauthorized request bodies are not logged. + // Transport chain (outermost to innermost): + // userAgentTransport -> tfLoggingTransport -> digestTransport -> networkLoggingTransport -> baseTransport + // + // This ordering ensures: + // 1. networkLoggingTransport logs ALL requests including digest auth 401 challenges + // 2. tfLoggingTransport only logs final authenticated requests (not sensitive auth details) + // 3. userAgentTransport modifies User-Agent before tfLoggingTransport logs it networkLoggingTransport := NewTransportWithNetworkLogging(baseTransport, logging.IsDebugOrHigher()) digestTransport := digest.NewTransportWithHTTPRoundTripper(cast.ToString(c.PublicKey), cast.ToString(c.PrivateKey), networkLoggingTransport) // Don't change logging.NewTransport to NewSubsystemLoggingHTTPTransport until all resources are in TPF. tfLoggingTransport := logging.NewTransport("Atlas", digestTransport) - client := &http.Client{Transport: tfLoggingTransport} + // Add UserAgentExtra fields to the User-Agent header, see wrapper_provider_server.go + userAgentTransport := NewUserAgentTransport(tfLoggingTransport, true) + client := &http.Client{Transport: userAgentTransport} optsAtlas := []matlasClient.ClientOpt{matlasClient.SetUserAgent(userAgent(c))} if c.BaseURL != "" { diff --git a/internal/config/resource_base.go b/internal/config/resource_base.go index 830883075b..4accdbbaeb 100644 --- a/internal/config/resource_base.go +++ b/internal/config/resource_base.go @@ -6,6 +6,8 @@ import ( "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" ) const ( @@ -13,12 +15,44 @@ const ( errorConfigure = "expected *MongoDBClient, got: %T. Please report this issue to the provider developers" ) +type ProviderMeta struct { + ModuleName types.String `tfsdk:"module_name"` + ModuleVersion types.String `tfsdk:"module_version"` + UserAgentExtra types.Map `tfsdk:"user_agent_extra"` +} + +type ImplementedResource interface { + resource.ResourceWithImportState + // Additional methods such as upgrade state & plan modifier are optional + SetClient(*MongoDBClient) + GetName() string +} + +func AnalyticsResourceFunc(iResource resource.Resource) func() resource.Resource { + a := func() resource.Resource { + commonResource, ok := iResource.(ImplementedResource) + if ok { + return analyticsResource(commonResource) + } + return iResource + } + return a +} + +func analyticsResource(iResource ImplementedResource) resource.Resource { + return &RSCommon{ + ResourceName: iResource.GetName(), + ImplementedResource: iResource, + } +} + // RSCommon is used as an embedded struct for all framework resources. Implements the following plugin-framework defined functions: // - Metadata // - Configure // Client is left empty and populated by the framework when envoking Configure method. // ResourceName must be defined when creating an instance of a resource. type RSCommon struct { + ImplementedResource Client *MongoDBClient ResourceName string } @@ -33,9 +67,117 @@ func (r *RSCommon) Configure(ctx context.Context, req resource.ConfigureRequest, resp.Diagnostics.AddError(errorConfigureSummary, err.Error()) return } + r.ImplementedResource.SetClient(client) +} + +func (r *RSCommon) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + extra := r.asUserAgentExtra(ctx, UserAgentOperationValueCreate, req.ProviderMeta) + ctx = AddUserAgentExtra(ctx, extra) + r.ImplementedResource.Create(ctx, req, resp) +} + +func (r *RSCommon) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + extra := r.asUserAgentExtra(ctx, UserAgentOperationValueRead, req.ProviderMeta) + ctx = AddUserAgentExtra(ctx, extra) + r.ImplementedResource.Read(ctx, req, resp) +} + +func (r *RSCommon) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + extra := r.asUserAgentExtra(ctx, UserAgentOperationValueUpdate, req.ProviderMeta) + ctx = AddUserAgentExtra(ctx, extra) + r.ImplementedResource.Update(ctx, req, resp) +} + +func (r *RSCommon) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + extra := r.asUserAgentExtra(ctx, UserAgentOperationValueDelete, req.ProviderMeta) + ctx = AddUserAgentExtra(ctx, extra) + r.ImplementedResource.Delete(ctx, req, resp) +} + +// Optional interfaces for resource.Resource +func (r *RSCommon) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + // req resource.ImportStateRequest doesn't have ProviderMeta + ctx = AddUserAgentExtra(ctx, UserAgentExtra{ + Name: r.ResourceName, + Operation: UserAgentOperationValueImport, + }) + r.ImplementedResource.ImportState(ctx, req, resp) +} + +func (r *RSCommon) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { + resourceWithModifier, ok := r.ImplementedResource.(resource.ResourceWithModifyPlan) + if !ok { + return + } + extra := r.asUserAgentExtra(ctx, UserAgentOperationValuePlanModify, req.ProviderMeta) + ctx = AddUserAgentExtra(ctx, extra) + resourceWithModifier.ModifyPlan(ctx, req, resp) +} + +func (r *RSCommon) MoveState(ctx context.Context) []resource.StateMover { + resourceWithMoveState, ok := r.ImplementedResource.(resource.ResourceWithMoveState) + if !ok { + return nil + } + ctx = AddUserAgentExtra(ctx, UserAgentExtra{ + Name: r.ResourceName, + Operation: UserAgentOperationValueMoveState, + }) + return resourceWithMoveState.MoveState(ctx) +} + +func (r *RSCommon) UpgradeState(ctx context.Context) map[int64]resource.StateUpgrader { + resourceWithUpgradeState, ok := r.ImplementedResource.(resource.ResourceWithUpgradeState) + if !ok { + return nil + } + ctx = AddUserAgentExtra(ctx, UserAgentExtra{ + Name: r.ResourceName, + Operation: UserAgentOperationValueUpgradeState, + }) + return resourceWithUpgradeState.UpgradeState(ctx) +} + +// Extra methods not found on resource.Resource +func (r *RSCommon) GetName() string { + return r.ResourceName +} + +func (r *RSCommon) SetClient(client *MongoDBClient) { r.Client = client } +func (r *RSCommon) asUserAgentExtra(ctx context.Context, reqOperation string, reqProviderMeta tfsdk.Config) UserAgentExtra { + var meta ProviderMeta + uaExtra := UserAgentExtra{ + Name: r.ResourceName, + Operation: reqOperation, + } + if reqProviderMeta.Raw.IsNull() { + return uaExtra + } + diags := reqProviderMeta.Get(ctx, &meta) + if diags.HasError() { + return uaExtra + } + + extrasLen := len(meta.UserAgentExtra.Elements()) + userExtras := make(map[string]types.String, extrasLen) + diags.Append(meta.UserAgentExtra.ElementsAs(ctx, &userExtras, false)...) + if diags.HasError() { + return uaExtra + } + userExtrasString := make(map[string]string, extrasLen) + for k, v := range userExtras { + userExtrasString[k] = v.ValueString() + } + return uaExtra.Combine(UserAgentExtra{ + Extras: userExtrasString, + ModuleName: meta.ModuleName.ValueString(), + ModuleVersion: meta.ModuleVersion.ValueString(), + }) +} + // DSCommon is used as an embedded struct for all framework data sources. Implements the following plugin-framework defined functions: // - Metadata // - Configure diff --git a/internal/config/resource_base_sdkv2.go b/internal/config/resource_base_sdkv2.go new file mode 100644 index 0000000000..7e9f8ee801 --- /dev/null +++ b/internal/config/resource_base_sdkv2.go @@ -0,0 +1,198 @@ +package config + +import ( + "context" + "log" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func NewAnalyticsResourceSDKv2(d *schema.Resource, name string) *schema.Resource { + analyticsResource := &AnalyticsResourceSDKv2{ + resource: d, + name: name, + } + /* + We are not initializing deprecated fields, for example Update to avoid the message: + resource mongodbatlas_cloud_backup_snapshot: All fields are ForceNew or Computed w/out Optional, Update is superfluous + + Ensure no deprecated fields are used by running `staticcheck ./internal/service/... | grep -v 'd.GetOkExists'` and looking for (SA1019) + GetOkExists we are using in many places; therefore, we use -v (invert match) to filter out lines with different deprecations + Example line: + internal/service/cluster/model_cluster.go:306:14: d.GetOkExists is deprecated: usage is discouraged due to undefined behaviors and may be removed in a future version of the SDK (SA1019) + */ + resource := &schema.Resource{ + CustomizeDiff: d.CustomizeDiff, + DeprecationMessage: d.DeprecationMessage, + Description: d.Description, + EnableLegacyTypeSystemApplyErrors: d.EnableLegacyTypeSystemApplyErrors, + EnableLegacyTypeSystemPlanErrors: d.EnableLegacyTypeSystemPlanErrors, + Identity: d.Identity, + ResourceBehavior: d.ResourceBehavior, + Schema: d.Schema, + SchemaFunc: d.SchemaFunc, + SchemaVersion: d.SchemaVersion, + StateUpgraders: d.StateUpgraders, + Timeouts: d.Timeouts, + UpdateWithoutTimeout: d.UpdateWithoutTimeout, + UseJSONNumber: d.UseJSONNumber, + ValidateRawResourceConfigFuncs: d.ValidateRawResourceConfigFuncs, + } + importer := d.Importer + if importer != nil { + resource.Importer = &schema.ResourceImporter{ + StateContext: analyticsResource.resourceImport, + } + } + // CreateContext or CreateWithoutTimeout, cannot use both + if d.CreateContext != nil { + resource.CreateContext = analyticsResource.CreateContext + } + if d.CreateWithoutTimeout != nil { + resource.CreateWithoutTimeout = analyticsResource.CreateWithoutTimeout + } + // ReadContext or ReadWithoutTimeout, cannot use both + if d.ReadContext != nil { + resource.ReadContext = analyticsResource.ReadContext + } + if d.ReadWithoutTimeout != nil { + resource.ReadWithoutTimeout = analyticsResource.ReadWithoutTimeout + } + // UpdateContext is not set on all resources + if d.UpdateContext != nil { + resource.UpdateContext = analyticsResource.UpdateContext + } + if d.UpdateWithoutTimeout != nil { + resource.UpdateWithoutTimeout = analyticsResource.UpdateWithoutTimeout + } + // DeleteContext or DeleteWithoutTimeout, cannot use both + if d.DeleteContext != nil { + resource.DeleteContext = analyticsResource.DeleteContext + } + if d.DeleteWithoutTimeout != nil { + resource.DeleteWithoutTimeout = analyticsResource.DeleteWithoutTimeout + } + return resource +} + +type ProviderMetaSDKv2 struct { + UserAgentExtra map[string]string `cty:"user_agent_extra"` + ModuleName *string `cty:"module_name"` + ModuleVersion *string `cty:"module_version"` +} + +type AnalyticsResourceSDKv2 struct { + resource *schema.Resource + name string +} + +func (a *AnalyticsResourceSDKv2) CreateContext(ctx context.Context, r *schema.ResourceData, m interface{}) diag.Diagnostics { + meta, err := parseProviderMeta(r) + if err != nil { + return a.resource.CreateContext(ctx, r, m) + } + ctx = a.updateContextWithProviderMeta(ctx, meta, UserAgentOperationValueCreate) + return a.resource.CreateContext(ctx, r, m) +} + +func (a *AnalyticsResourceSDKv2) CreateWithoutTimeout(ctx context.Context, r *schema.ResourceData, m interface{}) diag.Diagnostics { + meta, err := parseProviderMeta(r) + if err != nil { + return a.resource.CreateWithoutTimeout(ctx, r, m) + } + ctx = a.updateContextWithProviderMeta(ctx, meta, UserAgentOperationValueCreate) + return a.resource.CreateWithoutTimeout(ctx, r, m) +} + +func (a *AnalyticsResourceSDKv2) ReadWithoutTimeout(ctx context.Context, r *schema.ResourceData, m interface{}) diag.Diagnostics { + meta, err := parseProviderMeta(r) + if err != nil { + return a.resource.ReadWithoutTimeout(ctx, r, m) + } + ctx = a.updateContextWithProviderMeta(ctx, meta, UserAgentOperationValueRead) + return a.resource.ReadWithoutTimeout(ctx, r, m) +} + +func (a *AnalyticsResourceSDKv2) ReadContext(ctx context.Context, r *schema.ResourceData, m interface{}) diag.Diagnostics { + meta, err := parseProviderMeta(r) + if err != nil { + return a.resource.ReadContext(ctx, r, m) + } + ctx = a.updateContextWithProviderMeta(ctx, meta, UserAgentOperationValueRead) + return a.resource.ReadContext(ctx, r, m) +} + +func (a *AnalyticsResourceSDKv2) UpdateContext(ctx context.Context, r *schema.ResourceData, m interface{}) diag.Diagnostics { + meta, err := parseProviderMeta(r) + if err != nil { + return a.resource.UpdateContext(ctx, r, m) + } + ctx = a.updateContextWithProviderMeta(ctx, meta, UserAgentOperationValueUpdate) + return a.resource.UpdateContext(ctx, r, m) +} +func (a *AnalyticsResourceSDKv2) UpdateWithoutTimeout(ctx context.Context, r *schema.ResourceData, m interface{}) diag.Diagnostics { + meta, err := parseProviderMeta(r) + if err != nil { + return a.resource.UpdateWithoutTimeout(ctx, r, m) + } + ctx = a.updateContextWithProviderMeta(ctx, meta, UserAgentOperationValueUpdate) + return a.resource.UpdateWithoutTimeout(ctx, r, m) +} + +func (a *AnalyticsResourceSDKv2) DeleteContext(ctx context.Context, r *schema.ResourceData, m interface{}) diag.Diagnostics { + meta, err := parseProviderMeta(r) + if err != nil { + return a.resource.DeleteContext(ctx, r, m) + } + ctx = a.updateContextWithProviderMeta(ctx, meta, UserAgentOperationValueDelete) + return a.resource.DeleteContext(ctx, r, m) +} + +func (a *AnalyticsResourceSDKv2) DeleteWithoutTimeout(ctx context.Context, r *schema.ResourceData, m interface{}) diag.Diagnostics { + meta, err := parseProviderMeta(r) + if err != nil { + return a.resource.DeleteWithoutTimeout(ctx, r, m) + } + ctx = a.updateContextWithProviderMeta(ctx, meta, UserAgentOperationValueDelete) + return a.resource.DeleteWithoutTimeout(ctx, r, m) +} + +func (a *AnalyticsResourceSDKv2) resourceImport(ctx context.Context, d *schema.ResourceData, meta any) ([]*schema.ResourceData, error) { + // Import doesn't have providerMeta + ctx = AddUserAgentExtra(ctx, UserAgentExtra{ + Name: a.name, + Operation: UserAgentOperationValueImport, + }) + return a.resource.Importer.StateContext(ctx, d, meta) +} + +func (a *AnalyticsResourceSDKv2) updateContextWithProviderMeta(ctx context.Context, meta ProviderMetaSDKv2, operationName string) context.Context { + moduleName := "" + if meta.ModuleName != nil { + moduleName = *meta.ModuleName + } + moduleVersion := "" + if meta.ModuleVersion != nil { + moduleVersion = *meta.ModuleVersion + } + + uaExtra := UserAgentExtra{ + Name: a.name, + Operation: operationName, + Extras: meta.UserAgentExtra, + ModuleName: moduleName, + ModuleVersion: moduleVersion, + } + ctx = AddUserAgentExtra(ctx, uaExtra) + return ctx +} + +func parseProviderMeta(r *schema.ResourceData) (ProviderMetaSDKv2, error) { + meta := ProviderMetaSDKv2{} + err := r.GetProviderMeta(&meta) + if err != nil { + log.Printf("[WARN] failed to decode provider meta: %s, meta: %v", err, meta) + } + return meta, err +} diff --git a/internal/config/resource_base_test.go b/internal/config/resource_base_test.go new file mode 100644 index 0000000000..65da1e9ee5 --- /dev/null +++ b/internal/config/resource_base_test.go @@ -0,0 +1,24 @@ +package config_test + +import ( + "testing" + + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/mongodb/terraform-provider-mongodbatlas/internal/config" + "github.com/mongodb/terraform-provider-mongodbatlas/internal/service/advancedclustertpf" + "github.com/stretchr/testify/assert" +) + +func TestNoResourceInterfaceLoss(t *testing.T) { + analyticsResource := config.AnalyticsResourceFunc(advancedclustertpf.Resource())() + _, ok := analyticsResource.(resource.ResourceWithModifyPlan) + assert.True(t, ok) + _, ok = analyticsResource.(resource.ResourceWithUpgradeState) + assert.True(t, ok) + _, ok = analyticsResource.(resource.ResourceWithMoveState) + assert.True(t, ok) + _, ok = analyticsResource.(resource.ResourceWithUpgradeState) + assert.True(t, ok) + _, ok = analyticsResource.(resource.ResourceWithImportState) + assert.True(t, ok) +} diff --git a/internal/config/transport.go b/internal/config/transport.go index 766b6a6360..923d2dd671 100644 --- a/internal/config/transport.go +++ b/internal/config/transport.go @@ -7,6 +7,34 @@ import ( "time" ) +// UserAgentTransport wraps an http.RoundTripper to add User-Agent header with additional metadata. +type UserAgentTransport struct { + Transport http.RoundTripper + Enabled bool +} + +func NewUserAgentTransport(transport http.RoundTripper, enabled bool) *UserAgentTransport { + return &UserAgentTransport{ + Transport: transport, + Enabled: enabled, + } +} + +func (t *UserAgentTransport) RoundTrip(req *http.Request) (*http.Response, error) { + if !t.Enabled { + return t.Transport.RoundTrip(req) + } + ctx := req.Context() + extra := ReadUserAgentExtra(ctx) + if extra != nil { + userAgent := req.Header.Get(UserAgentHeader) + newVar := extra.ToHeaderValue(ctx, userAgent) + req.Header.Set(UserAgentHeader, newVar) + } + resp, err := t.Transport.RoundTrip(req) + return resp, err +} + // NetworkLoggingTransport wraps an http.RoundTripper to provide enhanced logging // for network operations, including timing, status codes, and error details. type NetworkLoggingTransport struct { diff --git a/internal/config/transport_test.go b/internal/config/transport_test.go index ec5a859a78..61abcfc0d6 100644 --- a/internal/config/transport_test.go +++ b/internal/config/transport_test.go @@ -150,7 +150,6 @@ func TestNetworkLoggingTransport_Disabled(t *testing.T) { logStr := logOutput.String() assert.Empty(t, logStr, "Expected no logs when network logging is disabled") } - func TestAccNetworkLogging(t *testing.T) { acc.SkipInUnitTest(t) acc.PreCheckBasic(t) diff --git a/internal/config/user_agent.go b/internal/config/user_agent.go new file mode 100644 index 0000000000..6621f9cc40 --- /dev/null +++ b/internal/config/user_agent.go @@ -0,0 +1,147 @@ +package config + +import ( + "context" + "fmt" + "log" + "maps" + "sort" + "strings" +) + +const ( + UserAgentKeyName = "Name" + UserAgentKeyOperation = "Operation" + UserAgentKeyModuleName = "ModuleName" + UserAgentKeyModuleVersion = "ModuleVersion" + UserAgentOperationValueCreate = "create" + UserAgentOperationValueRead = "read" + UserAgentOperationValueUpdate = "update" + UserAgentOperationValueDelete = "delete" + UserAgentOperationValueImport = "import" + UserAgentOperationValuePlanModify = "plan-modify" + UserAgentOperationValueUpgradeState = "upgrade-state" + UserAgentOperationValueMoveState = "move-state" +) + +// UserAgentExtra holds additional metadata to be appended to the User-Agent header and context. +type UserAgentExtra struct { + Extras map[string]string + Name string + Operation string + ModuleName string + ModuleVersion string +} + +// Combine returns a new UserAgentExtra by merging the receiver with another. +// Non-empty fields in 'other' take precedence over the receiver's fields. +func (e UserAgentExtra) Combine(other UserAgentExtra) UserAgentExtra { + name := e.Name + if other.Name != "" { + name = other.Name + } + operation := e.Operation + if other.Operation != "" { + operation = other.Operation + } + moduleName := e.ModuleName + if other.ModuleName != "" { + moduleName = other.ModuleName + } + moduleVersion := e.ModuleVersion + if other.ModuleVersion != "" { + moduleVersion = other.ModuleVersion + } + var newExtras map[string]string + if e.Extras != nil { + newExtras = map[string]string{} + maps.Copy(newExtras, e.Extras) + } + if other.Extras != nil { + if newExtras == nil { + newExtras = map[string]string{} + } + maps.Copy(newExtras, other.Extras) + } + return UserAgentExtra{ + Name: name, + Operation: operation, + ModuleName: moduleName, + ModuleVersion: moduleVersion, + Extras: newExtras, + } +} + +// ToHeaderValue returns a string representation suitable for use as a User-Agent header value. +// If oldHeader is non-empty, it is prepended to the new value. +func (e UserAgentExtra) ToHeaderValue(ctx context.Context, oldHeader string) string { + parts := map[string]string{} + addPart := func(key, part string) { + if part == "" { + return + } + if existing, found := parts[key]; found { + log.Printf("[WARN] Replaced UserAgent key %s: %s -> %s", key, existing, part) + } + parts[key] = part + } + // Start with Extras to avoid malicious usage of known keys + for k, v := range e.Extras { + addPart(k, v) + } + addPart(UserAgentKeyName, e.Name) + addPart(UserAgentKeyOperation, e.Operation) + addPart(UserAgentKeyModuleName, e.ModuleName) + addPart(UserAgentKeyModuleVersion, e.ModuleVersion) + partsLen := len(parts) + if partsLen == 0 { + return oldHeader + } + sortedKeys := make([]string, 0, partsLen) + for k := range parts { + sortedKeys = append(sortedKeys, k) + } + sort.Strings(sortedKeys) + newStrings := make([]string, partsLen) + for i, k := range sortedKeys { + newStrings[i] = fmt.Sprintf("%s/%s", k, parts[k]) + } + newPart := strings.Join(newStrings, " ") + if oldHeader == "" { + return newPart + } + return fmt.Sprintf("%s %s", oldHeader, newPart) +} + +type UserAgentKey string + +const ( + UserAgentExtraKey = UserAgentKey("user-agent-extra") + UserAgentHeader = "User-Agent" +) + +// ReadUserAgentExtra retrieves the UserAgentExtra from the context if present. +// Returns a pointer to the UserAgentExtra, or nil if not set or of the wrong type. +// Logs a warning if the value is not of the expected type. +func ReadUserAgentExtra(ctx context.Context) *UserAgentExtra { + extra := ctx.Value(UserAgentExtraKey) + if extra == nil { + return nil + } + if userAgentExtra, ok := extra.(UserAgentExtra); ok { + return &userAgentExtra + } + log.Printf("[WARN] UserAgentExtra in context is not of type UserAgentExtra, got %v", extra) + return nil +} + +// AddUserAgentExtra returns a new context with UserAgentExtra merged into any existing value. +// If a UserAgentExtra is already present in the context, the fields of 'extra' will override non-empty fields. +func AddUserAgentExtra(ctx context.Context, extra UserAgentExtra) context.Context { + oldExtra := ReadUserAgentExtra(ctx) + if oldExtra == nil { + return context.WithValue(ctx, UserAgentExtraKey, extra) + } + newExtra := oldExtra.Combine(extra) + return context.WithValue(ctx, UserAgentExtraKey, newExtra) +} diff --git a/internal/config/user_agent_test.go b/internal/config/user_agent_test.go new file mode 100644 index 0000000000..b8f8b39cc4 --- /dev/null +++ b/internal/config/user_agent_test.go @@ -0,0 +1,118 @@ +package config_test + +import ( + "testing" + + "github.com/mongodb/terraform-provider-mongodbatlas/internal/config" + "github.com/stretchr/testify/assert" +) + +func TestUserAgentExtra_ToHeaderValue(t *testing.T) { + testCases := map[string]struct { + extra config.UserAgentExtra + old string + expected string + }{ + "all fields": { + extra: config.UserAgentExtra{ + Name: "name1", + Operation: "op1", + }, + old: "base/1.0", + expected: "base/1.0 Name/name1 Operation/op1", + }, + "some fields empty": { + extra: config.UserAgentExtra{ + Name: "name2", + Operation: "", + }, + old: "", + expected: "Name/name2", + }, + "none": { + extra: config.UserAgentExtra{}, + old: "", + expected: "", + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + got := tc.extra.ToHeaderValue(t.Context(), tc.old) + assert.Equal(t, tc.expected, got) + }) + } +} + +func TestUserAgentExtra_Combine(t *testing.T) { + testCases := map[string]struct { + base config.UserAgentExtra + other config.UserAgentExtra + expected config.UserAgentExtra + }{ + "other overwrites non-empty": { + base: config.UserAgentExtra{Name: "B", Operation: "C"}, + other: config.UserAgentExtra{Name: "Y", Operation: "Z"}, + expected: config.UserAgentExtra{Name: "Y", Operation: "Z"}, + }, + "other empty": { + base: config.UserAgentExtra{Name: "B", Operation: "C"}, + other: config.UserAgentExtra{}, + expected: config.UserAgentExtra{Name: "B", Operation: "C"}, + }, + "mixed": { + base: config.UserAgentExtra{Name: "B", Operation: "O"}, + other: config.UserAgentExtra{Name: "Y"}, + expected: config.UserAgentExtra{Name: "Y", Operation: "O"}, + }, + "extras combine base set": { + base: config.UserAgentExtra{Extras: map[string]string{"A": "ok"}}, + other: config.UserAgentExtra{}, + expected: config.UserAgentExtra{Extras: map[string]string{"A": "ok"}}, + }, + "extras combine other set": { + base: config.UserAgentExtra{}, + other: config.UserAgentExtra{Extras: map[string]string{"A": "ok"}}, + expected: config.UserAgentExtra{Extras: map[string]string{"A": "ok"}}, + }, + "extras combine both set": { + base: config.UserAgentExtra{Extras: map[string]string{"A": "ok"}}, + other: config.UserAgentExtra{Extras: map[string]string{"B": "yes"}}, + expected: config.UserAgentExtra{Extras: map[string]string{"A": "ok", "B": "yes"}}, + }, + "all attributes set": { + other: config.UserAgentExtra{ + Extras: map[string]string{"B": "yes"}, + ModuleName: "module-name", + ModuleVersion: "1.2.3", + Name: "some-name", + Operation: "my-operation", + }, + expected: config.UserAgentExtra{ + Extras: map[string]string{"B": "yes"}, + ModuleName: "module-name", + ModuleVersion: "1.2.3", + Name: "some-name", + Operation: "my-operation", + }, + }, + } + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + got := tc.base.Combine(tc.other) + assert.Equal(t, tc.expected, got) + }) + } +} + +func TestAddUserAgentExtra(t *testing.T) { + base := config.UserAgentExtra{Name: "ChangedName", ModuleName: "FromBase"} + other := config.UserAgentExtra{Name: "NewName", Operation: "FromOther"} + ctx := config.AddUserAgentExtra(t.Context(), base) + ctx2 := config.AddUserAgentExtra(ctx, other) + ua := config.ReadUserAgentExtra(ctx2) + // Name from other + assert.Equal(t, "FromBase", ua.ModuleName) + assert.Equal(t, "NewName", ua.Name) + assert.Equal(t, "FromOther", ua.Operation) +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index d55969648c..beff7dd851 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -517,7 +517,11 @@ func (p *MongodbtlasProvider) Resources(context.Context) []func() resource.Resou if config.PreviewProviderV2AdvancedCluster() { resources = append(resources, advancedclustertpf.Resource) } - return resources + analyticsResources := []func() resource.Resource{} + for _, resourceFunc := range resources { + analyticsResources = append(analyticsResources, config.AnalyticsResourceFunc(resourceFunc())) + } + return analyticsResources } func NewFrameworkProvider() provider.Provider { diff --git a/internal/provider/provider_sdk2.go b/internal/provider/provider_sdk2.go index 5d31fe9492..991112c1c3 100644 --- a/internal/provider/provider_sdk2.go +++ b/internal/provider/provider_sdk2.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "regexp" + "strings" "time" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" @@ -289,7 +290,12 @@ func getResourcesMap() map[string]*schema.Resource { if !config.PreviewProviderV2AdvancedCluster() { resourcesMap["mongodbatlas_advanced_cluster"] = advancedcluster.Resource() } - return resourcesMap + analyticsMap := map[string]*schema.Resource{} + for fullName, resource := range resourcesMap { + name := strings.TrimPrefix(fullName, "mongodbatlas_") + analyticsMap[fullName] = config.NewAnalyticsResourceSDKv2(resource, name) + } + return analyticsMap } func providerConfigure(provider *schema.Provider) func(ctx context.Context, d *schema.ResourceData) (any, diag.Diagnostics) { diff --git a/internal/testutil/unit/provider_mock.go b/internal/testutil/unit/provider_mock.go index 5be1eaa890..a3c4cc0937 100644 --- a/internal/testutil/unit/provider_mock.go +++ b/internal/testutil/unit/provider_mock.go @@ -6,9 +6,7 @@ import ( "net/http" "testing" - "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/providerserver" - "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-go/tfprotov6" "github.com/hashicorp/terraform-plugin-mux/tf5to6server" "github.com/hashicorp/terraform-plugin-mux/tf6muxserver" @@ -28,19 +26,14 @@ type HTTPClientModifier interface { } type ProviderMocked struct { - OriginalProvider *provider.MongodbtlasProvider - ClientModifier HTTPClientModifier - t *testing.T + // Embed directly to support the same methods + *provider.MongodbtlasProvider + ClientModifier HTTPClientModifier + t *testing.T } -func (p *ProviderMocked) Metadata(ctx context.Context, req fwProvider.MetadataRequest, resp *fwProvider.MetadataResponse) { - p.OriginalProvider.Metadata(ctx, req, resp) -} -func (p *ProviderMocked) Schema(ctx context.Context, req fwProvider.SchemaRequest, resp *fwProvider.SchemaResponse) { - p.OriginalProvider.Schema(ctx, req, resp) -} func (p *ProviderMocked) Configure(ctx context.Context, req fwProvider.ConfigureRequest, resp *fwProvider.ConfigureResponse) { - p.OriginalProvider.Configure(ctx, req, resp) + p.MongodbtlasProvider.Configure(ctx, req, resp) rd := resp.ResourceData client, ok := rd.(*config.MongoDBClient) if !ok { @@ -58,13 +51,6 @@ func (p *ProviderMocked) Configure(ctx context.Context, req fwProvider.Configure } } -func (p *ProviderMocked) DataSources(ctx context.Context) []func() datasource.DataSource { - return p.OriginalProvider.DataSources(ctx) -} -func (p *ProviderMocked) Resources(ctx context.Context) []func() resource.Resource { - return p.OriginalProvider.Resources(ctx) -} - // Similar to provider.go#muxProviderFactory func muxProviderFactory(t *testing.T, clientModifier HTTPClientModifier) func() tfprotov6.ProviderServer { t.Helper() @@ -89,9 +75,9 @@ func muxProviderFactory(t *testing.T, clientModifier HTTPClientModifier) func() log.Fatal("Failed to cast provider to MongodbtlasProvider") } mockedProvider := &ProviderMocked{ - OriginalProvider: fwProviderInstanceTyped, - ClientModifier: clientModifier, - t: t, + MongodbtlasProvider: fwProviderInstanceTyped, + ClientModifier: clientModifier, + t: t, } upgradedSdkProvider, err := tf5to6server.UpgradeServer(t.Context(), v2Provider.GRPCProvider) if err != nil {