xdsrouting: balancer config parsing (#3734)
diff --git a/xds/internal/balancer/xdsrouting/routing_config.go b/xds/internal/balancer/xdsrouting/routing_config.go
new file mode 100644
index 0000000..6daea22
--- /dev/null
+++ b/xds/internal/balancer/xdsrouting/routing_config.go
@@ -0,0 +1,205 @@
+/*
+ *
+ * Copyright 2020 gRPC 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 xdsrouting
+
+import (
+ "encoding/json"
+ "fmt"
+
+ wrapperspb "github.com/golang/protobuf/ptypes/wrappers"
+ internalserviceconfig "google.golang.org/grpc/internal/serviceconfig"
+ "google.golang.org/grpc/serviceconfig"
+ xdsclient "google.golang.org/grpc/xds/internal/client"
+)
+
+type action struct {
+ // ChildPolicy is the child policy and it's config.
+ ChildPolicy *internalserviceconfig.BalancerConfig
+}
+
+type int64Range struct {
+ start, end int64
+}
+
+type headerMatcher struct {
+ name string
+ // matchSpecifiers
+ invertMatch bool
+
+ // At most one of the following has non-default value.
+ exactMatch, regexMatch, prefixMatch, suffixMatch string
+ rangeMatch *int64Range
+ presentMatch bool
+}
+
+type route struct {
+ // Path, Prefix and Regex can have at most one set. This is guaranteed by
+ // config parsing.
+ path, prefix, regex string
+
+ headers []headerMatcher
+ fraction *uint32
+
+ // Action is the name from the action list.
+ action string
+}
+
+// lbConfig is the balancer config for xds routing policy.
+type lbConfig struct {
+ serviceconfig.LoadBalancingConfig
+ routes []route
+ actions map[string]action
+}
+
+// The following structs with `JSON` in name are temporary structs to unmarshal
+// json into. The fields will be read into lbConfig, to be used by the balancer.
+
+// routeJSON is temporary struct for json unmarshal.
+type routeJSON struct {
+ // Path, Prefix and Regex can have at most one non-nil.
+ Path, Prefix, Regex *string
+ // Zero or more header matchers.
+ Headers []*xdsclient.HeaderMatcher
+ MatchFraction *wrapperspb.UInt32Value
+ // Action is the name from the action list.
+ Action string
+}
+
+// lbConfigJSON is temporary struct for json unmarshal.
+type lbConfigJSON struct {
+ Route []routeJSON
+ Action map[string]action
+}
+
+func (jc lbConfigJSON) toLBConfig() *lbConfig {
+ var ret lbConfig
+ for _, r := range jc.Route {
+ var tempR route
+ switch {
+ case r.Path != nil:
+ tempR.path = *r.Path
+ case r.Prefix != nil:
+ tempR.prefix = *r.Prefix
+ case r.Regex != nil:
+ tempR.regex = *r.Regex
+ }
+ for _, h := range r.Headers {
+ if h.RangeMatch != nil {
+ fmt.Println("range not nil", *h.RangeMatch)
+ }
+ var tempHeader headerMatcher
+ switch {
+ case h.ExactMatch != nil:
+ tempHeader.exactMatch = *h.ExactMatch
+ case h.RegexMatch != nil:
+ tempHeader.regexMatch = *h.RegexMatch
+ case h.PrefixMatch != nil:
+ tempHeader.prefixMatch = *h.PrefixMatch
+ case h.SuffixMatch != nil:
+ tempHeader.suffixMatch = *h.SuffixMatch
+ case h.RangeMatch != nil:
+ tempHeader.rangeMatch = &int64Range{
+ start: h.RangeMatch.Start,
+ end: h.RangeMatch.End,
+ }
+ case h.PresentMatch != nil:
+ tempHeader.presentMatch = *h.PresentMatch
+ }
+ tempHeader.name = h.Name
+ if h.InvertMatch != nil {
+ tempHeader.invertMatch = *h.InvertMatch
+ }
+ tempR.headers = append(tempR.headers, tempHeader)
+ }
+ if r.MatchFraction != nil {
+ tempR.fraction = &r.MatchFraction.Value
+ }
+ tempR.action = r.Action
+ ret.routes = append(ret.routes, tempR)
+ }
+ ret.actions = jc.Action
+ return &ret
+}
+
+func parseConfig(c json.RawMessage) (*lbConfig, error) {
+ var tempConfig lbConfigJSON
+ if err := json.Unmarshal(c, &tempConfig); err != nil {
+ return nil, err
+ }
+
+ // For each route:
+ // - at most one of path/prefix/regex.
+ // - action is in action list.
+
+ allRouteActions := make(map[string]bool)
+ for _, r := range tempConfig.Route {
+ var oneOfCount int
+ if r.Path != nil {
+ oneOfCount++
+ }
+ if r.Prefix != nil {
+ oneOfCount++
+ }
+ if r.Regex != nil {
+ oneOfCount++
+ }
+ if oneOfCount != 1 {
+ return nil, fmt.Errorf("%d (not exactly one) of path/prefix/regex is set in route %+v", oneOfCount, r)
+ }
+
+ for _, h := range r.Headers {
+ var oneOfCountH int
+ if h.ExactMatch != nil {
+ oneOfCountH++
+ }
+ if h.RegexMatch != nil {
+ oneOfCountH++
+ }
+ if h.PrefixMatch != nil {
+ oneOfCountH++
+ }
+ if h.SuffixMatch != nil {
+ oneOfCountH++
+ }
+ if h.RangeMatch != nil {
+ oneOfCountH++
+ }
+ if h.PresentMatch != nil {
+ oneOfCountH++
+ }
+ if oneOfCountH != 1 {
+ return nil, fmt.Errorf("%d (not exactly one) of header matcher specifier is set in route %+v", oneOfCountH, h)
+ }
+ }
+
+ if _, ok := tempConfig.Action[r.Action]; !ok {
+ return nil, fmt.Errorf("action %q from route %+v is not found in action list", r.Action, r)
+ }
+ allRouteActions[r.Action] = true
+ }
+
+ // Verify that actions are used by at least one route.
+ for n := range tempConfig.Action {
+ if _, ok := allRouteActions[n]; !ok {
+ return nil, fmt.Errorf("action %q is not used by any route", n)
+ }
+ }
+
+ return tempConfig.toLBConfig(), nil
+}
diff --git a/xds/internal/balancer/xdsrouting/routing_config_test.go b/xds/internal/balancer/xdsrouting/routing_config_test.go
new file mode 100644
index 0000000..c1ff461
--- /dev/null
+++ b/xds/internal/balancer/xdsrouting/routing_config_test.go
@@ -0,0 +1,369 @@
+/*
+ *
+ * Copyright 2020 gRPC 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 xdsrouting
+
+import (
+ "testing"
+
+ "github.com/google/go-cmp/cmp"
+ "google.golang.org/grpc/balancer"
+ internalserviceconfig "google.golang.org/grpc/internal/serviceconfig"
+ _ "google.golang.org/grpc/xds/internal/balancer/cdsbalancer"
+ _ "google.golang.org/grpc/xds/internal/balancer/weightedtarget"
+)
+
+const (
+ testJSONConfig = `{
+ "action":{
+ "cds:cluster_1":{
+ "childPolicy":[{
+ "cds_experimental":{"cluster":"cluster_1"}
+ }]
+ },
+ "weighted:cluster_1_cluster_2_1":{
+ "childPolicy":[{
+ "weighted_target_experimental":{
+ "targets": {
+ "cluster_1" : {
+ "weight":75,
+ "childPolicy":[{"cds_experimental":{"cluster":"cluster_1"}}]
+ },
+ "cluster_2" : {
+ "weight":25,
+ "childPolicy":[{"cds_experimental":{"cluster":"cluster_2"}}]
+ }
+ }
+ }
+ }]
+ },
+ "weighted:cluster_1_cluster_3_1":{
+ "childPolicy":[{
+ "weighted_target_experimental":{
+ "targets": {
+ "cluster_1": {
+ "weight":99,
+ "childPolicy":[{"cds_experimental":{"cluster":"cluster_1"}}]
+ },
+ "cluster_3": {
+ "weight":1,
+ "childPolicy":[{"cds_experimental":{"cluster":"cluster_3"}}]
+ }
+ }
+ }
+ }]
+ }
+ },
+
+ "route":[{
+ "path":"/service_1/method_1",
+ "action":"cds:cluster_1"
+ },
+ {
+ "path":"/service_1/method_2",
+ "action":"cds:cluster_1"
+ },
+ {
+ "prefix":"/service_2/method_1",
+ "action":"weighted:cluster_1_cluster_2_1"
+ },
+ {
+ "prefix":"/service_2",
+ "action":"weighted:cluster_1_cluster_2_1"
+ },
+ {
+ "regex":"^/service_2/method_3$",
+ "action":"weighted:cluster_1_cluster_3_1"
+ }]
+ }
+`
+
+ testJSONConfigWithAllMatchers = `{
+ "action":{
+ "cds:cluster_1":{
+ "childPolicy":[{
+ "cds_experimental":{"cluster":"cluster_1"}
+ }]
+ },
+ "cds:cluster_2":{
+ "childPolicy":[{
+ "cds_experimental":{"cluster":"cluster_2"}
+ }]
+ },
+ "cds:cluster_3":{
+ "childPolicy":[{
+ "cds_experimental":{"cluster":"cluster_3"}
+ }]
+ }
+ },
+
+ "route":[{
+ "path":"/service_1/method_1",
+ "action":"cds:cluster_1"
+ },
+ {
+ "prefix":"/service_2/method_1",
+ "action":"cds:cluster_1"
+ },
+ {
+ "regex":"^/service_2/method_3$",
+ "action":"cds:cluster_1"
+ },
+ {
+ "prefix":"",
+ "headers":[{"name":"header-1", "exactMatch":"value-1", "invertMatch":true}],
+ "action":"cds:cluster_2"
+ },
+ {
+ "prefix":"",
+ "headers":[{"name":"header-1", "regexMatch":"^value-1$"}],
+ "action":"cds:cluster_2"
+ },
+ {
+ "prefix":"",
+ "headers":[{"name":"header-1", "rangeMatch":{"start":-1, "end":7}}],
+ "action":"cds:cluster_3"
+ },
+ {
+ "prefix":"",
+ "headers":[{"name":"header-1", "presentMatch":true}],
+ "action":"cds:cluster_3"
+ },
+ {
+ "prefix":"",
+ "headers":[{"name":"header-1", "prefixMatch":"value-1"}],
+ "action":"cds:cluster_2"
+ },
+ {
+ "prefix":"",
+ "headers":[{"name":"header-1", "suffixMatch":"value-1"}],
+ "action":"cds:cluster_2"
+ },
+ {
+ "prefix":"",
+ "matchFraction":{"value": 31415},
+ "action":"cds:cluster_3"
+ }]
+ }
+`
+
+ cdsName = "cds_experimental"
+ wtName = "weighted_target_experimental"
+)
+
+var (
+ cdsConfigParser = balancer.Get(cdsName).(balancer.ConfigParser)
+ cdsConfigJSON1 = `{"cluster":"cluster_1"}`
+ cdsConfig1, _ = cdsConfigParser.ParseConfig([]byte(cdsConfigJSON1))
+ cdsConfigJSON2 = `{"cluster":"cluster_2"}`
+ cdsConfig2, _ = cdsConfigParser.ParseConfig([]byte(cdsConfigJSON2))
+ cdsConfigJSON3 = `{"cluster":"cluster_3"}`
+ cdsConfig3, _ = cdsConfigParser.ParseConfig([]byte(cdsConfigJSON3))
+
+ wtConfigParser = balancer.Get(wtName).(balancer.ConfigParser)
+ wtConfigJSON1 = `{
+ "targets": {
+ "cluster_1" : { "weight":75, "childPolicy":[{"cds_experimental":{"cluster":"cluster_1"}}] },
+ "cluster_2" : { "weight":25, "childPolicy":[{"cds_experimental":{"cluster":"cluster_2"}}] }
+ } }`
+ wtConfig1, _ = wtConfigParser.ParseConfig([]byte(wtConfigJSON1))
+ wtConfigJSON2 = `{
+ "targets": {
+ "cluster_1": { "weight":99, "childPolicy":[{"cds_experimental":{"cluster":"cluster_1"}}] },
+ "cluster_3": { "weight":1, "childPolicy":[{"cds_experimental":{"cluster":"cluster_3"}}] }
+ } }`
+ wtConfig2, _ = wtConfigParser.ParseConfig([]byte(wtConfigJSON2))
+)
+
+func Test_parseConfig(t *testing.T) {
+ tests := []struct {
+ name string
+ js string
+ want *lbConfig
+ wantErr bool
+ }{
+ {
+ name: "empty json",
+ js: "",
+ want: nil,
+ wantErr: true,
+ },
+ {
+ name: "more than one path matcher", // Path matcher is oneof, so this is an error.
+ js: `{
+ "Action":{
+ "cds:cluster_1":{ "childPolicy":[{ "cds_experimental":{"cluster":"cluster_1"} }]}
+ },
+ "Route": [{
+ "path":"/service_1/method_1",
+ "prefix":"/service_1/",
+ "action":"cds:cluster_1"
+ }]
+ }`,
+ want: nil,
+ wantErr: true,
+ },
+ {
+ name: "no path matcher",
+ js: `{
+ "Action":{
+ "cds:cluster_1":{ "childPolicy":[{ "cds_experimental":{"cluster":"cluster_1"} }]}
+ },
+ "Route": [{
+ "action":"cds:cluster_1"
+ }]
+ }`,
+ want: nil,
+ wantErr: true,
+ },
+ {
+ name: "route action not found in action list",
+ js: `{
+ "Action":{},
+ "Route": [{
+ "path":"/service_1/method_1",
+ "action":"cds:cluster_1"
+ }]
+ }`,
+ want: nil,
+ wantErr: true,
+ },
+ {
+ name: "action list contains action not used",
+ js: `{
+ "Action":{
+ "cds:cluster_1":{ "childPolicy":[{ "cds_experimental":{"cluster":"cluster_1"} }]},
+ "cds:cluster_not_used":{ "childPolicy":[{ "cds_experimental":{"cluster":"cluster_1"} }]}
+ },
+ "Route": [{
+ "path":"/service_1/method_1",
+ "action":"cds:cluster_1"
+ }]
+ }`,
+ want: nil,
+ wantErr: true,
+ },
+
+ {
+ name: "no header specifier in header matcher",
+ js: `{
+ "Action":{
+ "cds:cluster_1":{ "childPolicy":[{ "cds_experimental":{"cluster":"cluster_1"} }]}
+ },
+ "Route": [{
+ "path":"/service_1/method_1",
+ "headers":[{"name":"header-1"}],
+ "action":"cds:cluster_1"
+ }]
+ }`,
+ want: nil,
+ wantErr: true,
+ },
+ {
+ name: "more than one header specifier in header matcher",
+ js: `{
+ "Action":{
+ "cds:cluster_1":{ "childPolicy":[{ "cds_experimental":{"cluster":"cluster_1"} }]}
+ },
+ "Route": [{
+ "path":"/service_1/method_1",
+ "headers":[{"name":"header-1", "prefixMatch":"a", "suffixMatch":"b"}],
+ "action":"cds:cluster_1"
+ }]
+ }`,
+ want: nil,
+ wantErr: true,
+ },
+
+ {
+ name: "OK with path matchers only",
+ js: testJSONConfig,
+ want: &lbConfig{
+ routes: []route{
+ {path: "/service_1/method_1", action: "cds:cluster_1"},
+ {path: "/service_1/method_2", action: "cds:cluster_1"},
+ {prefix: "/service_2/method_1", action: "weighted:cluster_1_cluster_2_1"},
+ {prefix: "/service_2", action: "weighted:cluster_1_cluster_2_1"},
+ {regex: "^/service_2/method_3$", action: "weighted:cluster_1_cluster_3_1"},
+ },
+ actions: map[string]action{
+ "cds:cluster_1": {ChildPolicy: &internalserviceconfig.BalancerConfig{
+ Name: cdsName, Config: cdsConfig1},
+ },
+ "weighted:cluster_1_cluster_2_1": {ChildPolicy: &internalserviceconfig.BalancerConfig{
+ Name: wtName, Config: wtConfig1},
+ },
+ "weighted:cluster_1_cluster_3_1": {ChildPolicy: &internalserviceconfig.BalancerConfig{
+ Name: wtName, Config: wtConfig2},
+ },
+ },
+ },
+ wantErr: false,
+ },
+ {
+ name: "OK with all matchers",
+ js: testJSONConfigWithAllMatchers,
+ want: &lbConfig{
+ routes: []route{
+ {path: "/service_1/method_1", action: "cds:cluster_1"},
+ {prefix: "/service_2/method_1", action: "cds:cluster_1"},
+ {regex: "^/service_2/method_3$", action: "cds:cluster_1"},
+
+ {prefix: "", headers: []headerMatcher{{name: "header-1", exactMatch: "value-1", invertMatch: true}}, action: "cds:cluster_2"},
+ {prefix: "", headers: []headerMatcher{{name: "header-1", regexMatch: "^value-1$"}}, action: "cds:cluster_2"},
+ {prefix: "", headers: []headerMatcher{{name: "header-1", rangeMatch: &int64Range{start: -1, end: 7}}}, action: "cds:cluster_3"},
+ {prefix: "", headers: []headerMatcher{{name: "header-1", presentMatch: true}}, action: "cds:cluster_3"},
+ {prefix: "", headers: []headerMatcher{{name: "header-1", prefixMatch: "value-1"}}, action: "cds:cluster_2"},
+ {prefix: "", headers: []headerMatcher{{name: "header-1", suffixMatch: "value-1"}}, action: "cds:cluster_2"},
+ {prefix: "", fraction: newUInt32P(31415), action: "cds:cluster_3"},
+ },
+ actions: map[string]action{
+ "cds:cluster_1": {ChildPolicy: &internalserviceconfig.BalancerConfig{
+ Name: cdsName, Config: cdsConfig1},
+ },
+ "cds:cluster_2": {ChildPolicy: &internalserviceconfig.BalancerConfig{
+ Name: cdsName, Config: cdsConfig2},
+ },
+ "cds:cluster_3": {ChildPolicy: &internalserviceconfig.BalancerConfig{
+ Name: cdsName, Config: cdsConfig3},
+ },
+ },
+ },
+ wantErr: false,
+ },
+ }
+
+ cmpOptions := []cmp.Option{cmp.AllowUnexported(lbConfig{}, route{}, headerMatcher{}, int64Range{})}
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := parseConfig([]byte(tt.js))
+ if (err != nil) != tt.wantErr {
+ t.Errorf("parseConfig() error = %v, wantErr %v", err, tt.wantErr)
+ return
+ }
+ if !cmp.Equal(got, tt.want, cmpOptions...) {
+ t.Errorf("parseConfig() got unexpected result, diff: %v", cmp.Diff(got, tt.want, cmpOptions...))
+ }
+ })
+ }
+}
+
+func newUInt32P(i uint32) *uint32 {
+ return &i
+}
diff --git a/xds/internal/client/client_watchers_rds.go b/xds/internal/client/client_watchers_rds.go
index 06f5279..4c8f5d4 100644
--- a/xds/internal/client/client_watchers_rds.go
+++ b/xds/internal/client/client_watchers_rds.go
@@ -22,6 +22,24 @@
"time"
)
+// Int64Range is a range for header range match.
+type Int64Range struct {
+ Start int64 `json:"start"`
+ End int64 `json:"end"`
+}
+
+// HeaderMatcher represents header matchers.
+type HeaderMatcher struct {
+ Name string `json:"name"`
+ InvertMatch *bool `json:"invertMatch,omitempty"`
+ ExactMatch *string `json:"exactMatch,omitempty"`
+ RegexMatch *string `json:"regexMatch,omitempty"`
+ PrefixMatch *string `json:"prefixMatch,omitempty"`
+ SuffixMatch *string `json:"suffixMatch,omitempty"`
+ RangeMatch *Int64Range `json:"rangeMatch,omitempty"`
+ PresentMatch *bool `json:"presentMatch,omitempty"`
+}
+
type rdsUpdate struct {
weightedCluster map[string]uint32
}