diff --git a/.gitignore b/.gitignore index b41087695..f1c91b602 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,5 @@ build/ vendor/ app/dubbo-ui/dist/ +# Local router-rule-chain E2E reproduction and evidence +e2e/router-rule-chain/ diff --git a/api/mesh/v1alpha1/condition_route.pb.go b/api/mesh/v1alpha1/condition_route.pb.go index f9cbb44e9..20ab602b4 100644 --- a/api/mesh/v1alpha1/condition_route.pb.go +++ b/api/mesh/v1alpha1/condition_route.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.35.1 -// protoc v3.12.4 +// protoc-gen-go v1.36.10 +// protoc v3.19.4 // source: api/mesh/v1alpha1/condition_route.proto package v1alpha1 @@ -12,6 +12,7 @@ import ( protoimpl "google.golang.org/protobuf/runtime/protoimpl" reflect "reflect" sync "sync" + unsafe "unsafe" ) const ( @@ -22,18 +23,21 @@ const ( ) type ConditionRoute struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - ConfigVersion string `protobuf:"bytes,1,opt,name=configVersion,proto3" json:"configVersion,omitempty"` - Priority int32 `protobuf:"varint,2,opt,name=priority,proto3" json:"priority,omitempty"` - Enabled bool `protobuf:"varint,3,opt,name=enabled,proto3" json:"enabled,omitempty"` - Force bool `protobuf:"varint,4,opt,name=force,proto3" json:"force,omitempty"` - Runtime bool `protobuf:"varint,5,opt,name=runtime,proto3" json:"runtime,omitempty"` - Key string `protobuf:"bytes,6,opt,name=key,proto3" json:"key,omitempty"` - Scope string `protobuf:"bytes,7,opt,name=scope,proto3" json:"scope,omitempty"` - Conditions []string `protobuf:"bytes,8,rep,name=conditions,proto3" json:"conditions,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + ConfigVersion string `protobuf:"bytes,1,opt,name=configVersion,proto3" json:"configVersion,omitempty"` + Priority int32 `protobuf:"varint,2,opt,name=priority,proto3" json:"priority,omitempty"` + Enabled bool `protobuf:"varint,3,opt,name=enabled,proto3" json:"enabled,omitempty"` + Force bool `protobuf:"varint,4,opt,name=force,proto3" json:"force,omitempty"` + Runtime bool `protobuf:"varint,5,opt,name=runtime,proto3" json:"runtime,omitempty"` + Key string `protobuf:"bytes,6,opt,name=key,proto3" json:"key,omitempty"` + Scope string `protobuf:"bytes,7,opt,name=scope,proto3" json:"scope,omitempty"` + Conditions []string `protobuf:"bytes,8,rep,name=conditions,proto3" json:"conditions,omitempty"` + // conditionRules preserves the structured v3.1 conditions. External YAML + // still uses the field name `conditions`; the rule codec maps it by + // configVersion so v3.0 and v3.1 never overwrite each other. + ConditionRules []*ConditionRule `protobuf:"bytes,9,rep,name=conditionRules,proto3" json:"conditionRules,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ConditionRoute) Reset() { @@ -122,13 +126,19 @@ func (x *ConditionRoute) GetConditions() []string { return nil } +func (x *ConditionRoute) GetConditionRules() []*ConditionRule { + if x != nil { + return x.ConditionRules + } + return nil +} + type ConditionRule struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + From *ConditionRuleFrom `protobuf:"bytes,1,opt,name=from,proto3" json:"from,omitempty"` + To []*ConditionRuleTo `protobuf:"bytes,2,rep,name=to,proto3" json:"to,omitempty"` unknownFields protoimpl.UnknownFields - - From *ConditionRuleFrom `protobuf:"bytes,1,opt,name=from,proto3" json:"from,omitempty"` - To []*ConditionRuleTo `protobuf:"bytes,2,rep,name=to,proto3" json:"to,omitempty"` + sizeCache protoimpl.SizeCache } func (x *ConditionRule) Reset() { @@ -176,11 +186,10 @@ func (x *ConditionRule) GetTo() []*ConditionRuleTo { } type ConditionRuleFrom struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + Match string `protobuf:"bytes,1,opt,name=match,proto3" json:"match,omitempty"` unknownFields protoimpl.UnknownFields - - Match string `protobuf:"bytes,1,opt,name=match,proto3" json:"match,omitempty"` + sizeCache protoimpl.SizeCache } func (x *ConditionRuleFrom) Reset() { @@ -221,12 +230,11 @@ func (x *ConditionRuleFrom) GetMatch() string { } type ConditionRuleTo struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + Match string `protobuf:"bytes,1,opt,name=match,proto3" json:"match,omitempty"` + Weight int32 `protobuf:"varint,2,opt,name=weight,proto3" json:"weight,omitempty"` // default value will be handled by application logic unknownFields protoimpl.UnknownFields - - Match string `protobuf:"bytes,1,opt,name=match,proto3" json:"match,omitempty"` - Weight int32 `protobuf:"varint,2,opt,name=weight,proto3" json:"weight,omitempty"` // default value will be handled by application logic + sizeCache protoimpl.SizeCache } func (x *ConditionRuleTo) Reset() { @@ -275,59 +283,39 @@ func (x *ConditionRuleTo) GetWeight() int32 { var File_api_mesh_v1alpha1_condition_route_proto protoreflect.FileDescriptor -var file_api_mesh_v1alpha1_condition_route_proto_rawDesc = []byte{ - 0x0a, 0x27, 0x61, 0x70, 0x69, 0x2f, 0x6d, 0x65, 0x73, 0x68, 0x2f, 0x76, 0x31, 0x61, 0x6c, 0x70, - 0x68, 0x61, 0x31, 0x2f, 0x63, 0x6f, 0x6e, 0x64, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x72, 0x6f, - 0x75, 0x74, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x13, 0x64, 0x75, 0x62, 0x62, 0x6f, - 0x2e, 0x6d, 0x65, 0x73, 0x68, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x1a, 0x16, - 0x61, 0x70, 0x69, 0x2f, 0x6d, 0x65, 0x73, 0x68, 0x2f, 0x6f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, - 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x93, 0x02, 0x0a, 0x0e, 0x43, 0x6f, 0x6e, 0x64, 0x69, - 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x12, 0x24, 0x0a, 0x0d, 0x63, 0x6f, 0x6e, - 0x66, 0x69, 0x67, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x0d, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, - 0x1a, 0x0a, 0x08, 0x70, 0x72, 0x69, 0x6f, 0x72, 0x69, 0x74, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, - 0x05, 0x52, 0x08, 0x70, 0x72, 0x69, 0x6f, 0x72, 0x69, 0x74, 0x79, 0x12, 0x18, 0x0a, 0x07, 0x65, - 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x65, 0x6e, - 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x66, 0x6f, 0x72, 0x63, 0x65, 0x18, 0x04, - 0x20, 0x01, 0x28, 0x08, 0x52, 0x05, 0x66, 0x6f, 0x72, 0x63, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x72, - 0x75, 0x6e, 0x74, 0x69, 0x6d, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x72, 0x75, - 0x6e, 0x74, 0x69, 0x6d, 0x65, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x06, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x63, 0x6f, 0x70, 0x65, - 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x73, 0x63, 0x6f, 0x70, 0x65, 0x12, 0x1e, 0x0a, - 0x0a, 0x63, 0x6f, 0x6e, 0x64, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x08, 0x20, 0x03, 0x28, - 0x09, 0x52, 0x0a, 0x63, 0x6f, 0x6e, 0x64, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x3a, 0x2d, 0xaa, - 0x8c, 0x89, 0xa6, 0x01, 0x27, 0x0a, 0x0e, 0x43, 0x6f, 0x6e, 0x64, 0x69, 0x74, 0x69, 0x6f, 0x6e, - 0x52, 0x6f, 0x75, 0x74, 0x65, 0x12, 0x0f, 0x43, 0x6f, 0x6e, 0x64, 0x69, 0x74, 0x69, 0x6f, 0x6e, - 0x52, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x1a, 0x04, 0x6d, 0x65, 0x73, 0x68, 0x22, 0x81, 0x01, 0x0a, - 0x0d, 0x43, 0x6f, 0x6e, 0x64, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x75, 0x6c, 0x65, 0x12, 0x3a, - 0x0a, 0x04, 0x66, 0x72, 0x6f, 0x6d, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x26, 0x2e, 0x64, - 0x75, 0x62, 0x62, 0x6f, 0x2e, 0x6d, 0x65, 0x73, 0x68, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, - 0x61, 0x31, 0x2e, 0x43, 0x6f, 0x6e, 0x64, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x75, 0x6c, 0x65, - 0x46, 0x72, 0x6f, 0x6d, 0x52, 0x04, 0x66, 0x72, 0x6f, 0x6d, 0x12, 0x34, 0x0a, 0x02, 0x74, 0x6f, - 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x24, 0x2e, 0x64, 0x75, 0x62, 0x62, 0x6f, 0x2e, 0x6d, - 0x65, 0x73, 0x68, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x43, 0x6f, 0x6e, - 0x64, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x75, 0x6c, 0x65, 0x54, 0x6f, 0x52, 0x02, 0x74, 0x6f, - 0x22, 0x29, 0x0a, 0x11, 0x43, 0x6f, 0x6e, 0x64, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x75, 0x6c, - 0x65, 0x46, 0x72, 0x6f, 0x6d, 0x12, 0x14, 0x0a, 0x05, 0x6d, 0x61, 0x74, 0x63, 0x68, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6d, 0x61, 0x74, 0x63, 0x68, 0x22, 0x3f, 0x0a, 0x0f, 0x43, - 0x6f, 0x6e, 0x64, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x75, 0x6c, 0x65, 0x54, 0x6f, 0x12, 0x14, - 0x0a, 0x05, 0x6d, 0x61, 0x74, 0x63, 0x68, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6d, - 0x61, 0x74, 0x63, 0x68, 0x12, 0x16, 0x0a, 0x06, 0x77, 0x65, 0x69, 0x67, 0x68, 0x74, 0x18, 0x02, - 0x20, 0x01, 0x28, 0x05, 0x52, 0x06, 0x77, 0x65, 0x69, 0x67, 0x68, 0x74, 0x42, 0x31, 0x5a, 0x2f, - 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x61, 0x70, 0x61, 0x63, 0x68, - 0x65, 0x2f, 0x64, 0x75, 0x62, 0x62, 0x6f, 0x2d, 0x61, 0x64, 0x6d, 0x69, 0x6e, 0x2f, 0x61, 0x70, - 0x69, 0x2f, 0x6d, 0x65, 0x73, 0x68, 0x2f, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x62, - 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, -} +const file_api_mesh_v1alpha1_condition_route_proto_rawDesc = "" + + "\n" + + "'api/mesh/v1alpha1/condition_route.proto\x12\x13dubbo.mesh.v1alpha1\x1a\x16api/mesh/options.proto\"\xdf\x02\n" + + "\x0eConditionRoute\x12$\n" + + "\rconfigVersion\x18\x01 \x01(\tR\rconfigVersion\x12\x1a\n" + + "\bpriority\x18\x02 \x01(\x05R\bpriority\x12\x18\n" + + "\aenabled\x18\x03 \x01(\bR\aenabled\x12\x14\n" + + "\x05force\x18\x04 \x01(\bR\x05force\x12\x18\n" + + "\aruntime\x18\x05 \x01(\bR\aruntime\x12\x10\n" + + "\x03key\x18\x06 \x01(\tR\x03key\x12\x14\n" + + "\x05scope\x18\a \x01(\tR\x05scope\x12\x1e\n" + + "\n" + + "conditions\x18\b \x03(\tR\n" + + "conditions\x12J\n" + + "\x0econditionRules\x18\t \x03(\v2\".dubbo.mesh.v1alpha1.ConditionRuleR\x0econditionRules:-\xaa\x8c\x89\xa6\x01'\n" + + "\x0eConditionRoute\x12\x0fConditionRoutes\x1a\x04mesh\"\x81\x01\n" + + "\rConditionRule\x12:\n" + + "\x04from\x18\x01 \x01(\v2&.dubbo.mesh.v1alpha1.ConditionRuleFromR\x04from\x124\n" + + "\x02to\x18\x02 \x03(\v2$.dubbo.mesh.v1alpha1.ConditionRuleToR\x02to\")\n" + + "\x11ConditionRuleFrom\x12\x14\n" + + "\x05match\x18\x01 \x01(\tR\x05match\"?\n" + + "\x0fConditionRuleTo\x12\x14\n" + + "\x05match\x18\x01 \x01(\tR\x05match\x12\x16\n" + + "\x06weight\x18\x02 \x01(\x05R\x06weightB1Z/github.com/apache/dubbo-admin/api/mesh/v1alpha1b\x06proto3" var ( file_api_mesh_v1alpha1_condition_route_proto_rawDescOnce sync.Once - file_api_mesh_v1alpha1_condition_route_proto_rawDescData = file_api_mesh_v1alpha1_condition_route_proto_rawDesc + file_api_mesh_v1alpha1_condition_route_proto_rawDescData []byte ) func file_api_mesh_v1alpha1_condition_route_proto_rawDescGZIP() []byte { file_api_mesh_v1alpha1_condition_route_proto_rawDescOnce.Do(func() { - file_api_mesh_v1alpha1_condition_route_proto_rawDescData = protoimpl.X.CompressGZIP(file_api_mesh_v1alpha1_condition_route_proto_rawDescData) + file_api_mesh_v1alpha1_condition_route_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_api_mesh_v1alpha1_condition_route_proto_rawDesc), len(file_api_mesh_v1alpha1_condition_route_proto_rawDesc))) }) return file_api_mesh_v1alpha1_condition_route_proto_rawDescData } @@ -340,13 +328,14 @@ var file_api_mesh_v1alpha1_condition_route_proto_goTypes = []any{ (*ConditionRuleTo)(nil), // 3: dubbo.mesh.v1alpha1.ConditionRuleTo } var file_api_mesh_v1alpha1_condition_route_proto_depIdxs = []int32{ - 2, // 0: dubbo.mesh.v1alpha1.ConditionRule.from:type_name -> dubbo.mesh.v1alpha1.ConditionRuleFrom - 3, // 1: dubbo.mesh.v1alpha1.ConditionRule.to:type_name -> dubbo.mesh.v1alpha1.ConditionRuleTo - 2, // [2:2] is the sub-list for method output_type - 2, // [2:2] is the sub-list for method input_type - 2, // [2:2] is the sub-list for extension type_name - 2, // [2:2] is the sub-list for extension extendee - 0, // [0:2] is the sub-list for field type_name + 1, // 0: dubbo.mesh.v1alpha1.ConditionRoute.conditionRules:type_name -> dubbo.mesh.v1alpha1.ConditionRule + 2, // 1: dubbo.mesh.v1alpha1.ConditionRule.from:type_name -> dubbo.mesh.v1alpha1.ConditionRuleFrom + 3, // 2: dubbo.mesh.v1alpha1.ConditionRule.to:type_name -> dubbo.mesh.v1alpha1.ConditionRuleTo + 3, // [3:3] is the sub-list for method output_type + 3, // [3:3] is the sub-list for method input_type + 3, // [3:3] is the sub-list for extension type_name + 3, // [3:3] is the sub-list for extension extendee + 0, // [0:3] is the sub-list for field type_name } func init() { file_api_mesh_v1alpha1_condition_route_proto_init() } @@ -358,7 +347,7 @@ func file_api_mesh_v1alpha1_condition_route_proto_init() { out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: file_api_mesh_v1alpha1_condition_route_proto_rawDesc, + RawDescriptor: unsafe.Slice(unsafe.StringData(file_api_mesh_v1alpha1_condition_route_proto_rawDesc), len(file_api_mesh_v1alpha1_condition_route_proto_rawDesc)), NumEnums: 0, NumMessages: 4, NumExtensions: 0, @@ -369,7 +358,6 @@ func file_api_mesh_v1alpha1_condition_route_proto_init() { MessageInfos: file_api_mesh_v1alpha1_condition_route_proto_msgTypes, }.Build() File_api_mesh_v1alpha1_condition_route_proto = out.File - file_api_mesh_v1alpha1_condition_route_proto_rawDesc = nil file_api_mesh_v1alpha1_condition_route_proto_goTypes = nil file_api_mesh_v1alpha1_condition_route_proto_depIdxs = nil } diff --git a/api/mesh/v1alpha1/condition_route.proto b/api/mesh/v1alpha1/condition_route.proto index b2a9870ba..ac1587272 100644 --- a/api/mesh/v1alpha1/condition_route.proto +++ b/api/mesh/v1alpha1/condition_route.proto @@ -20,6 +20,10 @@ message ConditionRoute { string key = 6; string scope = 7; repeated string conditions = 8; + // conditionRules preserves the structured v3.1 conditions. External YAML + // still uses the field name `conditions`; the rule codec maps it by + // configVersion so v3.0 and v3.1 never overwrite each other. + repeated ConditionRule conditionRules = 9; } message ConditionRule { @@ -34,4 +38,4 @@ message ConditionRuleFrom { message ConditionRuleTo { string match = 1; int32 weight = 2; // default value will be handled by application logic -} \ No newline at end of file +} diff --git a/api/mesh/v1alpha1/script_route.pb.go b/api/mesh/v1alpha1/script_route.pb.go new file mode 100644 index 000000000..b73b88f76 --- /dev/null +++ b/api/mesh/v1alpha1/script_route.pb.go @@ -0,0 +1,169 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.10 +// protoc v3.19.4 +// source: api/mesh/v1alpha1/script_route.proto + +package v1alpha1 + +import ( + _ "github.com/apache/dubbo-admin/api/mesh" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type ScriptRoute struct { + state protoimpl.MessageState `protogen:"open.v1"` + ConfigVersion string `protobuf:"bytes,1,opt,name=configVersion,proto3" json:"configVersion,omitempty"` + Scope string `protobuf:"bytes,2,opt,name=scope,proto3" json:"scope,omitempty"` + Key string `protobuf:"bytes,3,opt,name=key,proto3" json:"key,omitempty"` + Enabled bool `protobuf:"varint,4,opt,name=enabled,proto3" json:"enabled,omitempty"` + Type string `protobuf:"bytes,5,opt,name=type,proto3" json:"type,omitempty"` + Script string `protobuf:"bytes,6,opt,name=script,proto3" json:"script,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ScriptRoute) Reset() { + *x = ScriptRoute{} + mi := &file_api_mesh_v1alpha1_script_route_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ScriptRoute) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ScriptRoute) ProtoMessage() {} + +func (x *ScriptRoute) ProtoReflect() protoreflect.Message { + mi := &file_api_mesh_v1alpha1_script_route_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ScriptRoute.ProtoReflect.Descriptor instead. +func (*ScriptRoute) Descriptor() ([]byte, []int) { + return file_api_mesh_v1alpha1_script_route_proto_rawDescGZIP(), []int{0} +} + +func (x *ScriptRoute) GetConfigVersion() string { + if x != nil { + return x.ConfigVersion + } + return "" +} + +func (x *ScriptRoute) GetScope() string { + if x != nil { + return x.Scope + } + return "" +} + +func (x *ScriptRoute) GetKey() string { + if x != nil { + return x.Key + } + return "" +} + +func (x *ScriptRoute) GetEnabled() bool { + if x != nil { + return x.Enabled + } + return false +} + +func (x *ScriptRoute) GetType() string { + if x != nil { + return x.Type + } + return "" +} + +func (x *ScriptRoute) GetScript() string { + if x != nil { + return x.Script + } + return "" +} + +var File_api_mesh_v1alpha1_script_route_proto protoreflect.FileDescriptor + +const file_api_mesh_v1alpha1_script_route_proto_rawDesc = "" + + "\n" + + "$api/mesh/v1alpha1/script_route.proto\x12\x13dubbo.mesh.v1alpha1\x1a\x16api/mesh/options.proto\"\xca\x01\n" + + "\vScriptRoute\x12$\n" + + "\rconfigVersion\x18\x01 \x01(\tR\rconfigVersion\x12\x14\n" + + "\x05scope\x18\x02 \x01(\tR\x05scope\x12\x10\n" + + "\x03key\x18\x03 \x01(\tR\x03key\x12\x18\n" + + "\aenabled\x18\x04 \x01(\bR\aenabled\x12\x12\n" + + "\x04type\x18\x05 \x01(\tR\x04type\x12\x16\n" + + "\x06script\x18\x06 \x01(\tR\x06script:'\xaa\x8c\x89\xa6\x01!\n" + + "\vScriptRoute\x12\fScriptRoutes\x1a\x04meshB1Z/github.com/apache/dubbo-admin/api/mesh/v1alpha1b\x06proto3" + +var ( + file_api_mesh_v1alpha1_script_route_proto_rawDescOnce sync.Once + file_api_mesh_v1alpha1_script_route_proto_rawDescData []byte +) + +func file_api_mesh_v1alpha1_script_route_proto_rawDescGZIP() []byte { + file_api_mesh_v1alpha1_script_route_proto_rawDescOnce.Do(func() { + file_api_mesh_v1alpha1_script_route_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_api_mesh_v1alpha1_script_route_proto_rawDesc), len(file_api_mesh_v1alpha1_script_route_proto_rawDesc))) + }) + return file_api_mesh_v1alpha1_script_route_proto_rawDescData +} + +var file_api_mesh_v1alpha1_script_route_proto_msgTypes = make([]protoimpl.MessageInfo, 1) +var file_api_mesh_v1alpha1_script_route_proto_goTypes = []any{ + (*ScriptRoute)(nil), // 0: dubbo.mesh.v1alpha1.ScriptRoute +} +var file_api_mesh_v1alpha1_script_route_proto_depIdxs = []int32{ + 0, // [0:0] is the sub-list for method output_type + 0, // [0:0] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_api_mesh_v1alpha1_script_route_proto_init() } +func file_api_mesh_v1alpha1_script_route_proto_init() { + if File_api_mesh_v1alpha1_script_route_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_api_mesh_v1alpha1_script_route_proto_rawDesc), len(file_api_mesh_v1alpha1_script_route_proto_rawDesc)), + NumEnums: 0, + NumMessages: 1, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_api_mesh_v1alpha1_script_route_proto_goTypes, + DependencyIndexes: file_api_mesh_v1alpha1_script_route_proto_depIdxs, + MessageInfos: file_api_mesh_v1alpha1_script_route_proto_msgTypes, + }.Build() + File_api_mesh_v1alpha1_script_route_proto = out.File + file_api_mesh_v1alpha1_script_route_proto_goTypes = nil + file_api_mesh_v1alpha1_script_route_proto_depIdxs = nil +} diff --git a/api/mesh/v1alpha1/script_route.proto b/api/mesh/v1alpha1/script_route.proto new file mode 100644 index 000000000..8b84b968b --- /dev/null +++ b/api/mesh/v1alpha1/script_route.proto @@ -0,0 +1,21 @@ +syntax = "proto3"; + +package dubbo.mesh.v1alpha1; + +option go_package = "github.com/apache/dubbo-admin/api/mesh/v1alpha1"; + +import "api/mesh/options.proto"; + +message ScriptRoute { + option (dubbo.mesh.resource).name = "ScriptRoute"; + option (dubbo.mesh.resource).plural_name = "ScriptRoutes"; + option (dubbo.mesh.resource).package = "mesh"; + option (dubbo.mesh.resource).is_experimental = false; + + string configVersion = 1; + string scope = 2; + string key = 3; + bool enabled = 4; + string type = 5; + string script = 6; +} diff --git a/pkg/common/constants/rule.go b/pkg/common/constants/rule.go index 1749e33ee..bbffc0359 100644 --- a/pkg/common/constants/rule.go +++ b/pkg/common/constants/rule.go @@ -19,7 +19,7 @@ package constants import set "github.com/duke-git/lancet/v2/datastructure/set" -var RuleSuffixSet = set.New(ConfiguratorsSuffix, ConditionRuleSuffix, TagRuleSuffix, AffinityRuleSuffix) +var RuleSuffixSet = set.New(ConfiguratorsSuffix, ConditionRuleSuffix, TagRuleSuffix, AffinityRuleSuffix, ScriptRuleSuffix) const ( ConfiguratorVersionV3 = `v3.0` @@ -29,6 +29,7 @@ const ( ScopeService = `service` SideProvider = `provider` SideConsumer = `consumer` + RuleConfigGroup = `dubbo` ConfiguratorRuleDotSuffix = ".configurators" ConfiguratorsSuffix = "configurators" @@ -38,6 +39,10 @@ const ( TagRuleSuffix = "tag-router" AffinityRuleDotSuffix = ".affinity-router" AffinityRuleSuffix = "affinity-router" + ScriptRuleDotSuffix = ".script-router" + ScriptRuleSuffix = "script-router" + ScriptTypeJavaScript = "javascript" + MaxScriptRuleSize = 64 * 1024 ) const ( diff --git a/pkg/console/handler/condition_rule.go b/pkg/console/handler/condition_rule.go index 38309a3e4..5552258ea 100644 --- a/pkg/console/handler/condition_rule.go +++ b/pkg/console/handler/condition_rule.go @@ -90,7 +90,17 @@ func PutConditionRuleWithRuleName(cs consolectx.Context) gin.HandlerFunc { return } res := meshresource.NewConditionRouteResourceWithAttributes(ruleName, mesh) - if err := c.ShouldBindJSON(res.Spec); err != nil { + input := &model.ConditionRuleInput{} + if err := c.ShouldBindJSON(input); err != nil { + util.HandleArgumentError(c, err) + return + } + res.Spec, err = input.ToProto() + if err != nil { + util.HandleArgumentError(c, err) + return + } + if err := meshresource.ValidateRule(res); err != nil { util.HandleArgumentError(c, err) return } @@ -114,7 +124,18 @@ func PostConditionRuleWithRuleName(cs consolectx.Context) gin.HandlerFunc { return } res := meshresource.NewConditionRouteResourceWithAttributes(ruleName, mesh) - if err := c.ShouldBindJSON(res.Spec); err != nil { + input := &model.ConditionRuleInput{} + if err := c.ShouldBindJSON(input); err != nil { + util.HandleArgumentError(c, err) + return + } + var err error + res.Spec, err = input.ToProto() + if err != nil { + util.HandleArgumentError(c, err) + return + } + if err := meshresource.ValidateRule(res); err != nil { util.HandleArgumentError(c, err) return } diff --git a/pkg/console/handler/router_rule.go b/pkg/console/handler/router_rule.go new file mode 100644 index 000000000..dbf8431f8 --- /dev/null +++ b/pkg/console/handler/router_rule.go @@ -0,0 +1,212 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0. + */ + +package handler + +import ( + "fmt" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + + meshproto "github.com/apache/dubbo-admin/api/mesh/v1alpha1" + "github.com/apache/dubbo-admin/pkg/common/bizerror" + "github.com/apache/dubbo-admin/pkg/common/constants" + consolectx "github.com/apache/dubbo-admin/pkg/console/context" + "github.com/apache/dubbo-admin/pkg/console/model" + "github.com/apache/dubbo-admin/pkg/console/service" + "github.com/apache/dubbo-admin/pkg/console/util" + meshresource "github.com/apache/dubbo-admin/pkg/core/resource/apis/mesh/v1alpha1" +) + +func AffinityRuleSearch(ctx consolectx.Context) gin.HandlerFunc { + return func(c *gin.Context) { + req := model.NewSearchConditionRuleReq() + if err := c.ShouldBindQuery(req); err != nil { + util.HandleArgumentError(c, err) + return + } + result, err := service.SearchAffinityRules(ctx, req) + if err != nil { + util.HandleServiceError(c, err) + return + } + c.JSON(http.StatusOK, model.NewSuccessResp(result)) + } +} + +func GetAffinityRuleWithRuleName(ctx consolectx.Context) gin.HandlerFunc { + return func(c *gin.Context) { + if !validRuleSuffix(c, constants.AffinityRuleDotSuffix) { + return + } + r, err := service.GetAffinityRule(ctx, c.Param("ruleName"), c.Query("mesh")) + if err != nil { + util.HandleServiceError(c, err) + return + } + if r == nil { + util.HandleNotFoundError(c, c.Param("ruleName")) + return + } + c.JSON(http.StatusOK, model.GenAffinityRuleResp(r.Spec)) + } +} +func PostAffinityRuleWithRuleName(ctx consolectx.Context) gin.HandlerFunc { + return mutateAffinityRule(ctx, false) +} +func PutAffinityRuleWithRuleName(ctx consolectx.Context) gin.HandlerFunc { + return mutateAffinityRule(ctx, true) +} +func mutateAffinityRule(ctx consolectx.Context, update bool) gin.HandlerFunc { + return func(c *gin.Context) { + if !validRuleSuffix(c, constants.AffinityRuleDotSuffix) { + return + } + input := &model.AffinityRuleInput{} + if err := c.ShouldBindJSON(input); err != nil { + util.HandleArgumentError(c, err) + return + } + r := meshresource.NewAffinityRouteResourceWithAttributes(c.Param("ruleName"), c.Query("mesh")) + r.Spec = input.ToProto() + if err := meshresource.ValidateRule(r); err != nil { + util.HandleArgumentError(c, err) + return + } + var err error + if update { + existing, getErr := service.GetAffinityRule(ctx, r.Name, r.Mesh) + if getErr != nil { + util.HandleServiceError(c, getErr) + return + } + if existing == nil { + util.HandleNotFoundError(c, r.Name) + return + } + err = service.UpdateAffinityRuleWithOptions(ctx, r, mutationOptions(c)) + } else { + err = service.CreateAffinityRuleWithOptions(ctx, r, mutationOptions(c)) + } + if err != nil { + util.HandleServiceError(c, err) + return + } + c.JSON(http.StatusOK, model.GenAffinityRuleResp(r.Spec)) + } +} +func DeleteAffinityRuleWithRuleName(ctx consolectx.Context) gin.HandlerFunc { + return func(c *gin.Context) { + if !validRuleSuffix(c, constants.AffinityRuleDotSuffix) { + return + } + if err := service.DeleteAffinityRuleWithOptions(ctx, c.Param("ruleName"), c.Query("mesh"), mutationOptions(c)); err != nil { + util.HandleServiceError(c, err) + return + } + c.JSON(http.StatusOK, model.NewSuccessResp("")) + } +} + +func ScriptRuleSearch(ctx consolectx.Context) gin.HandlerFunc { + return func(c *gin.Context) { + req := model.NewSearchConditionRuleReq() + if err := c.ShouldBindQuery(req); err != nil { + util.HandleArgumentError(c, err) + return + } + result, err := service.SearchScriptRules(ctx, req) + if err != nil { + util.HandleServiceError(c, err) + return + } + c.JSON(http.StatusOK, model.NewSuccessResp(result)) + } +} +func GetScriptRuleWithRuleName(ctx consolectx.Context) gin.HandlerFunc { + return func(c *gin.Context) { + if !validRuleSuffix(c, constants.ScriptRuleDotSuffix) { + return + } + r, err := service.GetScriptRule(ctx, c.Param("ruleName"), c.Query("mesh")) + if err != nil { + util.HandleServiceError(c, err) + return + } + if r == nil { + util.HandleNotFoundError(c, c.Param("ruleName")) + return + } + c.JSON(http.StatusOK, model.NewSuccessResp(r.Spec)) + } +} +func PostScriptRuleWithRuleName(ctx consolectx.Context) gin.HandlerFunc { + return mutateScriptRule(ctx, false) +} +func PutScriptRuleWithRuleName(ctx consolectx.Context) gin.HandlerFunc { + return mutateScriptRule(ctx, true) +} +func mutateScriptRule(ctx consolectx.Context, update bool) gin.HandlerFunc { + return func(c *gin.Context) { + if !validRuleSuffix(c, constants.ScriptRuleDotSuffix) { + return + } + spec := &meshproto.ScriptRoute{} + if err := c.ShouldBindJSON(spec); err != nil { + util.HandleArgumentError(c, err) + return + } + r := meshresource.NewScriptRouteResourceWithAttributes(c.Param("ruleName"), c.Query("mesh")) + r.Spec = spec + if err := meshresource.ValidateRule(r); err != nil { + util.HandleArgumentError(c, err) + return + } + var err error + if update { + existing, getErr := service.GetScriptRule(ctx, r.Name, r.Mesh) + if getErr != nil { + util.HandleServiceError(c, getErr) + return + } + if existing == nil { + util.HandleNotFoundError(c, r.Name) + return + } + err = service.UpdateScriptRuleWithOptions(ctx, r, mutationOptions(c)) + } else { + err = service.CreateScriptRuleWithOptions(ctx, r, mutationOptions(c)) + } + if err != nil { + util.HandleServiceError(c, err) + return + } + c.JSON(http.StatusOK, model.NewSuccessResp(r.Spec)) + } +} +func DeleteScriptRuleWithRuleName(ctx consolectx.Context) gin.HandlerFunc { + return func(c *gin.Context) { + if !validRuleSuffix(c, constants.ScriptRuleDotSuffix) { + return + } + if err := service.DeleteScriptRuleWithOptions(ctx, c.Param("ruleName"), c.Query("mesh"), mutationOptions(c)); err != nil { + util.HandleServiceError(c, err) + return + } + c.JSON(http.StatusOK, model.NewSuccessResp("")) + } +} + +func validRuleSuffix(c *gin.Context, suffix string) bool { + if strings.HasSuffix(c.Param("ruleName"), suffix) { + return true + } + c.JSON(http.StatusBadRequest, model.NewBizErrorResp(bizerror.New(bizerror.InvalidArgument, fmt.Sprintf("ruleName must end with %s", suffix)))) + return false +} diff --git a/pkg/console/handler/tag_rule.go b/pkg/console/handler/tag_rule.go index d07ad2c6b..4bd929369 100644 --- a/pkg/console/handler/tag_rule.go +++ b/pkg/console/handler/tag_rule.go @@ -103,6 +103,10 @@ func PutTagRuleWithRuleName(ctx consolectx.Context) gin.HandlerFunc { c.JSON(http.StatusOK, model.NewErrorResp(err.Error())) return } + if err = meshresource.ValidateRule(res); err != nil { + util.HandleArgumentError(c, err) + return + } opts := mutationOptions(c) if err = service.UpdateTagRuleWithOptions(ctx, res, opts); err != nil { c.JSON(http.StatusOK, model.NewErrorResp(err.Error())) @@ -128,6 +132,10 @@ func PostTagRuleWithRuleName(ctx consolectx.Context) gin.HandlerFunc { c.JSON(http.StatusOK, model.NewErrorResp(err.Error())) return } + if err = meshresource.ValidateRule(res); err != nil { + util.HandleArgumentError(c, err) + return + } opts := mutationOptions(c) if err = service.CreateTagRuleWithOptions(ctx, res, opts); err != nil { c.JSON(http.StatusOK, model.NewErrorResp(err.Error())) diff --git a/pkg/console/model/condition_rule.go b/pkg/console/model/condition_rule.go index a0e479d90..e75ac5235 100644 --- a/pkg/console/model/condition_rule.go +++ b/pkg/console/model/condition_rule.go @@ -240,22 +240,6 @@ func matchValueToDestinationCondition(val string) []DestinationCondition { return res } -func GenConditionRuleToResp(data *meshproto.ConditionRoute) *CommonResp { - if data == nil { - return NewSuccessResp(nil) - } - return NewSuccessResp(ConditionRuleResp{ - Conditions: data.Conditions, - ConfigVersion: data.ConfigVersion, - Enabled: data.Enabled, - Force: data.Force, - Key: data.Key, - Priority: data.Priority, - Runtime: data.Runtime, - Scope: data.Scope, - }) -} - type ConditionRuleV3X1 struct { Conditions []Condition `json:"conditions"` ConfigVersion string `json:"configVersion"` diff --git a/pkg/console/model/router_rule.go b/pkg/console/model/router_rule.go new file mode 100644 index 000000000..796ad812a --- /dev/null +++ b/pkg/console/model/router_rule.go @@ -0,0 +1,104 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0. + */ + +package model + +import ( + "encoding/json" + + meshproto "github.com/apache/dubbo-admin/api/mesh/v1alpha1" + "github.com/apache/dubbo-admin/pkg/common/constants" +) + +type RouterRuleSearchResp struct { + CreateTime string `json:"createTime"` + Enabled bool `json:"enabled"` + RuleName string `json:"ruleName"` + Scope string `json:"scope"` +} + +type ConditionRuleInput struct { + ConfigVersion string `json:"configVersion"` + Priority int32 `json:"priority"` + Enabled bool `json:"enabled"` + Force bool `json:"force"` + Runtime bool `json:"runtime"` + Key string `json:"key"` + Scope string `json:"scope"` + Conditions json.RawMessage `json:"conditions"` +} + +func (i *ConditionRuleInput) ToProto() (*meshproto.ConditionRoute, error) { + result := &meshproto.ConditionRoute{ + ConfigVersion: i.ConfigVersion, Priority: i.Priority, Enabled: i.Enabled, + Force: i.Force, Runtime: i.Runtime, Key: i.Key, Scope: i.Scope, + } + if i.ConfigVersion == constants.ConfiguratorVersionV3x1 { + if err := json.Unmarshal(i.Conditions, &result.ConditionRules); err != nil { + return nil, err + } + } else if err := json.Unmarshal(i.Conditions, &result.Conditions); err != nil { + return nil, err + } + return result, nil +} + +func GenConditionRuleToResp(data *meshproto.ConditionRoute) *CommonResp { + if data == nil { + return NewSuccessResp(nil) + } + conditions := any(data.Conditions) + if data.ConfigVersion == constants.ConfiguratorVersionV3x1 { + conditions = data.ConditionRules + } + return NewSuccessResp(struct { + ConfigVersion string `json:"configVersion"` + Priority int32 `json:"priority"` + Enabled bool `json:"enabled"` + Force bool `json:"force"` + Runtime bool `json:"runtime"` + Key string `json:"key"` + Scope string `json:"scope"` + Conditions any `json:"conditions"` + }{ + ConfigVersion: data.ConfigVersion, + Priority: data.Priority, + Enabled: data.Enabled, + Force: data.Force, + Runtime: data.Runtime, + Key: data.Key, + Scope: data.Scope, + Conditions: conditions, + }) +} + +type AffinityRuleInput struct { + ConfigVersion string `json:"configVersion"` + Scope string `json:"scope"` + Key string `json:"key"` + Runtime bool `json:"runtime"` + Enabled bool `json:"enabled"` + AffinityAware *meshproto.AffinityAware `json:"affinityAware"` +} + +func (i *AffinityRuleInput) ToProto() *meshproto.AffinityRoute { + return &meshproto.AffinityRoute{ConfigVersion: i.ConfigVersion, Scope: i.Scope, Key: i.Key, Runtime: i.Runtime, Enabled: i.Enabled, Affinity: i.AffinityAware} +} + +func GenAffinityRuleResp(data *meshproto.AffinityRoute) *CommonResp { + if data == nil { + return NewSuccessResp(nil) + } + return NewSuccessResp(AffinityRuleInput{ + ConfigVersion: data.ConfigVersion, + Scope: data.Scope, + Key: data.Key, + Runtime: data.Runtime, + Enabled: data.Enabled, + AffinityAware: data.Affinity, + }) +} diff --git a/pkg/console/model/router_rule_test.go b/pkg/console/model/router_rule_test.go new file mode 100644 index 000000000..af6ce827f --- /dev/null +++ b/pkg/console/model/router_rule_test.go @@ -0,0 +1,92 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0. + */ + +package model + +import ( + "encoding/json" + "testing" + + meshproto "github.com/apache/dubbo-admin/api/mesh/v1alpha1" + "github.com/apache/dubbo-admin/pkg/common/constants" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestConditionRuleInputUsesVersionSpecificConditions(t *testing.T) { + tests := []struct { + name string + version string + conditions string + wantLegacy int + wantV31 int + }{ + {name: "v3.0", version: "v3.0", conditions: `["=> application=demo"]`, wantLegacy: 1}, + {name: "v3.1", version: "v3.1", conditions: `[{"from":{"match":"method=SayHello"},"to":[{"match":"application=demo","weight":0}]}]`, wantV31: 1}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + input := &ConditionRuleInput{ + ConfigVersion: tt.version, + Conditions: json.RawMessage(tt.conditions), + } + got, err := input.ToProto() + require.NoError(t, err) + assert.Len(t, got.Conditions, tt.wantLegacy) + assert.Len(t, got.ConditionRules, tt.wantV31) + if tt.wantV31 != 0 { + assert.Zero(t, got.ConditionRules[0].To[0].Weight) + } + }) + } +} + +func TestConditionRuleInputRejectsCrossVersionConditionShape(t *testing.T) { + _, err := (&ConditionRuleInput{ + ConfigVersion: "v3.1", + Conditions: json.RawMessage(`["=> application=demo"]`), + }).ToProto() + assert.Error(t, err) +} + +func TestGenConditionRuleToRespPreservesV31Fields(t *testing.T) { + rule := &meshproto.ConditionRoute{ + ConfigVersion: constants.ConfiguratorVersionV3x1, + Priority: 7, + Enabled: true, + Force: true, + Runtime: true, + Key: "org.apache.dubbo.quickstart.Greeter:1.0.0:demo", + Scope: constants.ScopeService, + ConditionRules: []*meshproto.ConditionRule{ + { + From: &meshproto.ConditionRuleFrom{Match: "method=SayHello"}, + To: []*meshproto.ConditionRuleTo{{Match: "application=quickstart-provider", Weight: 100}}, + }, + }, + } + + encoded, err := json.Marshal(GenConditionRuleToResp(rule)) + require.NoError(t, err) + assert.JSONEq(t, `{ + "code": "Success", + "message": "success", + "data": { + "configVersion": "v3.1", + "priority": 7, + "enabled": true, + "force": true, + "runtime": true, + "key": "org.apache.dubbo.quickstart.Greeter:1.0.0:demo", + "scope": "service", + "conditions": [{ + "from": {"match": "method=SayHello"}, + "to": [{"match": "application=quickstart-provider", "weight": 100}] + }] + } + }`, string(encoded)) +} diff --git a/pkg/console/router/router.go b/pkg/console/router/router.go index 85704c476..3b26c026c 100644 --- a/pkg/console/router/router.go +++ b/pkg/console/router/router.go @@ -149,6 +149,32 @@ func InitRouter(r *gin.Engine, ctx consolectx.Context) { tagRule.DELETE("/:ruleName", handler.DeleteTagRuleWithRuleName(ctx)) } + { + affinityRule := router.Group("/affinity-rule") + affinityRule.GET("/search", handler.AffinityRuleSearch(ctx)) + affinityRule.GET("/:ruleName/versions", handler.ListRuleVersions(ctx, meshresource.AffinityRouteKind)) + affinityRule.GET("/:ruleName/versions/:versionNo", handler.GetRuleVersion(ctx, meshresource.AffinityRouteKind)) + affinityRule.GET("/:ruleName/versions/:versionNo/diff", handler.DiffRuleVersion(ctx, meshresource.AffinityRouteKind)) + affinityRule.POST("/:ruleName/versions/:versionNo/rollback", handler.RollbackRuleVersion(ctx, meshresource.AffinityRouteKind)) + affinityRule.GET("/:ruleName", handler.GetAffinityRuleWithRuleName(ctx)) + affinityRule.PUT("/:ruleName", handler.PutAffinityRuleWithRuleName(ctx)) + affinityRule.POST("/:ruleName", handler.PostAffinityRuleWithRuleName(ctx)) + affinityRule.DELETE("/:ruleName", handler.DeleteAffinityRuleWithRuleName(ctx)) + } + + { + scriptRule := router.Group("/script-rule") + scriptRule.GET("/search", handler.ScriptRuleSearch(ctx)) + scriptRule.GET("/:ruleName/versions", handler.ListRuleVersions(ctx, meshresource.ScriptRouteKind)) + scriptRule.GET("/:ruleName/versions/:versionNo", handler.GetRuleVersion(ctx, meshresource.ScriptRouteKind)) + scriptRule.GET("/:ruleName/versions/:versionNo/diff", handler.DiffRuleVersion(ctx, meshresource.ScriptRouteKind)) + scriptRule.POST("/:ruleName/versions/:versionNo/rollback", handler.RollbackRuleVersion(ctx, meshresource.ScriptRouteKind)) + scriptRule.GET("/:ruleName", handler.GetScriptRuleWithRuleName(ctx)) + scriptRule.PUT("/:ruleName", handler.PutScriptRuleWithRuleName(ctx)) + scriptRule.POST("/:ruleName", handler.PostScriptRuleWithRuleName(ctx)) + scriptRule.DELETE("/:ruleName", handler.DeleteScriptRuleWithRuleName(ctx)) + } + router.GET("/prometheus", handler.GetPrometheus(ctx)) router.GET("/search", handler.BannerGlobalSearch(ctx)) router.GET("/overview", handler.ClusterOverview(ctx)) diff --git a/pkg/console/service/affinity_rule.go b/pkg/console/service/affinity_rule.go index c7d5f2b2d..4226ce406 100644 --- a/pkg/console/service/affinity_rule.go +++ b/pkg/console/service/affinity_rule.go @@ -1,64 +1,75 @@ /* * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with + * contributor license agreements. See the NOTICE file distributed with * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You 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. + * The ASF licenses this file to You under the Apache License, Version 2.0. */ package service import ( + "github.com/duke-git/lancet/v2/slice" + "github.com/duke-git/lancet/v2/strutil" + + "github.com/apache/dubbo-admin/pkg/common/bizerror" consolectx "github.com/apache/dubbo-admin/pkg/console/context" - "github.com/apache/dubbo-admin/pkg/core/logger" + "github.com/apache/dubbo-admin/pkg/console/model" "github.com/apache/dubbo-admin/pkg/core/manager" meshresource "github.com/apache/dubbo-admin/pkg/core/resource/apis/mesh/v1alpha1" coremodel "github.com/apache/dubbo-admin/pkg/core/resource/model" + "github.com/apache/dubbo-admin/pkg/core/store/index" ) -func GetAffinityRule(ctx consolectx.Context, name string, mesh string) (*meshresource.AffinityRouteResource, error) { - res, _, err := manager.GetByKey[*meshresource.AffinityRouteResource]( - ctx.ResourceManager(), - meshresource.AffinityRouteKind, - coremodel.BuildResourceKey(mesh, name)) +func SearchAffinityRules(ctx consolectx.Context, req *model.SearchConditionRuleReq) (*model.SearchPaginationResult, error) { + if strutil.IsNotBlank(req.Keywords) { + res, exists, err := manager.GetByKey[*meshresource.AffinityRouteResource](ctx.ResourceManager(), meshresource.AffinityRouteKind, coremodel.BuildResourceKey(req.Mesh, req.Keywords)) + if err != nil { + return nil, err + } + if !exists { + return emptyRuleSearch(req.PageReq), nil + } + return singleAffinitySearch(res, req.PageReq), nil + } + page, err := manager.PageListByIndexes[*meshresource.AffinityRouteResource](ctx.ResourceManager(), meshresource.AffinityRouteKind, []index.IndexCondition{{IndexName: index.ByMeshIndex, Value: req.Mesh, Operator: index.Equals}}, req.PageReq) if err != nil { - logger.Warnf("get affinity rule %s error: %v", name, err) - return nil, err + return nil, bizerror.New(bizerror.InternalError, "search affinity rules failed") } - return res, nil + list := slice.Map(page.Data, func(_ int, r *meshresource.AffinityRouteResource) *model.RouterRuleSearchResp { + return affinitySearchItem(r) + }) + return &model.SearchPaginationResult{List: list, PageInfo: page.Pagination}, nil } -func UpdateAffinityRule(ctx consolectx.Context, res *meshresource.AffinityRouteResource) error { - if err := ctx.ResourceManager().Update(res); err != nil { - logger.Warnf("update %s affinity rule failed with error: %s", res.Name, err.Error()) - return err - } - return nil +func affinitySearchItem(r *meshresource.AffinityRouteResource) *model.RouterRuleSearchResp { + return &model.RouterRuleSearchResp{CreateTime: r.CreationTimestamp.String(), Enabled: r.Spec.Enabled, RuleName: r.Name, Scope: r.Spec.Scope} } - -func CreateAffinityRule(ctx consolectx.Context, res *meshresource.AffinityRouteResource) error { - if err := ctx.ResourceManager().Add(res); err != nil { - logger.Warnf("create %s condition failed with error: %s", res.Name, err.Error()) - return err - } - return nil +func singleAffinitySearch(r *meshresource.AffinityRouteResource, page coremodel.PageReq) *model.SearchPaginationResult { + return &model.SearchPaginationResult{List: []*model.RouterRuleSearchResp{affinitySearchItem(r)}, PageInfo: coremodel.Pagination{Total: 1, PageSize: page.PageSize, PageOffset: page.PageOffset}} +} +func emptyRuleSearch(page coremodel.PageReq) *model.SearchPaginationResult { + return &model.SearchPaginationResult{List: nil, PageInfo: coremodel.Pagination{PageSize: page.PageSize, PageOffset: page.PageOffset}} } -func DeleteAffinityRule(ctx consolectx.Context, name string, mesh string) error { - if err := ctx.ResourceManager().DeleteByKey( - meshresource.AffinityRouteKind, - mesh, - coremodel.BuildResourceKey(mesh, name)); err != nil { - return err - } - return nil +func GetAffinityRule(ctx consolectx.Context, name, mesh string) (*meshresource.AffinityRouteResource, error) { + r, _, err := manager.GetByKey[*meshresource.AffinityRouteResource](ctx.ResourceManager(), meshresource.AffinityRouteKind, coremodel.BuildResourceKey(mesh, name)) + return r, err +} +func CreateAffinityRule(ctx consolectx.Context, r *meshresource.AffinityRouteResource) error { + return CreateAffinityRuleWithOptions(ctx, r, RuleMutationOptions{}) +} +func CreateAffinityRuleWithOptions(ctx consolectx.Context, r *meshresource.AffinityRouteResource, opts RuleMutationOptions) error { + return createRule(ctx, r, opts) +} +func UpdateAffinityRule(ctx consolectx.Context, r *meshresource.AffinityRouteResource) error { + return UpdateAffinityRuleWithOptions(ctx, r, RuleMutationOptions{}) +} +func UpdateAffinityRuleWithOptions(ctx consolectx.Context, r *meshresource.AffinityRouteResource, opts RuleMutationOptions) error { + return updateRule(ctx, r, opts) +} +func DeleteAffinityRule(ctx consolectx.Context, name, mesh string) error { + return DeleteAffinityRuleWithOptions(ctx, name, mesh, RuleMutationOptions{}) +} +func DeleteAffinityRuleWithOptions(ctx consolectx.Context, name, mesh string, opts RuleMutationOptions) error { + return deleteRule(ctx, RuleRef{Kind: meshresource.AffinityRouteKind, Mesh: mesh, Name: name}, opts) } diff --git a/pkg/console/service/script_rule.go b/pkg/console/service/script_rule.go new file mode 100644 index 000000000..f54e87654 --- /dev/null +++ b/pkg/console/service/script_rule.go @@ -0,0 +1,64 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0. + */ + +package service + +import ( + consolectx "github.com/apache/dubbo-admin/pkg/console/context" + "github.com/apache/dubbo-admin/pkg/console/model" + "github.com/apache/dubbo-admin/pkg/core/manager" + meshresource "github.com/apache/dubbo-admin/pkg/core/resource/apis/mesh/v1alpha1" + coremodel "github.com/apache/dubbo-admin/pkg/core/resource/model" + "github.com/apache/dubbo-admin/pkg/core/store/index" + "github.com/duke-git/lancet/v2/slice" + "github.com/duke-git/lancet/v2/strutil" +) + +func SearchScriptRules(ctx consolectx.Context, req *model.SearchConditionRuleReq) (*model.SearchPaginationResult, error) { + if strutil.IsNotBlank(req.Keywords) { + r, ok, err := manager.GetByKey[*meshresource.ScriptRouteResource](ctx.ResourceManager(), meshresource.ScriptRouteKind, coremodel.BuildResourceKey(req.Mesh, req.Keywords)) + if err != nil { + return nil, err + } + if !ok { + return emptyRuleSearch(req.PageReq), nil + } + return &model.SearchPaginationResult{List: []*model.RouterRuleSearchResp{scriptSearchItem(r)}, PageInfo: coremodel.Pagination{Total: 1, PageSize: req.PageSize, PageOffset: req.PageOffset}}, nil + } + page, err := manager.PageListByIndexes[*meshresource.ScriptRouteResource](ctx.ResourceManager(), meshresource.ScriptRouteKind, []index.IndexCondition{{IndexName: index.ByMeshIndex, Value: req.Mesh, Operator: index.Equals}}, req.PageReq) + if err != nil { + return nil, err + } + return &model.SearchPaginationResult{List: slice.Map(page.Data, func(_ int, r *meshresource.ScriptRouteResource) *model.RouterRuleSearchResp { + return scriptSearchItem(r) + }), PageInfo: page.Pagination}, nil +} +func scriptSearchItem(r *meshresource.ScriptRouteResource) *model.RouterRuleSearchResp { + return &model.RouterRuleSearchResp{CreateTime: r.CreationTimestamp.String(), Enabled: r.Spec.Enabled, RuleName: r.Name, Scope: r.Spec.Scope} +} +func GetScriptRule(ctx consolectx.Context, name, mesh string) (*meshresource.ScriptRouteResource, error) { + r, _, err := manager.GetByKey[*meshresource.ScriptRouteResource](ctx.ResourceManager(), meshresource.ScriptRouteKind, coremodel.BuildResourceKey(mesh, name)) + return r, err +} +func CreateScriptRule(ctx consolectx.Context, r *meshresource.ScriptRouteResource) error { + return CreateScriptRuleWithOptions(ctx, r, RuleMutationOptions{}) +} +func CreateScriptRuleWithOptions(ctx consolectx.Context, r *meshresource.ScriptRouteResource, opts RuleMutationOptions) error { + return createRule(ctx, r, opts) +} +func UpdateScriptRule(ctx consolectx.Context, r *meshresource.ScriptRouteResource) error { + return UpdateScriptRuleWithOptions(ctx, r, RuleMutationOptions{}) +} +func UpdateScriptRuleWithOptions(ctx consolectx.Context, r *meshresource.ScriptRouteResource, opts RuleMutationOptions) error { + return updateRule(ctx, r, opts) +} +func DeleteScriptRule(ctx consolectx.Context, name, mesh string) error { + return DeleteScriptRuleWithOptions(ctx, name, mesh, RuleMutationOptions{}) +} +func DeleteScriptRuleWithOptions(ctx consolectx.Context, name, mesh string, opts RuleMutationOptions) error { + return deleteRule(ctx, RuleRef{Kind: meshresource.ScriptRouteKind, Mesh: mesh, Name: name}, opts) +} diff --git a/pkg/console/service/service.go b/pkg/console/service/service.go index a58347341..21c2016d1 100644 --- a/pkg/console/service/service.go +++ b/pkg/console/service/service.go @@ -730,9 +730,12 @@ func UpInsertServiceArgumentRouteConfig(ctx consolectx.Context, req model.BaseSe logger.Errorf("get service condition rule %s failed, cause: %v", serviceConditionRuleName, err) return err } + shouldCreate := conditionRouteRes == nil if conditionRouteRes == nil { conditionRouteRes = meshresource.NewConditionRouteResourceWithAttributes(serviceConditionRuleName, req.Mesh) conditionRouteRes.Spec.Conditions = make([]string, 0) + } else if conditionRouteRes.Spec == nil { + conditionRouteRes.Spec = &meshproto.ConditionRoute{Conditions: make([]string, 0)} } conditions := slice.Filter(conditionRouteRes.Spec.Conditions, func(index int, condition string) bool { return !isArgumentRoute(condition) @@ -747,12 +750,17 @@ func UpInsertServiceArgumentRouteConfig(ctx consolectx.Context, req model.BaseSe Enabled: true, Force: false, Runtime: true, - Key: req.ServiceName, + Key: req.ServiceKey(), Scope: constants.ScopeService, Conditions: conditions, } - if err = UpdateConditionRule(ctx, conditionRouteRes); err != nil { - logger.Errorf("create service condition rule %s failed, cause: %v", serviceConditionRuleName, err) + if shouldCreate { + err = CreateConditionRule(ctx, conditionRouteRes) + } else { + err = UpdateConditionRule(ctx, conditionRouteRes) + } + if err != nil { + logger.Errorf("upsert service condition rule %s failed, cause: %v", serviceConditionRuleName, err) return err } return nil diff --git a/pkg/console/service/service_argument_route_test.go b/pkg/console/service/service_argument_route_test.go new file mode 100644 index 000000000..ccaad74b6 --- /dev/null +++ b/pkg/console/service/service_argument_route_test.go @@ -0,0 +1,128 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 service + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/apache/dubbo-admin/pkg/common/constants" + "github.com/apache/dubbo-admin/pkg/console/model" + meshresource "github.com/apache/dubbo-admin/pkg/core/resource/apis/mesh/v1alpha1" + coremodel "github.com/apache/dubbo-admin/pkg/core/resource/model" + "github.com/apache/dubbo-admin/pkg/core/versioning" +) + +func TestUpInsertServiceArgumentRouteConfigCreatesMissingConditionRule(t *testing.T) { + ctx := setupRollbackTestEnv(t) + req := model.BaseServiceReq{ + ServiceName: "org.apache.demo.DemoService", + Version: "1.0.0", + Group: "demo", + } + + err := UpInsertServiceArgumentRouteConfig(ctx, req, model.ServiceArgumentRoute{ + Routes: []model.ServiceArgument{ + { + Method: "sayHello", + Conditions: []model.RouteCondition{ + {Index: "0", Relation: constants.Equal, Value: "foo"}, + }, + Destinations: []model.Destination{ + { + Conditions: []model.DestinationCondition{ + {Tag: "region", Relation: constants.Equal, Value: "hangzhou"}, + }, + }, + }, + }, + }, + }) + require.NoError(t, err) + + ruleName := "org.apache.demo.DemoService:1.0.0:demo.condition-router" + current, exists, err := ctx.rm.GetByKey(meshresource.ConditionRouteKind, coremodel.BuildResourceKey("", ruleName)) + require.NoError(t, err) + require.True(t, exists) + conditionRule := current.(*meshresource.ConditionRouteResource) + require.NotNil(t, conditionRule.Spec) + assert.Equal(t, constants.ConfiguratorVersionV3, conditionRule.Spec.ConfigVersion) + assert.Equal(t, constants.ScopeService, conditionRule.Spec.Scope) + assert.Equal(t, "org.apache.demo.DemoService:1.0.0:demo", conditionRule.Spec.Key) + assert.Equal(t, []string{"method=sayHello & arguments[0]=foo => region=hangzhou"}, conditionRule.Spec.Conditions) + + versions, err := ListRuleVersions(ctx, RuleRef{Kind: meshresource.ConditionRouteKind, Name: ruleName}) + require.NoError(t, err) + require.Len(t, versions.Items, 1) + assert.Equal(t, versioning.OperationCreate, versions.Items[0].Operation) +} + +func TestUpInsertServiceArgumentRouteConfigUpdatesExistingConditionRule(t *testing.T) { + ctx := setupRollbackTestEnv(t) + req := model.BaseServiceReq{ServiceName: "org.apache.demo.DemoService"} + ruleName := "org.apache.demo.DemoService::.condition-router" + require.NoError(t, CreateConditionRule(ctx, conditionRule(ruleName, "=>region=$region"))) + + err := UpInsertServiceArgumentRouteConfig(ctx, req, model.ServiceArgumentRoute{ + Routes: []model.ServiceArgument{ + { + Method: "sayHello", + Conditions: []model.RouteCondition{ + {Index: "0", Relation: constants.Equal, Value: "bar"}, + }, + }, + }, + }) + require.NoError(t, err) + + current, exists, err := ctx.rm.GetByKey(meshresource.ConditionRouteKind, coremodel.BuildResourceKey("", ruleName)) + require.NoError(t, err) + require.True(t, exists) + conditionRule := current.(*meshresource.ConditionRouteResource) + assert.Equal(t, []string{"=>region=$region", "method=sayHello & arguments[0]=bar"}, conditionRule.Spec.Conditions) + + versions, err := ListRuleVersions(ctx, RuleRef{Kind: meshresource.ConditionRouteKind, Name: ruleName}) + require.NoError(t, err) + require.Len(t, versions.Items, 2) + assert.Equal(t, versioning.OperationUpdate, versions.Items[0].Operation) +} + +func TestUpInsertServiceArgumentRouteConfigHandlesExistingRuleWithoutSpec(t *testing.T) { + ctx := setupRollbackTestEnv(t) + req := model.BaseServiceReq{ServiceName: "org.apache.demo.DemoService"} + ruleName := "org.apache.demo.DemoService::.condition-router" + res := meshresource.NewConditionRouteResourceWithAttributes(ruleName, "") + res.Spec = nil + require.NoError(t, ctx.stores[meshresource.ConditionRouteKind].Add(res)) + + err := UpInsertServiceArgumentRouteConfig(ctx, req, model.ServiceArgumentRoute{ + Routes: []model.ServiceArgument{ + {Method: "sayHello"}, + }, + }) + require.NoError(t, err) + + current, exists, err := ctx.rm.GetByKey(meshresource.ConditionRouteKind, coremodel.BuildResourceKey("", ruleName)) + require.NoError(t, err) + require.True(t, exists) + conditionRule := current.(*meshresource.ConditionRouteResource) + require.NotNil(t, conditionRule.Spec) + assert.Equal(t, []string{"method=sayHello"}, conditionRule.Spec.Conditions) +} diff --git a/pkg/core/discovery/subscriber/zk_config.go b/pkg/core/discovery/subscriber/zk_config.go index f9e0eec11..ee40214f8 100644 --- a/pkg/core/discovery/subscriber/zk_config.go +++ b/pkg/core/discovery/subscriber/zk_config.go @@ -116,6 +116,12 @@ func (z *ZKConfigEventSubscriber) processUpsert(configRes *meshresource.ZKConfig case constants.ConfiguratorsSuffix: return processConfigUpsert[*meshresource.DynamicConfigResource]( configRes, meshresource.ToDynamicConfigResource, z.storeRouter, z.emitter) + case constants.AffinityRuleSuffix: + return processConfigUpsert[*meshresource.AffinityRouteResource]( + configRes, meshresource.ToAffinityRouteResource, z.storeRouter, z.emitter) + case constants.ScriptRuleSuffix: + return processConfigUpsert[*meshresource.ScriptRouteResource]( + configRes, meshresource.ToScriptRouteResource, z.storeRouter, z.emitter) default: return bizerror.New(bizerror.UnknownError, fmt.Sprintf("unknown rule type in mesh %s, skipped processing, path: %s, raw content: %s", @@ -141,6 +147,12 @@ func (z *ZKConfigEventSubscriber) processDelete(configRes *meshresource.ZKConfig case constants.ConfiguratorsSuffix: return processConfigDelete[*meshresource.DynamicConfigResource]( configRes, meshresource.DynamicConfigKind, z.storeRouter, z.emitter) + case constants.AffinityRuleSuffix: + return processConfigDelete[*meshresource.AffinityRouteResource]( + configRes, meshresource.AffinityRouteKind, z.storeRouter, z.emitter) + case constants.ScriptRuleSuffix: + return processConfigDelete[*meshresource.ScriptRouteResource]( + configRes, meshresource.ScriptRouteKind, z.storeRouter, z.emitter) default: return bizerror.New(bizerror.UnknownError, fmt.Sprintf("unknown rule type in mesh %s, skipped processing, node: %s", diff --git a/pkg/core/governor/governor.go b/pkg/core/governor/governor.go index d29fd156a..255124a12 100644 --- a/pkg/core/governor/governor.go +++ b/pkg/core/governor/governor.go @@ -24,7 +24,13 @@ import ( "github.com/apache/dubbo-admin/pkg/core/resource/model" ) -var RuleResourceKinds = set.New(meshresource.DynamicConfigKind, meshresource.ConditionRouteKind, meshresource.TagRouteKind) +var RuleResourceKinds = set.New( + meshresource.DynamicConfigKind, + meshresource.ConditionRouteKind, + meshresource.TagRouteKind, + meshresource.AffinityRouteKind, + meshresource.ScriptRouteKind, +) // RuleGovernor makes the rule operations effective type RuleGovernor interface { diff --git a/pkg/core/resource/apis/mesh/v1alpha1/conditionroute_helper.go b/pkg/core/resource/apis/mesh/v1alpha1/conditionroute_helper.go index 6a79c60c8..492fe31f7 100644 --- a/pkg/core/resource/apis/mesh/v1alpha1/conditionroute_helper.go +++ b/pkg/core/resource/apis/mesh/v1alpha1/conditionroute_helper.go @@ -18,10 +18,7 @@ package v1alpha1 import ( - "sigs.k8s.io/yaml" - "github.com/apache/dubbo-admin/pkg/common/constants" - "github.com/apache/dubbo-admin/pkg/core/logger" coremodel "github.com/apache/dubbo-admin/pkg/core/resource/model" ) @@ -30,10 +27,5 @@ func BuildConditionRouteResName(scopeEntity string) string { } func ToConditionRouteResource(mesh, name, data string) coremodel.Resource { - res := NewConditionRouteResourceWithAttributes(name, mesh) - err := yaml.Unmarshal([]byte(data), res.Spec) - if err != nil { - logger.Warnf("cannot unmarshal condition route %s in %s, cause: %s, raw content:\n %s, ", name, mesh, err, data) - } - return res + return toRuleResource(ConditionRouteKind, mesh, name, data) } diff --git a/pkg/core/resource/apis/mesh/v1alpha1/dynamicconfig_helper.go b/pkg/core/resource/apis/mesh/v1alpha1/dynamicconfig_helper.go index 65a7c12e4..9b882471a 100644 --- a/pkg/core/resource/apis/mesh/v1alpha1/dynamicconfig_helper.go +++ b/pkg/core/resource/apis/mesh/v1alpha1/dynamicconfig_helper.go @@ -17,18 +17,8 @@ package v1alpha1 -import ( - "sigs.k8s.io/yaml" - - "github.com/apache/dubbo-admin/pkg/core/logger" - coremodel "github.com/apache/dubbo-admin/pkg/core/resource/model" -) +import coremodel "github.com/apache/dubbo-admin/pkg/core/resource/model" func ToDynamicConfigResource(mesh, name, data string) coremodel.Resource { - res := NewDynamicConfigResourceWithAttributes(name, mesh) - err := yaml.Unmarshal([]byte(data), res.Spec) - if err != nil { - logger.Warnf("cannot unmarshal dynamic config %s in %s, cause %s, raw content:\n %s, ", name, mesh, err, data) - } - return res + return toRuleResource(DynamicConfigKind, mesh, name, data) } diff --git a/pkg/core/resource/apis/mesh/v1alpha1/rule_codec.go b/pkg/core/resource/apis/mesh/v1alpha1/rule_codec.go new file mode 100644 index 000000000..3ea6ba7b5 --- /dev/null +++ b/pkg/core/resource/apis/mesh/v1alpha1/rule_codec.go @@ -0,0 +1,374 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0. + */ + +package v1alpha1 + +import ( + "fmt" + "strings" + + "sigs.k8s.io/yaml" + + meshproto "github.com/apache/dubbo-admin/api/mesh/v1alpha1" + "github.com/apache/dubbo-admin/pkg/common/bizerror" + "github.com/apache/dubbo-admin/pkg/common/constants" + coremodel "github.com/apache/dubbo-admin/pkg/core/resource/model" +) + +type conditionRouteV31YAML struct { + ConfigVersion string `json:"configVersion"` + Priority int32 `json:"priority,omitempty"` + Enabled bool `json:"enabled"` + Force bool `json:"force"` + Runtime bool `json:"runtime"` + Key string `json:"key"` + Scope string `json:"scope"` + Conditions []conditionRuleYAML `json:"conditions"` +} + +type conditionRouteV30YAML struct { + ConfigVersion string `json:"configVersion"` + Priority int32 `json:"priority,omitempty"` + Enabled bool `json:"enabled"` + Force bool `json:"force"` + Runtime bool `json:"runtime"` + Key string `json:"key"` + Scope string `json:"scope"` + Conditions []string `json:"conditions"` +} + +type conditionRuleYAML struct { + From conditionRuleFromYAML `json:"from"` + To []conditionRuleToYAML `json:"to"` +} + +type conditionRuleFromYAML struct { + Match string `json:"match"` +} + +type conditionRuleToYAML struct { + Match string `json:"match"` + Weight int32 `json:"weight"` +} + +type affinityRouteYAML struct { + ConfigVersion string `json:"configVersion"` + Scope string `json:"scope"` + Key string `json:"key"` + Runtime bool `json:"runtime"` + Enabled bool `json:"enabled"` + AffinityAware *affinityAwareYAML `json:"affinityAware"` +} + +type affinityAwareYAML struct { + Key string `json:"key"` + Ratio int32 `json:"ratio"` +} + +type scriptRouteYAML struct { + ConfigVersion string `json:"configVersion"` + Scope string `json:"scope"` + Key string `json:"key"` + Enabled bool `json:"enabled"` + Type string `json:"type"` + Script string `json:"script"` +} + +type tagRouteYAML struct { + Priority int32 `json:"priority,omitempty"` + Enabled bool `json:"enabled"` + Runtime bool `json:"runtime"` + Key string `json:"key"` + ConfigVersion string `json:"configVersion"` + Force bool `json:"force"` + Tags []*meshproto.Tag `json:"tags"` +} + +type dynamicConfigYAML struct { + Key string `json:"key"` + Scope string `json:"scope"` + ConfigVersion string `json:"configVersion"` + Enabled bool `json:"enabled"` + Configs []*meshproto.OverrideConfig `json:"configs"` +} + +// EncodeRule serializes an internal rule resource to the public Dubbo dynamic +// configuration YAML contract. +func EncodeRule(r coremodel.Resource) ([]byte, error) { + if err := ValidateRule(r); err != nil { + return nil, err + } + var value any = r.ResourceSpec() + switch typed := r.(type) { + case *ConditionRouteResource: + if typed.Spec.ConfigVersion == constants.ConfiguratorVersionV3x1 { + value = conditionRouteV31YAML{ + ConfigVersion: typed.Spec.ConfigVersion, + Priority: typed.Spec.Priority, + Enabled: typed.Spec.Enabled, + Force: typed.Spec.Force, + Runtime: typed.Spec.Runtime, + Key: typed.Spec.Key, + Scope: typed.Spec.Scope, + Conditions: conditionRulesToYAML(typed.Spec.ConditionRules), + } + } else { + value = conditionRouteV30YAML{ + ConfigVersion: typed.Spec.ConfigVersion, Priority: typed.Spec.Priority, + Enabled: typed.Spec.Enabled, Force: typed.Spec.Force, Runtime: typed.Spec.Runtime, + Key: typed.Spec.Key, Scope: typed.Spec.Scope, Conditions: typed.Spec.Conditions, + } + } + case *AffinityRouteResource: + var affinity *affinityAwareYAML + if typed.Spec.Affinity != nil { + affinity = &affinityAwareYAML{Key: typed.Spec.Affinity.Key, Ratio: typed.Spec.Affinity.Ratio} + } + value = affinityRouteYAML{ + ConfigVersion: typed.Spec.ConfigVersion, + Scope: typed.Spec.Scope, + Key: typed.Spec.Key, + Runtime: typed.Spec.Runtime, + Enabled: typed.Spec.Enabled, + AffinityAware: affinity, + } + case *ScriptRouteResource: + value = scriptRouteYAML{ + ConfigVersion: typed.Spec.ConfigVersion, + Scope: typed.Spec.Scope, + Key: typed.Spec.Key, + Enabled: typed.Spec.Enabled, + Type: typed.Spec.Type, + Script: typed.Spec.Script, + } + case *TagRouteResource: + value = tagRouteYAML{ + Priority: typed.Spec.Priority, + Enabled: typed.Spec.Enabled, + Runtime: typed.Spec.Runtime, + Key: typed.Spec.Key, + ConfigVersion: typed.Spec.ConfigVersion, + Force: typed.Spec.Force, + Tags: typed.Spec.Tags, + } + case *DynamicConfigResource: + value = dynamicConfigYAML{ + Key: typed.Spec.Key, + Scope: typed.Spec.Scope, + ConfigVersion: typed.Spec.ConfigVersion, + Enabled: typed.Spec.Enabled, + Configs: typed.Spec.Configs, + } + } + data, err := yaml.Marshal(value) + if err != nil { + return nil, bizerror.Wrap(err, bizerror.YamlError, "failed to marshal rule") + } + return data, nil +} + +// DecodeRule parses public Dubbo dynamic configuration YAML without losing +// version-specific fields. +func DecodeRule(kind coremodel.ResourceKind, mesh, name, data string) (coremodel.Resource, error) { + var r coremodel.Resource + switch kind { + case DynamicConfigKind: + r = NewDynamicConfigResourceWithAttributes(name, mesh) + case TagRouteKind: + r = NewTagRouteResourceWithAttributes(name, mesh) + case ScriptRouteKind: + r = NewScriptRouteResourceWithAttributes(name, mesh) + case AffinityRouteKind: + external := &affinityRouteYAML{} + if err := yaml.Unmarshal([]byte(data), external); err != nil { + return nil, ruleYAMLError(name, err) + } + r = NewAffinityRouteResourceWithAttributes(name, mesh) + var affinity *meshproto.AffinityAware + if external.AffinityAware != nil { + affinity = &meshproto.AffinityAware{Key: external.AffinityAware.Key, Ratio: external.AffinityAware.Ratio} + } + r.(*AffinityRouteResource).Spec = &meshproto.AffinityRoute{ + ConfigVersion: external.ConfigVersion, + Scope: external.Scope, Key: external.Key, Runtime: external.Runtime, + Enabled: external.Enabled, Affinity: affinity, + } + case ConditionRouteKind: + version := struct { + ConfigVersion string `json:"configVersion"` + }{} + if err := yaml.Unmarshal([]byte(data), &version); err != nil { + return nil, ruleYAMLError(name, err) + } + r = NewConditionRouteResourceWithAttributes(name, mesh) + if version.ConfigVersion == constants.ConfiguratorVersionV3x1 { + external := &conditionRouteV31YAML{} + if err := yaml.Unmarshal([]byte(data), external); err != nil { + return nil, ruleYAMLError(name, err) + } + r.(*ConditionRouteResource).Spec = &meshproto.ConditionRoute{ + ConfigVersion: external.ConfigVersion, Priority: external.Priority, + Enabled: external.Enabled, Force: external.Force, Runtime: external.Runtime, + Key: external.Key, Scope: external.Scope, ConditionRules: conditionRulesFromYAML(external.Conditions), + } + } else if err := yaml.Unmarshal([]byte(data), r.(*ConditionRouteResource).Spec); err != nil { + return nil, ruleYAMLError(name, err) + } + default: + return nil, bizerror.New(bizerror.InvalidArgument, fmt.Sprintf("unsupported rule kind %s", kind)) + } + if kind != AffinityRouteKind && kind != ConditionRouteKind { + if err := yaml.Unmarshal([]byte(data), r.ResourceSpec()); err != nil { + return nil, ruleYAMLError(name, err) + } + } + return r, nil +} + +func conditionRulesToYAML(rules []*meshproto.ConditionRule) []conditionRuleYAML { + result := make([]conditionRuleYAML, 0, len(rules)) + for _, rule := range rules { + if rule == nil { + continue + } + from := conditionRuleFromYAML{} + if rule.From != nil { + from.Match = rule.From.Match + } + to := make([]conditionRuleToYAML, 0, len(rule.To)) + for _, destination := range rule.To { + if destination != nil { + to = append(to, conditionRuleToYAML{Match: destination.Match, Weight: destination.Weight}) + } + } + result = append(result, conditionRuleYAML{From: from, To: to}) + } + return result +} + +func conditionRulesFromYAML(rules []conditionRuleYAML) []*meshproto.ConditionRule { + result := make([]*meshproto.ConditionRule, 0, len(rules)) + for _, rule := range rules { + to := make([]*meshproto.ConditionRuleTo, 0, len(rule.To)) + for _, destination := range rule.To { + to = append(to, &meshproto.ConditionRuleTo{Match: destination.Match, Weight: destination.Weight}) + } + result = append(result, &meshproto.ConditionRule{From: &meshproto.ConditionRuleFrom{Match: rule.From.Match}, To: to}) + } + return result +} + +func ruleYAMLError(name string, err error) error { + return bizerror.Wrap(err, bizerror.YamlError, fmt.Sprintf("invalid rule YAML %s", name)) +} + +// ValidateRule verifies the external contract before a governor writes it. +func ValidateRule(r coremodel.Resource) error { + if r == nil || r.ResourceSpec() == nil { + return invalidRule("rule spec is required") + } + name := r.ResourceMeta().Name + var scope, key, version, suffix string + switch typed := r.(type) { + case *DynamicConfigResource: + scope, key, version, suffix = typed.Spec.Scope, typed.Spec.Key, typed.Spec.ConfigVersion, constants.ConfiguratorRuleDotSuffix + case *TagRouteResource: + key, version, suffix = typed.Spec.Key, typed.Spec.ConfigVersion, constants.TagRuleDotSuffix + if name != key+suffix { + return invalidRule("tag rule name must be .tag-router") + } + return validateVersion(version) + case *ConditionRouteResource: + scope, key, version, suffix = typed.Spec.Scope, typed.Spec.Key, typed.Spec.ConfigVersion, constants.ConditionRuleDotSuffix + if version == constants.ConfiguratorVersionV3x1 { + if len(typed.Spec.Conditions) != 0 || len(typed.Spec.ConditionRules) == 0 { + return invalidRule("v3.1 condition rules require structured conditions only") + } + for _, condition := range typed.Spec.ConditionRules { + if condition == nil || condition.From == nil || strings.TrimSpace(condition.From.Match) == "" || len(condition.To) == 0 { + return invalidRule("each v3.1 condition requires from.match and at least one destination") + } + for _, to := range condition.To { + if to == nil || strings.TrimSpace(to.Match) == "" || to.Weight < 0 || to.Weight > 100 { + return invalidRule("condition destinations require match and weight in [0, 100]") + } + } + } + } else if len(typed.Spec.ConditionRules) != 0 { + return invalidRule("v3.0 condition rules cannot contain structured conditions") + } + case *AffinityRouteResource: + scope, key, version, suffix = typed.Spec.Scope, typed.Spec.Key, typed.Spec.ConfigVersion, constants.AffinityRuleDotSuffix + if typed.Spec.Affinity == nil || strings.TrimSpace(typed.Spec.Affinity.Key) == "" || typed.Spec.Affinity.Ratio < 0 || typed.Spec.Affinity.Ratio > 100 { + return invalidRule("affinityAware.key is required and ratio must be in [0, 100]") + } + case *ScriptRouteResource: + scope, key, version, suffix = typed.Spec.Scope, typed.Spec.Key, typed.Spec.ConfigVersion, constants.ScriptRuleDotSuffix + if scope != constants.ScopeApplication { + return invalidRule("script rules only support application scope") + } + if typed.Spec.Type != constants.ScriptTypeJavaScript { + return invalidRule("script type must be javascript") + } + if strings.TrimSpace(typed.Spec.Script) == "" || len(typed.Spec.Script) > constants.MaxScriptRuleSize { + return invalidRule(fmt.Sprintf("script must be non-empty and no larger than %d bytes", constants.MaxScriptRuleSize)) + } + default: + return invalidRule(fmt.Sprintf("unsupported rule kind %s", r.ResourceKind())) + } + if err := validateVersion(version); err != nil { + return err + } + if scope != constants.ScopeApplication && scope != constants.ScopeService { + return invalidRule("scope must be application or service") + } + if strings.TrimSpace(key) == "" || name != key+suffix { + return invalidRule(fmt.Sprintf("rule name must be key%s", suffix)) + } + return nil +} + +func validateVersion(version string) error { + if version != constants.ConfiguratorVersionV3 && version != constants.ConfiguratorVersionV3x1 { + return invalidRule("configVersion must be v3.0 or v3.1") + } + return nil +} + +func invalidRule(message string) error { + return bizerror.New(bizerror.InvalidArgument, message) +} + +func toRuleResource(kind coremodel.ResourceKind, mesh, name, data string) coremodel.Resource { + if strings.TrimSpace(data) == "" { + switch kind { + case DynamicConfigKind: + return NewDynamicConfigResourceWithAttributes(name, mesh) + case TagRouteKind: + return NewTagRouteResourceWithAttributes(name, mesh) + case ConditionRouteKind: + return NewConditionRouteResourceWithAttributes(name, mesh) + case AffinityRouteKind: + return NewAffinityRouteResourceWithAttributes(name, mesh) + case ScriptRouteKind: + return NewScriptRouteResourceWithAttributes(name, mesh) + } + } + r, err := DecodeRule(kind, mesh, name, data) + if err != nil { + return nil + } + return r +} + +func ToAffinityRouteResource(mesh, name, data string) coremodel.Resource { + return toRuleResource(AffinityRouteKind, mesh, name, data) +} + +func ToScriptRouteResource(mesh, name, data string) coremodel.Resource { + return toRuleResource(ScriptRouteKind, mesh, name, data) +} diff --git a/pkg/core/resource/apis/mesh/v1alpha1/rule_codec_test.go b/pkg/core/resource/apis/mesh/v1alpha1/rule_codec_test.go new file mode 100644 index 000000000..5579b5131 --- /dev/null +++ b/pkg/core/resource/apis/mesh/v1alpha1/rule_codec_test.go @@ -0,0 +1,215 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0. + */ + +package v1alpha1 + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + meshproto "github.com/apache/dubbo-admin/api/mesh/v1alpha1" + coremodel "github.com/apache/dubbo-admin/pkg/core/resource/model" +) + +func TestConditionRuleCodecRoundTrip(t *testing.T) { + tests := []struct { + name string + res *ConditionRouteResource + }{ + { + name: "v3.0", + res: conditionRouteForTest("demo.condition-router", &meshproto.ConditionRoute{ + ConfigVersion: "v3.0", Scope: "application", Key: "demo", Enabled: true, + Conditions: []string{"env=gray => env=gray"}, + }), + }, + { + name: "v3.1", + res: conditionRouteForTest("org.demo.Service:1.0.0:demo.condition-router", &meshproto.ConditionRoute{ + ConfigVersion: "v3.1", Scope: "service", Key: "org.demo.Service:1.0.0:demo", Enabled: true, + ConditionRules: []*meshproto.ConditionRule{{ + From: &meshproto.ConditionRuleFrom{Match: "env=gray"}, + To: []*meshproto.ConditionRuleTo{ + {Match: "env=gray", Weight: 100}, + {Match: "env!=gray", Weight: 0}, + }, + }}, + }), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + raw, err := EncodeRule(tt.res) + require.NoError(t, err) + decoded, err := DecodeRule(ConditionRouteKind, tt.res.Mesh, tt.res.Name, string(raw)) + require.NoError(t, err) + assert.Equal(t, tt.res.Spec, decoded.(*ConditionRouteResource).Spec) + if tt.name == "v3.1" { + assert.Contains(t, string(raw), "conditions:") + assert.NotContains(t, string(raw), "conditionRules:") + assert.Contains(t, string(raw), "weight: 0") + } + }) + } +} + +func TestAffinityRuleCodecUsesAffinityAware(t *testing.T) { + res := NewAffinityRouteResourceWithAttributes("demo.affinity-router", "default") + res.Spec = &meshproto.AffinityRoute{ + ConfigVersion: "v3.1", Scope: "application", Key: "demo", Enabled: true, + Affinity: &meshproto.AffinityAware{Key: "region", Ratio: 80}, + } + raw, err := EncodeRule(res) + require.NoError(t, err) + assert.Contains(t, string(raw), "affinityAware:") + assert.NotContains(t, string(raw), "affinity:") + decoded, err := DecodeRule(AffinityRouteKind, "default", res.Name, string(raw)) + require.NoError(t, err) + assert.Equal(t, res.Spec, decoded.(*AffinityRouteResource).Spec) +} + +func TestScriptRuleValidation(t *testing.T) { + res := NewScriptRouteResourceWithAttributes("demo.script-router", "default") + res.Spec = &meshproto.ScriptRoute{ + ConfigVersion: "v3.0", Scope: "application", Key: "demo", Enabled: true, + Type: "javascript", Script: "return invokers;", + } + raw, err := EncodeRule(res) + require.NoError(t, err) + decoded, err := DecodeRule(ScriptRouteKind, "default", res.Name, string(raw)) + require.NoError(t, err) + assert.Equal(t, res.Spec, decoded.(*ScriptRouteResource).Spec) + + res.Spec.Type = "lua" + assert.Error(t, ValidateRule(res)) +} + +func TestValidateRuleRejectsInvalidRouterContracts(t *testing.T) { + tests := []struct { + name string + res coremodel.Resource + }{ + { + name: "affinity ratio", + res: affinityRouteForTest("demo.affinity-router", &meshproto.AffinityRoute{ + ConfigVersion: "v3.1", Scope: "application", Key: "demo", Enabled: true, + Affinity: &meshproto.AffinityAware{Key: "region", Ratio: 101}, + }), + }, + { + name: "affinity rule name", + res: affinityRouteForTest("other.affinity-router", &meshproto.AffinityRoute{ + ConfigVersion: "v3.1", Scope: "application", Key: "demo", Enabled: true, + Affinity: &meshproto.AffinityAware{Key: "region", Ratio: 80}, + }), + }, + { + name: "condition destination weight", + res: conditionRouteForTest("demo.condition-router", &meshproto.ConditionRoute{ + ConfigVersion: "v3.1", Scope: "application", Key: "demo", Enabled: true, + ConditionRules: []*meshproto.ConditionRule{{ + From: &meshproto.ConditionRuleFrom{Match: "method=SayHello"}, + To: []*meshproto.ConditionRuleTo{{Match: "region=hangzhou", Weight: -1}}, + }}, + }), + }, + { + name: "condition empty destination", + res: conditionRouteForTest("demo.condition-router", &meshproto.ConditionRoute{ + ConfigVersion: "v3.1", Scope: "application", Key: "demo", Enabled: true, + ConditionRules: []*meshproto.ConditionRule{{ + From: &meshproto.ConditionRuleFrom{Match: "method=SayHello"}, + }}, + }), + }, + { + name: "script service scope", + res: scriptRouteForTest("org.demo.Service:1.0.0:demo.script-router", &meshproto.ScriptRoute{ + ConfigVersion: "v3.0", Scope: "service", Key: "org.demo.Service:1.0.0:demo", + Enabled: true, Type: "javascript", Script: "return invokers;", + }), + }, + { + name: "script empty body", + res: scriptRouteForTest("demo.script-router", &meshproto.ScriptRoute{ + ConfigVersion: "v3.0", Scope: "application", Key: "demo", Enabled: true, + Type: "javascript", Script: " ", + }), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Error(t, ValidateRule(tt.res)) + }) + } +} + +func TestRuleCodecPreservesExplicitDisabledState(t *testing.T) { + res := NewScriptRouteResourceWithAttributes("demo.script-router", "default") + res.Spec = &meshproto.ScriptRoute{ConfigVersion: "v3.0", Scope: "application", Key: "demo", Enabled: false, Type: "javascript", Script: "return invokers;"} + raw, err := EncodeRule(res) + require.NoError(t, err) + assert.Contains(t, string(raw), "enabled: false") +} + +func TestRuleConvertersCreateDeleteTombstonesFromEmptyContent(t *testing.T) { + tests := []struct { + kind coremodel.ResourceKind + fn ToRuleResourceFunc + }{ + {DynamicConfigKind, ToDynamicConfigResource}, + {ConditionRouteKind, ToConditionRouteResource}, + {TagRouteKind, ToTagRouteResource}, + {AffinityRouteKind, ToAffinityRouteResource}, + {ScriptRouteKind, ToScriptRouteResource}, + } + for _, tt := range tests { + r := tt.fn("default", "demo", "") + require.NotNil(t, r) + assert.Equal(t, tt.kind, r.ResourceKind()) + assert.Equal(t, "default/demo", r.ResourceKey()) + } +} + +func TestDecodeRuleKeepsLegacyServiceKeyReadable(t *testing.T) { + raw := `configVersion: v3.0 +scope: service +key: org.apache.demo.DemoService +enabled: true +runtime: true +conditions: + - => application=demo-provider +` + r, err := DecodeRule( + ConditionRouteKind, + "default", + "org.apache.demo.DemoService:1.0.0:demo.condition-router", + raw, + ) + require.NoError(t, err) + assert.Equal(t, "org.apache.demo.DemoService", r.(*ConditionRouteResource).Spec.Key) +} + +func conditionRouteForTest(name string, spec *meshproto.ConditionRoute) *ConditionRouteResource { + r := NewConditionRouteResourceWithAttributes(name, "default") + r.Spec = spec + return r +} + +func affinityRouteForTest(name string, spec *meshproto.AffinityRoute) *AffinityRouteResource { + r := NewAffinityRouteResourceWithAttributes(name, "default") + r.Spec = spec + return r +} + +func scriptRouteForTest(name string, spec *meshproto.ScriptRoute) *ScriptRouteResource { + r := NewScriptRouteResourceWithAttributes(name, "default") + r.Spec = spec + return r +} diff --git a/pkg/core/resource/apis/mesh/v1alpha1/scriptroute_types.go b/pkg/core/resource/apis/mesh/v1alpha1/scriptroute_types.go new file mode 100644 index 000000000..3355f25ea --- /dev/null +++ b/pkg/core/resource/apis/mesh/v1alpha1/scriptroute_types.go @@ -0,0 +1,188 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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. + */ +// Generated by tools/resourcegen +// Run "make generate" to update this file. + +// nolint:whitespace +package v1alpha1 + +import ( + "encoding/json" + + "google.golang.org/protobuf/proto" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + k8sruntime "k8s.io/apimachinery/pkg/runtime" + + meshproto "github.com/apache/dubbo-admin/api/mesh/v1alpha1" + "github.com/apache/dubbo-admin/pkg/core/logger" + coremodel "github.com/apache/dubbo-admin/pkg/core/resource/model" +) + +const ScriptRouteKind coremodel.ResourceKind = "ScriptRoute" + +func init() { + coremodel.RegisterResourceSchema(ScriptRouteKind, NewScriptRouteResource, NewScriptRouteResourceList) +} + +type ScriptRouteResource struct { + metav1.TypeMeta `json:",inline"` + + metav1.ObjectMeta `json:"metadata,omitempty"` + + // Mesh is the name of the dubbo mesh this resource belongs to. + // It may be omitted for cluster-scoped resources. + Mesh string `json:"mesh,omitempty"` + + // Spec is the specification of the Dubbo ScriptRoute resource. + Spec *meshproto.ScriptRoute `json:"spec,omitempty"` + + // Status is the status of the Dubbo ScriptRoute resource. + Status ScriptRouteResourceStatus `json:"status,omitempty"` +} + +type ScriptRouteResourceStatus struct { + // define resource-specific status here +} + +func (r *ScriptRouteResource) ResourceKind() coremodel.ResourceKind { + return ScriptRouteKind +} + +func (r *ScriptRouteResource) ResourceMesh() string { + return r.Mesh +} + +func (r *ScriptRouteResource) ResourceKey() string { + return coremodel.BuildResourceKey(r.Mesh, r.Name) +} + +func (r *ScriptRouteResource) ResourceMeta() metav1.ObjectMeta { + return r.ObjectMeta +} + +func (r *ScriptRouteResource) ResourceSpec() coremodel.ResourceSpec { + return r.Spec +} + +func (r *ScriptRouteResource) DeepCopyObject() k8sruntime.Object { + out := &ScriptRouteResource{ + TypeMeta: r.TypeMeta, + Mesh: r.Mesh, + Status: r.Status, + } + + r.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + + if r.Spec != nil { + spec, ok := proto.Clone(r.Spec).(*meshproto.ScriptRoute) + if !ok { + logger.Warnf("failed to clone spec %v, spec is not conformed to %s", r.Spec, r.ResourceKind()) + return out + } + out.Spec = spec + } + + return out +} + +func (r *ScriptRouteResource) String() string { + jsonStr, err := json.Marshal(r) + if err != nil { + logger.Errorf("failed to encode ScriptRouteResource: %s to json, err: %v", r.ResourceKey(), err) + return "" + } + return string(jsonStr) +} + +func NewScriptRouteResourceWithAttributes(name string, mesh string) *ScriptRouteResource { + return &ScriptRouteResource{ + TypeMeta: metav1.TypeMeta{ + Kind: string(ScriptRouteKind), + APIVersion: "v1alpha1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Labels: map[string]string{}, + }, + Mesh: mesh, + Spec: &meshproto.ScriptRoute{}, + } +} + +func NewScriptRouteResource() coremodel.Resource { + return &ScriptRouteResource{ + TypeMeta: metav1.TypeMeta{ + Kind: string(ScriptRouteKind), + APIVersion: "v1alpha1", + }, + Spec: &meshproto.ScriptRoute{}, + } +} + +type ScriptRouteResourceList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []*ScriptRouteResource `json:"items"` +} + +func (r *ScriptRouteResourceList) DeepCopyObject() k8sruntime.Object { + out := &ScriptRouteResourceList{ + TypeMeta: r.TypeMeta, + } + r.ListMeta.DeepCopyInto(&out.ListMeta) + + if len(r.Items) == 0 { + return out + } + out.Items = make([]*ScriptRouteResource, len(r.Items)) + for i := range r.Items { + out.Items[i] = r.Items[i].DeepCopyObject().(*ScriptRouteResource) + } + return out +} + +func NewScriptRouteResourceList() coremodel.ResourceList { + return &ScriptRouteResourceList{ + TypeMeta: metav1.TypeMeta{ + Kind: string(ScriptRouteKind), + APIVersion: "v1alpha1", + }, + Items: make([]*ScriptRouteResource, 0), + } +} + +func (r *ScriptRouteResourceList) SetItems(items []coremodel.Resource) { + r.Items = make([]*ScriptRouteResource, len(items)) + for i := range items { + res, ok := items[i].(*ScriptRouteResource) + if !ok { + logger.Errorf("unexpected resource type, expected: %s, get %s", ScriptRouteKind, res.ResourceKind()) + continue + } + r.Items[i] = res + } +} + +func NewScriptRouteResourceListWithItems(items ...*ScriptRouteResource) *ScriptRouteResourceList { + return &ScriptRouteResourceList{ + TypeMeta: metav1.TypeMeta{ + Kind: string(ScriptRouteKind), + APIVersion: "v1alpha1", + }, + Items: items, + } +} diff --git a/pkg/core/resource/apis/mesh/v1alpha1/tagroute_helper.go b/pkg/core/resource/apis/mesh/v1alpha1/tagroute_helper.go index 92b7aeea9..f0fc9c24b 100644 --- a/pkg/core/resource/apis/mesh/v1alpha1/tagroute_helper.go +++ b/pkg/core/resource/apis/mesh/v1alpha1/tagroute_helper.go @@ -17,18 +17,8 @@ package v1alpha1 -import ( - "sigs.k8s.io/yaml" - - "github.com/apache/dubbo-admin/pkg/core/logger" - coremodel "github.com/apache/dubbo-admin/pkg/core/resource/model" -) +import coremodel "github.com/apache/dubbo-admin/pkg/core/resource/model" func ToTagRouteResource(mesh, name, data string) coremodel.Resource { - res := NewTagRouteResourceWithAttributes(name, mesh) - err := yaml.Unmarshal([]byte(data), res.Spec) - if err != nil { - logger.Warnf("cannot unmarshal tag route %s in %s, cause: %s, raw content:\n %s, ", name, mesh, err, data) - } - return res + return toRuleResource(TagRouteKind, mesh, name, data) } diff --git a/pkg/core/versioning/normalize.go b/pkg/core/versioning/normalize.go index 34cfce72b..bc6fad95a 100644 --- a/pkg/core/versioning/normalize.go +++ b/pkg/core/versioning/normalize.go @@ -86,8 +86,7 @@ func NormalizeResource(res coremodel.Resource) (string, string, error) { // ResourceFromSpecJSON rebuilds a typed rule Resource from a stored version's // spec JSON. Used by rollback to re-publish a historical snapshot through the -// normal ResourceManager mutation path. Only the three governor-managed rule -// kinds are supported. protojson is tried first (matching how specs are +// normal ResourceManager mutation path. protojson is tried first (matching how specs are // normalized), falling back to plain JSON for resilience. func ResourceFromSpecJSON(kind coremodel.ResourceKind, mesh, ruleName, specJSON string) (coremodel.Resource, error) { switch kind { @@ -115,6 +114,22 @@ func ResourceFromSpecJSON(kind coremodel.ResourceKind, mesh, ruleName, specJSON } res.Spec = &spec return res, nil + case meshresource.AffinityRouteKind: + res := meshresource.NewAffinityRouteResourceWithAttributes(ruleName, mesh) + var spec meshproto.AffinityRoute + if err := unmarshalSpec(specJSON, &spec); err != nil { + return nil, err + } + res.Spec = &spec + return res, nil + case meshresource.ScriptRouteKind: + res := meshresource.NewScriptRouteResourceWithAttributes(ruleName, mesh) + var spec meshproto.ScriptRoute + if err := unmarshalSpec(specJSON, &spec); err != nil { + return nil, err + } + res.Spec = &spec + return res, nil default: return nil, bizerror.New(bizerror.InvalidArgument, "unsupported rule kind") } diff --git a/pkg/discovery/nacos2/factory.go b/pkg/discovery/nacos2/factory.go index 65d391a56..0d0eca015 100644 --- a/pkg/discovery/nacos2/factory.go +++ b/pkg/discovery/nacos2/factory.go @@ -53,6 +53,9 @@ func (f *Factory) Support(d discoverycfg.Type) bool { func (f *Factory) NewListWatchers( cfg *discoverycfg.Config) ([]controller.ResourceListerWatcher, error) { nacosConfigClient, nacosNamingClient, err := f.initNacosClients(cfg) + if err != nil { + return nil, err + } listerWatchers, err := f.initListerWatchers(cfg, nacosConfigClient, nacosNamingClient) if err != nil { return nil, err @@ -127,6 +130,30 @@ func (f *Factory) initListerWatchers( if err != nil { return nil, err } + affinityRouteLW, err := listerwatcher.NewConfigListerWatcher( + meshresource.AffinityRouteKind, + cfg, + nacosConfigClient, + meshresource.ToAffinityRouteResource, + true, + constants.WildcardCharacter+constants.AffinityRuleDotSuffix, + constants.RuleConfigGroup, + ) + if err != nil { + return nil, err + } + scriptRouteLW, err := listerwatcher.NewConfigListerWatcher( + meshresource.ScriptRouteKind, + cfg, + nacosConfigClient, + meshresource.ToScriptRouteResource, + true, + constants.WildcardCharacter+constants.ScriptRuleDotSuffix, + constants.RuleConfigGroup, + ) + if err != nil { + return nil, err + } serviceProviderMetadataLW, err := listerwatcher.NewConfigListerWatcher( meshresource.ServiceProviderMetadataKind, cfg, @@ -156,6 +183,8 @@ func (f *Factory) initListerWatchers( dynamicConfigLW, conditionRouteLW, tagRouteLW, + affinityRouteLW, + scriptRouteLW, serviceProviderMetadataLW, serviceProviderMappingLW, }, nil diff --git a/pkg/discovery/zk/factory.go b/pkg/discovery/zk/factory.go index 7518af8f0..7e1aff589 100644 --- a/pkg/discovery/zk/factory.go +++ b/pkg/discovery/zk/factory.go @@ -116,11 +116,10 @@ func toDeleteMappingResource(mesh, nodePath string) coremodel.Resource { } func toUpsertZKConfigResource(mesh, nodePath, nodeData string) coremodel.Resource { - paths := strings.Split(nodePath, constants.PathSeparator) - if len(paths) != 4 { + configName, ok := zkConfigName(nodePath) + if !ok { return nil } - configName := paths[3] res := meshresource.NewZKConfigResourceWithAttributes(configName, mesh) res.Spec = &meshproto.ZKConfig{ NodeName: configName, @@ -130,11 +129,10 @@ func toUpsertZKConfigResource(mesh, nodePath, nodeData string) coremodel.Resourc } func toDeleteZKConfigResource(mesh, nodePath string) coremodel.Resource { - paths := strings.Split(nodePath, constants.PathSeparator) - if len(paths) != 4 { + configName, ok := zkConfigName(nodePath) + if !ok { return nil } - configName := paths[3] res := meshresource.NewZKConfigResourceWithAttributes(configName, mesh) res.Spec = &meshproto.ZKConfig{ NodeName: configName, @@ -142,6 +140,21 @@ func toDeleteZKConfigResource(mesh, nodePath string) coremodel.Resource { return res } +func zkConfigName(nodePath string) (string, bool) { + paths := strings.Split(nodePath, constants.PathSeparator) + switch len(paths) { + case 4: + if paths[3] != "" && paths[3] != constants.RuleConfigGroup { + return paths[3], true + } + case 5: + if paths[3] == constants.RuleConfigGroup && paths[4] != "" { + return paths[4], true + } + } + return "", false +} + func toUpsertZKMetadataResource(mesh, nodePath, nodeData string) coremodel.Resource { paths := strings.Split(nodePath, constants.PathSeparator) if len(paths) < 5 { diff --git a/pkg/discovery/zk/factory_test.go b/pkg/discovery/zk/factory_test.go new file mode 100644 index 000000000..6b750dd4a --- /dev/null +++ b/pkg/discovery/zk/factory_test.go @@ -0,0 +1,69 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 zk + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestZKConfigNameSupportsLegacyAndGroupedConfigPaths(t *testing.T) { + tests := []struct { + name string + nodePath string + wantName string + wantOK bool + }{ + { + name: "legacy direct config key", + nodePath: "/dubbo/config/demo-provider.condition-router", + wantName: "demo-provider.condition-router", + wantOK: true, + }, + { + name: "dubbo-go grouped config key", + nodePath: "/dubbo/config/dubbo/org.apache.demo.DemoService:1.0.0:demo.condition-router", + wantName: "org.apache.demo.DemoService:1.0.0:demo.condition-router", + wantOK: true, + }, + { + name: "config root", + nodePath: "/dubbo/config", + wantOK: false, + }, + { + name: "group root", + nodePath: "/dubbo/config/dubbo", + wantOK: false, + }, + { + name: "unknown nested group", + nodePath: "/dubbo/config/other/demo.condition-router", + wantOK: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotName, gotOK := zkConfigName(tt.nodePath) + assert.Equal(t, tt.wantOK, gotOK) + assert.Equal(t, tt.wantName, gotName) + }) + } +} diff --git a/pkg/discovery/zk/listerwatcher/listerwatcher.go b/pkg/discovery/zk/listerwatcher/listerwatcher.go index 61b0f1581..77fec438d 100644 --- a/pkg/discovery/zk/listerwatcher/listerwatcher.go +++ b/pkg/discovery/zk/listerwatcher/listerwatcher.go @@ -31,6 +31,7 @@ import ( discoverycfg "github.com/apache/dubbo-admin/pkg/config/discovery" "github.com/apache/dubbo-admin/pkg/core/clients" "github.com/apache/dubbo-admin/pkg/core/logger" + meshresource "github.com/apache/dubbo-admin/pkg/core/resource/apis/mesh/v1alpha1" coremodel "github.com/apache/dubbo-admin/pkg/core/resource/model" "github.com/apache/dubbo-admin/pkg/discovery/zk/zkwatcher" ) @@ -51,6 +52,7 @@ type ListerWatcher[T coremodel.Resource] struct { watcher *zkwatcher.RecursiveWatcher resultChan chan watch.Event stopChan chan struct{} + address string } func NewListerWatcher( @@ -67,7 +69,11 @@ func NewListerWatcher( if err != nil { return nil, err } - conn, err := clients.NewZKConnection(cfg.Address.Registry) + address := cfg.Address.Registry + if rk == meshresource.ZKConfigKind { + address = cfg.Address.ConfigCenter + } + conn, err := clients.NewZKConnection(address) if err != nil { return nil, err } @@ -82,6 +88,7 @@ func NewListerWatcher( newResListFunc: newResListFunc, resultChan: make(chan watch.Event, 1000), stopChan: make(chan struct{}), + address: address, }, nil } @@ -198,7 +205,7 @@ func (lw *ListerWatcher[T]) handleEvent(event zkwatcher.ZookeeperEvent) { } func (lw *ListerWatcher[T]) zkAddr() string { - return lw.cfg.Address.Registry + return lw.address } func (lw *ListerWatcher[T]) mesh() string { diff --git a/pkg/governor/nacos2/governor.go b/pkg/governor/nacos2/governor.go index 626f665f4..ca1dd42b8 100644 --- a/pkg/governor/nacos2/governor.go +++ b/pkg/governor/nacos2/governor.go @@ -26,7 +26,6 @@ import ( nacosnamingclient "github.com/nacos-group/nacos-sdk-go/v2/clients/naming_client" nacosvo "github.com/nacos-group/nacos-sdk-go/v2/vo" "k8s.io/client-go/tools/cache" - "sigs.k8s.io/yaml" "github.com/apache/dubbo-admin/pkg/common/bizerror" "github.com/apache/dubbo-admin/pkg/common/constants" @@ -65,7 +64,7 @@ func NewNacos2Governor( } func (g *RuleGovernor) CreateRule(r coremodel.Resource) error { - rawContent, err := yaml.Marshal(r.ResourceSpec()) + rawContent, err := meshresource.EncodeRule(r) if err != nil { return bizerror.Wrap(err, bizerror.NacosError, fmt.Sprintf("failed to marshal resource spec, res: %s", r.String())) @@ -132,14 +131,10 @@ func (g *RuleGovernor) GetConfigAndUpdateStore(r coremodel.Resource) { logger.Errorf("failed to get store in %s, res: %s, cause: %s", r.String(), r.ResourceMesh(), err) return } - var res coremodel.Resource - switch r.ResourceKind() { - case meshresource.DynamicConfigKind: - res = meshresource.ToDynamicConfigResource(r.ResourceMesh(), r.ResourceMeta().Name, content) - case meshresource.ConditionRouteKind: - res = meshresource.ToConditionRouteResource(r.ResourceMesh(), r.ResourceMeta().Name, content) - case meshresource.TagRouteKind: - res = meshresource.ToTagRouteResource(r.ResourceMesh(), r.ResourceMeta().Name, content) + res, err := meshresource.DecodeRule(r.ResourceKind(), r.ResourceMesh(), r.ResourceMeta().Name, content) + if err != nil { + logger.Errorf("failed to decode config in %s, res: %s, cause: %s", r.String(), r.ResourceMesh(), err) + return } obj, exists, err := st.GetByKey(r.ResourceKey()) if err != nil { diff --git a/pkg/governor/zk/governor.go b/pkg/governor/zk/governor.go index a811557b2..2e191227a 100644 --- a/pkg/governor/zk/governor.go +++ b/pkg/governor/zk/governor.go @@ -19,19 +19,23 @@ package zk import ( "fmt" + "strings" "github.com/dubbogo/go-zookeeper/zk" - "sigs.k8s.io/yaml" "github.com/apache/dubbo-admin/pkg/common/bizerror" + "github.com/apache/dubbo-admin/pkg/common/constants" discoverycfg "github.com/apache/dubbo-admin/pkg/config/discovery" "github.com/apache/dubbo-admin/pkg/core/clients" "github.com/apache/dubbo-admin/pkg/core/events" "github.com/apache/dubbo-admin/pkg/core/logger" + meshresource "github.com/apache/dubbo-admin/pkg/core/resource/apis/mesh/v1alpha1" coremodel "github.com/apache/dubbo-admin/pkg/core/resource/model" "github.com/apache/dubbo-admin/pkg/core/store" ) +const zkConfigRootPath = "/dubbo/config" + type RuleGovernor struct { cfg *discoverycfg.Config storeRouter store.Router @@ -40,7 +44,7 @@ type RuleGovernor struct { } func NewZKRuleGovernor(cfg *discoverycfg.Config, router store.Router, emitter events.Emitter) (*RuleGovernor, error) { - address := cfg.Address.Registry + address := cfg.Address.ConfigCenter conn, err := clients.NewZKConnection(address) if err != nil { return nil, err @@ -54,12 +58,15 @@ func NewZKRuleGovernor(cfg *discoverycfg.Config, router store.Router, emitter ev } func (g *RuleGovernor) CreateRule(r coremodel.Resource) error { - path := "/dubbo/config/" + r.ResourceMeta().Name - content, err := yaml.Marshal(r.ResourceSpec()) + path := ruleConfigPath(r.ResourceMeta().Name) + content, err := meshresource.EncodeRule(r) if err != nil { return bizerror.Wrap(err, bizerror.YamlError, fmt.Sprintf("failed to marshal resource spec, res: %s", r.String())) } + if err := g.ensurePath(ruleConfigGroupPath()); err != nil { + return err + } _, err = g.conn.Create(path, content, 0, zk.WorldACL(zk.PermAll)) if err != nil { return bizerror.Wrap(err, bizerror.ZKError, @@ -81,13 +88,19 @@ func (g *RuleGovernor) CreateRule(r coremodel.Resource) error { } func (g *RuleGovernor) UpdateRule(r coremodel.Resource) error { - path := "/dubbo/config/" + r.ResourceMeta().Name - content, err := yaml.Marshal(r.ResourceSpec()) + path := ruleConfigPath(r.ResourceMeta().Name) + content, err := meshresource.EncodeRule(r) if err != nil { return bizerror.Wrap(err, bizerror.YamlError, fmt.Sprintf("failed to marshal resource spec, res: %s", r.String())) } _, err = g.conn.Set(path, content, -1) + if err == zk.ErrNoNode { + if err := g.ensurePath(ruleConfigGroupPath()); err != nil { + return err + } + _, err = g.conn.Create(path, content, 0, zk.WorldACL(zk.PermAll)) + } if err != nil { return bizerror.Wrap(err, bizerror.ZKError, fmt.Sprintf("failed to update zk node, path: %s", path)) @@ -105,7 +118,7 @@ func (g *RuleGovernor) UpdateRule(r coremodel.Resource) error { } func (g *RuleGovernor) DeleteRule(r coremodel.Resource) error { - path := "/dubbo/config/" + r.ResourceMeta().Name + path := ruleConfigPath(r.ResourceMeta().Name) err := g.conn.Delete(path, -1) if err != nil { return bizerror.Wrap(err, bizerror.ZKError, @@ -121,3 +134,37 @@ func (g *RuleGovernor) DeleteRule(r coremodel.Resource) error { } return nil } + +func ruleConfigPath(ruleName string) string { + return ruleConfigGroupPath() + constants.PathSeparator + ruleName +} + +func ruleConfigGroupPath() string { + return zkConfigRootPath + constants.PathSeparator + constants.RuleConfigGroup +} + +func legacyRuleConfigPath(ruleName string) string { + return zkConfigRootPath + constants.PathSeparator + ruleName +} + +func (g *RuleGovernor) ensurePath(targetPath string) error { + parts := strings.Split(strings.Trim(targetPath, constants.PathSeparator), constants.PathSeparator) + currentPath := "" + for _, part := range parts { + currentPath += constants.PathSeparator + part + exists, _, err := g.conn.Exists(currentPath) + if err != nil { + return bizerror.Wrap(err, bizerror.ZKError, + fmt.Sprintf("failed to check zk node, path: %s", currentPath)) + } + if exists { + continue + } + _, err = g.conn.Create(currentPath, nil, 0, zk.WorldACL(zk.PermAll)) + if err != nil && err != zk.ErrNodeExists { + return bizerror.Wrap(err, bizerror.ZKError, + fmt.Sprintf("failed to create zk node, path: %s", currentPath)) + } + } + return nil +} diff --git a/pkg/governor/zk/governor_test.go b/pkg/governor/zk/governor_test.go new file mode 100644 index 000000000..b8638a883 --- /dev/null +++ b/pkg/governor/zk/governor_test.go @@ -0,0 +1,31 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 zk + +import "testing" + +import "github.com/stretchr/testify/assert" + +func TestRuleConfigPathUsesDubboConfigGroup(t *testing.T) { + assert.Equal(t, + "/dubbo/config/dubbo/org.apache.demo.DemoService:1.0.0:demo.condition-router", + ruleConfigPath("org.apache.demo.DemoService:1.0.0:demo.condition-router")) + assert.Equal(t, + "/dubbo/config/org.apache.demo.DemoService:1.0.0:demo.condition-router", + legacyRuleConfigPath("org.apache.demo.DemoService:1.0.0:demo.condition-router")) +} diff --git a/task--20260813--router-rule-chain.md b/task--20260813--router-rule-chain.md new file mode 100644 index 000000000..3424360ca --- /dev/null +++ b/task--20260813--router-rule-chain.md @@ -0,0 +1,154 @@ +# Router 规则下发链路任务留痕 + +## 任务信息 + +- 日期:2026-08-13 +- 分支:`feature/admin-router-rule-chain` +- Issue:`[Admin Feature] Align router in Dubbo-Go on Dubbo-Java version and Dubbo-Admin #1523` +- 需求来源:`agent.md`、`TODO.md` +- 分支策略:用户明确要求在当前分支完成,不新建分支。 + +## 背景与目标 + +当前分支已经修复 Service Argument Route upsert、Condition/Tag 规则命名及部分 Zookeeper 标准路径和配置中心地址问题。本任务继续以 `develop` 为基线补齐其余链路,使 Admin 创建、更新、删除的 Router 规则能够按 Dubbo 外部 YAML 契约写入 Zookeeper/Nacos,被 Admin watcher 无损回读,并覆盖 Affinity、Condition v3.1 和 Script Router 的最小可用管理能力。 + +## 实现边界 + +1. 公共链路:统一规则 codec;Zookeeper 标准 group、父节点、legacy 只读兼容和根节点过滤;ConfigCenter 地址;Nacos/Zookeeper 一致的外部 YAML。 +2. Affinity:资源接线、`affinityAware` codec、校验、Console CRUD、ZK/Nacos watcher 与最小前端 CRUD。 +3. Condition v3.1:v3.0 字符串与 v3.1 结构化 conditions 并存,无损 codec、校验、Console API 和 YAML 编辑/展示。 +4. Script:proto/resource、codec、校验、Console CRUD、ZK/Nacos watcher、最小前端 CRUD。 +5. 验证:单元测试、前端测试与构建、仓库格式检查、Zookeeper/Nacos 及 dubbo-go RouterChain 端到端验证;保存运行证据和截图。 + +本期遵循需求限定:先打通核心链路;性能、重试、可靠性增强及 legacy 主动迁移不在本次范围,Admin 不执行用户脚本。 + +## 关键设计 + +- 以 `ResourceKind` 分派统一 codec,内部 proto/resource 不直接作为外部配置中心契约。 +- Condition 根据 `configVersion` 分派 v3.0/v3.1 模型;任何回读和再次发布不得跨版本隐式转换。 +- 规则名、scope/key、ratio/weight、脚本类型/大小等在 ResourceManager 写入前校验。 +- Zookeeper 新写入固定为 `/dubbo/config/dubbo/`;legacy 仅回读,不随新规则写入或删除而清理。 +- 前端优先提供完整 YAML 路径;已有 v3.0 表单保持兼容,结构化 v3.1 不进入字符串表单解析。 + +## 风险与测试策略 + +- proto 变更:运行生成流程并检查生成文件一致性。 +- v3.0 回归:Condition/Tag/Configurator 现有测试及新增 codec round-trip。 +- watcher 删除语义:覆盖 Add/Update/Delete 与根节点过滤测试。 +- API 契约:覆盖非法输入在 governor 调用前失败、合法资源 CRUD。 +- 前端:组件单测、类型检查、lint、build,并验证 YAML create/update/detail。 +- E2E:分别记录配置中心真实内容、Admin 回读、dubbo-go 配置事件和最终路由结果。 + +## 实现结果 + +### 公共下发与回读 + +- Service Argument Route 改为 Create/Update upsert,并覆盖首次创建、已有规则更新和 nil Spec。 +- Condition service/application 与 Tag application 规则名按 Dubbo 外部契约生成。 +- 新增统一 `EncodeRule`、`DecodeRule`、`ValidateRule`,Zookeeper 与 Nacos 不再直接暴露内部 proto YAML。 +- Zookeeper 新写入固定为 `/dubbo/config/dubbo/`,自动创建父节点;标准节点不存在时 Update 会完成创建。 +- Zookeeper Governor 与配置 watcher 使用 `Address.ConfigCenter`;保留 Registry 回退和 legacy 只读兼容,不主动迁移或删除 legacy 节点。 +- Nacos 初始化失败会立即返回,避免后续客户端初始化覆盖原始错误。 + +### Affinity、Condition v3.1 与 Script + +- Affinity 已接入资源集合、codec、校验、Console CRUD、版本历史/差异/回滚、ZK/Nacos watcher 与前端列表/YAML 编辑器。 +- Condition 以 `configVersion` 分派 v3.0 `[]string` 和 v3.1 结构化 `from/to/weight`,API、codec 与 YAML 页面保持无损;显式保留 `weight: 0`。 +- Script 已新增 proto/resource、codec、校验、Console CRUD、版本历史/差异/回滚、ZK/Nacos watcher 与前端脚本 YAML 编辑器;只允许 application scope、`javascript` 和非空且受大小限制的脚本。 +- Affinity/Script URL 中规则名统一编码,写操作带 author/reason 等 mutation options。 + +## 自动化验证记录 + +在 `D:/environment/github/dubbo-go/dubbo-admin` 执行: + +```powershell +& "D:\tools\Git\bin\bash.exe" -lc 'export GOROOT="D:/tools/go"; export PATH="/c/Users/57512/go/bin:$PATH"; cd /d/environment/github/dubbo-go/dubbo-admin && make fmt' +git diff --check +go test ./... +``` + +结果:全部通过。当前 Makefile 没有 `check-fmt` target,因此按仓库实际能力使用 `make fmt` 后以 `git diff --check` 检查差异。 + +前端在 `ui-vue3` 执行: + +```powershell +yarn vitest run +yarn eslint <本次修改的 TS/Vue 文件> +yarn vite build +yarn type-check +yarn prettier-check +``` + +- Vitest:6 files / 11 tests passed。 +- 定向 ESLint:0 error;仅有仓库既有未使用变量 warning。 +- Vite 生产构建:成功;保留既有 `updateInstanceTrafficSwitch` 未导出及大 chunk warning。 +- `yarn type-check`:仓库基线失败,错误集中在 home、resource detail/Grafana/instance 等既有文件;输出中没有本次新增 Affinity、Script、Condition 或 shared router-rule 文件,日志为 `e2e/router-rule-chain/type-check.log`。 +- `yarn prettier-check`:仓库基线的 159 个文件不符合 Prettier;本次改动文件已通过定向格式化/检查,日志为 `e2e/router-rule-chain/prettier-check.log`。 + +独立复现工程在 `D:/environment/github/dubbo-go/quickstart-demo` 执行: + +```powershell +go test ./e2e/admin-router-rule-chain/... +``` + +四个 Condition/Affinity client/server package 均编译通过。复现说明位于 `e2e/admin-router-rule-chain/README.md`。 + +## 真实 E2E 验证 + +### ZooKeeper + dubbo-go RouterChain + +环境:已有 ZooKeeper `127.0.0.1:2181`、Admin `127.0.0.1:8888`、两个 dubbo-go provider `20000/20001` 和持续调用 consumer。 + +已完成 Condition v3.0 和 v3.1 的 create/update/delete: + +- Admin 写入标准路径 `/dubbo/config/dubbo/org.apache.dubbo.quickstart.Greeter:1.0.0:demo.condition-router`。 +- consumer 收到动态配置变化,v3.1 更新为单一 Hangzhou destination 后,连续调用全部命中 Hangzhou;切换 v3.0 destination 后能够命中 Shanghai;删除后恢复两 provider 的无约束选择。 +- Admin 更新前后回读保持 v3.1 `from/to/weight` 结构,证据为 `e2e/router-rule-chain/condition-v31-update-read.json`。 +- `weight: 0` 不表示禁用 destination:当前 dubbo-go `newCondSet` 会把 `<= 0` 替换为默认权重。这是 dubbo-go 既有语义,不是 Admin codec 丢失;Admin 已验证 0 值无损保存。 + +测试过程日志在被 `.gitignore` 排除的 `e2e/router-rule-chain/admin-zk.log`、`consumer.log` 与 provider 日志中。 + +### Nacos + +临时使用 `nacos/nacos-server:v2.3.2` standalone 容器,Admin 监听 `127.0.0.1:8889`,mesh 为 `local-nacos`。 + +- Condition、Affinity、Script 均通过 Console API create/read/update/delete。 +- Nacos 中 `Group=dubbo`,DataId 使用对应 `*.condition-router`、`*.affinity-router`、`*.script-router`,原始 YAML 与统一外部契约一致。 +- Affinity 更新保留 `enabled: false`;从 Nacos 外部直接把 ratio 改为 33 后,Admin watcher/API 回读同步为 33。 +- 删除后详情返回 `NotFoundError`;测试数据均已删除。 +- 证据保存在被忽略的 `e2e/router-rule-chain/admin-nacos.log`。 + +### UI 运行证据 + +使用真实 Admin `8888` 和 Vite `8881` 登录并由 Cypress/Edge headless 验证页面和 API 数据,测试通过 `1 spec / 1 test`。截图保存在被忽略的: + +- `e2e/router-rule-chain/affinity-rule-list.png` +- `e2e/router-rule-chain/script-rule-list.png` +- `e2e/router-rule-chain/condition-v31-yaml-detail.png` + +截图用临时规则在测试结束后已通过 API 删除。 + +## 上游能力边界 + +### Script Router + +当前依赖的 dubbo-go 中 Script factory 注册被注释: + +```go +// TODO(finalt) Temporarily removed until fixed +// extension.SetRouterFactory(constant.ScriptRouterFactoryKey, NewScriptRouterFactory) +``` + +因此本次真实验证覆盖 Admin 发布、Nacos/ZK 外部契约、watcher 回读和删除,但默认 RouterChain 无法执行 Script;不将其记录为路由执行 E2E 通过。 + +### Affinity Router + +- 当前 `dubbo-go/v3/imports` 未 blank-import affinity router,复现 consumer 必须显式 import。 +- 显式注册后,配置下发和 ZK 变化已验证;但当前 service-discovery URL 合并会让用于测试的 consumer reference 参数在两个 invoker 上相同,无法可靠证明最终筛选结果。 +- `quickstart-demo/e2e/admin-router-rule-chain/affinity` 保留了该诊断流程,README 明确其用途和限制,不将它作为最终 Affinity 路由命中的证明。 + +## 清理与交付状态 + +- E2E 创建的 Nacos 数据和临时 UI 规则已删除;临时 Admin、provider、consumer、Vite 进程及 Nacos 容器在收尾时精确清理。 +- 不清理用户已有 legacy 配置;只删除本任务明确创建的标准 ZK 测试节点。 +- 当前分支保持未提交、未推送,等待用户 review 后再执行远程推送和 PR。 diff --git a/ui-vue3/src/api/service/traffic.ts b/ui-vue3/src/api/service/traffic.ts index 06dd41664..8993d44f2 100644 --- a/ui-vue3/src/api/service/traffic.ts +++ b/ui-vue3/src/api/service/traffic.ts @@ -17,7 +17,14 @@ import request from '@/base/http/request' -export type TrafficRuleKind = 'condition-rule' | 'tag-rule' | 'configurator' +export type TrafficRuleKind = + | 'condition-rule' + | 'tag-rule' + | 'configurator' + | 'affinity-rule' + | 'script-rule' + +export type RouterRuleKind = 'affinity-rule' | 'script-rule' export interface RuleVersion { ruleKind: string @@ -61,7 +68,9 @@ export interface RollbackRuleVersionResult { } const ruleNameForPath = (kind: TrafficRuleKind, ruleName: string): string => { - return kind === 'configurator' ? encodeURIComponent(ruleName) : ruleName + return ['configurator', 'affinity-rule', 'script-rule'].includes(kind) + ? encodeURIComponent(ruleName) + : ruleName } export const listRuleVersionsAPI = ( @@ -193,6 +202,34 @@ export const addTagRuleAPI = (ruleName: string, data: any): Promise => { }) } +export const searchRouterRuleAPI = (kind: RouterRuleKind, params: any): Promise => { + return request({ url: `/${kind}/search`, method: 'get', params }) +} + +export const getRouterRuleAPI = (kind: RouterRuleKind, ruleName: string): Promise => { + return request({ url: `/${kind}/${encodeURIComponent(ruleName)}`, method: 'get' }) +} + +export const addRouterRuleAPI = ( + kind: RouterRuleKind, + ruleName: string, + data: any +): Promise => { + return request({ url: `/${kind}/${encodeURIComponent(ruleName)}`, method: 'post', data }) +} + +export const updateRouterRuleAPI = ( + kind: RouterRuleKind, + ruleName: string, + data: any +): Promise => { + return request({ url: `/${kind}/${encodeURIComponent(ruleName)}`, method: 'put', data }) +} + +export const deleteRouterRuleAPI = (kind: RouterRuleKind, ruleName: string): Promise => { + return request({ url: `/${kind}/${encodeURIComponent(ruleName)}`, method: 'delete' }) +} + export const searchDynamicConfig = (params: any): Promise => { return request({ url: '/configurator/search', diff --git a/ui-vue3/src/base/i18n/en.ts b/ui-vue3/src/base/i18n/en.ts index ebdf3443d..db4774035 100644 --- a/ui-vue3/src/base/i18n/en.ts +++ b/ui-vue3/src/base/i18n/en.ts @@ -53,6 +53,7 @@ const words: I18nType = { runtime: 'Runtime Effective', enabled: 'Enable Now', priority: 'Priority', + configVersion: 'Config version', routeList: 'Route List', route: 'Route', matchRequest: 'Request Match', @@ -60,6 +61,13 @@ const words: I18nType = { addMatchRequest: 'Add Request Match', routeDistribution: 'Route Distribution', addRoute: 'Add Route', + sourceMatch: 'Source match expression', + destinations: 'Destinations', + destinationMatch: 'Destination match expression', + weight: 'Weight', + addDestination: 'Add destination', + structuredConditionHint: + 'v3.1 uses structured conditions: from.match selects requests and each to entry defines a destination match and weight.', addArgument: 'Add Argument', addAttachment: 'Add Attachment', addOther: 'Add Other', @@ -262,6 +270,10 @@ const words: I18nType = { trafficManagement: 'Traffic Management', routingRule: 'Condition Rule', tagRule: 'Tag Rule', + affinityRule: 'Affinity Rule', + affinityRuleEditor: 'Edit Affinity Rule', + scriptRule: 'Script Rule', + scriptRuleEditor: 'Edit Script Rule', meshRule: 'Mesh Rule', dynamicConfig: 'Dynamic Config', accessControl: 'Black White List', diff --git a/ui-vue3/src/base/i18n/zh.ts b/ui-vue3/src/base/i18n/zh.ts index 776cf5958..1bea909ab 100644 --- a/ui-vue3/src/base/i18n/zh.ts +++ b/ui-vue3/src/base/i18n/zh.ts @@ -59,6 +59,7 @@ const words: I18nType = { runtime: '运行时生效', enabled: '立即启用', priority: '优先级', + configVersion: '配置版本', routeList: '路由列表', route: '路由', matchRequest: '请求匹配', @@ -66,6 +67,13 @@ const words: I18nType = { addMatchRequest: '增加匹配条件', routeDistribution: '路由分发', addRoute: '增加路由', + sourceMatch: '来源匹配表达式', + destinations: '路由目的地', + destinationMatch: '目的地匹配表达式', + weight: '权重', + addDestination: '增加目的地', + structuredConditionHint: + 'v3.1 使用结构化条件:from.match 描述请求匹配,每个 to 条目描述目的地匹配及权重。', addArgument: '添加argument', addAttachment: '添加attachment', addOther: '添加other', @@ -243,6 +251,10 @@ const words: I18nType = { serviceRelation: '服务关系', routingRule: '条件路由', tagRule: '标签路由', + affinityRule: '亲和路由', + affinityRuleEditor: '编辑亲和路由', + scriptRule: '脚本路由', + scriptRuleEditor: '编辑脚本路由', meshRule: 'Mesh路由', dynamicConfig: '动态配置', accessControl: '黑白名单', diff --git a/ui-vue3/src/layout/menu/layout_menu.vue b/ui-vue3/src/layout/menu/layout_menu.vue index 970e446f3..60d4c6945 100644 --- a/ui-vue3/src/layout/menu/layout_menu.vue +++ b/ui-vue3/src/layout/menu/layout_menu.vue @@ -19,8 +19,7 @@ @@ -32,41 +31,35 @@ import type { RouteRecordType } from '@/router/defaultRoutes' import { routes as defaultRoutes } from '@/router/defaultRoutes' import type { ItemType, MenuProps } from 'ant-design-vue' -import type { ComponentInternalInstance } from 'vue' -import { computed, getCurrentInstance, h, reactive } from 'vue' +import { computed, getCurrentInstance, h, reactive, ref, watch } from 'vue' import { Icon } from '@iconify/vue' import { useRoute, useRouter } from 'vue-router' import type { RouterMeta } from '@/router/RouterMeta' +import { activeMenuRoute, ancestorMenuKeys } from './menuState' const { appContext: { config: { globalProperties } } -} = getCurrentInstance() +} = getCurrentInstance()! const routesForMenu = defaultRoutes const nowRoute = useRoute() -// load active menu -let selectedKeys = computed(() => [getLoadSelectedKeys(nowRoute.meta)]) -let openKeys: any = reactive([]) -function getLoadSelectedKeys(meta: RouterMeta): string { - return meta.tab || meta.hidden ? getLoadSelectedKeys(meta.parent?.meta!) : meta._router_key! -} -function loadOpenedKeys() { - let p: any = nowRoute.meta.parent - while (p) { - openKeys.push(p.meta?._router_key) - p = p.meta?.parent - } -} - -loadOpenedKeys() - -function selectMenu(e: any) { - selectedKeys[0] = e.key -} +const activeRoute = computed(() => activeMenuRoute(routesForMenu, nowRoute.meta as RouterMeta)) +const selectedKeys = computed(() => { + const key = activeRoute.value?.meta?._router_key + return key ? [key] : [] +}) +const openKeys = ref([]) +watch( + activeRoute, + (route) => { + openKeys.value = ancestorMenuKeys(route) + }, + { immediate: true } +) function getItem( label: any, @@ -100,15 +93,11 @@ const items: ItemType[] = reactive([]) * @param arr * @param arr2 */ -function prepareRoutes( - arr: readonly RouteRecordType[] | undefined, - arr2: ItemType[], - parent = 'root' -) { +function prepareRoutes(arr: readonly RouteRecordType[] | undefined, arr2: ItemType[]) { if (!arr || arr.length === 0) return for (let r of arr) { if (r.meta?.skip) { - prepareRoutes(r.children, arr2, r.name) + prepareRoutes(r.children, arr2) continue } if (!r.meta?.hidden) { @@ -120,7 +109,7 @@ function prepareRoutes( ) } else { const tmp: ItemType[] = reactive([]) - prepareRoutes(r.children, tmp, r.name) + prepareRoutes(r.children, tmp) arr2.push(getItem(r.name, r.path, r.meta?._router_key, r.meta?.icon, tmp)) } } @@ -133,7 +122,7 @@ const router = useRouter() const handleClick: MenuProps['onClick'] = (e) => { // console.log(e.item?.title) - router.push(e.item?.title) + router.push(e.item?.title as string) } diff --git a/ui-vue3/src/views/traffic/_shared/RouterRuleYamlEditor.vue b/ui-vue3/src/views/traffic/_shared/RouterRuleYamlEditor.vue new file mode 100644 index 000000000..a1e975fb8 --- /dev/null +++ b/ui-vue3/src/views/traffic/_shared/RouterRuleYamlEditor.vue @@ -0,0 +1,109 @@ + + + + + + diff --git a/ui-vue3/src/views/traffic/affinityRule/editor.vue b/ui-vue3/src/views/traffic/affinityRule/editor.vue new file mode 100644 index 000000000..e8e6771bc --- /dev/null +++ b/ui-vue3/src/views/traffic/affinityRule/editor.vue @@ -0,0 +1,5 @@ + + diff --git a/ui-vue3/src/views/traffic/affinityRule/index.vue b/ui-vue3/src/views/traffic/affinityRule/index.vue new file mode 100644 index 000000000..f767ba2c8 --- /dev/null +++ b/ui-vue3/src/views/traffic/affinityRule/index.vue @@ -0,0 +1,5 @@ + + diff --git a/ui-vue3/src/views/traffic/routingRule/components/StructuredConditionRuleList.vue b/ui-vue3/src/views/traffic/routingRule/components/StructuredConditionRuleList.vue new file mode 100644 index 000000000..751346b44 --- /dev/null +++ b/ui-vue3/src/views/traffic/routingRule/components/StructuredConditionRuleList.vue @@ -0,0 +1,145 @@ + + + + + diff --git a/ui-vue3/src/views/traffic/routingRule/model/ConditionRuleModel.spec.ts b/ui-vue3/src/views/traffic/routingRule/model/ConditionRuleModel.spec.ts new file mode 100644 index 000000000..66c3acc7e --- /dev/null +++ b/ui-vue3/src/views/traffic/routingRule/model/ConditionRuleModel.spec.ts @@ -0,0 +1,49 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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. + */ + +import { describe, expect, it } from 'vitest' +import { isCompleteConditionRule, normalizeStructuredConditions } from './ConditionRuleModel' + +describe('ConditionRuleModel', () => { + it('normalizes v3.1 conditions and preserves an explicit zero weight', () => { + expect( + normalizeStructuredConditions([ + { + from: { match: 'method=SayHello' }, + to: [ + { match: 'region=hangzhou', weight: 100 }, + { match: 'region=shanghai', weight: 0 } + ] + } + ]) + ).toEqual([ + { + from: { match: 'method=SayHello' }, + to: [ + { match: 'region=hangzhou', weight: 100 }, + { match: 'region=shanghai', weight: 0 } + ] + } + ]) + }) + + it('rejects the partial tab state that caused YAML fields to disappear', () => { + expect( + isCompleteConditionRule({ enabled: true, key: 'demo', runtime: true, scope: 'service' }) + ).toBe(false) + }) +}) diff --git a/ui-vue3/src/views/traffic/routingRule/model/ConditionRuleModel.ts b/ui-vue3/src/views/traffic/routingRule/model/ConditionRuleModel.ts new file mode 100644 index 000000000..3f3a29f15 --- /dev/null +++ b/ui-vue3/src/views/traffic/routingRule/model/ConditionRuleModel.ts @@ -0,0 +1,62 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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. + */ + +export interface ConditionRuleDestination { + match: string + weight: number +} + +export interface StructuredConditionRule { + from: { match: string } + to: ConditionRuleDestination[] +} + +export const newStructuredConditionRule = (): StructuredConditionRule => ({ + from: { match: '' }, + to: [{ match: '', weight: 0 }] +}) + +export const normalizeStructuredConditions = (conditions: unknown): StructuredConditionRule[] => { + if (!Array.isArray(conditions)) { + return [] + } + return conditions.map((condition: any) => ({ + from: { match: String(condition?.from?.match || '') }, + to: Array.isArray(condition?.to) + ? condition.to.map((destination: any) => ({ + match: String(destination?.match || ''), + weight: Number(destination?.weight ?? 0) + })) + : [] + })) +} + +export const isCompleteConditionRule = (data: unknown): data is Record => { + if (!data || typeof data !== 'object' || Array.isArray(data)) { + return false + } + return [ + 'configVersion', + 'priority', + 'enabled', + 'force', + 'runtime', + 'key', + 'scope', + 'conditions' + ].every((field) => Object.prototype.hasOwnProperty.call(data, field)) +} diff --git a/ui-vue3/src/views/traffic/routingRule/tabs/addByFormView.spec.ts b/ui-vue3/src/views/traffic/routingRule/tabs/addByFormView.spec.ts new file mode 100644 index 000000000..7a92943cd --- /dev/null +++ b/ui-vue3/src/views/traffic/routingRule/tabs/addByFormView.spec.ts @@ -0,0 +1,208 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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. + */ + +import { flushPromises, mount } from '@vue/test-utils' +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { defineComponent, h } from 'vue' +import { HTTP_STATUS } from '@/base/http/constants' +import { PROVIDE_INJECT_KEY } from '@/base/enums/ProvideInject' +import type AddByFormViewType from './addByFormView.vue' + +const mocks = vi.hoisted(() => ({ + addConditionRuleAPI: vi.fn(), + push: vi.fn() +})) + +vi.hoisted(() => { + Object.defineProperty(globalThis, 'localStorage', { + value: { + getItem: () => null, + setItem: () => undefined, + removeItem: () => undefined + }, + configurable: true + }) +}) + +vi.mock('vue-router', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useRouter: () => ({ push: mocks.push }) + } +}) + +vi.mock('@/api/service/traffic', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + addConditionRuleAPI: mocks.addConditionRuleAPI + } +}) + +vi.mock('ant-design-vue', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + message: { + success: vi.fn(), + warning: vi.fn(), + error: vi.fn() + } + } +}) + +vi.mock('vue-clipboard3', () => ({ + default: () => ({ toClipboard: vi.fn() }) +})) + +const passthrough = defineComponent({ + setup(_props, { slots }) { + return () => h('div', slots.default?.()) + } +}) + +const buttonStub = defineComponent({ + emits: ['click'], + setup(_props, { emit, slots }) { + return () => h('button', { type: 'button', onClick: () => emit('click') }, slots.default?.()) + } +}) + +let i18n: typeof import('@/base/i18n').i18n +let AddByFormView: typeof AddByFormViewType + +beforeAll(async () => { + i18n = (await import('@/base/i18n')).i18n + AddByFormView = (await import('./addByFormView.vue')).default +}) + +beforeEach(() => { + mocks.addConditionRuleAPI.mockReset() + mocks.push.mockReset() +}) + +function mountForm(tabState: any) { + return mount(AddByFormView, { + global: { + plugins: [i18n], + provide: { + [PROVIDE_INJECT_KEY.TAB_LAYOUT_STATE]: tabState + }, + stubs: { + RoutingRuleList: passthrough, + AFlex: passthrough, + 'a-flex': passthrough, + ACol: passthrough, + 'a-col': passthrough, + ACard: passthrough, + 'a-card': passthrough, + ASpace: passthrough, + 'a-space': passthrough, + ARow: passthrough, + 'a-row': passthrough, + AForm: passthrough, + 'a-form': passthrough, + AFormItem: passthrough, + 'a-form-item': passthrough, + ADescriptions: passthrough, + 'a-descriptions': passthrough, + ADescriptionsItem: passthrough, + 'a-descriptions-item': passthrough, + ASelect: passthrough, + 'a-select': passthrough, + AInput: passthrough, + 'a-input': passthrough, + ASwitch: passthrough, + 'a-switch': passthrough, + AInputNumber: passthrough, + 'a-input-number': passthrough, + AButton: buttonStub, + 'a-button': buttonStub, + DoubleLeftOutlined: passthrough, + DoubleRightOutlined: passthrough + } + } + }) +} + +describe('condition route add form', () => { + it('uses the service condition-router rule name expected by dubbo-go', async () => { + mocks.addConditionRuleAPI.mockResolvedValue({ code: HTTP_STATUS.SUCCESS }) + const tabState = { + conditionRule: { + enabled: true, + key: 'org.apache.demo.DemoService', + scope: 'service', + runtime: true, + conditions: ['method=sayHello => region=hangzhou'] + }, + addConditionRuleSate: { + version: '1.0.0', + group: 'demo' + } + } + + const wrapper = mountForm(tabState) + await flushPromises() + + expect(tabState.conditionRule.key).toBe('org.apache.demo.DemoService:1.0.0:demo') + + const submitButton = wrapper.findAll('button')[1] + await submitButton.trigger('click') + await flushPromises() + + expect(mocks.addConditionRuleAPI).toHaveBeenCalledWith( + 'org.apache.demo.DemoService:1.0.0:demo.condition-router', + expect.objectContaining({ + configVersion: 'v3.0', + scope: 'service', + key: 'org.apache.demo.DemoService:1.0.0:demo', + conditions: ['method=sayHello => region=hangzhou'] + }) + ) + }) + + it('uses the application condition-router rule name expected by dubbo-go', async () => { + mocks.addConditionRuleAPI.mockResolvedValue({ code: HTTP_STATUS.SUCCESS }) + const tabState = { + conditionRule: { + enabled: true, + key: 'demo-provider', + scope: 'application', + runtime: true, + conditions: ['host=1.1.1.1 => host=2.2.2.2'] + } + } + + const wrapper = mountForm(tabState) + await flushPromises() + + const submitButton = wrapper.findAll('button')[1] + await submitButton.trigger('click') + await flushPromises() + + expect(mocks.addConditionRuleAPI).toHaveBeenCalledWith( + 'demo-provider.condition-router', + expect.objectContaining({ + scope: 'application', + key: 'demo-provider', + conditions: ['host=1.1.1.1 => host=2.2.2.2'] + }) + ) + }) +}) diff --git a/ui-vue3/src/views/traffic/routingRule/tabs/addByFormView.vue b/ui-vue3/src/views/traffic/routingRule/tabs/addByFormView.vue index 180ba501d..12de02600 100644 --- a/ui-vue3/src/views/traffic/routingRule/tabs/addByFormView.vue +++ b/ui-vue3/src/views/traffic/routingRule/tabs/addByFormView.vue @@ -65,6 +65,13 @@ :un-checked-children="t('flowControlDomain.off')" /> + + + @@ -94,7 +101,12 @@ + diff --git a/ui-vue3/src/views/traffic/scriptRule/index.vue b/ui-vue3/src/views/traffic/scriptRule/index.vue new file mode 100644 index 000000000..3c0ca0305 --- /dev/null +++ b/ui-vue3/src/views/traffic/scriptRule/index.vue @@ -0,0 +1,5 @@ + + diff --git a/ui-vue3/src/views/traffic/tagRule/tabs/addByFormView.spec.ts b/ui-vue3/src/views/traffic/tagRule/tabs/addByFormView.spec.ts new file mode 100644 index 000000000..38a5b4ff5 --- /dev/null +++ b/ui-vue3/src/views/traffic/tagRule/tabs/addByFormView.spec.ts @@ -0,0 +1,187 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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. + */ + +import { flushPromises, mount } from '@vue/test-utils' +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { defineComponent, h } from 'vue' +import { HTTP_STATUS } from '@/base/http/constants' +import { PROVIDE_INJECT_KEY } from '@/base/enums/ProvideInject' +import type AddByFormViewType from './addByFormView.vue' + +const mocks = vi.hoisted(() => ({ + addTagRuleAPI: vi.fn(), + push: vi.fn() +})) + +vi.hoisted(() => { + Object.defineProperty(globalThis, 'localStorage', { + value: { + getItem: () => null, + setItem: () => undefined, + removeItem: () => undefined + }, + configurable: true + }) +}) + +vi.mock('vue-router', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useRouter: () => ({ push: mocks.push }) + } +}) + +vi.mock('@/api/service/traffic', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + addTagRuleAPI: mocks.addTagRuleAPI + } +}) + +vi.mock('ant-design-vue', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + message: { + success: vi.fn(), + warning: vi.fn(), + error: vi.fn() + } + } +}) + +vi.mock('vue-clipboard3', () => ({ + default: () => ({ toClipboard: vi.fn() }) +})) + +const passthrough = defineComponent({ + setup(_props, { slots }) { + return () => h('div', slots.default?.()) + } +}) + +const buttonStub = defineComponent({ + emits: ['click'], + setup(_props, { emit, slots }) { + return () => h('button', { type: 'button', onClick: () => emit('click') }, slots.default?.()) + } +}) + +let i18n: typeof import('@/base/i18n').i18n +let AddByFormView: typeof AddByFormViewType + +beforeAll(async () => { + i18n = (await import('@/base/i18n')).i18n + AddByFormView = (await import('./addByFormView.vue')).default +}) + +beforeEach(() => { + mocks.addTagRuleAPI.mockReset() + mocks.push.mockReset() +}) + +function mountForm(tabState: any) { + return mount(AddByFormView, { + global: { + plugins: [i18n], + provide: { + [PROVIDE_INJECT_KEY.TAB_LAYOUT_STATE]: tabState + }, + stubs: { + AFlex: passthrough, + 'a-flex': passthrough, + ACol: passthrough, + 'a-col': passthrough, + ACard: passthrough, + 'a-card': passthrough, + ASpace: passthrough, + 'a-space': passthrough, + ARow: passthrough, + 'a-row': passthrough, + AForm: passthrough, + 'a-form': passthrough, + AFormItem: passthrough, + 'a-form-item': passthrough, + ADescriptions: passthrough, + 'a-descriptions': passthrough, + ADescriptionsItem: passthrough, + 'a-descriptions-item': passthrough, + ATooltip: passthrough, + 'a-tooltip': passthrough, + ATable: passthrough, + 'a-table': passthrough, + ASelect: passthrough, + 'a-select': passthrough, + AInput: passthrough, + 'a-input': passthrough, + ATextarea: passthrough, + 'a-textarea': passthrough, + ASwitch: passthrough, + 'a-switch': passthrough, + AInputNumber: passthrough, + 'a-input-number': passthrough, + ARadioGroup: passthrough, + 'a-radio-group': passthrough, + ATag: passthrough, + 'a-tag': passthrough, + AAffix: passthrough, + 'a-affix': passthrough, + AButton: buttonStub, + 'a-button': buttonStub, + Icon: passthrough, + DoubleLeftOutlined: passthrough, + DoubleRightOutlined: passthrough + } + } + }) +} + +describe('tag route add form', () => { + it('uses the application tag-router rule name expected by dubbo-go', async () => { + mocks.addTagRuleAPI.mockResolvedValue({ code: HTTP_STATUS.SUCCESS }) + const tabState = { + tagRule: { + configVersion: 'v3.0', + enabled: true, + key: 'demo-provider', + scope: 'application', + runtime: true, + tags: [{ name: 'gray', match: [{ key: 'env', value: { exact: 'gray' } }] }] + } + } + + const wrapper = mountForm(tabState) + await flushPromises() + + const submitButton = wrapper.findAll('button').find((button) => button.text().includes('确认')) + expect(submitButton).toBeDefined() + await submitButton!.trigger('click') + await flushPromises() + + expect(mocks.addTagRuleAPI).toHaveBeenCalledWith( + 'demo-provider.tag-router', + expect.objectContaining({ + configVersion: 'v3.0', + scope: 'application', + key: 'demo-provider', + tags: [{ name: 'gray', match: [{ key: 'env', value: { exact: 'gray' } }] }] + }) + ) + }) +}) diff --git a/ui-vue3/src/views/traffic/tagRule/tabs/addByFormView.vue b/ui-vue3/src/views/traffic/tagRule/tabs/addByFormView.vue index a9d4fe332..be061fb70 100644 --- a/ui-vue3/src/views/traffic/tagRule/tabs/addByFormView.vue +++ b/ui-vue3/src/views/traffic/tagRule/tabs/addByFormView.vue @@ -475,7 +475,6 @@ const tagList = ref([]) watch( tagList, (newVal) => { - console.log(newVal) const tags: any[] = [] newVal.forEach((tagItem) => { const { tagName, scope } = tagItem @@ -584,12 +583,7 @@ const addTagRule = async () => { }) data.tags.push(tag) }) - let ruleName = '' - if (ruleGranularity == 'application') { - ruleName = `${objectOfAction}.tag-router` - } else { - ruleName = `${objectOfAction}:${configVersion}.tag-router` - } + const ruleName = `${objectOfAction}.tag-router` const res = await addTagRuleAPI(ruleName, data) if (res.code === HTTP_STATUS.SUCCESS) { router.push('/traffic/tagRule') diff --git a/ui-vue3/src/views/traffic/tagRule/tabs/addByYAMLView.spec.ts b/ui-vue3/src/views/traffic/tagRule/tabs/addByYAMLView.spec.ts new file mode 100644 index 000000000..c0ca0fee8 --- /dev/null +++ b/ui-vue3/src/views/traffic/tagRule/tabs/addByYAMLView.spec.ts @@ -0,0 +1,148 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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. + */ + +import { flushPromises, mount } from '@vue/test-utils' +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { defineComponent, h } from 'vue' +import { HTTP_STATUS } from '@/base/http/constants' +import { PROVIDE_INJECT_KEY } from '@/base/enums/ProvideInject' +import type AddByYAMLViewType from './addByYAMLView.vue' + +const mocks = vi.hoisted(() => ({ + addTagRuleAPI: vi.fn(), + push: vi.fn() +})) + +vi.hoisted(() => { + Object.defineProperty(globalThis, 'localStorage', { + value: { + getItem: () => null, + setItem: () => undefined, + removeItem: () => undefined + }, + configurable: true + }) +}) + +vi.mock('vue-router', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useRouter: () => ({ push: mocks.push }) + } +}) + +vi.mock('@/api/service/traffic', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + addTagRuleAPI: mocks.addTagRuleAPI + } +}) + +vi.mock('@/components/editor/MonacoEditor.vue', () => ({ + default: defineComponent({ + setup(_props, { slots }) { + return () => h('div', slots.default?.()) + } + }) +})) + +const passthrough = defineComponent({ + setup(_props, { slots }) { + return () => h('div', slots.default?.()) + } +}) + +const buttonStub = defineComponent({ + emits: ['click'], + setup(_props, { emit, slots }) { + return () => h('button', { type: 'button', onClick: () => emit('click') }, slots.default?.()) + } +}) + +let AddByYAMLView: typeof AddByYAMLViewType + +beforeAll(async () => { + AddByYAMLView = (await import('./addByYAMLView.vue')).default +}) + +beforeEach(() => { + mocks.addTagRuleAPI.mockReset() + mocks.push.mockReset() +}) + +describe('tag route add YAML', () => { + it('uses the application tag-router rule name expected by dubbo-go', async () => { + mocks.addTagRuleAPI.mockResolvedValue({ code: HTTP_STATUS.SUCCESS }) + const tabState = { + tagRule: { + configVersion: 'v3.0', + enabled: true, + key: 'demo-provider', + scope: 'application', + runtime: true, + tags: [{ name: 'gray', match: [{ key: 'env', value: { exact: 'gray' } }] }] + } + } + + const wrapper = mount(AddByYAMLView, { + global: { + provide: { + [PROVIDE_INJECT_KEY.TAB_LAYOUT_STATE]: tabState + }, + stubs: { + MonacoEditor: passthrough, + AFlex: passthrough, + 'a-flex': passthrough, + ACol: passthrough, + 'a-col': passthrough, + ACard: passthrough, + 'a-card': passthrough, + ASpace: passthrough, + 'a-space': passthrough, + ADescriptions: passthrough, + 'a-descriptions': passthrough, + ADescriptionsItem: passthrough, + 'a-descriptions-item': passthrough, + AAffix: passthrough, + 'a-affix': passthrough, + AButton: buttonStub, + 'a-button': buttonStub, + DoubleLeftOutlined: passthrough, + DoubleRightOutlined: passthrough + } + } + }) + await flushPromises() + + const submitButton = wrapper.findAll('button').find((button) => button.text().includes('确认')) + expect(submitButton).toBeDefined() + await submitButton!.trigger('click') + await flushPromises() + + expect(mocks.addTagRuleAPI).toHaveBeenCalledWith( + 'demo-provider.tag-router', + expect.objectContaining({ + configVersion: 'v3.0', + scope: 'application', + key: 'demo-provider', + tags: [{ name: 'gray', match: [{ key: 'env', value: { exact: 'gray' } }] }] + }) + ) + }) +}) diff --git a/ui-vue3/src/views/traffic/tagRule/tabs/addByYAMLView.vue b/ui-vue3/src/views/traffic/tagRule/tabs/addByYAMLView.vue index 94deb2bef..98c8ca5f6 100644 --- a/ui-vue3/src/views/traffic/tagRule/tabs/addByYAMLView.vue +++ b/ui-vue3/src/views/traffic/tagRule/tabs/addByYAMLView.vue @@ -127,20 +127,8 @@ const changeEditor = (val: string) => { const updateTagRule = async () => { const data = yaml.load(YAMLValue.value) - const { - configVersion, - scope: ruleGranularity, - key: objectOfAction, - runtime, - force, - conditions - } = data - let ruleName = '' - if (ruleGranularity == 'application') { - ruleName = `${objectOfAction}.tag-router` - } else { - ruleName = `${objectOfAction}:${configVersion}.tag-router` - } + const { key: objectOfAction } = data + const ruleName = `${objectOfAction}.tag-router` const res = await addTagRuleAPI(ruleName, data) if (res.code === HTTP_STATUS.SUCCESS) { router.push('/traffic/tagRule')