Skip to content
Open
43 changes: 42 additions & 1 deletion pkg/receive/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 == ""
}
Expand Down Expand Up @@ -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.
Expand Down
75 changes: 75 additions & 0 deletions pkg/receive/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
62 changes: 61 additions & 1 deletion pkg/receive/hashring_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -713,7 +713,7 @@ func TestShuffleShardHashring(t *testing.T) {
Overrides: []ShuffleShardingOverrideConfig{
{
Tenants: []string{"special-tenant"},
ShardSize: 2,
ShardSize: 3, // Override Value changed
},
},
},
Expand Down Expand Up @@ -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).
Expand Down
Loading