add config api (#1113)

* add config api

* fix: add ut

* fix: linter

* fix

* fix: some pr comments

* fix: go mod tidy

* fix: ut

* Update redis_value.go

* Update redis_value_test.go

* Update redis_test.go

* Update requests.go

* Update responses.go

* Update redis_value.go

* Update redis_value_test.go

Co-authored-by: Yaron Schneider <yaronsc@microsoft.com>
Co-authored-by: Artur Souza <artursouza.ms@outlook.com>
Co-authored-by: Long Dai <long.dai@intel.com>
This commit is contained in:
Laurence 2021-10-20 04:55:38 +08:00 committed by GitHub
parent b7d9c0c5e3
commit a21897bc81
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 791 additions and 0 deletions

11
configuration/metadata.go Normal file
View File

@ -0,0 +1,11 @@
// ------------------------------------------------------------
// Copyright (c) Microsoft Corporation and Dapr Contributors.
// Licensed under the MIT License.
// ------------------------------------------------------------
package configuration
// Metadata contains a configuration store specific set of metadata property.
type Metadata struct {
Properties map[string]string `json:"properties"`
}

View File

@ -0,0 +1,36 @@
// ------------------------------------------------------------
// Copyright (c) Microsoft Corporation and Dapr Contributors.
// Licensed under the MIT License.
// ------------------------------------------------------------
package internal
import (
"fmt"
"strings"
)
const (
channelPrefix = "__keyspace@0__:"
separator = "||"
)
func GetRedisValueAndVersion(redisValue string) (string, string) {
valueAndRevision := strings.Split(redisValue, separator)
if len(valueAndRevision) == 0 {
return "", ""
}
if len(valueAndRevision) == 1 {
return valueAndRevision[0], ""
}
return valueAndRevision[0], valueAndRevision[1]
}
func ParseRedisKeyFromEvent(eventChannel string) (string, error) {
index := strings.Index(eventChannel, channelPrefix)
if index == -1 {
return "", fmt.Errorf("wrong format of event channel, it should start with '%s': eventChannel=%s", channelPrefix, eventChannel)
}
return eventChannel[len(channelPrefix):], nil
}

View File

@ -0,0 +1,104 @@
// ------------------------------------------------------------
// Copyright (c) Microsoft Corporation and Dapr Contributors.
// Licensed under the MIT License.
// ------------------------------------------------------------
package internal
import "testing"
func TestGetRedisValueAndVersion(t *testing.T) {
type args struct {
redisValue string
}
tests := []struct {
name string
args args
want string
want1 string
}{
{
name: "empty value",
args: args{
redisValue: "",
},
want: "",
want1: "",
},
{
name: "value without version",
args: args{
redisValue: "mockValue",
},
want: "mockValue",
want1: "",
},
{
name: "value without version",
args: args{
redisValue: "mockValue||",
},
want: "mockValue",
want1: "",
},
{
name: "value with version",
args: args{
redisValue: "mockValue||v1.0.0",
},
want: "mockValue",
want1: "v1.0.0",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, got1 := GetRedisValueAndVersion(tt.args.redisValue)
if got != tt.want {
t.Errorf("GetRedisValueAndVersion() got = %v, want %v", got, tt.want)
}
if got1 != tt.want1 {
t.Errorf("GetRedisValueAndVersion() got1 = %v, want %v", got1, tt.want1)
}
})
}
}
func TestParseRedisKeyFromEvent(t *testing.T) {
type args struct {
eventChannel string
}
tests := []struct {
name string
args args
want string
wantErr bool
}{
{
name: "invalid channel name",
args: args{
eventChannel: "invalie channel name",
},
want: "",
wantErr: true,
}, {
name: "valid channel name",
args: args{
eventChannel: channelPrefix + "key",
},
want: "key",
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseRedisKeyFromEvent(tt.args.eventChannel)
if (err != nil) != tt.wantErr {
t.Errorf("ParseRedisKeyFromEvent() error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("ParseRedisKeyFromEvent() got = %v, want %v", got, tt.want)
}
})
}
}

View File

@ -0,0 +1,18 @@
// ------------------------------------------------------------
// Copyright (c) Microsoft Corporation and Dapr Contributors.
// Licensed under the MIT License.
// ------------------------------------------------------------
package redis
import "time"
type metadata struct {
host string
password string
sentinelMasterName string
maxRetries int
maxRetryBackoff time.Duration
enableTLS bool
failover bool
}

