diff --git a/nameresolution/structuredformat/README.md b/nameresolution/structuredformat/README.md new file mode 100644 index 0000000000..5581d1344f --- /dev/null +++ b/nameresolution/structuredformat/README.md @@ -0,0 +1,107 @@ +# Structured Format Name Resolution + +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 enable the resolver, configure it in your Dapr `Configuration` resource: + +```yaml +apiVersion: dapr.io/v1alpha1 +kind: Configuration +metadata: + name: appconfig +spec: + nameResolution: + component: "structuredformat" + configuration: + structuredType: "json" # or "yaml", "jsonFile", "yamlFile" + stringValue: '{"appInstances":{"myapp":[{"ipv4":"127.0.0.1","port":4433}]}}' +``` + +## 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 + +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 +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 +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) + +### 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 new file mode 100644 index 0000000000..cbbf7a6a7d --- /dev/null +++ b/nameresolution/structuredformat/structuredformat.go @@ -0,0 +1,249 @@ +/* +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" + "time" + + 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 ( + JSONStructuredValue = "json" + YAMLStructuredValue = "yaml" + JSONFileStructuredValue = "jsonFile" + YAMLFileStructuredValue = "yamlFile" +) + +var allowedStructuredTypes = []string{ + JSONStructuredValue, + YAMLStructuredValue, + JSONFileStructuredValue, + YAMLFileStructuredValue, +} + +// StructuredFormatResolver parses service names from a structured string +// defined in the configuration. +type StructuredFormatResolver struct { + meta structuredFormatMetadata + instances appInstances + 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 `mapstructure:"structuredType"` + StringValue string `mapstructure:"stringValue"` + FilePath string `mapstructure:"filePath"` +} + +// 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. +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"` +} + +// 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 + 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 JSONStructuredValue, YAMLStructuredValue: + if meta.StringValue == "" { + return fmt.Errorf("stringValue is required when structuredType is %q", meta.StructuredType) + } + case JSONFileStructuredValue, YAMLFileStructuredValue: + if meta.FilePath == "" { + return fmt.Errorf("filePath is required when structuredType is %q", meta.StructuredType) + } + } + + r.meta = meta + + instances, err := loadStructuredFormatData(r) + if err != nil { + 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 + 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") + } + + addresses, exists := r.instances.AppInstances[req.ID] + if !exists || len(addresses) == 0 { + return "", fmt.Errorf("no services found with ID %q", 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. +func (r *StructuredFormatResolver) Close() error { + return nil +} + +// GetComponentMetadata returns metadata info used for documentation and validation. +func (r *StructuredFormatResolver) GetComponentMetadata() metadata.MetadataMap { + 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 from structured input. +func loadStructuredFormatData(r *StructuredFormatResolver) (appInstances, error) { + var instances appInstances + + var data []byte + var err error + + switch r.meta.StructuredType { + 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("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) + case YAMLStructuredValue, YAMLFileStructuredValue: + err = yaml.Unmarshal(data, &instances) + } + + 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 new file mode 100644 index 0000000000..825f3d13f6 --- /dev/null +++ b/nameresolution/structuredformat/structuredformat_test.go @@ -0,0 +1,277 @@ +/* +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 ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + nr "github.com/dapr/components-contrib/nameresolution" + "github.com/dapr/kit/logger" +) + +const ( + validJSON = `{ + "appInstances": { + "myapp": [ + { + "domain": "github.com", + "ipv4": "", + "ipv6": "", + "port": 443 + } + ] + } + }` + + validJSONIPv6 = `{ + "appInstances": { + "myapp": [ + { + "domain": "", + "ipv4": "", + "ipv6": "::1", + "port": 443 + } + ] + } + }` + + validYAML = `appInstances: + myapp: + - domain: '' + ipv4: '127.127.127.127' + ipv6: '' + 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 + metadata nr.Metadata + expectedError string + }{ + { + name: "valid json string", + metadata: nr.Metadata{ + Configuration: map[string]string{ + "structuredType": "json", + "stringValue": validJSON, + }, + }, + }, + { + name: "valid yaml string", + metadata: nr.Metadata{ + Configuration: map[string]string{ + "structuredType": "yaml", + "stringValue": validYAML, + }, + }, + }, + { + name: "valid json file", + metadata: nr.Metadata{ + Configuration: map[string]string{ + "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": "invalid", + "stringValue": validJSON, + }, + }, + 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]", + }, + } + + 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: "resolve by domain", + structuredType: "json", + stringValue: validJSON, + request: nr.ResolveRequest{ID: "myapp"}, + expectedResult: "github.com:443", + }, + { + name: "resolve by IPv6", + structuredType: "json", + stringValue: validJSONIPv6, + request: nr.ResolveRequest{ID: "myapp"}, + expectedResult: "[::1]:443", + }, + { + name: "resolve by IPv4 from YAML", + structuredType: "yaml", + stringValue: validYAML, + request: nr.ResolveRequest{ID: "myapp"}, + expectedResult: "127.127.127.127:443", + }, + { + name: "non-existent app ID", + structuredType: "json", + stringValue: validJSON, + request: nr.ResolveRequest{ID: "unknown"}, + expectedError: "no services found with ID \"unknown\"", + }, + { + name: "empty app ID", + structuredType: "json", + stringValue: validJSON, + 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) +}