diff --git a/nameresolution/structuredformat/README.md b/nameresolution/structuredformat/README.md new file mode 100644 index 0000000000..b4244dfade --- /dev/null +++ b/nameresolution/structuredformat/README.md @@ -0,0 +1,76 @@ +# Structured Format Name Resolution + +The Structured Format name resolver provides a flexible way to define services and their instances in a structured format via JSON or YAML configuration files, suitable for scenarios where explicit declaration of service topology is required. + +## Configuration Format + +To use the Structured Format name resolver, create a configuration in your Dapr environment: + +```yaml +apiVersion: dapr.io/v1alpha1 +kind: Configuration +metadata: + name: appconfig +spec: + nameResolution: + component: "structuredformat" + configuration: + structuredType: "jsonString" + stringValue: '{"appInstances":{"myapp":[{"domain":"","ipv4":"127.0.0.1","ipv6":"","port":4433,"extendedInfo":{"hello":"world"}}]}}' +``` + +## Configuration Fields + +| Field | Required | Details | Example | +|---------|----------|---------|---------| +| structuredType | Y | Structured type: jsonString, yamlString, jsonFile, yamlFile. | jsonString | +| stringValue | N | This field must be configured when structuredType is set to jsonString or yamlString. | {"appInstances":{"myapp":[{"domain":"","ipv4":"127.0.0.1","ipv6":"","port":4433,"extendedInfo":{"hello":"world"}}]}} | +| filePath | N | This field must be configured when structuredType is set to jsonFile or yamlFile. | /path/to/yamlfile.yaml | + + +## Examples + +```yaml +apiVersion: dapr.io/v1alpha1 +kind: Configuration +metadata: + name: appconfig +spec: + nameResolution: + component: "structuredformat" + configuration: + structuredType: "jsonString" + stringValue: '{"appInstances":{"myapp":[{"domain":"","ipv4":"127.0.0.1","ipv6":"","port":4433,"extendedInfo":{"hello":"world"}}]}}' +``` + +```yaml +apiVersion: dapr.io/v1alpha1 +kind: Configuration +metadata: + name: appconfig +spec: + nameResolution: + component: "structuredformat" + configuration: + structuredType: "yamlString" + stringValue: | + appInstances: + myapp: + - domain: "" + ipv4: "127.0.0.1" + ipv6: "" + port: 4433 + extendedInfo: + hello: world +``` + +- Service ID "myapp" → "127.0.0.1:4433" + + +## Notes + +- Empty service IDs are not allowed and will result in an error +- Accessing a non-existent service will also result in an error +- The structured format string must be provided in the configuration +- The program selects the first available address according to the priority order: domain → IPv4 → IPv6, and appends the port to form the final target address + diff --git a/nameresolution/structuredformat/structuredformat.go b/nameresolution/structuredformat/structuredformat.go new file mode 100644 index 0000000000..282ef0f265 --- /dev/null +++ b/nameresolution/structuredformat/structuredformat.go @@ -0,0 +1,197 @@ +/* +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 structuredformat + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "math/rand" + "net" + "os" + "reflect" + "strconv" + "strings" + + yaml "gopkg.in/yaml.v3" + + "github.com/dapr/components-contrib/metadata" + nr "github.com/dapr/components-contrib/nameresolution" + "github.com/dapr/kit/logger" + kitmd "github.com/dapr/kit/metadata" +) + +const ( + JSONStringStructuredValue = "jsonString" + YAMLStringStructuredValue = "yamlString" + JSONFileStructuredValue = "jsonFile" + YAMLFileStructuredValue = "yamlFile" +) + +var allowedStructuredTypes = []string{ + JSONStringStructuredValue, + YAMLStringStructuredValue, JSONFileStructuredValue, YAMLFileStructuredValue, +} + +// StructuredFormatResolver parses service names from a structured string +// defined in the configuration. +type StructuredFormatResolver struct { + meta structuredFormatMetadata + instances appInstances + + logger logger.Logger +} + +// structuredFormatMetadata represents the structured string (such as JSON or YAML) +// provided in the configuration for name resolution. +type structuredFormatMetadata struct { + StructuredType string + StringValue string + FilePath string +} + +// appInstances stores the relationship between services and their instances. +type appInstances struct { + AppInstances map[string][]address `json:"appInstances" yaml:"appInstances"` +} + +// address contains service instance information, including Domain, IPv4, IPv6, Port, +// +// and ExtendedInfo. +type address struct { + Domain string `json:"domain" yaml:"domain"` + IPV4 string `json:"ipv4" yaml:"ipv4"` + IPV6 string `json:"ipv6" yaml:"ipv6"` + Port int `json:"port" yaml:"port"` + ExtendedInfo map[string]string `json:"extendedInfo" yaml:"extendedInfo"` +} + +// NewResolver creates a new Structured Format resolver. +func NewResolver(logger logger.Logger) nr.Resolver { + return &StructuredFormatResolver{ + logger: logger, + } +} + +// Init initializes the structured format resolver with the given metadata. +func (r *StructuredFormatResolver) Init(ctx context.Context, metadata nr.Metadata) error { + var meta structuredFormatMetadata + err := kitmd.DecodeMetadata(metadata.Configuration, &meta) + if err != nil { + return fmt.Errorf("failed to decode metadata: %w", err) + } + + switch meta.StructuredType { + case JSONStringStructuredValue, YAMLStringStructuredValue: + if meta.StringValue == "" { + return fmt.Errorf("structuredType = %s, stringValue must be not empty", meta.StructuredType) + } + case JSONFileStructuredValue, YAMLFileStructuredValue: + if meta.FilePath == "" { + return fmt.Errorf("structuredType = %s, filePath must be not empty", meta.StructuredType) + } + default: + return fmt.Errorf("structuredType must be one of: %s", + strings.Join(allowedStructuredTypes, ", ")) + } + + r.meta = meta + + instances, err := loadStructuredFormatData(r) + if err != nil { + return err + } + r.instances = instances + + return nil +} + +// ResolveID resolves a service ID to an address using the configured value. +func (r *StructuredFormatResolver) ResolveID(ctx context.Context, req nr.ResolveRequest) (string, error) { + if req.ID == "" { + return "", errors.New("empty ID not allowed") + } + + if addresses, exists := r.instances.AppInstances[req.ID]; exists && len(addresses) > 0 { + // gosec is complaining that we are using a non-crypto-safe PRNG. This is fine in this scenario since we are using it only for selecting a random address for load-balancing. + //nolint:gosec + address := addresses[rand.Int()%len(addresses)] + + net.JoinHostPort(address.Domain, strconv.Itoa(address.Port)) + if address.Domain != "" { + return net.JoinHostPort(address.Domain, strconv.Itoa(address.Port)), nil + } else if address.IPV4 != "" { + return net.JoinHostPort(address.IPV4, strconv.Itoa(address.Port)), nil + } else if address.IPV6 != "" { + return net.JoinHostPort(address.IPV6, strconv.Itoa(address.Port)), nil + } + } + + return "", fmt.Errorf("no services found with AppID '%s'", req.ID) +} + +// Close implements io.Closer +func (r *StructuredFormatResolver) Close() error { + return nil +} + +// GetComponentMetadata returns the metadata information for the component. +func (r *StructuredFormatResolver) GetComponentMetadata() metadata.MetadataMap { + metadataInfo := metadata.MetadataMap{} + metadata.GetMetadataInfoFromStructType(reflect.TypeOf(structuredFormatMetadata{}), + &metadataInfo, metadata.NameResolutionType) + return metadataInfo +} + +// loadStructuredFormatData loads the mapping between services and their instances from a configuration file. +func loadStructuredFormatData(r *StructuredFormatResolver) (appInstances, error) { + var instances appInstances + switch r.meta.StructuredType { + case JSONStringStructuredValue: + err := json.Unmarshal([]byte(r.meta.StringValue), &instances) + if err != nil { + return instances, err + } + case YAMLStringStructuredValue: + err := yaml.Unmarshal([]byte(r.meta.StringValue), &instances) + if err != nil { + return instances, err + } + case JSONFileStructuredValue: + data, err := os.ReadFile(r.meta.FilePath) + if err != nil { + return instances, fmt.Errorf("error reading file: %s", err) + } + + err = json.Unmarshal(data, &instances) + if err != nil { + return instances, err + } + case YAMLFileStructuredValue: + data, err := os.ReadFile(r.meta.FilePath) + if err != nil { + return instances, fmt.Errorf("error reading file: %s", err) + } + + err = yaml.Unmarshal(data, &instances) + if err != nil { + return instances, err + } + default: + return instances, fmt.Errorf("structuredType must be one of: %s", + strings.Join(allowedStructuredTypes, ", ")) + } + return instances, nil +} diff --git a/nameresolution/structuredformat/structuredformat_test.go b/nameresolution/structuredformat/structuredformat_test.go new file mode 100644 index 0000000000..b859e1bc8f --- /dev/null +++ b/nameresolution/structuredformat/structuredformat_test.go @@ -0,0 +1,213 @@ +/* +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 structuredformat + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + nr "github.com/dapr/components-contrib/nameresolution" + "github.com/dapr/kit/logger" +) + +var ( + jsonValue = `{ + "appInstances": { + "myapp": [ + { + "domain": "github.com", + "ipv4": "", + "ipv6": "", + "port": 443, + "extendedInfo": { + "hello": "world" + } + } + ] + } +}` + + jsonValueIPV6 = `{ + "appInstances": { + "myapp": [ + { + "domain": "", + "ipv4": "", + "ipv6": "::1", + "port": 443, + "extendedInfo": { + "hello": "world" + } + } + ] + } + }` + + yamlValue = `appInstances: + myapp: + - domain: '' + ipv4: '127.127.127.127' + ipv6: '' + port: 443 + extendedInfo: + hello: world` +) + +func TestInit(t *testing.T) { + tests := []struct { + name string + metadata nr.Metadata + expectedError string + }{ + { + name: "valid metadata with json string format", + metadata: nr.Metadata{ + Configuration: map[string]string{ + "structuredType": "jsonString", + "stringValue": jsonValue, + }, + }, + }, + { + name: "valid metadata with json string format ipv6", + metadata: nr.Metadata{ + Configuration: map[string]string{ + "structuredType": "jsonString", + "stringValue": jsonValueIPV6, + }, + }, + }, + { + name: "valid metadata with yaml string format", + metadata: nr.Metadata{ + Configuration: map[string]string{ + "structuredType": "yamlString", + "stringValue": yamlValue, + }, + }, + }, + { + name: "invalid structuredType", + metadata: nr.Metadata{ + Configuration: map[string]string{ + "structuredType": "invalidType", + "stringValue": yamlValue, + }, + }, + expectedError: "structuredType must be one of: jsonString, yamlString, jsonFile, yamlFile", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := NewResolver(logger.NewLogger("test")) + err := r.Init(t.Context(), tt.metadata) + + if tt.expectedError != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.expectedError) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestResolveID(t *testing.T) { + tests := []struct { + name string + structuredType string + stringValue string + request nr.ResolveRequest + expectedResult string + expectedError string + }{ + { + name: "valid app name with json string format", + structuredType: "jsonString", + stringValue: jsonValue, + request: nr.ResolveRequest{ + ID: "myapp", + }, + expectedResult: "github.com:443", + }, + { + name: "valid app name with json string format ipv6", + structuredType: "jsonString", + stringValue: jsonValueIPV6, + request: nr.ResolveRequest{ + ID: "myapp", + }, + expectedResult: "[::1]:443", + }, + { + name: "valid app name with yaml string format", + structuredType: "yamlString", + stringValue: yamlValue, + request: nr.ResolveRequest{ + ID: "myapp", + }, + expectedResult: "127.127.127.127:443", + }, + { + name: "Verify non-existent app_id", + structuredType: "yamlString", + stringValue: yamlValue, + request: nr.ResolveRequest{ + ID: "non-existentAppID", + }, + expectedError: "no services found with AppID 'non-existentAppID'", + }, + { + name: "empty app name", + structuredType: "yamlString", + stringValue: yamlValue, + request: nr.ResolveRequest{ + ID: "", + }, + expectedError: "empty ID not allowed", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := NewResolver(logger.NewLogger("test")) + err := r.Init(t.Context(), nr.Metadata{ + Configuration: map[string]string{ + "structuredType": tt.structuredType, + "stringValue": tt.stringValue, + }, + }) + require.NoError(t, err) + + result, err := r.ResolveID(t.Context(), tt.request) + + if tt.expectedError != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.expectedError) + } else { + require.NoError(t, err) + assert.Equal(t, tt.expectedResult, result) + } + }) + } +} + +func TestClose(t *testing.T) { + r := NewResolver(logger.NewLogger("test")) + err := r.Close() + require.NoError(t, err) +}