View File

@ -0,0 +1,300 @@
// ------------------------------------------------------------
// Copyright (c) Microsoft Corporation and Dapr Contributors.
// Licensed under the MIT License.
// ------------------------------------------------------------
package redis
import (
"context"
"crypto/tls"
"errors"
"fmt"
"strconv"
"strings"
"time"
"github.com/go-redis/redis/v8"
jsoniter "github.com/json-iterator/go"
"github.com/dapr/components-contrib/configuration"
"github.com/dapr/components-contrib/configuration/redis/internal"
"github.com/dapr/kit/logger"
)
const (
connectedSlavesReplicas = "connected_slaves:"
infoReplicationDelimiter = "\r\n"
host = "redisHost"
password = "redisPassword"
enableTLS = "enableTLS"
maxRetries = "maxRetries"
maxRetryBackoff = "maxRetryBackoff"
failover = "failover"
sentinelMasterName = "sentinelMasterName"
defaultBase = 10
defaultBitSize = 0
defaultDB = 0
defaultMaxRetries = 3
defaultMaxRetryBackoff = time.Second * 2
defaultEnableTLS = false
keySpacePrefix = "__keyspace@0__:"
keySpaceAny = "__keyspace@0__:*"
)
// ConfigurationStore is a Redis configuration store.
type ConfigurationStore struct {
client redis.UniversalClient
json jsoniter.API
metadata metadata
replicas int
logger logger.Logger
}
// NewRedisConfigurationStore returns a new redis state store.
func NewRedisConfigurationStore(logger logger.Logger) configuration.Store {
s := &ConfigurationStore{
json: jsoniter.ConfigFastest,
logger: logger,
}
return s
}
func parseRedisMetadata(meta configuration.Metadata) (metadata, error) {
m := metadata{}
if val, ok := meta.Properties[host]; ok && val != "" {
m.host = val
} else {
return m, errors.New("redis store error: missing host address")
}
if val, ok := meta.Properties[password]; ok && val != "" {
m.password = val
}
m.enableTLS = defaultEnableTLS
if val, ok := meta.Properties[enableTLS]; ok && val != "" {
tls, err := strconv.ParseBool(val)
if err != nil {
return m, fmt.Errorf("redis store error: can't parse enableTLS field: %s", err)
}
m.enableTLS = tls
}
m.maxRetries = defaultMaxRetries
if val, ok := meta.Properties[maxRetries]; ok && val != "" {
parsedVal, err := strconv.ParseInt(val, defaultBase, defaultBitSize)
if err != nil {
return m, fmt.Errorf("redis store error: can't parse maxRetries field: %s", err)
}
m.maxRetries = int(parsedVal)
}
m.maxRetryBackoff = defaultMaxRetryBackoff
if val, ok := meta.Properties[maxRetryBackoff]; ok && val != "" {
parsedVal, err := strconv.ParseInt(val, defaultBase, defaultBitSize)
if err != nil {
return m, fmt.Errorf("redis store error: can't parse maxRetryBackoff field: %s", err)
}
m.maxRetryBackoff = time.Duration(parsedVal)
}
if val, ok := meta.Properties[failover]; ok && val != "" {
failover, err := strconv.ParseBool(val)
if err != nil {
return m, fmt.Errorf("redis store error: can't parse failover field: %s", err)
}
m.failover = failover
}
// set the sentinelMasterName only with failover == true.
if m.failover {
if val, ok := meta.Properties[sentinelMasterName]; ok && val != "" {
m.sentinelMasterName = val
} else {
return m, errors.New("redis store error: missing sentinelMasterName")
}
}
return m, nil
}
// Init does metadata and connection parsing.
func (r *ConfigurationStore) Init(metadata configuration.Metadata) error {
m, err := parseRedisMetadata(metadata)
if err != nil {
return err
}
r.metadata = m
if r.metadata.failover {
r.client = r.newFailoverClient(m)
} else {
r.client = r.newClient(m)
}
if _, err = r.client.Ping(context.TODO()).Result(); err != nil {
return fmt.Errorf("redis store: error connecting to redis at %s: %s", m.host, err)
}
r.replicas, err = r.getConnectedSlaves()
return err
}
func (r *ConfigurationStore) newClient(m metadata) *redis.Client {
opts := &redis.Options{
Addr: m.host,
Password: m.password,
DB: defaultDB,
MaxRetries: m.maxRetries,
MaxRetryBackoff: m.maxRetryBackoff,
}
// tell the linter to skip a check here.
/* #nosec */
if m.enableTLS {
opts.TLSConfig = &tls.Config{
InsecureSkipVerify: m.enableTLS,
}
}
return redis.NewClient(opts)
}
func (r *ConfigurationStore) newFailoverClient(m metadata) *redis.Client {
opts := &redis.FailoverOptions{
MasterName: r.metadata.sentinelMasterName,
SentinelAddrs: []string{r.metadata.host},
DB: defaultDB,
MaxRetries: m.maxRetries,
MaxRetryBackoff: m.maxRetryBackoff,
}
/* #nosec */
if m.enableTLS {
opts.TLSConfig = &tls.Config{
InsecureSkipVerify: m.enableTLS,
}
}
return redis.NewFailoverClient(opts)
}
func (r *ConfigurationStore) getConnectedSlaves() (int, error) {
res, err := r.client.Do(context.Background(), "INFO", "replication").Result()
if err != nil {
return 0, err
}
// Response example: https://redis.io/commands/info#return-value
// # Replication\r\nrole:master\r\nconnected_slaves:1\r\n
s, _ := strconv.Unquote(fmt.Sprintf("%q", res))
if len(s) == 0 {
return 0, nil
}
return r.parseConnectedSlaves(s), nil
}
func (r *ConfigurationStore) parseConnectedSlaves(res string) int {
infos := strings.Split(res, infoReplicationDelimiter)
for _, info := range infos {
if strings.Contains(info, connectedSlavesReplicas) {
parsedReplicas, _ := strconv.ParseUint(info[len(connectedSlavesReplicas):], 10, 32)
return int(parsedReplicas)
}
}
return 0
}
func (r *ConfigurationStore) Get(ctx context.Context, req *configuration.GetRequest) (*configuration.GetResponse, error) {
keys := req.Keys
var err error
if len(keys) == 0 {
if keys, err = r.client.Keys(ctx, "*").Result(); err != nil {
r.logger.Errorf("failed to all keys, error is %s", err)
}
}
items := make([]*configuration.Item, 0, 16)
// query by keys
for _, redisKey := range keys {
item := &configuration.Item{
Metadata: map[string]string{},
}
redisValue, err := r.client.Get(ctx, redisKey).Result()
if err != nil {
return &configuration.GetResponse{}, fmt.Errorf("fail to get configuration for redis key=%s, error is %s", redisKey, err)
}
val, version := internal.GetRedisValueAndVersion(redisValue)
item.Key = redisKey
item.Version = version
item.Value = val
if item.Value != "" {
items = append(items, item)
}
}
return &configuration.GetResponse{
Items: items,
}, nil
}
func (r *ConfigurationStore) Subscribe(ctx context.Context, req *configuration.SubscribeRequest, handler configuration.UpdateHandler) error {
if len(req.Keys) == 0 {
go r.doSubscribe(ctx, req, handler, keySpaceAny)
return nil
}
for _, k := range req.Keys {
go r.doSubscribe(ctx, req, handler, keySpacePrefix+k)
}
return nil
}
func (r *ConfigurationStore) doSubscribe(ctx context.Context, req *configuration.SubscribeRequest, handler configuration.UpdateHandler, redisChannel4revision string) {
// enable notify-keyspace-events by redis Set command
r.client.ConfigSet(ctx, "notify-keyspace-events", "KA")
p := r.client.Subscribe(ctx, redisChannel4revision)
for msg := range p.Channel() {
r.handleSubscribedChange(ctx, req, handler, msg)
}
}
func (r *ConfigurationStore) handleSubscribedChange(ctx context.Context, req *configuration.SubscribeRequest, handler configuration.UpdateHandler, msg *redis.Message) {
defer func() {
if err := recover(); err != nil {
r.logger.Errorf("panic in handleSubscribedChange(method and recovered: %s", err)
}
}()
targetKey, err := internal.ParseRedisKeyFromEvent(msg.Channel)
if err != nil {
r.logger.Errorf("parse redis key failed: %s", err)
return
}
// get all keys if only one is changed
getResponse, err := r.Get(ctx, &configuration.GetRequest{
Metadata: req.Metadata,
Keys: []string{targetKey},
})
if err != nil {
r.logger.Errorf("get response from redis failed: %s", err)
return
}
e := &configuration.UpdateEvent{
Items: getResponse.Items,
}
err = handler(ctx, e)
if err != nil {
r.logger.Errorf("fail to call handler to notify event for configuration update subscribe: %s", err)
}
}

