From 520f0945e606412e59728f2ae61aa7988f88c79a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=99=BD?= <575121043@qq.com> Date: Tue, 11 Aug 2026 11:41:12 +0800 Subject: [PATCH 1/6] Fix condition route rule chain --- pkg/console/service/service.go | 12 +- .../service/service_argument_route_test.go | 128 +++++++++++ .../routingRule/tabs/addByFormView.spec.ts | 206 ++++++++++++++++++ .../routingRule/tabs/addByFormView.vue | 4 +- 4 files changed, 347 insertions(+), 3 deletions(-) create mode 100644 pkg/console/service/service_argument_route_test.go create mode 100644 ui-vue3/src/views/traffic/routingRule/tabs/addByFormView.spec.ts diff --git a/pkg/console/service/service.go b/pkg/console/service/service.go index a58347341..e974d1e78 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) @@ -751,8 +754,13 @@ func UpInsertServiceArgumentRouteConfig(ctx consolectx.Context, req model.BaseSe 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..3f19af300 --- /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", 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/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..631aac61c --- /dev/null +++ b/ui-vue3/src/views/traffic/routingRule/tabs/addByFormView.spec.ts @@ -0,0 +1,206 @@ +/* + * 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() + + 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', + 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..ca10315aa 100644 --- a/ui-vue3/src/views/traffic/routingRule/tabs/addByFormView.vue +++ b/ui-vue3/src/views/traffic/routingRule/tabs/addByFormView.vue @@ -293,7 +293,9 @@ const addRoutingRule = async () => { } = baseInfo let ruleName = - ruleGranularity === 'service' ? `${objectOfAction}:${version}:${group}` : `${objectOfAction}` // application + ruleGranularity === 'service' + ? `${objectOfAction}:${version}:${group}.condition-router` + : `${objectOfAction}.condition-router` const data = { configVersion: 'v3.0', From a8ac17ff04d38f257fc0e8c015d831def282463e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=99=BD?= <575121043@qq.com> Date: Tue, 11 Aug 2026 14:57:58 +0800 Subject: [PATCH 2/6] fix: align router rule config chain --- pkg/common/constants/rule.go | 1 + pkg/discovery/zk/factory.go | 22 ++- pkg/discovery/zk/factory_test.go | 65 ++++++ pkg/governor/zk/governor.go | 82 +++++++- pkg/governor/zk/governor_test.go | 31 +++ .../tagRule/tabs/addByFormView.spec.ts | 187 ++++++++++++++++++ .../traffic/tagRule/tabs/addByFormView.vue | 12 +- .../tagRule/tabs/addByYAMLView.spec.ts | 148 ++++++++++++++ .../traffic/tagRule/tabs/addByYAMLView.vue | 16 +- 9 files changed, 531 insertions(+), 33 deletions(-) create mode 100644 pkg/discovery/zk/factory_test.go create mode 100644 pkg/governor/zk/governor_test.go create mode 100644 ui-vue3/src/views/traffic/tagRule/tabs/addByFormView.spec.ts create mode 100644 ui-vue3/src/views/traffic/tagRule/tabs/addByYAMLView.spec.ts diff --git a/pkg/common/constants/rule.go b/pkg/common/constants/rule.go index 1749e33ee..ab8b6422f 100644 --- a/pkg/common/constants/rule.go +++ b/pkg/common/constants/rule.go @@ -29,6 +29,7 @@ const ( ScopeService = `service` SideProvider = `provider` SideConsumer = `consumer` + RuleConfigGroup = `dubbo` ConfiguratorRuleDotSuffix = ".configurators" ConfiguratorsSuffix = "configurators" diff --git a/pkg/discovery/zk/factory.go b/pkg/discovery/zk/factory.go index 7518af8f0..cab1e5fa5 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,18 @@ 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: + return paths[3], paths[3] != "" + case 5: + return paths[4], paths[3] != "" && paths[4] != "" + default: + 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..da3f79e27 --- /dev/null +++ b/pkg/discovery/zk/factory_test.go @@ -0,0 +1,65 @@ +/* + * 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: true, + wantName: "dubbo", + }, + } + + 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/governor/zk/governor.go b/pkg/governor/zk/governor.go index a811557b2..86cb2720a 100644 --- a/pkg/governor/zk/governor.go +++ b/pkg/governor/zk/governor.go @@ -19,11 +19,13 @@ 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" @@ -32,6 +34,8 @@ import ( "github.com/apache/dubbo-admin/pkg/core/store" ) +const zkConfigRootPath = "/dubbo/config" + type RuleGovernor struct { cfg *discoverycfg.Config storeRouter store.Router @@ -54,17 +58,21 @@ func NewZKRuleGovernor(cfg *discoverycfg.Config, router store.Router, emitter ev } func (g *RuleGovernor) CreateRule(r coremodel.Resource) error { - path := "/dubbo/config/" + r.ResourceMeta().Name + path := ruleConfigPath(r.ResourceMeta().Name) content, err := yaml.Marshal(r.ResourceSpec()) 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, fmt.Sprintf("failed to create zk node, path: %s", path)) } + g.deleteLegacyRulePath(r.ResourceMeta().Name) // save to store once znode is created in zk to insure local store is consistent to zk timely. // if save to store failed, the discovery will watch and update the store finally. st, err := g.storeRouter.ResourceRoute(r) @@ -81,13 +89,22 @@ func (g *RuleGovernor) CreateRule(r coremodel.Resource) error { } func (g *RuleGovernor) UpdateRule(r coremodel.Resource) error { - path := "/dubbo/config/" + r.ResourceMeta().Name + path := ruleConfigPath(r.ResourceMeta().Name) content, err := yaml.Marshal(r.ResourceSpec()) 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 { + g.deleteLegacyRulePath(r.ResourceMeta().Name) + } + } if err != nil { return bizerror.Wrap(err, bizerror.ZKError, fmt.Sprintf("failed to update zk node, path: %s", path)) @@ -105,12 +122,27 @@ 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) + legacyPath := legacyRuleConfigPath(r.ResourceMeta().Name) + deleted := false err := g.conn.Delete(path, -1) - if err != nil { + if err == nil { + deleted = true + } else if err != zk.ErrNoNode { return bizerror.Wrap(err, bizerror.ZKError, fmt.Sprintf("failed to delete zk node, path: %s", path)) } + err = g.conn.Delete(legacyPath, -1) + if err == nil { + deleted = true + } else if err != zk.ErrNoNode { + return bizerror.Wrap(err, bizerror.ZKError, + fmt.Sprintf("failed to delete zk node, path: %s", legacyPath)) + } + if !deleted { + return bizerror.Wrap(zk.ErrNoNode, bizerror.ZKError, + fmt.Sprintf("failed to delete zk node, path: %s", path)) + } st, err := g.storeRouter.ResourceRoute(r) if err != nil { logger.Warnf("cannot find store for rk: %s, cause: %v", r.ResourceKind(), err) @@ -121,3 +153,45 @@ 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) deleteLegacyRulePath(ruleName string) { + legacyPath := legacyRuleConfigPath(ruleName) + err := g.conn.Delete(legacyPath, -1) + if err != nil && err != zk.ErrNoNode { + logger.Warnf("delete legacy zk rule path failed, path: %s, cause: %v", legacyPath, err) + } +} + +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/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..8d3dff367 100644 --- a/ui-vue3/src/views/traffic/tagRule/tabs/addByFormView.vue +++ b/ui-vue3/src/views/traffic/tagRule/tabs/addByFormView.vue @@ -127,7 +127,7 @@ :columns="labelsColumns" :data-source="tagItem.scope?.labels" > -