diff --git a/internal/config/client.go b/internal/config/client.go index 260f9b9482..509efbbcf7 100644 --- a/internal/config/client.go +++ b/internal/config/client.go @@ -97,13 +97,22 @@ 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 := UserAgentTransport{ + Transport: tfLoggingTransport, + } + client := &http.Client{Transport: &userAgentTransport} optsAtlas := []matlasClient.ClientOpt{matlasClient.SetUserAgent(userAgent(c))} if c.BaseURL != "" { diff --git a/internal/config/transport.go b/internal/config/transport.go index 766b6a6360..72b2cf98c6 100644 --- a/internal/config/transport.go +++ b/internal/config/transport.go @@ -1,12 +1,119 @@ package config import ( + "context" + "fmt" "log" "net/http" "strings" "time" ) +// UserAgentExtra holds additional metadata to be appended to the User-Agent header and context. +type UserAgentExtra struct { + Type string // Type of the operation (e.g., "Resource", "Datasource", etc.) + Name string // Full name, for example mongodbatlas_database_user + Operation string // GrpcCall for example, ReadResource, see wrapped_provider_server.go for details + ScriptLocation string // TODO: Support setting this field as opt-in on resources and datasources +} + +// 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 { + typeName := e.Type + if other.Type != "" { + typeName = other.Type + } + name := e.Name + if other.Name != "" { + name = other.Name + } + operation := e.Operation + if other.Operation != "" { + operation = other.Operation + } + scriptLocation := e.ScriptLocation + if other.ScriptLocation != "" { + scriptLocation = other.ScriptLocation + } + return UserAgentExtra{ + Type: typeName, + Name: name, + Operation: operation, + ScriptLocation: scriptLocation, + } +} + +// 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(oldHeader string) string { + parts := []string{} + addPart := func(key, part string) { + if part == "" { + return + } + parts = append(parts, fmt.Sprintf("%s/%s", key, part)) + } + addPart("Type", e.Type) + addPart("Name", e.Name) + addPart("Operation", e.Operation) + addPart("ScriptLocation", e.ScriptLocation) + newPart := strings.Join(parts, " ") + 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) +} + +// UserAgentTransport wraps an http.RoundTripper to add User-Agent header with additional metadata. +type UserAgentTransport struct { + Transport http.RoundTripper +} + +func (t *UserAgentTransport) RoundTrip(req *http.Request) (*http.Response, error) { + extra := ReadUserAgentExtra(req.Context()) + if extra != nil { + userAgent := req.Header.Get(UserAgentHeader) + newVar := extra.ToHeaderValue(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..44b54794f6 100644 --- a/internal/config/transport_test.go +++ b/internal/config/transport_test.go @@ -151,6 +151,95 @@ func TestNetworkLoggingTransport_Disabled(t *testing.T) { assert.Empty(t, logStr, "Expected no logs when network logging is disabled") } +func TestUserAgentExtra_ToHeaderValue(t *testing.T) { + testCases := map[string]struct { + extra config.UserAgentExtra + old string + expected string + }{ + "all fields": { + extra: config.UserAgentExtra{ + Type: "type1", + Name: "name1", + Operation: "op1", + ScriptLocation: "loc1", + }, + old: "base/1.0", + expected: "base/1.0 Type/type1 Name/name1 Operation/op1 ScriptLocation/loc1", + }, + "some fields empty": { + extra: config.UserAgentExtra{ + Type: "", + 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(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{Type: "A", Name: "B", Operation: "C", ScriptLocation: "D"}, + other: config.UserAgentExtra{Type: "X", Name: "Y", Operation: "Z", ScriptLocation: "Q"}, + expected: config.UserAgentExtra{Type: "X", Name: "Y", Operation: "Z", ScriptLocation: "Q"}, + }, + "other empty": { + base: config.UserAgentExtra{Type: "A", Name: "B", Operation: "C", ScriptLocation: "D"}, + other: config.UserAgentExtra{}, + expected: config.UserAgentExtra{Type: "A", Name: "B", Operation: "C", ScriptLocation: "D"}, + }, + "mixed": { + base: config.UserAgentExtra{Type: "A", Name: "B"}, + other: config.UserAgentExtra{Name: "Y", ScriptLocation: "Q"}, + expected: config.UserAgentExtra{Type: "A", Name: "Y", Operation: "", ScriptLocation: "Q"}, + }, + } + 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{Type: "A", Name: "B"} + other := config.UserAgentExtra{Name: "Y", ScriptLocation: "Q"} + ctx := config.AddUserAgentExtra(t.Context(), base) + ctx2 := config.AddUserAgentExtra(ctx, other) + // Should combine base and other + e := ctx2.Value(config.UserAgentExtraKey) + assert.NotNil(t, e) + ua := config.UserAgentExtra{} + if v, ok := e.(config.UserAgentExtra); ok { + ua = v + } + // The combined should have Type from base, Name from other, ScriptLocation from other + assert.Equal(t, "A", ua.Type) + assert.Equal(t, "Y", ua.Name) + assert.Equal(t, "Q", ua.ScriptLocation) + assert.Empty(t, ua.Operation) +} + func TestAccNetworkLogging(t *testing.T) { acc.SkipInUnitTest(t) acc.PreCheckBasic(t) diff --git a/internal/provider/provider.go b/internal/provider/provider.go index c05a5cec1e..64aa154d71 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -506,7 +506,7 @@ func MuxProviderFactory() func() tfprotov6.ProviderServer { if err != nil { log.Fatal(err) } - return muxServer.ProviderServer + return NewWrappedProviderServer(muxServer.ProviderServer) } func MultiEnvDefaultFunc(ks []string, def any) any { diff --git a/internal/provider/wrapper_provider_server.go b/internal/provider/wrapper_provider_server.go new file mode 100644 index 0000000000..0c72ca478c --- /dev/null +++ b/internal/provider/wrapper_provider_server.go @@ -0,0 +1,185 @@ +package provider + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-go/tfprotov6" + "github.com/mongodb/terraform-provider-mongodbatlas/internal/config" +) + +// NewWrappedProviderServer returns a new ProviderServer that wraps the old one. +// This is used to add additional metadata to the User-Agent header and context. +func NewWrappedProviderServer(old func() tfprotov6.ProviderServer) func() tfprotov6.ProviderServer { + return func() tfprotov6.ProviderServer { + return &WrappedProviderServer{ + OldServer: old(), + } + } +} + +type WrappedProviderServer struct { + OldServer tfprotov6.ProviderServer +} + +func (s *WrappedProviderServer) GetMetadata(ctx context.Context, req *tfprotov6.GetMetadataRequest) (*tfprotov6.GetMetadataResponse, error) { + return s.OldServer.GetMetadata(ctx, req) +} + +func (s *WrappedProviderServer) GetProviderSchema(ctx context.Context, req *tfprotov6.GetProviderSchemaRequest) (*tfprotov6.GetProviderSchemaResponse, error) { + return s.OldServer.GetProviderSchema(ctx, req) +} + +func (s *WrappedProviderServer) GetResourceIdentitySchemas(ctx context.Context, req *tfprotov6.GetResourceIdentitySchemasRequest) (*tfprotov6.GetResourceIdentitySchemasResponse, error) { + return s.OldServer.GetResourceIdentitySchemas(ctx, req) +} + +func (s *WrappedProviderServer) ValidateProviderConfig(ctx context.Context, req *tfprotov6.ValidateProviderConfigRequest) (*tfprotov6.ValidateProviderConfigResponse, error) { + return s.OldServer.ValidateProviderConfig(ctx, req) +} + +func (s *WrappedProviderServer) ConfigureProvider(ctx context.Context, req *tfprotov6.ConfigureProviderRequest) (*tfprotov6.ConfigureProviderResponse, error) { + return s.OldServer.ConfigureProvider(ctx, req) +} + +func (s *WrappedProviderServer) StopProvider(ctx context.Context, req *tfprotov6.StopProviderRequest) (*tfprotov6.StopProviderResponse, error) { + return s.OldServer.StopProvider(ctx, req) +} + +func (s *WrappedProviderServer) ValidateResourceConfig(ctx context.Context, req *tfprotov6.ValidateResourceConfigRequest) (*tfprotov6.ValidateResourceConfigResponse, error) { + ctx = config.AddUserAgentExtra(ctx, config.UserAgentExtra{ + Type: "Resource", + Name: req.TypeName, + Operation: "ValidateResourceConfig", + }) + return s.OldServer.ValidateResourceConfig(ctx, req) +} + +func (s *WrappedProviderServer) UpgradeResourceState(ctx context.Context, req *tfprotov6.UpgradeResourceStateRequest) (*tfprotov6.UpgradeResourceStateResponse, error) { + ctx = config.AddUserAgentExtra(ctx, config.UserAgentExtra{ + Type: "Resource", + Name: req.TypeName, + Operation: "UpgradeResourceState", + }) + return s.OldServer.UpgradeResourceState(ctx, req) +} + +func (s *WrappedProviderServer) ReadResource(ctx context.Context, req *tfprotov6.ReadResourceRequest) (*tfprotov6.ReadResourceResponse, error) { + ctx = config.AddUserAgentExtra(ctx, config.UserAgentExtra{ + Type: "Resource", + Name: req.TypeName, + Operation: "ReadResource", + }) + return s.OldServer.ReadResource(ctx, req) +} + +func (s *WrappedProviderServer) PlanResourceChange(ctx context.Context, req *tfprotov6.PlanResourceChangeRequest) (*tfprotov6.PlanResourceChangeResponse, error) { + ctx = config.AddUserAgentExtra(ctx, config.UserAgentExtra{ + Type: "Resource", + Name: req.TypeName, + Operation: "PlanResourceChange", + }) + return s.OldServer.PlanResourceChange(ctx, req) +} + +func (s *WrappedProviderServer) ApplyResourceChange(ctx context.Context, req *tfprotov6.ApplyResourceChangeRequest) (*tfprotov6.ApplyResourceChangeResponse, error) { + ctx = config.AddUserAgentExtra(ctx, config.UserAgentExtra{ + Type: "Resource", + Name: req.TypeName, + Operation: "ApplyResourceChange", + }) + return s.OldServer.ApplyResourceChange(ctx, req) +} + +func (s *WrappedProviderServer) ImportResourceState(ctx context.Context, req *tfprotov6.ImportResourceStateRequest) (*tfprotov6.ImportResourceStateResponse, error) { + ctx = config.AddUserAgentExtra(ctx, config.UserAgentExtra{ + Type: "Resource", + Name: req.TypeName, + Operation: "ImportResourceState", + }) + return s.OldServer.ImportResourceState(ctx, req) +} + +func (s *WrappedProviderServer) MoveResourceState(ctx context.Context, req *tfprotov6.MoveResourceStateRequest) (*tfprotov6.MoveResourceStateResponse, error) { + ctx = config.AddUserAgentExtra(ctx, config.UserAgentExtra{ + Type: "Resource", + Name: req.TargetTypeName, + Operation: "MoveResourceState", + }) + return s.OldServer.MoveResourceState(ctx, req) +} + +func (s *WrappedProviderServer) UpgradeResourceIdentity(ctx context.Context, req *tfprotov6.UpgradeResourceIdentityRequest) (*tfprotov6.UpgradeResourceIdentityResponse, error) { + ctx = config.AddUserAgentExtra(ctx, config.UserAgentExtra{ + Type: "Resource", + Name: req.TypeName, + Operation: "UpgradeResourceIdentity", + }) + return s.OldServer.UpgradeResourceIdentity(ctx, req) +} + +func (s *WrappedProviderServer) ValidateDataResourceConfig(ctx context.Context, req *tfprotov6.ValidateDataResourceConfigRequest) (*tfprotov6.ValidateDataResourceConfigResponse, error) { + ctx = config.AddUserAgentExtra(ctx, config.UserAgentExtra{ + Type: "Datasource", + Name: req.TypeName, + Operation: "ValidateDataResourceConfig", + }) + return s.OldServer.ValidateDataResourceConfig(ctx, req) +} + +func (s *WrappedProviderServer) ReadDataSource(ctx context.Context, req *tfprotov6.ReadDataSourceRequest) (*tfprotov6.ReadDataSourceResponse, error) { + ctx = config.AddUserAgentExtra(ctx, config.UserAgentExtra{ + Type: "Datasource", + Name: req.TypeName, + Operation: "ReadDataSource", + }) + return s.OldServer.ReadDataSource(ctx, req) +} + +func (s *WrappedProviderServer) CallFunction(ctx context.Context, req *tfprotov6.CallFunctionRequest) (*tfprotov6.CallFunctionResponse, error) { + ctx = config.AddUserAgentExtra(ctx, config.UserAgentExtra{ + Type: "Function", + Name: req.Name, + Operation: "CallFunction", + }) + return s.OldServer.CallFunction(ctx, req) +} + +func (s *WrappedProviderServer) GetFunctions(ctx context.Context, req *tfprotov6.GetFunctionsRequest) (*tfprotov6.GetFunctionsResponse, error) { + return s.OldServer.GetFunctions(ctx, req) +} + +func (s *WrappedProviderServer) ValidateEphemeralResourceConfig(ctx context.Context, req *tfprotov6.ValidateEphemeralResourceConfigRequest) (*tfprotov6.ValidateEphemeralResourceConfigResponse, error) { + ctx = config.AddUserAgentExtra(ctx, config.UserAgentExtra{ + Type: "Ephemeral", + Name: req.TypeName, + Operation: "ValidateEphemeralResourceConfig", + }) + return s.OldServer.ValidateEphemeralResourceConfig(ctx, req) +} + +func (s *WrappedProviderServer) OpenEphemeralResource(ctx context.Context, req *tfprotov6.OpenEphemeralResourceRequest) (*tfprotov6.OpenEphemeralResourceResponse, error) { + ctx = config.AddUserAgentExtra(ctx, config.UserAgentExtra{ + Type: "Ephemeral", + Name: req.TypeName, + Operation: "OpenEphemeralResource", + }) + return s.OldServer.OpenEphemeralResource(ctx, req) +} + +func (s *WrappedProviderServer) RenewEphemeralResource(ctx context.Context, req *tfprotov6.RenewEphemeralResourceRequest) (*tfprotov6.RenewEphemeralResourceResponse, error) { + ctx = config.AddUserAgentExtra(ctx, config.UserAgentExtra{ + Type: "Ephemeral", + Name: req.TypeName, + Operation: "RenewEphemeralResource", + }) + return s.OldServer.RenewEphemeralResource(ctx, req) +} + +func (s *WrappedProviderServer) CloseEphemeralResource(ctx context.Context, req *tfprotov6.CloseEphemeralResourceRequest) (*tfprotov6.CloseEphemeralResourceResponse, error) { + ctx = config.AddUserAgentExtra(ctx, config.UserAgentExtra{ + Type: "Ephemeral", + Name: req.TypeName, + Operation: "CloseEphemeralResource", + }) + return s.OldServer.CloseEphemeralResource(ctx, req) +}