View File

@ -0,0 +1,257 @@
// ------------------------------------------------------------
// Copyright (c) Microsoft Corporation and Dapr Contributors.
// Licensed under the MIT License.
// ------------------------------------------------------------
package redis
import (
"context"
"reflect"
"testing"
"time"
"github.com/alicebob/miniredis/v2"
"github.com/go-redis/redis/v8"
jsoniter "github.com/json-iterator/go"
"github.com/stretchr/testify/assert"
"github.com/dapr/components-contrib/configuration"
"github.com/dapr/kit/logger"
)
func TestConfigurationStore_Get(t *testing.T) {
s, c := setupMiniredis()
defer s.Close()
assert.Nil(t, s.Set("testKey", "testValue"))
assert.Nil(t, s.Set("testKey2", "testValue2"))
type fields struct {
client *redis.Client
json jsoniter.API
metadata metadata
replicas int
logger logger.Logger
}
type args struct {
ctx context.Context
req *configuration.GetRequest
}
tests := []struct {
name string
fields fields
args args
want *configuration.GetResponse
wantErr bool
}{
{
name: "normal get redis value",
fields: fields{
client: c,
json: jsoniter.ConfigFastest,
logger: logger.NewLogger("test"),
},
args: args{
req: &configuration.GetRequest{
Keys: []string{"testKey"},
},
ctx: context.Background(),
},
want: &configuration.GetResponse{
Items: []*configuration.Item{
{
Key: "testKey",
Value: "testValue",
Metadata: make(map[string]string),
},
},
},
},
{
name: "get with no request key",
fields: fields{
client: c,
json: jsoniter.ConfigFastest,
logger: logger.NewLogger("test"),
},
args: args{
req: &configuration.GetRequest{},
ctx: context.Background(),
},
want: &configuration.GetResponse{
Items: []*configuration.Item{
{
Key: "testKey",
Value: "testValue",
Metadata: make(map[string]string),
}, {
Key: "testKey2",
Value: "testValue2",
Metadata: make(map[string]string),
},
},
},
},
{
name: "get with not exists key",
fields: fields{
client: c,
json: jsoniter.ConfigFastest,
logger: logger.NewLogger("test"),
},
args: args{
req: &configuration.GetRequest{
Keys: []string{"notExistKey"},
},
ctx: context.Background(),
},
want: &configuration.GetResponse{
Items: []*configuration.Item{},
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := &ConfigurationStore{
client: tt.fields.client,
json: tt.fields.json,
metadata: tt.fields.metadata,
replicas: tt.fields.replicas,
logger: tt.fields.logger,
}
got, err := r.Get(tt.args.ctx, tt.args.req)
if (err != nil) != tt.wantErr {
t.Errorf("Get() error = %v, wantErr %v", err, tt.wantErr)
return
}
if got == nil {
t.Errorf("Get() got configuration response is nil")
return
}
if len(got.Items) != len(tt.want.Items) {
t.Errorf("Get() got len = %v, want len = %v", len(got.Items), len(tt.want.Items))
return
}
if len(got.Items) == 0 {
return
}
for k := range got.Items {
assert.Equal(t, tt.want.Items[k], got.Items[k])
}
})
}
}
func TestParseConnectedSlaves(t *testing.T) {
store := &ConfigurationStore{logger: logger.NewLogger("test")}
t.Run("Empty info", func(t *testing.T) {
slaves := store.parseConnectedSlaves("")
assert.Equal(t, 0, slaves, "connected slaves must be 0")
})
t.Run("connectedSlaves property is not included", func(t *testing.T) {
slaves := store.parseConnectedSlaves("# Replication\r\nrole:master\r\n")
assert.Equal(t, 0, slaves, "connected slaves must be 0")
})
t.Run("connectedSlaves is 2", func(t *testing.T) {
slaves := store.parseConnectedSlaves("# Replication\r\nrole:master\r\nconnected_slaves:2\r\n")
assert.Equal(t, 2, slaves, "connected slaves must be 2")
})
t.Run("connectedSlaves is 1", func(t *testing.T) {
slaves := store.parseConnectedSlaves("# Replication\r\nrole:master\r\nconnected_slaves:1")
assert.Equal(t, 1, slaves, "connected slaves must be 1")
})
}
func TestNewRedisConfigurationStore(t *testing.T) {
type args struct {
logger logger.Logger
}
tests := []struct {
name string
args args
want configuration.Store
}{
{
args: args{
logger: logger.NewLogger("test"),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := NewRedisConfigurationStore(tt.args.logger)
assert.NotNil(t, got)
})
}
}
func Test_parseRedisMetadata(t *testing.T) {
type args struct {
meta configuration.Metadata
}
testProperties := make(map[string]string)
testProperties[host] = "testHost"
testProperties[password] = "testPassword"
testProperties[enableTLS] = "true"
testProperties[maxRetries] = "10"
testProperties[maxRetryBackoff] = "1000000000"
testProperties[failover] = "true"
testProperties[sentinelMasterName] = "tesSentinelMasterName"
tests := []struct {
name string
args args
want metadata
wantErr bool
}{
{
args: args{
meta: configuration.Metadata{
Properties: testProperties,
},
},
want: metadata{
host: "testHost",
password: "testPassword",
enableTLS: true,
maxRetries: 10,
maxRetryBackoff: time.Second,
failover: true,
sentinelMasterName: "tesSentinelMasterName",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parseRedisMetadata(tt.args.meta)
if (err != nil) != tt.wantErr {
t.Errorf("parseRedisMetadata() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("parseRedisMetadata() got = %v, want %v", got, tt.want)
}
})
}
}
func setupMiniredis() (*miniredis.Miniredis, *redis.Client) {
s, err := miniredis.Run()
if err != nil {
panic(err)
}
opts := &redis.Options{
Addr: s.Addr(),
DB: defaultDB,
}
return s, redis.NewClient(opts)
}

31
configuration/requests.go Normal file
View File

@ -0,0 +1,31 @@
// ------------------------------------------------------------
// Copyright (c) Microsoft Corporation and Dapr Contributors.
// Licensed under the MIT License.
// ------------------------------------------------------------
package configuration
// ConfigurationItem represents a configuration item with name, content and other information.
type Item struct {
Key string `json:"key"`
Value string `json:"value,omitempty"`
Version string `json:"version,omitempty"`
Metadata map[string]string `json:"metadata,omitempty"`
}
// GetRequest is the object describing a request to get configuration.
type GetRequest struct {
Keys []string `json:"keys"`
Metadata map[string]string `json:"metadata"`
}
// SubscribeRequest is the object describing a request to subscribe configuration.
type SubscribeRequest struct {
Keys []string `json:"keys"`
Metadata map[string]string `json:"metadata"`
}
// UpdateEvent is the object describing a configuration update event.
type UpdateEvent struct {
Items []*Item `json:"items"`
}

View File

@ -0,0 +1,11 @@
// ------------------------------------------------------------
// Copyright (c) Microsoft Corporation and Dapr Contributors.
// Licensed under the MIT License.
// ------------------------------------------------------------
package configuration
// GetResponse is the request object for getting configuration.
type GetResponse struct {
Items []*Item `json:"items"`
}

23
configuration/store.go Normal file
View File

@ -0,0 +1,23 @@
// ------------------------------------------------------------
// Copyright (c) Microsoft Corporation and Dapr Contributors.
// Licensed under the MIT License.
// ------------------------------------------------------------
package configuration
import "context"
// Store is an interface to perform operations on store.
type Store interface {
// Init configuration store.
Init(metadata Metadata) error
// Get configuration.
Get(ctx context.Context, req *GetRequest) (*GetResponse, error)
// Subscribe configuration by update event.
Subscribe(ctx context.Context, req *SubscribeRequest, handler UpdateHandler) error
}
// UpdateHandler is the handler used to send event to daprd.
type UpdateHandler func(ctx context.Context, e *UpdateEvent) error