diff --git a/bindings/azure/servicebusqueues/servicebusqueues.go b/bindings/azure/servicebusqueues/servicebusqueues.go index af2bc3b53..bc1291ac3 100644 --- a/bindings/azure/servicebusqueues/servicebusqueues.go +++ b/bindings/azure/servicebusqueues/servicebusqueues.go @@ -62,7 +62,7 @@ func (a *AzureServiceBusQueues) Init(ctx context.Context, metadata bindings.Meta return err } - a.client, err = impl.NewClient(a.metadata, metadata.Properties) + a.client, err = impl.NewClient(a.metadata, metadata.Properties, a.logger) if err != nil { return err } diff --git a/bindings/gcp/bucket/bucket.go b/bindings/gcp/bucket/bucket.go index 155d96abe..027702f57 100644 --- a/bindings/gcp/bucket/bucket.go +++ b/bindings/gcp/bucket/bucket.go @@ -110,13 +110,7 @@ func (g *GCPStorage) Init(ctx context.Context, metadata bindings.Metadata) error return err } - b, err := json.Marshal(m) - if err != nil { - return err - } - - clientOptions := option.WithCredentialsJSON(b) - client, err := storage.NewClient(ctx, clientOptions) + client, err := g.getClient(ctx, m) if err != nil { return err } @@ -127,6 +121,41 @@ func (g *GCPStorage) Init(ctx context.Context, metadata bindings.Metadata) error return nil } +func (g *GCPStorage) getClient(ctx context.Context, m *gcpMetadata) (*storage.Client, error) { + var client *storage.Client + var err error + + if m.Bucket == "" { + return nil, errors.New("missing property `bucket` in metadata") + } + if m.ProjectID == "" { + return nil, errors.New("missing property `project_id` in metadata") + } + + // Explicit authentication + if m.PrivateKeyID != "" { + var b []byte + b, err = json.Marshal(m) + if err != nil { + return nil, err + } + + clientOptions := option.WithCredentialsJSON(b) + client, err = storage.NewClient(ctx, clientOptions) + if err != nil { + return nil, err + } + } else { + // Implicit authentication, using GCP Application Default Credentials (ADC) + // Credentials search order: https://cloud.google.com/docs/authentication/application-default-credentials#order + client, err = storage.NewClient(ctx) + if err != nil { + return nil, err + } + } + return client, nil +} + func (g *GCPStorage) parseMetadata(meta bindings.Metadata) (*gcpMetadata, error) { m := gcpMetadata{} err := kitmd.DecodeMetadata(meta.Properties, &m) diff --git a/bindings/gcp/bucket/bucket_test.go b/bindings/gcp/bucket/bucket_test.go index e5200c00c..09392f044 100644 --- a/bindings/gcp/bucket/bucket_test.go +++ b/bindings/gcp/bucket/bucket_test.go @@ -15,6 +15,7 @@ package bucket import ( "encoding/json" + "errors" "testing" "github.com/stretchr/testify/assert" @@ -234,6 +235,30 @@ func TestMergeWithRequestMetadata(t *testing.T) { }) } +func TestInit(t *testing.T) { + t.Run("Init missing bucket from metadata", func(t *testing.T) { + m := bindings.Metadata{} + m.Properties = map[string]string{ + "projectID": "my_project_id", + } + gs := GCPStorage{logger: logger.NewLogger("test")} + err := gs.Init(t.Context(), m) + require.Error(t, err) + assert.Equal(t, err, errors.New("missing property `bucket` in metadata")) + }) + + t.Run("Init missing projectID from metadata", func(t *testing.T) { + m := bindings.Metadata{} + m.Properties = map[string]string{ + "bucket": "my_bucket", + } + gs := GCPStorage{logger: logger.NewLogger("test")} + err := gs.Init(t.Context(), m) + require.Error(t, err) + assert.Equal(t, err, errors.New("missing property `project_id` in metadata")) + }) +} + func TestGetOption(t *testing.T) { gs := GCPStorage{logger: logger.NewLogger("test")} gs.metadata = &gcpMetadata{} diff --git a/bindings/redis/metadata.yaml b/bindings/redis/metadata.yaml index 217f70757..ad2bd7c62 100644 --- a/bindings/redis/metadata.yaml +++ b/bindings/redis/metadata.yaml @@ -42,6 +42,30 @@ authenticationProfiles: secret reference example: "KeFg23!" default: "" + - name: sentinelUsername + type: string + required: false + description: | + Username for Redis Sentinel. Applicable only when "failover" is true, and + Redis Sentinel has authentication enabled. Defaults to empty. + example: "my-sentinel-username" + default: "" + url: + title: "Redis Sentinel authentication documentation" + url: "https://redis.io/docs/latest/operate/oss_and_stack/management/sentinel/#configuring-sentinel-instances-with-authentication" + - name: sentinelPassword + type: string + required: false + sensitive: true + description: | + Password for Redis Sentinel. Applicable only when "failover" is true, and + Redis Sentinel has authentication enabled. Use secretKeyRef for + secret reference. Defaults to empty. + example: "KeFg23!" + default: "" + url: + title: "Redis Sentinel authentication documentation" + url: "https://redis.io/docs/latest/operate/oss_and_stack/management/sentinel/#configuring-sentinel-instances-with-authentication" metadata: - name: redisHost required: true diff --git a/common/component/azure/servicebus/client.go b/common/component/azure/servicebus/client.go index 5ee1a77c4..c8b878e0a 100644 --- a/common/component/azure/servicebus/client.go +++ b/common/component/azure/servicebus/client.go @@ -16,6 +16,7 @@ package servicebus import ( "context" "fmt" + "strings" "sync" "time" @@ -40,7 +41,7 @@ type Client struct { } // NewClient creates a new Client object. -func NewClient(metadata *Metadata, rawMetadata map[string]string) (*Client, error) { +func NewClient(metadata *Metadata, rawMetadata map[string]string, log logger.Logger) (*Client, error) { client := &Client{ metadata: metadata, lock: &sync.RWMutex{}, @@ -86,9 +87,17 @@ func NewClient(metadata *Metadata, rawMetadata map[string]string) (*Client, erro } if !metadata.DisableEntityManagement { - client.adminClient, err = sbadmin.NewClient(metadata.NamespaceName, token, nil) - if err != nil { - return nil, err + if isAzureEmulator(metadata.ConnectionString) { + log.Warn( + "UseDevelopmentEmulator=true detected in connection string. " + + "Azure emulator does not support topic management APIs. " + + "Dapr will skip admin operations. " + + "To suppress this warning, explicitly set disableEntityManagement: true.") + } else { + client.adminClient, err = sbadmin.NewClient(metadata.NamespaceName, token, nil) + if err != nil { + return nil, err + } } } } @@ -394,3 +403,7 @@ func notEqual(a, b *bool) bool { } return *a != *b } + +func isAzureEmulator(connectionString string) bool { + return strings.Contains(strings.ToLower(connectionString), "usedevelopmentemulator=true") +} diff --git a/common/component/redis/redis_test.go b/common/component/redis/redis_test.go index 1445dc2cf..a4788e129 100644 --- a/common/component/redis/redis_test.go +++ b/common/component/redis/redis_test.go @@ -25,6 +25,8 @@ const ( host = "redisHost" password = "redisPassword" username = "redisUsername" + sentinelUsername = "sentinelUsername" + sentinelPassword = "sentinelPassword" db = "redisDB" redisType = "redisType" redisMaxRetries = "redisMaxRetries" @@ -51,6 +53,8 @@ func getFakeProperties() map[string]string { host: "fake.redis.com", password: "fakePassword", username: "fakeUsername", + sentinelUsername: "fakeSentinelUsername", + sentinelPassword: "fakeSentinelPassword", redisType: "node", enableTLS: "true", clientCert: "fakeCert", @@ -86,6 +90,8 @@ func TestParseRedisMetadata(t *testing.T) { assert.Equal(t, fakeProperties[host], m.Host) assert.Equal(t, fakeProperties[password], m.Password) assert.Equal(t, fakeProperties[username], m.Username) + assert.Equal(t, fakeProperties[sentinelUsername], m.SentinelUsername) + assert.Equal(t, fakeProperties[sentinelPassword], m.SentinelPassword) assert.Equal(t, fakeProperties[redisType], m.RedisType) assert.True(t, m.EnableTLS) assert.Equal(t, fakeProperties[clientCert], m.ClientCert) diff --git a/common/component/redis/settings.go b/common/component/redis/settings.go index d031f1c23..13cba00a3 100644 --- a/common/component/redis/settings.go +++ b/common/component/redis/settings.go @@ -29,6 +29,10 @@ type Settings struct { Password string `mapstructure:"redisPassword"` // The Redis username Username string `mapstructure:"redisUsername"` + // The Redis Sentinel password + SentinelPassword string `mapstructure:"sentinelPassword"` + // The Redis Sentinel username + SentinelUsername string `mapstructure:"sentinelUsername"` // Database to be selected after connecting to the server. DB int `mapstructure:"redisDB"` // The redis type node or cluster diff --git a/common/component/redis/v8client.go b/common/component/redis/v8client.go index c74a05701..8bcb4ad61 100644 --- a/common/component/redis/v8client.go +++ b/common/component/redis/v8client.go @@ -330,6 +330,8 @@ func newV8FailoverClient(s *Settings) (RedisClient, error) { DB: s.DB, MasterName: s.SentinelMasterName, SentinelAddrs: []string{s.Host}, + SentinelUsername: s.SentinelUsername, + SentinelPassword: s.SentinelPassword, Password: s.Password, Username: s.Username, MaxRetries: s.RedisMaxRetries, diff --git a/common/component/redis/v9client.go b/common/component/redis/v9client.go index 8399e1e7a..eaeb94487 100644 --- a/common/component/redis/v9client.go +++ b/common/component/redis/v9client.go @@ -330,6 +330,8 @@ func newV9FailoverClient(s *Settings) (RedisClient, error) { DB: s.DB, MasterName: s.SentinelMasterName, SentinelAddrs: []string{s.Host}, + SentinelUsername: s.SentinelUsername, + SentinelPassword: s.SentinelPassword, Password: s.Password, Username: s.Username, MaxRetries: s.RedisMaxRetries, diff --git a/configuration/redis/metadata.yaml b/configuration/redis/metadata.yaml index 00451159c..30643bcff 100644 --- a/configuration/redis/metadata.yaml +++ b/configuration/redis/metadata.yaml @@ -30,6 +30,30 @@ authenticationProfiles: secret reference example: "KeFg23!" default: "" + - name: sentinelUsername + type: string + required: false + description: | + Username for Redis Sentinel. Applicable only when "failover" is true, and + Redis Sentinel has authentication enabled. Defaults to empty. + example: "my-sentinel-username" + default: "" + url: + title: "Redis Sentinel authentication documentation" + url: "https://redis.io/docs/latest/operate/oss_and_stack/management/sentinel/#configuring-sentinel-instances-with-authentication" + - name: sentinelPassword + type: string + required: false + sensitive: true + description: | + Password for Redis Sentinel. Applicable only when "failover" is true, and + Redis Sentinel has authentication enabled. Use secretKeyRef for + secret reference. Defaults to empty. + example: "KeFg23!" + default: "" + url: + title: "Redis Sentinel authentication documentation" + url: "https://redis.io/docs/latest/operate/oss_and_stack/management/sentinel/#configuring-sentinel-instances-with-authentication" metadata: - name: redisHost required: true diff --git a/configuration/redis/redis_test.go b/configuration/redis/redis_test.go index 5d770a088..46edca489 100644 --- a/configuration/redis/redis_test.go +++ b/configuration/redis/redis_test.go @@ -244,6 +244,8 @@ func Test_parseRedisMetadata(t *testing.T) { testProperties := make(map[string]string) testProperties["redisHost"] = "testHost" testProperties["redisPassword"] = "testPassword" + testProperties["sentinelUsername"] = "testSentinelUsername" + testProperties["sentinelPassword"] = "testSentinelPassword" testProperties["enableTLS"] = "true" testProperties["redisMaxRetries"] = "10" testProperties["redisMaxRetryInterval"] = "100ms" @@ -254,6 +256,8 @@ func Test_parseRedisMetadata(t *testing.T) { testSettings := redisComponent.Settings{ Host: "testHost", Password: "testPassword", + SentinelUsername: "testSentinelUsername", + SentinelPassword: "testSentinelPassword", EnableTLS: true, RedisMaxRetries: 10, RedisMaxRetryInterval: redisComponent.Duration(100 * time.Millisecond), @@ -268,6 +272,8 @@ func Test_parseRedisMetadata(t *testing.T) { defaultSettings := redisComponent.Settings{ Host: "testHost", Password: "", + SentinelUsername: "", + SentinelPassword: "", EnableTLS: false, RedisMaxRetries: 3, RedisMaxRetryInterval: redisComponent.Duration(time.Second * 2), @@ -311,6 +317,8 @@ func Test_parseRedisMetadata(t *testing.T) { } assert.Equal(t, tt.want.Host, got.Host) assert.Equal(t, tt.want.Password, got.Password) + assert.Equal(t, tt.want.SentinelUsername, got.SentinelUsername) + assert.Equal(t, tt.want.SentinelPassword, got.SentinelPassword) assert.Equal(t, tt.want.EnableTLS, got.EnableTLS) assert.Equal(t, tt.want.RedisMaxRetries, got.RedisMaxRetries) assert.Equal(t, tt.want.RedisMaxRetryInterval, got.RedisMaxRetryInterval) diff --git a/conversation/openai/metadata.go b/conversation/openai/metadata.go new file mode 100644 index 000000000..d7a699f5c --- /dev/null +++ b/conversation/openai/metadata.go @@ -0,0 +1,25 @@ +/* +Copyright 2025 The Dapr Authors +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package openai + +import "github.com/dapr/components-contrib/conversation" + +// OpenAILangchainMetadata extends LangchainMetadata with OpenAI-specific properties. +type OpenAILangchainMetadata struct { + conversation.LangchainMetadata `json:",inline" mapstructure:",squash"` + APIType string `json:"apiType" mapstructure:"apiType"` + APIVersion string `json:"apiVersion" mapstructure:"apiVersion"` +} diff --git a/conversation/openai/metadata.yaml b/conversation/openai/metadata.yaml index dcf6cef3c..dc01eb4ea 100644 --- a/conversation/openai/metadata.yaml +++ b/conversation/openai/metadata.yaml @@ -40,3 +40,17 @@ metadata: A time-to-live value for a prompt cache to expire. Uses Golang durations type: string example: '10m' + - name: apiVersion + required: false + description: | + The API version to use for the Azure OpenAI service. This is required when using Azure OpenAI. + type: string + example: '2025-01-01-preview' + default: '' + - name: apiType + required: false + description: | + The type of API to use for the OpenAI service. This is required when using Azure OpenAI. + type: string + example: 'azure' + default: '' \ No newline at end of file diff --git a/conversation/openai/metadata_test.go b/conversation/openai/metadata_test.go new file mode 100644 index 000000000..e7a5b4b21 --- /dev/null +++ b/conversation/openai/metadata_test.go @@ -0,0 +1,67 @@ +/* +Copyright 2025 The Dapr Authors +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package openai + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/dapr/components-contrib/conversation" +) + +func TestOpenaiLangchainMetadata(t *testing.T) { + t.Run("json marshaling with endpoint", func(t *testing.T) { + metadata := OpenAILangchainMetadata{ + LangchainMetadata: conversation.LangchainMetadata{ + Key: "test-key", + Model: "gpt-4", + CacheTTL: "10m", + Endpoint: "https://custom-endpoint.openai.azure.com/", + }, + APIType: "azure", + APIVersion: "2025-01-01-preview", + } + + bytes, err := json.Marshal(metadata) + require.NoError(t, err) + + var unmarshaled OpenAILangchainMetadata + err = json.Unmarshal(bytes, &unmarshaled) + require.NoError(t, err) + + assert.Equal(t, metadata.Key, unmarshaled.Key) + assert.Equal(t, metadata.Model, unmarshaled.Model) + assert.Equal(t, metadata.CacheTTL, unmarshaled.CacheTTL) + assert.Equal(t, metadata.Endpoint, unmarshaled.Endpoint) + assert.Equal(t, metadata.APIType, unmarshaled.APIType) + assert.Equal(t, metadata.APIVersion, unmarshaled.APIVersion) + }) + + t.Run("json unmarshaling with endpoint", func(t *testing.T) { + jsonStr := `{"key": "test-key", "model": "gpt-4", "endpoint": "https://custom-endpoint.openai.azure.com/", "apiType": "azure", "apiVersion": "2025-01-01-preview"}` + + var metadata OpenAILangchainMetadata + err := json.Unmarshal([]byte(jsonStr), &metadata) + require.NoError(t, err) + + assert.Equal(t, "test-key", metadata.Key) + assert.Equal(t, "gpt-4", metadata.Model) + assert.Equal(t, "https://custom-endpoint.openai.azure.com/", metadata.Endpoint) + assert.Equal(t, "azure", metadata.APIType) + assert.Equal(t, "2025-01-01-preview", metadata.APIVersion) + }) +} diff --git a/conversation/openai/openai.go b/conversation/openai/openai.go index 6fc6b0529..9dfea3031 100644 --- a/conversation/openai/openai.go +++ b/conversation/openai/openai.go @@ -16,6 +16,7 @@ package openai import ( "context" + "errors" "reflect" "github.com/dapr/components-contrib/conversation" @@ -44,7 +45,7 @@ func NewOpenAI(logger logger.Logger) conversation.Conversation { const defaultModel = "gpt-4o" func (o *OpenAI) Init(ctx context.Context, meta conversation.Metadata) error { - md := conversation.LangchainMetadata{} + md := OpenAILangchainMetadata{} err := kmeta.DecodeMetadata(meta.Properties, &md) if err != nil { return err @@ -65,6 +66,14 @@ func (o *OpenAI) Init(ctx context.Context, meta conversation.Metadata) error { options = append(options, openai.WithBaseURL(md.Endpoint)) } + if md.APIType == "azure" { + if md.Endpoint == "" || md.APIVersion == "" { + return errors.New("endpoint and apiVersion must be provided when apiType is set to 'azure'") + } + + options = append(options, openai.WithAPIType(openai.APITypeAzure), openai.WithAPIVersion(md.APIVersion)) + } + llm, err := openai.New(options...) if err != nil { return err @@ -84,7 +93,7 @@ func (o *OpenAI) Init(ctx context.Context, meta conversation.Metadata) error { } func (o *OpenAI) GetComponentMetadata() (metadataInfo metadata.MetadataMap) { - metadataStruct := conversation.LangchainMetadata{} + metadataStruct := OpenAILangchainMetadata{} metadata.GetMetadataInfoFromStructType(reflect.TypeOf(metadataStruct), &metadataInfo, metadata.ConversationType) return } diff --git a/conversation/openai/openai_test.go b/conversation/openai/openai_test.go index 16cc203b3..1e9a7102e 100644 --- a/conversation/openai/openai_test.go +++ b/conversation/openai/openai_test.go @@ -55,8 +55,47 @@ func TestInit(t *testing.T) { // we're mainly testing that initialization succeeds }, }, + { + name: "with apiType azure and missing apiVersion", + metadata: map[string]string{ + "key": "test-key", + "model": "gpt-4", + "apiType": "azure", + "endpoint": "https://custom-endpoint.openai.azure.com/", + }, + testFn: func(t *testing.T, o *OpenAI, err error) { + require.Error(t, err) + assert.EqualError(t, err, "endpoint and apiVersion must be provided when apiType is set to 'azure'") + }, + }, + { + name: "with apiType azure and custom apiVersion", + metadata: map[string]string{ + "key": "test-key", + "model": "gpt-4", + "apiType": "azure", + "endpoint": "https://custom-endpoint.openai.azure.com/", + "apiVersion": "2025-01-01-preview", + }, + testFn: func(t *testing.T, o *OpenAI, err error) { + require.NoError(t, err) + assert.NotNil(t, o.LLM) + }, + }, + { + name: "with apiType azure but missing endpoint", + metadata: map[string]string{ + "key": "test-key", + "model": "gpt-4", + "apiType": "azure", + "apiVersion": "2025-01-01-preview", + }, + testFn: func(t *testing.T, o *OpenAI, err error) { + require.Error(t, err) + assert.EqualError(t, err, "endpoint and apiVersion must be provided when apiType is set to 'azure'") + }, + }, } - for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { o := NewOpenAI(logger.NewLogger("openai test")) diff --git a/pubsub/azure/servicebus/queues/servicebus.go b/pubsub/azure/servicebus/queues/servicebus.go index 4af688e3c..342a4e227 100644 --- a/pubsub/azure/servicebus/queues/servicebus.go +++ b/pubsub/azure/servicebus/queues/servicebus.go @@ -57,7 +57,7 @@ func (a *azureServiceBus) Init(_ context.Context, metadata pubsub.Metadata) (err return err } - a.client, err = impl.NewClient(a.metadata, metadata.Properties) + a.client, err = impl.NewClient(a.metadata, metadata.Properties, a.logger) if err != nil { return err } diff --git a/pubsub/azure/servicebus/topics/servicebus.go b/pubsub/azure/servicebus/topics/servicebus.go index 2e6ceed6e..1f83add61 100644 --- a/pubsub/azure/servicebus/topics/servicebus.go +++ b/pubsub/azure/servicebus/topics/servicebus.go @@ -58,7 +58,7 @@ func (a *azureServiceBus) Init(_ context.Context, metadata pubsub.Metadata) (err return err } - a.client, err = impl.NewClient(a.metadata, metadata.Properties) + a.client, err = impl.NewClient(a.metadata, metadata.Properties, a.logger) if err != nil { return err } diff --git a/pubsub/redis/metadata.yaml b/pubsub/redis/metadata.yaml index f841e194b..5e0684d1e 100644 --- a/pubsub/redis/metadata.yaml +++ b/pubsub/redis/metadata.yaml @@ -31,6 +31,30 @@ authenticationProfiles: secret reference example: "KeFg23!" default: "" + - name: sentinelUsername + type: string + required: false + description: | + Username for Redis Sentinel. Applicable only when "failover" is true, and + Redis Sentinel has authentication enabled. Defaults to empty. + example: "my-sentinel-username" + default: "" + url: + title: "Redis Sentinel authentication documentation" + url: "https://redis.io/docs/latest/operate/oss_and_stack/management/sentinel/#configuring-sentinel-instances-with-authentication" + - name: sentinelPassword + type: string + required: false + sensitive: true + description: | + Password for Redis Sentinel. Applicable only when "failover" is true, and + Redis Sentinel has authentication enabled. Use secretKeyRef for + secret reference. Defaults to empty. + example: "KeFg23!" + default: "" + url: + title: "Redis Sentinel authentication documentation" + url: "https://redis.io/docs/latest/operate/oss_and_stack/management/sentinel/#configuring-sentinel-instances-with-authentication" metadata: - name: redisHost required: true diff --git a/secretstores/gcp/secretmanager/secretmanager.go b/secretstores/gcp/secretmanager/secretmanager.go index 19c795204..21d20566f 100644 --- a/secretstores/gcp/secretmanager/secretmanager.go +++ b/secretstores/gcp/secretmanager/secretmanager.go @@ -88,12 +88,38 @@ func (s *Store) Init(ctx context.Context, metadataRaw secretstores.Metadata) err } func (s *Store) getClient(ctx context.Context, metadata *GcpSecretManagerMetadata) (*secretmanager.Client, error) { - b, _ := json.Marshal(metadata) - clientOptions := option.WithCredentialsJSON(b) + var client *secretmanager.Client + var err error - client, err := secretmanager.NewClient(ctx, clientOptions) - if err != nil { - return nil, err + if metadata.ProjectID == "" { + return nil, errors.New("missing property `project_id` in metadata") + } + + // Explicit authentication + if metadata.PrivateKeyID != "" { + if metadata.Type == "" { + return nil, errors.New("missing property `type` in metadata") + } + if metadata.PrivateKey == "" { + return nil, errors.New("missing property `private_key` in metadata") + } + if metadata.ClientEmail == "" { + return nil, errors.New("missing property `client_email` in metadata") + } + + b, _ := json.Marshal(metadata) + clientOptions := option.WithCredentialsJSON(b) + client, err = secretmanager.NewClient(ctx, clientOptions) + if err != nil { + return nil, err + } + } else { + // Implicit authentication, using GCP Application Default Credentials (ADC) + // Credentials search order: https://cloud.google.com/docs/authentication/application-default-credentials#order + client, err = secretmanager.NewClient(ctx) + if err != nil { + return nil, err + } } return client, nil @@ -183,18 +209,9 @@ func (s *Store) parseSecretManagerMetadata(metadataRaw secretstores.Metadata) (* return nil, fmt.Errorf("failed to decode metadata: %w", err) } - if meta.Type == "" { - return nil, errors.New("missing property `type` in metadata") - } if meta.ProjectID == "" { return nil, errors.New("missing property `project_id` in metadata") } - if meta.PrivateKey == "" { - return nil, errors.New("missing property `private_key` in metadata") - } - if meta.ClientEmail == "" { - return nil, errors.New("missing property `client_email` in metadata") - } return &meta, nil } diff --git a/secretstores/gcp/secretmanager/secretmanager_test.go b/secretstores/gcp/secretmanager/secretmanager_test.go index f8cb74dd0..1c478dd9d 100644 --- a/secretstores/gcp/secretmanager/secretmanager_test.go +++ b/secretstores/gcp/secretmanager/secretmanager_test.go @@ -76,11 +76,38 @@ func TestInit(t *testing.T) { t.Run("Init with missing `type` metadata", func(t *testing.T) { m.Properties = map[string]string{ - "dummy": "a", + "dummy": "a", + "private_key_id": "a", + "project_id": "a", } err := sm.Init(ctx, m) require.Error(t, err) - assert.Equal(t, err, errors.New("missing property `type` in metadata")) + assert.Equal(t, errors.New("failed to setup secretmanager client: missing property `type` in metadata"), err) + }) + + t.Run("Init with missing `private_key` metadata", func(t *testing.T) { + m.Properties = map[string]string{ + "dummy": "a", + "private_key_id": "a", + "type": "a", + "project_id": "a", + } + err := sm.Init(ctx, m) + require.Error(t, err) + assert.Equal(t, errors.New("failed to setup secretmanager client: missing property `private_key` in metadata"), err) + }) + + t.Run("Init with missing `client_email` metadata", func(t *testing.T) { + m.Properties = map[string]string{ + "dummy": "a", + "private_key_id": "a", + "private_key": "a", + "type": "a", + "project_id": "a", + } + err := sm.Init(ctx, m) + require.Error(t, err) + assert.Equal(t, errors.New("failed to setup secretmanager client: missing property `client_email` in metadata"), err) }) t.Run("Init with missing `project_id` metadata", func(t *testing.T) { @@ -91,6 +118,22 @@ func TestInit(t *testing.T) { require.Error(t, err) assert.Equal(t, err, errors.New("missing property `project_id` in metadata")) }) + + t.Run("Init with missing `project_id` metadata", func(t *testing.T) { + m.Properties = map[string]string{ + "type": "service_account", + } + err := sm.Init(ctx, m) + require.Error(t, err) + assert.Equal(t, err, errors.New("missing property `project_id` in metadata")) + }) + + t.Run("Init with empty metadata", func(t *testing.T) { + m.Properties = map[string]string{} + err := sm.Init(ctx, m) + require.Error(t, err) + assert.Equal(t, err, errors.New("missing property `project_id` in metadata")) + }) } func TestGetSecret(t *testing.T) { diff --git a/state/alicloud/tablestore/mock_client.go b/state/alicloud/tablestore/mock_client.go index f29678525..4718c2d83 100644 --- a/state/alicloud/tablestore/mock_client.go +++ b/state/alicloud/tablestore/mock_client.go @@ -16,6 +16,7 @@ package tablestore import ( "bytes" "encoding/binary" + "sync" "github.com/aliyun/aliyun-tablestore-go-sdk/tablestore" ) @@ -24,6 +25,7 @@ type mockClient struct { tablestore.TableStoreClient data map[string][]byte + mu sync.RWMutex } func (m *mockClient) DeleteRow(request *tablestore.DeleteRowRequest) (*tablestore.DeleteRowResponse, error) { @@ -36,7 +38,9 @@ func (m *mockClient) DeleteRow(request *tablestore.DeleteRowRequest) (*tablestor } } + m.mu.Lock() delete(m.data, key) + m.mu.Unlock() return nil, nil } @@ -51,7 +55,9 @@ func (m *mockClient) GetRow(request *tablestore.GetRowRequest) (*tablestore.GetR } } + m.mu.RLock() val := m.data[key] + m.mu.RUnlock() resp := &tablestore.GetRowResponse{ Columns: []*tablestore.AttributeColumn{{ @@ -87,7 +93,9 @@ func (m *mockClient) UpdateRow(req *tablestore.UpdateRowRequest) (*tablestore.Up } } + m.mu.Lock() m.data[key] = val + m.mu.Unlock() return nil, nil } @@ -97,6 +105,7 @@ func (m *mockClient) BatchGetRow(request *tablestore.BatchGetRowRequest) (*table TableToRowsResult: map[string][]tablestore.RowResult{}, } + m.mu.RLock() for _, criteria := range request.MultiRowQueryCriteria { tableRes := resp.TableToRowsResult[criteria.TableName] if tableRes == nil { @@ -136,12 +145,14 @@ func (m *mockClient) BatchGetRow(request *tablestore.BatchGetRowRequest) (*table } } } + m.mu.RUnlock() return resp, nil } func (m *mockClient) BatchWriteRow(request *tablestore.BatchWriteRowRequest) (*tablestore.BatchWriteRowResponse, error) { resp := &tablestore.BatchWriteRowResponse{} + m.mu.Lock() for _, changes := range request.RowChangesGroupByTable { for _, change := range changes { switch inst := change.(type) { @@ -174,6 +185,7 @@ func (m *mockClient) BatchWriteRow(request *tablestore.BatchWriteRowRequest) (*t } } } + m.mu.Unlock() return resp, nil } diff --git a/state/oracledatabase/oracledatabaseaccess.go b/state/oracledatabase/oracledatabaseaccess.go index 9a62ce6d4..fdd5374a5 100644 --- a/state/oracledatabase/oracledatabaseaccess.go +++ b/state/oracledatabase/oracledatabaseaccess.go @@ -514,8 +514,16 @@ func (o *oracleDatabaseAccess) ensureStateTable(stateTableName string) error { } func tableExists(db *sql.DB, tableName string) (bool, error) { - var tblCount int32 - err := db.QueryRow("SELECT count(table_name) tbl_count FROM user_tables WHERE table_name = upper(:tablename)", tableName).Scan(&tblCount) - exists := tblCount > 0 - return exists, err + //nolint:gosec + query := fmt.Sprintf("SELECT 1 FROM %s WHERE ROWNUM = 1", tableName) + + var dummy int + err := db.QueryRow(query).Scan(&dummy) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return true, nil // Table exists but is empty + } + return false, nil // Likely a table does not exist error + } + return true, nil } diff --git a/state/redis/metadata.yaml b/state/redis/metadata.yaml index 5fcd934ea..cab536678 100644 --- a/state/redis/metadata.yaml +++ b/state/redis/metadata.yaml @@ -36,6 +36,30 @@ authenticationProfiles: secret reference example: "KeFg23!" default: "" + - name: sentinelUsername + type: string + required: false + description: | + Username for Redis Sentinel. Applicable only when "failover" is true, and + Redis Sentinel has authentication enabled. Defaults to empty. + example: "my-sentinel-username" + default: "" + url: + title: "Redis Sentinel authentication documentation" + url: "https://redis.io/docs/latest/operate/oss_and_stack/management/sentinel/#configuring-sentinel-instances-with-authentication" + - name: sentinelPassword + type: string + required: false + sensitive: true + description: | + Password for Redis Sentinel. Applicable only when "failover" is true, and + Redis Sentinel has authentication enabled. Use secretKeyRef for + secret reference. Defaults to empty. + example: "KeFg23!" + default: "" + url: + title: "Redis Sentinel authentication documentation" + url: "https://redis.io/docs/latest/operate/oss_and_stack/management/sentinel/#configuring-sentinel-instances-with-authentication" metadata: - name: redisHost required: true diff --git a/tests/config/conversation/README.md b/tests/config/conversation/README.md index 1d30933d8..c8f097a7a 100644 --- a/tests/config/conversation/README.md +++ b/tests/config/conversation/README.md @@ -5,7 +5,7 @@ This directory contains conformance tests for all conversation components, inclu ## Available Components - **echo** - Simple echo component for testing (no configuration needed) -- **openai** - OpenAI GPT models +- **openai** - OpenAI GPT models (also supports Azure OpenAI) - **anthropic** - Anthropic Claude models - **googleai** - Google Gemini models - **mistral** - Mistral AI models @@ -52,6 +52,14 @@ export OPENAI_API_KEY="your_openai_api_key" ``` Get your API key from: https://platform.openai.com/api-keys +### Azure OpenAI +```bash +export AZURE_OPENAI_API_KEY="your_openai_api_key" +export AZURE_OPENAI_ENDPOINT="your_azureopenai_endpoint_here" +export AZURE_OPENAI_API_VERSION="your_azreopenai_api_version_here" +``` +Get your configuration values from: https://ai.azure.com/ + ### Anthropic ```bash export ANTHROPIC_API_KEY="your_anthropic_api_key" @@ -142,4 +150,5 @@ This approach provides better reliability and compatibility while maintaining ac - Cost-effective models are used by default to minimize API costs - HuggingFace uses the OpenAI compatibility layer as a workaround due to langchaingo API issues - Ollama requires a local server and must be explicitly enabled +- OpenAI component is tested for OpenAI and Azure - All tests include proper initialization and basic conversation functionality testing \ No newline at end of file diff --git a/tests/config/conversation/env.template b/tests/config/conversation/env.template index fb27ceaf1..4763ea5d5 100644 --- a/tests/config/conversation/env.template +++ b/tests/config/conversation/env.template @@ -4,6 +4,11 @@ # OpenAI API Key - Get from https://platform.openai.com/api-keys OPENAI_API_KEY=your_openai_api_key_here +# Azure OpenAI - Get from https://ai.azure.com/ +AZURE_OPENAI_API_KEY=your_azureopenai_api_key_here +AZURE_OPENAI_ENDPOINT=your_azureopenai_endpoint_here +AZURE_OPENAI_API_VERSION=your_azreopenai_api_version_here + # Anthropic API Key - Get from https://console.anthropic.com/ ANTHROPIC_API_KEY=your_anthropic_api_key_here diff --git a/tests/config/conversation/openai/azure/openai.yml b/tests/config/conversation/openai/azure/openai.yml new file mode 100644 index 000000000..106c59dc5 --- /dev/null +++ b/tests/config/conversation/openai/azure/openai.yml @@ -0,0 +1,18 @@ +apiVersion: dapr.io/v1alpha1 +kind: Component +metadata: + name: openai +spec: + type: conversation.openai + version: v1 + metadata: + - name: key + value: "${{AZURE_OPENAI_API_KEY}}" + - name: model + value: "gpt-4o-mini" + - name: endpoint + value: "${{AZURE_OPENAI_ENDPOINT}}" + - name: apiType + value: "azure" + - name: apiVersion + value: "${{AZURE_OPENAI_API_VERSION}}" diff --git a/tests/config/conversation/openai/openai.yml b/tests/config/conversation/openai/openai/openai.yml similarity index 100% rename from tests/config/conversation/openai/openai.yml rename to tests/config/conversation/openai/openai/openai.yml diff --git a/tests/config/conversation/test_conformance.sh b/tests/config/conversation/test_conformance.sh index 083d9eda7..6ec105204 100644 --- a/tests/config/conversation/test_conformance.sh +++ b/tests/config/conversation/test_conformance.sh @@ -21,6 +21,9 @@ echo " ./test_conformance.sh" echo "" echo "Option 2: Set environment variables directly:" echo " export OPENAI_API_KEY=\"your_openai_api_key\"" +echo " export AZURE_OPENAI_API_KEY=\"your_azureopenai_api_key\"" +echo " export AZURE_OPENAI_ENDPOINT=\"your_azureopenai_endpoint\"" +echo " export AZURE_OPENAI_API_VERSION=\"your_azureopenai_api_version\"" echo " export ANTHROPIC_API_KEY=\"your_anthropic_api_key\"" echo " export GOOGLE_AI_API_KEY=\"your_google_ai_api_key\"" echo " export MISTRAL_API_KEY=\"your_mistral_api_key\"" diff --git a/tests/config/conversation/tests.yml b/tests/config/conversation/tests.yml index 1626c06bb..0dbeba0c7 100644 --- a/tests/config/conversation/tests.yml +++ b/tests/config/conversation/tests.yml @@ -2,7 +2,9 @@ componentType: conversation components: - component: echo operations: [] - - component: openai + - component: openai.openai + operations: [] + - component: openai.azure operations: [] - component: anthropic operations: [] diff --git a/tests/conformance/conversation_test.go b/tests/conformance/conversation_test.go index 3d7e1f4a2..5ed08c346 100644 --- a/tests/conformance/conversation_test.go +++ b/tests/conformance/conversation_test.go @@ -73,11 +73,16 @@ func TestConversationConformance(t *testing.T) { // shouldSkipComponent checks if a component test should be skipped due to missing environment variables func shouldSkipComponent(t *testing.T, componentName string) bool { switch componentName { - case "openai": + case "openai.openai": if os.Getenv("OPENAI_API_KEY") == "" { t.Skipf("Skipping OpenAI conformance test: OPENAI_API_KEY environment variable not set") return true } + case "openai.azure": + if os.Getenv("AZURE_OPENAI_API_KEY") == "" || os.Getenv("AZURE_OPENAI_ENDPOINT") == "" || os.Getenv("AZURE_OPENAI_API_TYPE") == "" || os.Getenv("AZURE_OPENAI_API_VERSION") == "" { + t.Skipf("Skipping Azure OpenAI conformance test: AZURE_OPENAI_API_KEY, AZURE_OPENAI_ENDPOINT, AZURE_OPENAI_API_TYPE, and AZURE_OPENAI_API_VERSION environment variables must be set") + return true + } case "anthropic": if os.Getenv("ANTHROPIC_API_KEY") == "" { t.Skipf("Skipping Anthropic conformance test: ANTHROPIC_API_KEY environment variable not set") @@ -117,7 +122,7 @@ func loadConversationComponent(name string) conversation.Conversation { switch name { case "echo": return echo.NewEcho(testLogger) - case "openai": + case "openai.openai", "openai.azure": return openai.NewOpenAI(testLogger) case "anthropic": return anthropic.NewAnthropic(testLogger)