From aa23b2df0a937cd61065213bf4caa86ec3ad4bf6 Mon Sep 17 00:00:00 2001 From: seal90 <578935869@qq.com> Date: Sat, 1 Nov 2025 09:22:39 +0000 Subject: [PATCH 1/3] Add a new name resolution method: structuredformat. Signed-off-by: seal90 <578935869@qq.com> --- nameresolution/structuredformat/README.md | 76 +++++++ .../structuredformat/structuredformat.go | 192 ++++++++++++++++ .../structuredformat/structuredformat_test.go | 214 ++++++++++++++++++ 3 files changed, 482 insertions(+) create mode 100644 nameresolution/structuredformat/README.md create mode 100644 nameresolution/structuredformat/structuredformat.go create mode 100644 nameresolution/structuredformat/structuredformat_test.go 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..0e4db46d1d --- /dev/null +++ b/nameresolution/structuredformat/structuredformat.go @@ -0,0 +1,192 @@ +/* +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" + + "github.com/dapr/components-contrib/metadata" + nr "github.com/dapr/components-contrib/nameresolution" + "github.com/dapr/kit/logger" + kitmd "github.com/dapr/kit/metadata" + yaml "gopkg.in/yaml.v3" +) + +const ( + JSON_STRING_STRUCTURED_VALUE = "jsonString" + YAML_STRING_STRUCTURED_VALUE = "yamlString" + JSON_FILE_STRUCTURED_VALUE = "jsonFile" + YAML_FILE_STRUCTURED_VALUE = "yamlFile" +) + +var allowedStructuredTypes = []string{JSON_STRING_STRUCTURED_VALUE, + YAML_STRING_STRUCTURED_VALUE, JSON_FILE_STRUCTURED_VALUE, YAML_FILE_STRUCTURED_VALUE} + +// StructuredFormatResolver parses service names from a structured string +// defined in the configuration. +type StructuredFormatResolver struct { + meta structuredFormatMetadata + appAddress appAddress + + 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 +} + +// appAddress stores the relationship between services and their instances. +type appAddress 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 JSON_STRING_STRUCTURED_VALUE, YAML_STRING_STRUCTURED_VALUE: + if meta.StringValue == "" { + return fmt.Errorf("structuredType = %s, stringValue must be not empty", meta.StructuredType) + } + case JSON_FILE_STRUCTURED_VALUE, YAML_FILE_STRUCTURED_VALUE: + 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 + + appAddress, err := loadStructuredFormatData(r) + if err != nil { + return err + } + r.appAddress = appAddress + + 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.appAddress.AppInstances[req.ID]; exists && len(addresses) > 0 { + 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) (appAddress, error) { + var appAddress appAddress + switch r.meta.StructuredType { + case JSON_STRING_STRUCTURED_VALUE: + err := json.Unmarshal([]byte(r.meta.StringValue), &appAddress) + if err != nil { + return appAddress, err + } + case YAML_STRING_STRUCTURED_VALUE: + err := yaml.Unmarshal([]byte(r.meta.StringValue), &appAddress) + if err != nil { + return appAddress, err + } + case JSON_FILE_STRUCTURED_VALUE: + data, err := os.ReadFile(r.meta.FilePath) + if err != nil { + return appAddress, fmt.Errorf("error reading file: %s", err) + } + + err = json.Unmarshal(data, &appAddress) + if err != nil { + return appAddress, err + } + case YAML_FILE_STRUCTURED_VALUE: + data, err := os.ReadFile(r.meta.FilePath) + if err != nil { + return appAddress, fmt.Errorf("error reading file: %s", err) + } + + err = yaml.Unmarshal(data, &appAddress) + if err != nil { + return appAddress, err + } + default: + return appAddress, fmt.Errorf("structuredType must be one of: %s", + strings.Join(allowedStructuredTypes, ", ")) + } + return appAddress, nil +} diff --git a/nameresolution/structuredformat/structuredformat_test.go b/nameresolution/structuredformat/structuredformat_test.go new file mode 100644 index 0000000000..a3ee02daf1 --- /dev/null +++ b/nameresolution/structuredformat/structuredformat_test.go @@ -0,0 +1,214 @@ +/* +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) +} From 1b7b3435f1351293ef005f35318c937e02197087 Mon Sep 17 00:00:00 2001 From: seal90 <578935869@qq.com> Date: Wed, 5 Nov 2025 13:21:17 +0000 Subject: [PATCH 2/3] fix checkstyle error Signed-off-by: seal90 <578935869@qq.com> --- .../structuredformat/structuredformat.go | 73 ++++++++++--------- .../structuredformat/structuredformat_test.go | 1 - 2 files changed, 39 insertions(+), 35 deletions(-) diff --git a/nameresolution/structuredformat/structuredformat.go b/nameresolution/structuredformat/structuredformat.go index 0e4db46d1d..282ef0f265 100644 --- a/nameresolution/structuredformat/structuredformat.go +++ b/nameresolution/structuredformat/structuredformat.go @@ -25,28 +25,31 @@ import ( "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" - yaml "gopkg.in/yaml.v3" ) const ( - JSON_STRING_STRUCTURED_VALUE = "jsonString" - YAML_STRING_STRUCTURED_VALUE = "yamlString" - JSON_FILE_STRUCTURED_VALUE = "jsonFile" - YAML_FILE_STRUCTURED_VALUE = "yamlFile" + JSONStringStructuredValue = "jsonString" + YAMLStringStructuredValue = "yamlString" + JSONFileStructuredValue = "jsonFile" + YAMLFileStructuredValue = "yamlFile" ) -var allowedStructuredTypes = []string{JSON_STRING_STRUCTURED_VALUE, - YAML_STRING_STRUCTURED_VALUE, JSON_FILE_STRUCTURED_VALUE, YAML_FILE_STRUCTURED_VALUE} +var allowedStructuredTypes = []string{ + JSONStringStructuredValue, + YAMLStringStructuredValue, JSONFileStructuredValue, YAMLFileStructuredValue, +} // StructuredFormatResolver parses service names from a structured string // defined in the configuration. type StructuredFormatResolver struct { - meta structuredFormatMetadata - appAddress appAddress + meta structuredFormatMetadata + instances appInstances logger logger.Logger } @@ -59,8 +62,8 @@ type structuredFormatMetadata struct { FilePath string } -// appAddress stores the relationship between services and their instances. -type appAddress struct { +// appInstances stores the relationship between services and their instances. +type appInstances struct { AppInstances map[string][]address `json:"appInstances" yaml:"appInstances"` } @@ -91,11 +94,11 @@ func (r *StructuredFormatResolver) Init(ctx context.Context, metadata nr.Metadat } switch meta.StructuredType { - case JSON_STRING_STRUCTURED_VALUE, YAML_STRING_STRUCTURED_VALUE: + case JSONStringStructuredValue, YAMLStringStructuredValue: if meta.StringValue == "" { return fmt.Errorf("structuredType = %s, stringValue must be not empty", meta.StructuredType) } - case JSON_FILE_STRUCTURED_VALUE, YAML_FILE_STRUCTURED_VALUE: + case JSONFileStructuredValue, YAMLFileStructuredValue: if meta.FilePath == "" { return fmt.Errorf("structuredType = %s, filePath must be not empty", meta.StructuredType) } @@ -106,11 +109,11 @@ func (r *StructuredFormatResolver) Init(ctx context.Context, metadata nr.Metadat r.meta = meta - appAddress, err := loadStructuredFormatData(r) + instances, err := loadStructuredFormatData(r) if err != nil { return err } - r.appAddress = appAddress + r.instances = instances return nil } @@ -121,7 +124,9 @@ func (r *StructuredFormatResolver) ResolveID(ctx context.Context, req nr.Resolve return "", errors.New("empty ID not allowed") } - if addresses, exists := r.appAddress.AppInstances[req.ID]; exists && len(addresses) > 0 { + 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)) @@ -151,42 +156,42 @@ func (r *StructuredFormatResolver) GetComponentMetadata() metadata.MetadataMap { } // loadStructuredFormatData loads the mapping between services and their instances from a configuration file. -func loadStructuredFormatData(r *StructuredFormatResolver) (appAddress, error) { - var appAddress appAddress +func loadStructuredFormatData(r *StructuredFormatResolver) (appInstances, error) { + var instances appInstances switch r.meta.StructuredType { - case JSON_STRING_STRUCTURED_VALUE: - err := json.Unmarshal([]byte(r.meta.StringValue), &appAddress) + case JSONStringStructuredValue: + err := json.Unmarshal([]byte(r.meta.StringValue), &instances) if err != nil { - return appAddress, err + return instances, err } - case YAML_STRING_STRUCTURED_VALUE: - err := yaml.Unmarshal([]byte(r.meta.StringValue), &appAddress) + case YAMLStringStructuredValue: + err := yaml.Unmarshal([]byte(r.meta.StringValue), &instances) if err != nil { - return appAddress, err + return instances, err } - case JSON_FILE_STRUCTURED_VALUE: + case JSONFileStructuredValue: data, err := os.ReadFile(r.meta.FilePath) if err != nil { - return appAddress, fmt.Errorf("error reading file: %s", err) + return instances, fmt.Errorf("error reading file: %s", err) } - err = json.Unmarshal(data, &appAddress) + err = json.Unmarshal(data, &instances) if err != nil { - return appAddress, err + return instances, err } - case YAML_FILE_STRUCTURED_VALUE: + case YAMLFileStructuredValue: data, err := os.ReadFile(r.meta.FilePath) if err != nil { - return appAddress, fmt.Errorf("error reading file: %s", err) + return instances, fmt.Errorf("error reading file: %s", err) } - err = yaml.Unmarshal(data, &appAddress) + err = yaml.Unmarshal(data, &instances) if err != nil { - return appAddress, err + return instances, err } default: - return appAddress, fmt.Errorf("structuredType must be one of: %s", + return instances, fmt.Errorf("structuredType must be one of: %s", strings.Join(allowedStructuredTypes, ", ")) } - return appAddress, nil + return instances, nil } diff --git a/nameresolution/structuredformat/structuredformat_test.go b/nameresolution/structuredformat/structuredformat_test.go index a3ee02daf1..b859e1bc8f 100644 --- a/nameresolution/structuredformat/structuredformat_test.go +++ b/nameresolution/structuredformat/structuredformat_test.go @@ -67,7 +67,6 @@ var ( ) func TestInit(t *testing.T) { - tests := []struct { name string metadata nr.Metadata From a5fd97b33bb1e5df4beaede40002f660433bff0c Mon Sep 17 00:00:00 2001 From: seal90 <578935869@qq.com> Date: Sat, 13 Dec 2025 03:24:55 +0000 Subject: [PATCH 3/3] optimize structuredformat code and docs Signed-off-by: seal90 <578935869@qq.com> --- nameresolution/structuredformat/README.md | 125 +++++++---- nameresolution/structuredformat/metadata.yaml | 27 +++ .../structuredformat/structuredformat.go | 208 +++++++++++------- .../structuredformat/structuredformat_test.go | 204 +++++++++++------ 4 files changed, 369 insertions(+), 195 deletions(-) create mode 100644 nameresolution/structuredformat/metadata.yaml diff --git a/nameresolution/structuredformat/README.md b/nameresolution/structuredformat/README.md index b4244dfade..5581d1344f 100644 --- a/nameresolution/structuredformat/README.md +++ b/nameresolution/structuredformat/README.md @@ -1,10 +1,14 @@ # 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. +The **Structured Format** name resolver enables you to explicitly define service instances using structured configuration in **JSON or YAML**, either as inline strings or external files. It is designed for scenarios where service topology is **static and known in advance**, such as: + +- Local development and testing +- Integration or end-to-end test environments +- Edge deployments ## Configuration Format -To use the Structured Format name resolver, create a configuration in your Dapr environment: +To enable the resolver, configure it in your Dapr `Configuration` resource: ```yaml apiVersion: dapr.io/v1alpha1 @@ -15,62 +19,89 @@ spec: nameResolution: component: "structuredformat" configuration: - structuredType: "jsonString" - stringValue: '{"appInstances":{"myapp":[{"domain":"","ipv4":"127.0.0.1","ipv6":"","port":4433,"extendedInfo":{"hello":"world"}}]}}' + structuredType: "json" # or "yaml", "jsonFile", "yamlFile" + stringValue: '{"appInstances":{"myapp":[{"ipv4":"127.0.0.1","port":4433}]}}' ``` -## Configuration Fields +## Spec configuration fields + +| Field | Required? | Description | Example | +|------------------|-----------|-----------------------------------------------------------------------------|---------| +| `structuredType` | Yes | Format and source type. Must be one of: `json`, `yaml`, `jsonFile`, `yamlFile` | `json` | +| `stringValue` | Conditional | Required when `structuredType` is `json` or `yaml` | `{"appInstances":{"myapp":[{"ipv4":"127.0.0.1","port":4433}]}}` | +| `filePath` | Conditional | Required when `structuredType` is `jsonFile` or `yamlFile` | `/etc/dapr/services.yaml` | + +> **Important**: Only one of `stringValue` or `filePath` should be provided, based on `structuredType`. + +## `appInstances` Schema + +The configuration must contain a top-level `appInstances` object that maps **service IDs** to **lists of address instances**. + +### Supported Address Fields + +| Field | Type | Required? | Description | +|----------|--------|-----------|-------------| +| `domain` | string | No | Hostname or FQDN (e.g., `"api.example.com"`). Highest priority. | +| `ipv4` | string | No | IPv4 address in dotted-decimal format (e.g., `"192.168.1.10"`). | +| `ipv6` | string | No | Unbracketed IPv6 address (e.g., `"::1"`, `"2001:db8::1"`). | +| `port` | int | **Yes** | TCP port number (**must be 1–65535**). | + +> **Notes**: +> - Service IDs must be non-empty strings. +> - **At least one** of `domain`, `ipv4`, or `ipv6` must be non-empty per instance. +> - Invalid or missing ports will cause initialization to fail. + +## Address Selection Logic -| 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 | +For each instance, the resolver selects the **first non-empty address** in this priority order: +1. `domain` → e.g., `github.com` +2. `ipv4` → e.g., `192.168.1.10` +3. `ipv6` → e.g., `::1` + +The final target address is formatted as: + +- `host:port` for domain/IPv4 +- `[ipv6]:port` for IPv6 (automatically bracketed) + +If a service has **multiple instances**, one is selected **uniformly at random** on each call. ## Examples +### Inline JSON ```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: + structuredType: "json" + stringValue: '{"appInstances":{"myapp":[{"ipv4":"127.0.0.1","port":4433}]}}' ``` +→ Resolves `"myapp"` to `127.0.0.1:4433` +### Inline YAML (multi-line) ```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 +configuration: + structuredType: "yaml" + stringValue: | + appInstances: + myapp: + - domain: "example.com" + port: 80 + - ipv6: "::1" + port: 8080 ``` +→ Possible results: `example.com:80` or `[::1]:8080` (chosen randomly) -- 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 +### From External File +```yaml +configuration: + structuredType: "yamlFile" + filePath: "/etc/dapr/services.yaml" +``` +With `/etc/dapr/services.yaml`: +```yaml +appInstances: + backend: + - ipv4: "10.0.0.5" + port: 3000 +``` +→ Resolves `"backend"` to `10.0.0.5:3000` diff --git a/nameresolution/structuredformat/metadata.yaml b/nameresolution/structuredformat/metadata.yaml new file mode 100644 index 0000000000..cee5ebe30e --- /dev/null +++ b/nameresolution/structuredformat/metadata.yaml @@ -0,0 +1,27 @@ +# yaml-language-server: $schema=../../component-metadata-schema.json +schemaVersion: v1 +type: nameresolution +name: structuredformat +version: v1 +status: alpha +title: "StructuredFormat" +urls: + - title: Reference + url: https://docs.dapr.io/reference/components-reference/supported-name-resolution/nr-structuredformat/ +metadata: + - name: structuredType + type: string + required: true + allowedValues: ["json", "yaml", "jsonFile", "yamlFile"] + description: Format type of the structured data. + example: "json" + - name: stringValue + type: string + required: false + description: Inline JSON/YAML string (required if structuredType is json/yaml). + example: '{"appInstances":{"myapp":[{"ipv4":"127.0.0.1","port":4433}]}}' + - name: filePath + type: string + required: false + description: Path to JSON/YAML file (required if structuredType is jsonFile/yamlFile). + example: "/etc/dapr/services.yaml" \ No newline at end of file diff --git a/nameresolution/structuredformat/structuredformat.go b/nameresolution/structuredformat/structuredformat.go index 282ef0f265..cbbf7a6a7d 100644 --- a/nameresolution/structuredformat/structuredformat.go +++ b/nameresolution/structuredformat/structuredformat.go @@ -24,6 +24,7 @@ import ( "reflect" "strconv" "strings" + "time" yaml "gopkg.in/yaml.v3" @@ -34,15 +35,17 @@ import ( ) const ( - JSONStringStructuredValue = "jsonString" - YAMLStringStructuredValue = "yamlString" - JSONFileStructuredValue = "jsonFile" - YAMLFileStructuredValue = "yamlFile" + JSONStructuredValue = "json" + YAMLStructuredValue = "yaml" + JSONFileStructuredValue = "jsonFile" + YAMLFileStructuredValue = "yamlFile" ) var allowedStructuredTypes = []string{ - JSONStringStructuredValue, - YAMLStringStructuredValue, JSONFileStructuredValue, YAMLFileStructuredValue, + JSONStructuredValue, + YAMLStructuredValue, + JSONFileStructuredValue, + YAMLFileStructuredValue, } // StructuredFormatResolver parses service names from a structured string @@ -50,16 +53,16 @@ var allowedStructuredTypes = []string{ type StructuredFormatResolver struct { meta structuredFormatMetadata instances appInstances - - logger logger.Logger + logger logger.Logger + rand *rand.Rand } // 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 + StructuredType string `mapstructure:"structuredType"` + StringValue string `mapstructure:"stringValue"` + FilePath string `mapstructure:"filePath"` } // appInstances stores the relationship between services and their instances. @@ -67,54 +70,76 @@ type appInstances struct { AppInstances map[string][]address `json:"appInstances" yaml:"appInstances"` } -// address contains service instance information, including Domain, IPv4, IPv6, Port, -// -// and ExtendedInfo. +// address contains service instance information. 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"` + Domain string `json:"domain" yaml:"domain"` + IPv4 string `json:"ipv4" yaml:"ipv4"` + IPv6 string `json:"ipv6" yaml:"ipv6"` + Port int `json:"port" yaml:"port"` +} + +// isValid checks if the address has at least one valid host field. +func (a address) isValid() bool { + return (a.Domain != "" || a.IPv4 != "" || a.IPv6 != "") } // NewResolver creates a new Structured Format resolver. func NewResolver(logger logger.Logger) nr.Resolver { + src := rand.NewSource(time.Now().UnixNano()) return &StructuredFormatResolver{ logger: logger, + // 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 + rand: rand.New(src), } } // 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 { + if err := kitmd.DecodeMetadata(metadata.Configuration, &meta); err != nil { return fmt.Errorf("failed to decode metadata: %w", err) } + // Validate structuredType + if !isValidStructuredType(meta.StructuredType) { + return fmt.Errorf("invalid structuredType %q; must be one of: %s", + meta.StructuredType, strings.Join(allowedStructuredTypes, ", ")) + } + + // Validate required fields based on type switch meta.StructuredType { - case JSONStringStructuredValue, YAMLStringStructuredValue: + case JSONStructuredValue, YAMLStructuredValue: if meta.StringValue == "" { - return fmt.Errorf("structuredType = %s, stringValue must be not empty", meta.StructuredType) + return fmt.Errorf("stringValue is required when structuredType is %q", meta.StructuredType) } case JSONFileStructuredValue, YAMLFileStructuredValue: if meta.FilePath == "" { - return fmt.Errorf("structuredType = %s, filePath must be not empty", meta.StructuredType) + return fmt.Errorf("filePath is required when structuredType is %q", 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 + return fmt.Errorf("failed to load structured data: %w", err) + } + + // validate that all addresses are valid + for serviceID, addrs := range instances.AppInstances { + for i, addr := range addrs { + if !addr.isValid() { + return fmt.Errorf("invalid address at AppInstances[%q][%d]: missing domain, ipv4, and ipv6", serviceID, i) + } + if addr.Port <= 0 || addr.Port > 65535 { + return fmt.Errorf("invalid port %d for AppInstances[%q][%d]", addr.Port, serviceID, i) + } + } } - r.instances = instances + r.instances = instances return nil } @@ -124,74 +149,101 @@ func (r *StructuredFormatResolver) ResolveID(ctx context.Context, req nr.Resolve 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 - } + addresses, exists := r.instances.AppInstances[req.ID] + if !exists || len(addresses) == 0 { + return "", fmt.Errorf("no services found with ID %q", req.ID) } - return "", fmt.Errorf("no services found with AppID '%s'", req.ID) + // Select a random instance (load balancing) + selected := addresses[r.rand.Intn(len(addresses))] + + // Prefer Domain > IPv4 > IPv6 + host := selected.Domain + if host == "" { + host = selected.IPv4 + } + if host == "" { + host = selected.IPv6 + } + + // This should not happen due to validation in Init, but be defensive. + if host == "" { + return "", fmt.Errorf("resolved address for %q has no valid host", req.ID) + } + + return net.JoinHostPort(host, strconv.Itoa(selected.Port)), nil } -// Close implements io.Closer +// Close implements io.Closer. func (r *StructuredFormatResolver) Close() error { return nil } -// GetComponentMetadata returns the metadata information for the component. +// GetComponentMetadata returns metadata info used for documentation and validation. func (r *StructuredFormatResolver) GetComponentMetadata() metadata.MetadataMap { - metadataInfo := metadata.MetadataMap{} - metadata.GetMetadataInfoFromStructType(reflect.TypeOf(structuredFormatMetadata{}), - &metadataInfo, metadata.NameResolutionType) - return metadataInfo + m := metadata.MetadataMap{} + metadata.GetMetadataInfoFromStructType( + reflect.TypeOf(structuredFormatMetadata{}), + &m, + metadata.NameResolutionType, + ) + return m +} + +// isValidStructuredType checks if the given type is allowed. +func isValidStructuredType(t string) bool { + for _, allowed := range allowedStructuredTypes { + if t == allowed { + return true + } + } + return false } -// loadStructuredFormatData loads the mapping between services and their instances from a configuration file. +// loadStructuredFormatData loads the mapping from structured input. func loadStructuredFormatData(r *StructuredFormatResolver) (appInstances, error) { var instances appInstances + + var data []byte + var err error + 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) + case JSONStructuredValue, YAMLStructuredValue: + data = []byte(r.meta.StringValue) + case JSONFileStructuredValue, YAMLFileStructuredValue: + // Security note: Consider restricting file access in production (e.g., allowlist paths). + data, err = os.ReadFile(r.meta.FilePath) if err != nil { - return instances, fmt.Errorf("error reading file: %s", err) + return instances, fmt.Errorf("failed to read file %q: %w", r.meta.FilePath, err) } + default: + // Should not happen due to prior validation + return instances, fmt.Errorf("unsupported structuredType: %s", r.meta.StructuredType) + } + // Parse based on format + switch r.meta.StructuredType { + case JSONStructuredValue, JSONFileStructuredValue: 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) - } - + case YAMLStructuredValue, YAMLFileStructuredValue: 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, ", ")) } + + if err != nil { + return instances, fmt.Errorf("failed to parse %s data: %w", getFormatName(r.meta.StructuredType), err) + } + return instances, nil } + +// getFormatName returns a human-readable format name. +func getFormatName(t string) string { + switch t { + case JSONStructuredValue, JSONFileStructuredValue: + return "JSON" + case YAMLStructuredValue, YAMLFileStructuredValue: + return "YAML" + default: + return t + } +} diff --git a/nameresolution/structuredformat/structuredformat_test.go b/nameresolution/structuredformat/structuredformat_test.go index b859e1bc8f..825f3d13f6 100644 --- a/nameresolution/structuredformat/structuredformat_test.go +++ b/nameresolution/structuredformat/structuredformat_test.go @@ -14,6 +14,8 @@ limitations under the License. package structuredformat import ( + "os" + "path/filepath" "testing" "github.com/stretchr/testify/assert" @@ -23,49 +25,74 @@ import ( "github.com/dapr/kit/logger" ) -var ( - jsonValue = `{ - "appInstances": { - "myapp": [ - { - "domain": "github.com", - "ipv4": "", - "ipv6": "", - "port": 443, - "extendedInfo": { - "hello": "world" - } - } - ] - } -}` - - jsonValueIPV6 = `{ +const ( + validJSON = `{ + "appInstances": { + "myapp": [ + { + "domain": "github.com", + "ipv4": "", + "ipv6": "", + "port": 443 + } + ] + } + }` + + validJSONIPv6 = `{ "appInstances": { "myapp": [ { "domain": "", "ipv4": "", "ipv6": "::1", - "port": 443, - "extendedInfo": { - "hello": "world" - } + "port": 443 } ] } }` - yamlValue = `appInstances: + validYAML = `appInstances: myapp: - domain: '' ipv4: '127.127.127.127' ipv6: '' - port: 443 - extendedInfo: - hello: world` + port: 443` + + invalidJSONNoHost = `{ + "appInstances": { + "badapp": [ + { + "domain": "", + "ipv4": "", + "ipv6": "", + "port": 80 + } + ] + } + }` + + invalidJSONBadPort = `{ + "appInstances": { + "badapp": [ + { + "domain": "example.com", + "port": -1 + } + ] + } + }` ) +// Helper to create temp file for file-based tests +func writeTempFile(t *testing.T, content string, ext string) string { + t.Helper() + tmpFile := filepath.Join(t.TempDir(), "config"+ext) + err := os.WriteFile(tmpFile, []byte(content), 0o600) + require.NoError(t, err) + return tmpFile +} + func TestInit(t *testing.T) { tests := []struct { name string @@ -73,41 +100,88 @@ func TestInit(t *testing.T) { expectedError string }{ { - name: "valid metadata with json string format", + name: "valid json string", metadata: nr.Metadata{ Configuration: map[string]string{ - "structuredType": "jsonString", - "stringValue": jsonValue, + "structuredType": "json", + "stringValue": validJSON, }, }, }, { - name: "valid metadata with json string format ipv6", + name: "valid yaml string", metadata: nr.Metadata{ Configuration: map[string]string{ - "structuredType": "jsonString", - "stringValue": jsonValueIPV6, + "structuredType": "yaml", + "stringValue": validYAML, }, }, }, { - name: "valid metadata with yaml string format", + name: "valid json file", metadata: nr.Metadata{ Configuration: map[string]string{ - "structuredType": "yamlString", - "stringValue": yamlValue, + "structuredType": "jsonFile", + "filePath": writeTempFile(t, validJSON, ".json"), }, }, }, + { + name: "valid yaml file", + metadata: nr.Metadata{ + Configuration: map[string]string{ + "structuredType": "yamlFile", + "filePath": writeTempFile(t, validYAML, ".yaml"), + }, + }, + }, + { + name: "missing stringValue for json", + metadata: nr.Metadata{ + Configuration: map[string]string{ + "structuredType": "json", + }, + }, + expectedError: "stringValue is required when structuredType is \"json\"", + }, + { + name: "missing filePath for jsonFile", + metadata: nr.Metadata{ + Configuration: map[string]string{ + "structuredType": "jsonFile", + }, + }, + expectedError: "filePath is required when structuredType is \"jsonFile\"", + }, { name: "invalid structuredType", metadata: nr.Metadata{ Configuration: map[string]string{ - "structuredType": "invalidType", - "stringValue": yamlValue, + "structuredType": "invalid", + "stringValue": validJSON, }, }, - expectedError: "structuredType must be one of: jsonString, yamlString, jsonFile, yamlFile", + expectedError: "invalid structuredType \"invalid\"; must be one of: json, yaml, jsonFile, yamlFile", + }, + { + name: "invalid address: no host", + metadata: nr.Metadata{ + Configuration: map[string]string{ + "structuredType": "json", + "stringValue": invalidJSONNoHost, + }, + }, + expectedError: "invalid address at AppInstances[\"badapp\"][0]: missing domain, ipv4, and ipv6", + }, + { + name: "invalid address: bad port", + metadata: nr.Metadata{ + Configuration: map[string]string{ + "structuredType": "json", + "stringValue": invalidJSONBadPort, + }, + }, + expectedError: "invalid port -1 for AppInstances[\"badapp\"][0]", }, } @@ -136,49 +210,39 @@ func TestResolveID(t *testing.T) { expectedError string }{ { - name: "valid app name with json string format", - structuredType: "jsonString", - stringValue: jsonValue, - request: nr.ResolveRequest{ - ID: "myapp", - }, + name: "resolve by domain", + structuredType: "json", + stringValue: validJSON, + 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", - }, + name: "resolve by IPv6", + structuredType: "json", + stringValue: validJSONIPv6, + 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", - }, + name: "resolve by IPv4 from YAML", + structuredType: "yaml", + stringValue: validYAML, + 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: "non-existent app ID", + structuredType: "json", + stringValue: validJSON, + request: nr.ResolveRequest{ID: "unknown"}, + expectedError: "no services found with ID \"unknown\"", }, { - name: "empty app name", - structuredType: "yamlString", - stringValue: yamlValue, - request: nr.ResolveRequest{ - ID: "", - }, - expectedError: "empty ID not allowed", + name: "empty app ID", + structuredType: "json", + stringValue: validJSON, + request: nr.ResolveRequest{ID: ""}, + expectedError: "empty ID not allowed", }, }