diff --git a/pkg/receive/config.go b/pkg/receive/config.go index e1b9754757..98afa60be3 100644 --- a/pkg/receive/config.go +++ b/pkg/receive/config.go @@ -153,6 +153,30 @@ const ( TenantMatcherGlob tenantMatcher = "glob" ) +// UnmarshalJSON implements the json.Unmarshaler interface for tenantMatcher. +// We override the default unmarshaling to provide parse-time validation, +// ensuring that only valid matchers ("exact" or "glob") are accepted. +// This allows Thanos to fail fast during configuration loading if a user +// provides an invalid matcher type, rather than silently falling back at runtime. +func (t *tenantMatcher) UnmarshalJSON(data []byte) error { + // Convert Raw Json bytes into a string + var str string + if err := json.Unmarshal(data, &str); err != nil { + return err + } + + // Check if that string is valid + switch str { + case "exact": + *t = TenantMatcherTypeExact + case "glob": + *t = tenantMatcher(str) + default: + return fmt.Errorf("invalid tenant matcher type: %s", str) + } + return nil +} + func isExactMatcher(m tenantMatcher) bool { return m == TenantMatcherTypeExact || m == "" } @@ -387,7 +411,24 @@ func ConfigFromWatcher(ctx context.Context, updates chan<- []HashringConfig, cw func ParseConfig(content []byte) ([]HashringConfig, error) { var config []HashringConfig err := json.Unmarshal(content, &config) - return config, err + if err != nil { + return nil, err + } + + // Post-Parsing Normalization Pass + // If the user completely omitted the TenantMatcherType field, Go leaves it as "". + // We must explicitly default these omitted fields to "exact" to prevent routing failures. + for i := range config { + if config[i].TenantMatcherType == "" { + config[i].TenantMatcherType = TenantMatcherTypeExact + } + for j := range config[i].ShuffleShardingConfig.Overrides { + if config[i].ShuffleShardingConfig.Overrides[j].TenantMatcherType == "" { + config[i].ShuffleShardingConfig.Overrides[j].TenantMatcherType = TenantMatcherTypeExact + } + } + } + return config, nil } // loadConfig loads raw configuration content and returns a configuration. diff --git a/pkg/receive/config_test.go b/pkg/receive/config_test.go index fc7ef76118..9737bfaea1 100644 --- a/pkg/receive/config_test.go +++ b/pkg/receive/config_test.go @@ -74,6 +74,81 @@ func TestValidateConfig(t *testing.T) { } } +// TestParseConfig verifies that ParseConfig successfully unmarshals raw JSON +// configuration and executes a post-parsing normalization pass. Omitted +// "tenant_matcher_type" fields (both top-level and inside overrides) must +// automatically hydrate to "exact" to prevent downstream routing bugs. +func TestParseConfig(t *testing.T) { + // JSON payload testing three distinct cases: + // 1. Explicit "exact" matcher (should remain unchanged) + // 2. Missing "tenant_matcher_type" (should trigger hydration to "exact") + // 3. Explicit "glob" matcher (should preserve non-default matchers) + inputJSON := []byte(`[ + { + "hashring": "test-ring", + "endpoints": ["node-1"], + "shuffle_sharding_config": { + "shard_size": 2, + "overrides": [ + { + "shard_size": 3, + "tenants": ["tenant-1"], + "tenant_matcher_type": "exact" + }, + { + "shard_size": 4, + "tenants": ["tenant-2"] + }, + { + "shard_size": 5, + "tenants": ["tenant-3"], + "tenant_matcher_type": "glob" + } + ] + } + } +]`) + configs, err := ParseConfig(inputJSON) + testutil.Ok(t, err) + // Assert that ParseConfig returns fully normalized data: + // - Top-level TenantMatcherType is hydrated to TenantMatcherTypeExact. + // - Omitted override matcher ("tenant-2") is hydrated to TenantMatcherTypeExact. + // - Explicit matchers ("tenant-1", "tenant-3") retain their exact values. + expected := []HashringConfig{ + { + Hashring: "test-ring", + TenantMatcherType: TenantMatcherTypeExact, + Endpoints: []Endpoint{ + { + Address: "node-1", + CapNProtoAddress: "node-1:19391", + }, + }, + ShuffleShardingConfig: ShuffleShardingConfig{ + ShardSize: 2, + Overrides: []ShuffleShardingOverrideConfig{ + { + ShardSize: 3, + Tenants: []string{"tenant-1"}, + TenantMatcherType: TenantMatcherTypeExact, + }, + { + ShardSize: 4, + Tenants: []string{"tenant-2"}, + TenantMatcherType: TenantMatcherTypeExact, + }, + { + ShardSize: 5, + Tenants: []string{"tenant-3"}, + TenantMatcherType: TenantMatcherGlob, + }, + }, + }, + }, + } + testutil.Equals(t, expected, configs) +} + func TestUnmarshalEndpointSlice(t *testing.T) { t.Parallel() diff --git a/pkg/receive/hashring_test.go b/pkg/receive/hashring_test.go index 288dde8987..b0632c7c12 100644 --- a/pkg/receive/hashring_test.go +++ b/pkg/receive/hashring_test.go @@ -713,7 +713,7 @@ func TestShuffleShardHashring(t *testing.T) { Overrides: []ShuffleShardingOverrideConfig{ { Tenants: []string{"special-tenant"}, - ShardSize: 2, + ShardSize: 3, // Override Value changed }, }, }, @@ -927,6 +927,66 @@ func assignReplicatedSeries(series []prompb.TimeSeries, nodes []Endpoint, replic return assignments, nil } +// TestTenantMatcher_UnmarshalJSON verifies that the custom JSON unmarshaling +// logic for the tenantMatcher type strictly validates incoming matcher strings. +// It ensures supported types ("exact", "glob") are correctly parsed, while +// unrecognized strings and explicit empty strings ("") are rejected immediately. +func TestTenantMatcher_UnmarshalJSON(t *testing.T) { + t.Parallel() + // Table-driven test cases covering valid, malformed, and edge-case inputs: + for _, tc := range []struct { + name string + input []byte + expected tenantMatcher + expectError bool + }{ + { + // Unrecognized matcher strings must fail validation at parse time. + name: "Invalid garbage matcher", + input: []byte(`"exakt"`), + expected: "", + expectError: true, + }, + { + // Explicit "exact" matcher unmarshals successfully. + name: "Valid exact matcher", + input: []byte(`"exact"`), + expected: TenantMatcherTypeExact, + expectError: false, + }, + { + // Explicit "glob" matcher unmarshals successfully. + name: "Valid glob matcher", + input: []byte(`"glob"`), + expected: TenantMatcherGlob, + expectError: false, + }, + { + // Explicitly providing "" in JSON must be rejected by UnmarshalJSON. + // Defaulting omitted fields to "exact" is handled during post-parse + // normalization, NOT by accepting raw empty JSON strings. + name: "Invalid empty matcher (explicitly rejected)", + input: []byte(`""`), + expected: "", + expectError: true, + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + var tm tenantMatcher + err := tm.UnmarshalJSON(tc.input) + // Verify error behavior matches expectation. + if (err != nil) != tc.expectError { + t.Errorf("UnmarshalJSON() error = %v, wantErr %v", err, tc.expectError) + } + // Verify resulting enum value matches expected parsed type. + if tm != tc.expected { + t.Errorf("UnmarshalJSON() got = %v, want %v", tm, tc.expected) + } + }) + } +} + // TestShuffleShardHashringStability tests that shuffle sharding is stable when // adding/removing nodes. When scaling from N to N+1 nodes, at most 1 node should // change in a tenant's shard (the "consistency" property).