diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4065f4254..5aa4afc3c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,21 +69,9 @@ jobs: uses: actions/setup-go@v5 with: go-version-file: go.mod - - name: Cache dependencies - # ref: https://github.com/actions/cache/blob/main/examples.md#go---module - uses: actions/cache@v4 - with: - # Cache, works only on Linux - path: | - ~/.cache/go-build - ~/go/pkg/mod - # Cache key - key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} - # An ordered list of keys to use for restoring the cache if no cache hit occurred for key - restore-keys: | - ${{ runner.os }}-go- + cache-dependency-path: go.sum - name: Check Code Format - run: make fmt && git status && [[ -z `git status -s` ]] + run: make fmt && git status && [[ -z $(git status -s) ]] - name: Run Unit Test run: make test # TODO(marsevilspirit): add lint diff --git a/api/mesh/v1alpha1/rule_version.pb.go b/api/mesh/v1alpha1/rule_version.pb.go new file mode 100644 index 000000000..806c4b88a --- /dev/null +++ b/api/mesh/v1alpha1/rule_version.pb.go @@ -0,0 +1,265 @@ +// +// 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. + +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc v7.34.1 +// source: api/mesh/v1alpha1/rule_version.proto + +package v1alpha1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + timestamppb "google.golang.org/protobuf/types/known/timestamppb" + 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) +) + +// RuleVersion is a traffic-rule version entry. Entries are immutable after +// creation. Rollback appends a new RuleVersion, while retention may delete the +// oldest entries. +type RuleVersion struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Parent rule information + ParentRuleKind string `protobuf:"bytes,1,opt,name=parent_rule_kind,json=parentRuleKind,proto3" json:"parent_rule_kind,omitempty"` // e.g., "ConditionRoute" + ParentRuleMesh string `protobuf:"bytes,2,opt,name=parent_rule_mesh,json=parentRuleMesh,proto3" json:"parent_rule_mesh,omitempty"` // Mesh name + ParentRuleName string `protobuf:"bytes,3,opt,name=parent_rule_name,json=parentRuleName,proto3" json:"parent_rule_name,omitempty"` // Rule name + // Version metadata + VersionNo int64 `protobuf:"varint,4,opt,name=version_no,json=versionNo,proto3" json:"version_no,omitempty"` // Sequential version number (1, 2, 3...) + ContentHash string `protobuf:"bytes,5,opt,name=content_hash,json=contentHash,proto3" json:"content_hash,omitempty"` // SHA256 of normalized spec JSON + // Spec snapshot + SpecJson string `protobuf:"bytes,6,opt,name=spec_json,json=specJson,proto3" json:"spec_json,omitempty"` // JSON-serialized rule spec at this version + // Mutation context + Operation string `protobuf:"bytes,7,opt,name=operation,proto3" json:"operation,omitempty"` // CREATE, UPDATE, DELETE + Source string `protobuf:"bytes,8,opt,name=source,proto3" json:"source,omitempty"` // ADMIN, BOOTSTRAP, ROLLBACK + Author string `protobuf:"bytes,9,opt,name=author,proto3" json:"author,omitempty"` // User or system identifier + Reason string `protobuf:"bytes,10,opt,name=reason,proto3" json:"reason,omitempty"` // Change description + // rolled_back_from_id records the historical version whose snapshot was + // re-published to produce this version. It is audit metadata only and MUST NOT + // be used as a live-state pointer. + RolledBackFromId int64 `protobuf:"varint,11,opt,name=rolled_back_from_id,json=rolledBackFromId,proto3" json:"rolled_back_from_id,omitempty"` + // Timestamps + CreatedAt *timestamppb.Timestamp `protobuf:"bytes,12,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` + RecordedAt *timestamppb.Timestamp `protobuf:"bytes,13,opt,name=recorded_at,json=recordedAt,proto3" json:"recorded_at,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RuleVersion) Reset() { + *x = RuleVersion{} + mi := &file_api_mesh_v1alpha1_rule_version_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RuleVersion) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RuleVersion) ProtoMessage() {} + +func (x *RuleVersion) ProtoReflect() protoreflect.Message { + mi := &file_api_mesh_v1alpha1_rule_version_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 RuleVersion.ProtoReflect.Descriptor instead. +func (*RuleVersion) Descriptor() ([]byte, []int) { + return file_api_mesh_v1alpha1_rule_version_proto_rawDescGZIP(), []int{0} +} + +func (x *RuleVersion) GetParentRuleKind() string { + if x != nil { + return x.ParentRuleKind + } + return "" +} + +func (x *RuleVersion) GetParentRuleMesh() string { + if x != nil { + return x.ParentRuleMesh + } + return "" +} + +func (x *RuleVersion) GetParentRuleName() string { + if x != nil { + return x.ParentRuleName + } + return "" +} + +func (x *RuleVersion) GetVersionNo() int64 { + if x != nil { + return x.VersionNo + } + return 0 +} + +func (x *RuleVersion) GetContentHash() string { + if x != nil { + return x.ContentHash + } + return "" +} + +func (x *RuleVersion) GetSpecJson() string { + if x != nil { + return x.SpecJson + } + return "" +} + +func (x *RuleVersion) GetOperation() string { + if x != nil { + return x.Operation + } + return "" +} + +func (x *RuleVersion) GetSource() string { + if x != nil { + return x.Source + } + return "" +} + +func (x *RuleVersion) GetAuthor() string { + if x != nil { + return x.Author + } + return "" +} + +func (x *RuleVersion) GetReason() string { + if x != nil { + return x.Reason + } + return "" +} + +func (x *RuleVersion) GetRolledBackFromId() int64 { + if x != nil { + return x.RolledBackFromId + } + return 0 +} + +func (x *RuleVersion) GetCreatedAt() *timestamppb.Timestamp { + if x != nil { + return x.CreatedAt + } + return nil +} + +func (x *RuleVersion) GetRecordedAt() *timestamppb.Timestamp { + if x != nil { + return x.RecordedAt + } + return nil +} + +var File_api_mesh_v1alpha1_rule_version_proto protoreflect.FileDescriptor + +const file_api_mesh_v1alpha1_rule_version_proto_rawDesc = "" + + "\n" + + "$api/mesh/v1alpha1/rule_version.proto\x12\x13dubbo.mesh.v1alpha1\x1a\x1fgoogle/protobuf/timestamp.proto\"\xf7\x03\n" + + "\vRuleVersion\x12(\n" + + "\x10parent_rule_kind\x18\x01 \x01(\tR\x0eparentRuleKind\x12(\n" + + "\x10parent_rule_mesh\x18\x02 \x01(\tR\x0eparentRuleMesh\x12(\n" + + "\x10parent_rule_name\x18\x03 \x01(\tR\x0eparentRuleName\x12\x1d\n" + + "\n" + + "version_no\x18\x04 \x01(\x03R\tversionNo\x12!\n" + + "\fcontent_hash\x18\x05 \x01(\tR\vcontentHash\x12\x1b\n" + + "\tspec_json\x18\x06 \x01(\tR\bspecJson\x12\x1c\n" + + "\toperation\x18\a \x01(\tR\toperation\x12\x16\n" + + "\x06source\x18\b \x01(\tR\x06source\x12\x16\n" + + "\x06author\x18\t \x01(\tR\x06author\x12\x16\n" + + "\x06reason\x18\n" + + " \x01(\tR\x06reason\x12-\n" + + "\x13rolled_back_from_id\x18\v \x01(\x03R\x10rolledBackFromId\x129\n" + + "\n" + + "created_at\x18\f \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x12;\n" + + "\vrecorded_at\x18\r \x01(\v2\x1a.google.protobuf.TimestampR\n" + + "recordedAtB1Z/github.com/apache/dubbo-admin/api/mesh/v1alpha1b\x06proto3" + +var ( + file_api_mesh_v1alpha1_rule_version_proto_rawDescOnce sync.Once + file_api_mesh_v1alpha1_rule_version_proto_rawDescData []byte +) + +func file_api_mesh_v1alpha1_rule_version_proto_rawDescGZIP() []byte { + file_api_mesh_v1alpha1_rule_version_proto_rawDescOnce.Do(func() { + file_api_mesh_v1alpha1_rule_version_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_api_mesh_v1alpha1_rule_version_proto_rawDesc), len(file_api_mesh_v1alpha1_rule_version_proto_rawDesc))) + }) + return file_api_mesh_v1alpha1_rule_version_proto_rawDescData +} + +var file_api_mesh_v1alpha1_rule_version_proto_msgTypes = make([]protoimpl.MessageInfo, 1) +var file_api_mesh_v1alpha1_rule_version_proto_goTypes = []any{ + (*RuleVersion)(nil), // 0: dubbo.mesh.v1alpha1.RuleVersion + (*timestamppb.Timestamp)(nil), // 1: google.protobuf.Timestamp +} +var file_api_mesh_v1alpha1_rule_version_proto_depIdxs = []int32{ + 1, // 0: dubbo.mesh.v1alpha1.RuleVersion.created_at:type_name -> google.protobuf.Timestamp + 1, // 1: dubbo.mesh.v1alpha1.RuleVersion.recorded_at:type_name -> google.protobuf.Timestamp + 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 +} + +func init() { file_api_mesh_v1alpha1_rule_version_proto_init() } +func file_api_mesh_v1alpha1_rule_version_proto_init() { + if File_api_mesh_v1alpha1_rule_version_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_rule_version_proto_rawDesc), len(file_api_mesh_v1alpha1_rule_version_proto_rawDesc)), + NumEnums: 0, + NumMessages: 1, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_api_mesh_v1alpha1_rule_version_proto_goTypes, + DependencyIndexes: file_api_mesh_v1alpha1_rule_version_proto_depIdxs, + MessageInfos: file_api_mesh_v1alpha1_rule_version_proto_msgTypes, + }.Build() + File_api_mesh_v1alpha1_rule_version_proto = out.File + file_api_mesh_v1alpha1_rule_version_proto_goTypes = nil + file_api_mesh_v1alpha1_rule_version_proto_depIdxs = nil +} diff --git a/api/mesh/v1alpha1/rule_version.proto b/api/mesh/v1alpha1/rule_version.proto new file mode 100644 index 000000000..968110969 --- /dev/null +++ b/api/mesh/v1alpha1/rule_version.proto @@ -0,0 +1,56 @@ +/* + * 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. + */ + +syntax = "proto3"; + +package dubbo.mesh.v1alpha1; + +option go_package = "github.com/apache/dubbo-admin/api/mesh/v1alpha1"; + +import "google/protobuf/timestamp.proto"; + +// RuleVersion is a traffic-rule version entry. Entries are immutable after +// creation. Rollback appends a new RuleVersion, while retention may delete the +// oldest entries. +message RuleVersion { + // Parent rule information + string parent_rule_kind = 1; // e.g., "ConditionRoute" + string parent_rule_mesh = 2; // Mesh name + string parent_rule_name = 3; // Rule name + + // Version metadata + int64 version_no = 4; // Sequential version number (1, 2, 3...) + string content_hash = 5; // SHA256 of normalized spec JSON + + // Spec snapshot + string spec_json = 6; // JSON-serialized rule spec at this version + + // Mutation context + string operation = 7; // CREATE, UPDATE, DELETE + string source = 8; // ADMIN, BOOTSTRAP, ROLLBACK + string author = 9; // User or system identifier + string reason = 10; // Change description + + // rolled_back_from_id records the historical version whose snapshot was + // re-published to produce this version. It is audit metadata only and MUST NOT + // be used as a live-state pointer. + int64 rolled_back_from_id = 11; + + // Timestamps + google.protobuf.Timestamp created_at = 12; + google.protobuf.Timestamp recorded_at = 13; +} diff --git a/app/dubbo-admin/dubbo-admin.yaml b/app/dubbo-admin/dubbo-admin.yaml index 1e74b47ea..a9588337e 100644 --- a/app/dubbo-admin/dubbo-admin.yaml +++ b/app/dubbo-admin/dubbo-admin.yaml @@ -63,6 +63,13 @@ store: # type: mysql # address: root:123456@tcp(127.0.0.1:23306)/dubbo-admin?charset=utf8mb4&parseTime=True&loc=Asia%2FShanghai +# [Optional] version history and rollback for traffic rules. +# This records RuleVersion audit history. Supported governance rule mutations +# fail closed if the version ledger is unavailable or cannot record the +# mutation. Set maxVersionsPerRule to 0 to disable retention trimming. +ruleVersioning: + maxVersionsPerRule: 20 + # [Necessary] configs for service discovery discovery: # [Necessary] discovery type, options are nacos2, zookeeper, mock(only for dev) diff --git a/docs/server-develop.md b/docs/server-develop.md index 310905734..0c71ef67d 100644 --- a/docs/server-develop.md +++ b/docs/server-develop.md @@ -43,8 +43,17 @@ If you're using GoLand, you can run it locally by following steps: 2. Fill the block with the config that screenshot shows below: ![ide_configuration.png](./static/images/ide-config.png) 3. Modify the config file(app/dubbo-admin/dubbo-admin.yaml), make sure that the discovery, engine, store is configured. + Traffic-rule version history records RuleVersion audit entries for history, diff, and rollback material. Supported governance rule mutations fail closed if the version ledger is unavailable or cannot record the mutation. 4. Run the application, you can open the browser and visit localhost:8888/admin if everything works. +### Traffic-rule versioning notes + +Traffic-rule versioning is always enabled for supported governance rule mutations. It uses the existing `RuleVersion` resource store to create a baseline/import entry for existing `ConditionRoute`, `TagRoute`, and `DynamicConfig` rules that have no history yet. Bootstrap is not reconciliation and does not record external registry changes after history exists. + +Version entries are immutable after creation. Create, update, delete, and rollback first record the corresponding ledger entry and then use the normal ResourceManager write path. If the version ledger cannot record the entry, the registry mutation is rejected. DELETE versions are absence markers kept for audit/history continuity and cannot be used as rollback targets. `maxVersionsPerRule` retention may physically delete the oldest entries, so this is a bounded audit history and rollback aid, not a permanent compliance audit log or source of truth for current rule state. + +The traffic-form field preservation fix stays with this versioning PR because version history smoke tests depend on round-tripping `priority`, `force`, and `configVersion` without losing fields. These notes document the review boundary instead of splitting the PR. + ### Project catalog We are currently restructuring the entire project, so the directory structure of the project will be changed in the near future. diff --git a/pkg/config/app/admin.go b/pkg/config/app/admin.go index 71cf3f95f..f272fab84 100644 --- a/pkg/config/app/admin.go +++ b/pkg/config/app/admin.go @@ -31,6 +31,7 @@ import ( "github.com/apache/dubbo-admin/pkg/config/log" "github.com/apache/dubbo-admin/pkg/config/observability" "github.com/apache/dubbo-admin/pkg/config/store" + "github.com/apache/dubbo-admin/pkg/config/versioning" ) type AdminConfig struct { @@ -51,6 +52,9 @@ type AdminConfig struct { Engine *engine.Config `json:"engine" yaml:"engine"` // EventBus configuration EventBus *eventbus.Config `json:"eventBus,omitempty" yaml:"eventBus,omitempty"` + // RuleVersioning records lightweight audit history for governor-managed traffic rules. + // Live rule state remains in ResourceManager/registry. + RuleVersioning *versioning.Config `json:"ruleVersioning,omitempty" yaml:"ruleVersioning,omitempty"` } var _ = &AdminConfig{} @@ -58,17 +62,18 @@ var _ = &AdminConfig{} var DefaultAdminConfig = func() AdminConfig { eventBusCfg := eventbus.Default() return AdminConfig{ - Log: log.DefaultLogConfig(), - Store: store.DefaultStoreConfig(), - Engine: engine.DefaultResourceEngineConfig(), - Observability: observability.DefaultObservabilityConfig(), - Diagnostics: diagnostics.DefaultDiagnosticsConfig(), - Console: console.DefaultConsoleConfig(), - EventBus: &eventBusCfg, + Log: log.DefaultLogConfig(), + Store: store.DefaultStoreConfig(), + Engine: engine.DefaultResourceEngineConfig(), + Observability: observability.DefaultObservabilityConfig(), + Diagnostics: diagnostics.DefaultDiagnosticsConfig(), + Console: console.DefaultConsoleConfig(), + EventBus: &eventBusCfg, + RuleVersioning: versioning.Default(), } } -func (c AdminConfig) Sanitize() { +func (c *AdminConfig) Sanitize() { c.Engine.Sanitize() for _, d := range c.Discovery { d.Sanitize() @@ -78,9 +83,13 @@ func (c AdminConfig) Sanitize() { c.Observability.Sanitize() c.Diagnostics.Sanitize() c.Log.Sanitize() + if c.RuleVersioning == nil { + c.RuleVersioning = versioning.Default() + } + c.RuleVersioning.Sanitize() } -func (c AdminConfig) PreProcess() error { +func (c *AdminConfig) PreProcess() error { discoveryPreProcess := func() error { for _, d := range c.Discovery { if err := d.PreProcess(); err != nil { @@ -89,6 +98,9 @@ func (c AdminConfig) PreProcess() error { } return nil } + if c.RuleVersioning == nil { + c.RuleVersioning = versioning.Default() + } return multierr.Combine( c.Engine.PreProcess(), discoveryPreProcess(), @@ -97,10 +109,11 @@ func (c AdminConfig) PreProcess() error { c.Observability.PreProcess(), c.Diagnostics.PreProcess(), c.Log.PreProcess(), + c.RuleVersioning.PreProcess(), ) } -func (c AdminConfig) PostProcess() error { +func (c *AdminConfig) PostProcess() error { discoveryPostProcess := func() error { for _, d := range c.Discovery { if err := d.PostProcess(); err != nil { @@ -109,6 +122,9 @@ func (c AdminConfig) PostProcess() error { } return nil } + if c.RuleVersioning == nil { + c.RuleVersioning = versioning.Default() + } return multierr.Combine( c.Engine.PostProcess(), discoveryPostProcess(), @@ -117,10 +133,11 @@ func (c AdminConfig) PostProcess() error { c.Observability.PostProcess(), c.Diagnostics.PostProcess(), c.Log.PostProcess(), + c.RuleVersioning.PostProcess(), ) } -func (c AdminConfig) Validate() error { +func (c *AdminConfig) Validate() error { if c.Log == nil { c.Log = log.DefaultLogConfig() } else if err := c.Log.Validate(); err != nil { @@ -171,6 +188,11 @@ func (c AdminConfig) Validate() error { } else if err := c.EventBus.Validate(); err != nil { return bizerror.Wrap(err, bizerror.ConfigError, "event bus config validation failed") } + if c.RuleVersioning == nil { + c.RuleVersioning = versioning.Default() + } else if err := c.RuleVersioning.Validate(); err != nil { + return bizerror.Wrap(err, bizerror.ConfigError, "versioning config validation failed") + } return nil } diff --git a/pkg/config/versioning/config.go b/pkg/config/versioning/config.go new file mode 100644 index 000000000..0a112f5a8 --- /dev/null +++ b/pkg/config/versioning/config.go @@ -0,0 +1,68 @@ +/* + * 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 versioning + +import ( + "encoding/json" + + "github.com/apache/dubbo-admin/pkg/common/bizerror" + "github.com/apache/dubbo-admin/pkg/config" +) + +const ( + // DefaultMaxVersionsPerRule is the retention window used when configuration + // omits maxVersionsPerRule or provides a negative value. + DefaultMaxVersionsPerRule = int64(20) +) + +// Config controls RuleVersion audit-history retention. Versioning is always on +// for supported governance rule mutations; a zero MaxVersionsPerRule disables +// cleanup only, not history recording. +type Config struct { + config.BaseConfig + MaxVersionsPerRule int64 `json:"maxVersionsPerRule" yaml:"maxVersionsPerRule"` +} + +func (c *Config) UnmarshalJSON(data []byte) error { + type config Config + defaults := Default() + *c = *defaults + return json.Unmarshal(data, (*config)(c)) +} + +// Default returns rule-history configuration with bounded retention enabled. +func Default() *Config { + return &Config{ + MaxVersionsPerRule: DefaultMaxVersionsPerRule, + } +} + +// Sanitize normalizes invalid retention values to the default window. +func (c *Config) Sanitize() { + if c.MaxVersionsPerRule < 0 { + c.MaxVersionsPerRule = DefaultMaxVersionsPerRule + } +} + +// Validate rejects negative retention values before startup. +func (c *Config) Validate() error { + if c.MaxVersionsPerRule < 0 { + return bizerror.New(bizerror.ConfigError, "ruleVersioning.maxVersionsPerRule must be greater than or equal to 0") + } + return nil +} diff --git a/pkg/console/context/context.go b/pkg/console/context/context.go index f4c64ce5b..496011d26 100644 --- a/pkg/console/context/context.go +++ b/pkg/console/context/context.go @@ -25,6 +25,7 @@ import ( "github.com/apache/dubbo-admin/pkg/console/counter" "github.com/apache/dubbo-admin/pkg/core/manager" "github.com/apache/dubbo-admin/pkg/core/runtime" + "github.com/apache/dubbo-admin/pkg/core/versioning" ) type Context interface { @@ -35,6 +36,7 @@ type Context interface { AppContext() ctx.Context LockManager() lock.Lock + RuleVersioning() *versioning.Service } var _ Context = &context{} @@ -81,3 +83,15 @@ func (c *context) LockManager() lock.Lock { } return distributedLock } + +func (c *context) RuleVersioning() *versioning.Service { + comp, err := c.coreRt.GetComponent(versioning.ComponentType) + if err != nil { + return nil + } + versioningComp, ok := comp.(versioning.Component) + if !ok { + return nil + } + return versioningComp.Service() +} diff --git a/pkg/console/handler/condition_rule.go b/pkg/console/handler/condition_rule.go index 653c12e71..38309a3e4 100644 --- a/pkg/console/handler/condition_rule.go +++ b/pkg/console/handler/condition_rule.go @@ -94,8 +94,8 @@ func PutConditionRuleWithRuleName(cs consolectx.Context) gin.HandlerFunc { util.HandleArgumentError(c, err) return } - - if err := service.UpdateConditionRule(cs, res); err != nil { + opts := mutationOptions(c) + if err := service.UpdateConditionRuleWithOptions(cs, res, opts); err != nil { util.HandleServiceError(c, err) return } else { @@ -118,8 +118,8 @@ func PostConditionRuleWithRuleName(cs consolectx.Context) gin.HandlerFunc { util.HandleArgumentError(c, err) return } - - if err := service.CreateConditionRule(cs, res); err != nil { + opts := mutationOptions(c) + if err := service.CreateConditionRuleWithOptions(cs, res, opts); err != nil { c.JSON(http.StatusOK, model.NewErrorResp(err.Error())) return } else { @@ -137,7 +137,8 @@ func DeleteConditionRuleWithRuleName(cs consolectx.Context) gin.HandlerFunc { fmt.Sprintf("ruleName must end with %s", constants.ConditionRuleDotSuffix)))) return } - if err := service.DeleteConditionRule(cs, ruleName, mesh); err != nil { + opts := mutationOptions(c) + if err := service.DeleteConditionRuleWithOptions(cs, ruleName, mesh, opts); err != nil { c.JSON(http.StatusOK, model.NewErrorResp(err.Error())) return } diff --git a/pkg/console/handler/configurator_rule.go b/pkg/console/handler/configurator_rule.go index 0b806715b..0323eaaf5 100644 --- a/pkg/console/handler/configurator_rule.go +++ b/pkg/console/handler/configurator_rule.go @@ -105,7 +105,8 @@ func PutConfiguratorWithRuleName(ctx consolectx.Context) gin.HandlerFunc { c.JSON(http.StatusOK, model.NewBizErrorResp( bizerror.New(bizerror.NotFoundError, fmt.Sprintf("%s not found", ruleName)))) } - if err = service.UpdateConfigurator(ctx, res); err != nil { + opts := mutationOptions(c) + if err = service.UpdateConfiguratorWithOptions(ctx, res, opts); err != nil { c.JSON(http.StatusOK, model.NewErrorResp(err.Error())) return } @@ -128,7 +129,8 @@ func PostConfiguratorWithRuleName(ctx consolectx.Context) gin.HandlerFunc { util.HandleArgumentError(c, err) return } - if err = service.CreateConfigurator(ctx, res); err != nil { + opts := mutationOptions(c) + if err = service.CreateConfiguratorWithOptions(ctx, res, opts); err != nil { c.JSON(http.StatusOK, model.NewErrorResp(err.Error())) return } @@ -146,7 +148,8 @@ func DeleteConfiguratorWithRuleName(ctx consolectx.Context) gin.HandlerFunc { fmt.Sprintf("dynamic config name must end with %s", constants.ConfiguratorRuleDotSuffix)))) return } - if err := service.DeleteConfigurator(ctx, ruleName, mesh); err != nil { + opts := mutationOptions(c) + if err := service.DeleteConfiguratorWithOptions(ctx, ruleName, mesh, opts); err != nil { c.JSON(http.StatusOK, model.NewErrorResp(err.Error())) return } diff --git a/pkg/console/handler/rule_version.go b/pkg/console/handler/rule_version.go new file mode 100644 index 000000000..8d201ec43 --- /dev/null +++ b/pkg/console/handler/rule_version.go @@ -0,0 +1,320 @@ +/* + * 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 handler + +import ( + "errors" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" + + "github.com/apache/dubbo-admin/pkg/common/bizerror" + 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" + coremodel "github.com/apache/dubbo-admin/pkg/core/resource/model" + "github.com/apache/dubbo-admin/pkg/core/versioning" +) + +type rollbackReq struct { + Reason string `json:"reason"` +} + +const maxRuleVersionReasonLength = 1024 + +func ListRuleVersions(cs consolectx.Context, kind coremodel.ResourceKind) gin.HandlerFunc { + return func(c *gin.Context) { + if !ensureVersioningAvailable(c, cs) { + return + } + resp, err := service.ListRuleVersions(cs, service.RuleKindName{Kind: kind, Mesh: c.Query("mesh"), Name: c.Param("ruleName")}) + writeVersioningResp(c, resp, err) + } +} + +func GetRuleVersion(cs consolectx.Context, kind coremodel.ResourceKind) gin.HandlerFunc { + return func(c *gin.Context) { + if !ensureVersioningAvailable(c, cs) { + return + } + id, ok := parseVersionID(c) + if !ok { + return + } + resp, err := service.GetRuleVersion(cs, service.RuleKindName{Kind: kind, Mesh: c.Query("mesh"), Name: c.Param("ruleName")}, id) + writeVersioningResp(c, resp, err) + } +} + +func DiffRuleVersion(cs consolectx.Context, kind coremodel.ResourceKind) gin.HandlerFunc { + return func(c *gin.Context) { + if !ensureVersioningAvailable(c, cs) { + return + } + id, ok := parseVersionID(c) + if !ok { + return + } + resp, err := service.DiffRuleVersion(cs, service.RuleKindName{Kind: kind, Mesh: c.Query("mesh"), Name: c.Param("ruleName")}, id, c.Query("against")) + writeVersioningResp(c, resp, err) + } +} + +func RollbackRuleVersion(cs consolectx.Context, kind coremodel.ResourceKind) gin.HandlerFunc { + return func(c *gin.Context) { + if !ensureVersioningAvailable(c, cs) { + return + } + id, ok := parseVersionID(c) + if !ok { + return + } + req := rollbackReq{} + if err := c.ShouldBindJSON(&req); err != nil { + writeVersioningInvalidArgument(c, err.Error()) + return + } + if !validateRuleVersionReasonLength(c, req.Reason) { + return + } + resp, err := service.RollbackRuleVersion(cs, service.RuleKindName{Kind: kind, Mesh: c.Query("mesh"), Name: c.Param("ruleName")}, id, req.Reason, currentUser(c)) + writeVersioningResp(c, resp, err) + } +} + +func validateRuleVersionReasonLength(c *gin.Context, reason string) bool { + if len(strings.TrimSpace(reason)) <= maxRuleVersionReasonLength { + return true + } + writeVersioningResp(c, nil, bizerror.New(bizerror.InvalidArgument, "reason must be at most 1024 characters")) + return false +} + +func mutationOptions(c *gin.Context) service.RuleMutationOptions { + return service.RuleMutationOptions{Author: currentUser(c)} +} + +func parseVersionID(c *gin.Context) (int64, bool) { + id, err := parseProtocolInt64(c.Param("versionId")) + if err != nil { + writeVersioningInvalidArgument(c, "versionId must be a positive decimal string") + return 0, false + } + return id, true +} + +func parseProtocolInt64(raw string) (int64, error) { + if raw == "" { + return 0, fmt.Errorf("empty id") + } + for i := range raw { + if raw[i] < '0' || raw[i] > '9' { + return 0, fmt.Errorf("invalid decimal id") + } + } + if len(raw) > 1 && raw[0] == '0' { + return 0, fmt.Errorf("invalid leading zero") + } + id, err := strconv.ParseInt(raw, 10, 64) + if err != nil { + return 0, err + } + if id <= 0 { + return 0, fmt.Errorf("id must be positive") + } + return id, nil +} + +func currentUser(c *gin.Context) string { + session := sessions.Default(c) + if user, ok := session.Get("user").(string); ok && strings.TrimSpace(user) != "" { + return user + } + return "system:unknown" +} + +func ensureVersioningAvailable(c *gin.Context, cs consolectx.Context) bool { + if cs.RuleVersioning() != nil { + return true + } + util.HandleServiceError(c, bizerror.New(bizerror.InternalError, "rule history service is unavailable")) + return false +} + +func writeVersioningResp(c *gin.Context, data any, err error) { + if err == nil { + c.JSON(http.StatusOK, model.NewSuccessResp(versioningAPIData(data))) + return + } + util.HandleServiceError(c, versioningServiceError(err)) +} + +func versioningServiceError(err error) error { + var bizErr bizerror.Error + switch { + case errors.Is(err, versioning.ErrVersionNotFound): + return bizerror.New(bizerror.NotFoundError, err.Error()) + case errors.Is(err, versioning.ErrRollbackToDelete), errors.Is(err, versioning.ErrRollbackToCurrent): + return bizerror.New(bizerror.InvalidArgument, err.Error()) + case errors.As(err, &bizErr): + return bizErr + default: + return err + } +} + +type ruleVersionAPI struct { + ID string `json:"id"` + RuleKind coremodel.ResourceKind `json:"ruleKind"` + Mesh string `json:"mesh"` + ResourceKey string `json:"resourceKey"` + RuleName string `json:"ruleName"` + VersionNo int64 `json:"versionNo"` + ContentHash string `json:"contentHash"` + SpecJSON string `json:"specJson"` + Source versioning.Source `json:"source"` + Operation versioning.Operation `json:"operation"` + Author string `json:"author"` + Reason string `json:"reason,omitempty"` + RolledBackFromID *string `json:"rolledBackFromId,omitempty"` + CreatedAt time.Time `json:"createdAt"` + RecordedAt time.Time `json:"recordedAt"` + IsLatestRecorded bool `json:"isLatestRecorded"` +} + +type ruleVersionListAPI struct { + Items []ruleVersionAPI `json:"items"` + Total int64 `json:"total"` + LatestRecordedVersionID *string `json:"latestRecordedVersionId,omitempty"` + LatestRecordedVersionNo int64 `json:"latestRecordedVersionNo,omitempty"` + LatestRecordedDeleted bool `json:"latestRecordedDeleted"` +} + +type ruleVersionDiffAPI struct { + Left ruleVersionDiffSideAPI `json:"left"` + Right ruleVersionDiffSideAPI `json:"right"` +} + +type ruleVersionDiffSideAPI struct { + ID string `json:"id"` + VersionNo int64 `json:"versionNo"` + SpecJSON string `json:"specJson"` +} + +type rollbackRuleVersionAPI struct { + RolledBackFromID string `json:"rolledBackFromId"` + VersionID string `json:"versionId"` + VersionNo int64 `json:"versionNo"` + Source string `json:"source"` +} + +func versioningAPIData(data any) any { + switch v := data.(type) { + case *versioning.ListResult: + if v == nil { + return nil + } + items := make([]ruleVersionAPI, 0, len(v.Items)) + for i := range v.Items { + items = append(items, toRuleVersionAPI(&v.Items[i])) + } + return &ruleVersionListAPI{ + Items: items, + Total: v.Total, + LatestRecordedVersionID: formatOptionalInt64(v.LatestRecordedVersionID), + LatestRecordedVersionNo: v.LatestRecordedVersionNo, + LatestRecordedDeleted: v.LatestRecordedDeleted, + } + case *versioning.Version: + if v == nil { + return nil + } + return toRuleVersionAPI(v) + case *versioning.DiffResult: + if v == nil { + return nil + } + return &ruleVersionDiffAPI{ + Left: toRuleVersionDiffSideAPI(v.Left), + Right: toRuleVersionDiffSideAPI(v.Right), + } + case *service.RollbackResult: + if v == nil { + return nil + } + return &rollbackRuleVersionAPI{ + RolledBackFromID: formatInt64(v.RolledBackFromID), + VersionID: formatInt64(v.VersionID), + VersionNo: v.VersionNo, + Source: v.Source, + } + default: + return data + } +} + +func toRuleVersionAPI(v *versioning.Version) ruleVersionAPI { + return ruleVersionAPI{ + ID: formatInt64(v.ID), + RuleKind: v.RuleKind, + Mesh: v.Mesh, + ResourceKey: v.ResourceKey, + RuleName: v.RuleName, + VersionNo: v.VersionNo, + ContentHash: v.ContentHash, + SpecJSON: v.SpecJSON, + Source: v.Source, + Operation: v.Operation, + Author: v.Author, + Reason: v.Reason, + RolledBackFromID: formatOptionalInt64(v.RolledBackFromID), + CreatedAt: v.CreatedAt, + RecordedAt: v.RecordedAt, + IsLatestRecorded: v.IsLatestRecorded, + } +} + +func toRuleVersionDiffSideAPI(side versioning.DiffSide) ruleVersionDiffSideAPI { + return ruleVersionDiffSideAPI{ + ID: formatInt64(side.ID), + VersionNo: side.VersionNo, + SpecJSON: side.SpecJSON, + } +} + +func formatInt64(id int64) string { + return strconv.FormatInt(id, 10) +} + +func formatOptionalInt64(id *int64) *string { + if id == nil { + return nil + } + value := formatInt64(*id) + return &value +} + +func writeVersioningInvalidArgument(c *gin.Context, message string) { + writeVersioningResp(c, nil, bizerror.New(bizerror.InvalidArgument, message)) +} diff --git a/pkg/console/handler/rule_version_test.go b/pkg/console/handler/rule_version_test.go new file mode 100644 index 000000000..c52ea3ac1 --- /dev/null +++ b/pkg/console/handler/rule_version_test.go @@ -0,0 +1,105 @@ +/* + * 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 handler + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/apache/dubbo-admin/pkg/common/bizerror" + appcfg "github.com/apache/dubbo-admin/pkg/config/app" + "github.com/apache/dubbo-admin/pkg/console/counter" + "github.com/apache/dubbo-admin/pkg/console/model" + "github.com/apache/dubbo-admin/pkg/core/lock" + "github.com/apache/dubbo-admin/pkg/core/manager" + "github.com/apache/dubbo-admin/pkg/core/versioning" +) + +type ruleVersionHandlerTestContext struct{} + +func (ruleVersionHandlerTestContext) ResourceManager() manager.ResourceManager { return nil } +func (ruleVersionHandlerTestContext) CounterManager() counter.CounterManager { return nil } +func (ruleVersionHandlerTestContext) Config() appcfg.AdminConfig { return appcfg.AdminConfig{} } +func (ruleVersionHandlerTestContext) AppContext() context.Context { return context.Background() } +func (ruleVersionHandlerTestContext) LockManager() lock.Lock { return nil } +func (ruleVersionHandlerTestContext) RuleVersioning() *versioning.Service { return nil } + +func testGinContext() (*gin.Context, *httptest.ResponseRecorder) { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + return ctx, recorder +} + +func decodeCommonResp(t *testing.T, recorder *httptest.ResponseRecorder) model.CommonResp { + t.Helper() + var resp model.CommonResp + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &resp)) + return resp +} + +func TestEnsureVersioningAvailableUsesCommonRespContract(t *testing.T) { + ctx, recorder := testGinContext() + + ok := ensureVersioningAvailable(ctx, ruleVersionHandlerTestContext{}) + + require.False(t, ok) + assert.Equal(t, http.StatusOK, recorder.Code) + resp := decodeCommonResp(t, recorder) + assert.Equal(t, string(bizerror.InternalError), resp.Code) + assert.Equal(t, "rule history service is unavailable", resp.Message) +} + +func TestWriteVersioningRespMapsBusinessErrorsToCommonResp(t *testing.T) { + tests := []struct { + name string + err error + code string + }{ + { + name: "version not found", + err: versioning.ErrVersionNotFound, + code: string(bizerror.NotFoundError), + }, + { + name: "rollback to current", + err: versioning.ErrRollbackToCurrent, + code: string(bizerror.InvalidArgument), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx, recorder := testGinContext() + + writeVersioningResp(ctx, nil, tt.err) + + assert.Equal(t, http.StatusOK, recorder.Code) + resp := decodeCommonResp(t, recorder) + assert.Equal(t, tt.code, resp.Code) + assert.Equal(t, tt.err.Error(), resp.Message) + }) + } +} diff --git a/pkg/console/handler/tag_rule.go b/pkg/console/handler/tag_rule.go index a6fe3637c..d07ad2c6b 100644 --- a/pkg/console/handler/tag_rule.go +++ b/pkg/console/handler/tag_rule.go @@ -103,7 +103,8 @@ func PutTagRuleWithRuleName(ctx consolectx.Context) gin.HandlerFunc { c.JSON(http.StatusOK, model.NewErrorResp(err.Error())) return } - if err = service.UpdateTagRule(ctx, res); err != nil { + opts := mutationOptions(c) + if err = service.UpdateTagRuleWithOptions(ctx, res, opts); err != nil { c.JSON(http.StatusOK, model.NewErrorResp(err.Error())) return } else { @@ -127,7 +128,8 @@ func PostTagRuleWithRuleName(ctx consolectx.Context) gin.HandlerFunc { c.JSON(http.StatusOK, model.NewErrorResp(err.Error())) return } - if err = service.CreateTagRule(ctx, res); err != nil { + opts := mutationOptions(c) + if err = service.CreateTagRuleWithOptions(ctx, res, opts); err != nil { c.JSON(http.StatusOK, model.NewErrorResp(err.Error())) return } else { @@ -145,7 +147,8 @@ func DeleteTagRuleWithRuleName(ctx consolectx.Context) gin.HandlerFunc { c.JSON(http.StatusBadRequest, model.NewBizErrorResp(err)) return } - if err := service.DeleteTagRule(ctx, ruleName, mesh); err != nil { + opts := mutationOptions(c) + if err := service.DeleteTagRuleWithOptions(ctx, ruleName, mesh, opts); err != nil { c.JSON(http.StatusOK, model.NewErrorResp(err.Error())) return } diff --git a/pkg/console/model/condition_rule.go b/pkg/console/model/condition_rule.go index 11b92fe11..a0e479d90 100644 --- a/pkg/console/model/condition_rule.go +++ b/pkg/console/model/condition_rule.go @@ -52,7 +52,9 @@ type ConditionRuleResp struct { Conditions []string `json:"conditions"` ConfigVersion string `json:"configVersion"` Enabled bool `json:"enabled"` + Force bool `json:"force"` Key string `json:"key"` + Priority int32 `json:"priority"` Runtime bool `json:"runtime"` Scope string `json:"scope"` } @@ -246,7 +248,9 @@ func GenConditionRuleToResp(data *meshproto.ConditionRoute) *CommonResp { Conditions: data.Conditions, ConfigVersion: data.ConfigVersion, Enabled: data.Enabled, + Force: data.Force, Key: data.Key, + Priority: data.Priority, Runtime: data.Runtime, Scope: data.Scope, }) diff --git a/pkg/console/model/tag_rule.go b/pkg/console/model/tag_rule.go index 9428771ea..b4d733f2e 100644 --- a/pkg/console/model/tag_rule.go +++ b/pkg/console/model/tag_rule.go @@ -31,7 +31,9 @@ type TagRuleSearchResp struct { type TagRuleResp struct { ConfigVersion string `json:"configVersion"` Enabled bool `json:"enabled"` + Force bool `json:"force"` Key string `json:"key"` + Priority int32 `json:"priority"` Runtime bool `json:"runtime"` Scope string `json:"scope"` Tags []RespTagElement `json:"tags"` @@ -50,7 +52,9 @@ func GenTagRouteResp(pb *meshproto.TagRoute) *CommonResp { return NewSuccessResp(TagRuleResp{ ConfigVersion: pb.ConfigVersion, Enabled: pb.Enabled, + Force: pb.Force, Key: pb.Key, + Priority: pb.Priority, Runtime: pb.Runtime, Scope: constants.ScopeApplication, Tags: tagToRespTagElement(pb.Tags), diff --git a/pkg/console/router/router.go b/pkg/console/router/router.go index 24cd7d44c..971aff4bf 100644 --- a/pkg/console/router/router.go +++ b/pkg/console/router/router.go @@ -22,6 +22,7 @@ import ( consolectx "github.com/apache/dubbo-admin/pkg/console/context" "github.com/apache/dubbo-admin/pkg/console/handler" + meshresource "github.com/apache/dubbo-admin/pkg/core/resource/apis/mesh/v1alpha1" ) func InitRouter(r *gin.Engine, ctx consolectx.Context) { @@ -112,6 +113,10 @@ func InitRouter(r *gin.Engine, ctx consolectx.Context) { { configuration := router.Group("/configurator") configuration.GET("/search", handler.ConfiguratorSearch(ctx)) + configuration.GET("/:ruleName/versions", handler.ListRuleVersions(ctx, meshresource.DynamicConfigKind)) + configuration.GET("/:ruleName/versions/:versionId", handler.GetRuleVersion(ctx, meshresource.DynamicConfigKind)) + configuration.GET("/:ruleName/versions/:versionId/diff", handler.DiffRuleVersion(ctx, meshresource.DynamicConfigKind)) + configuration.POST("/:ruleName/versions/:versionId/rollback", handler.RollbackRuleVersion(ctx, meshresource.DynamicConfigKind)) configuration.GET("/:ruleName", handler.GetConfiguratorWithRuleName(ctx)) configuration.PUT("/:ruleName", handler.PutConfiguratorWithRuleName(ctx)) configuration.POST("/:ruleName", handler.PostConfiguratorWithRuleName(ctx)) @@ -121,6 +126,10 @@ func InitRouter(r *gin.Engine, ctx consolectx.Context) { { conditionRule := router.Group("/condition-rule") conditionRule.GET("/search", handler.ConditionRuleSearch(ctx)) + conditionRule.GET("/:ruleName/versions", handler.ListRuleVersions(ctx, meshresource.ConditionRouteKind)) + conditionRule.GET("/:ruleName/versions/:versionId", handler.GetRuleVersion(ctx, meshresource.ConditionRouteKind)) + conditionRule.GET("/:ruleName/versions/:versionId/diff", handler.DiffRuleVersion(ctx, meshresource.ConditionRouteKind)) + conditionRule.POST("/:ruleName/versions/:versionId/rollback", handler.RollbackRuleVersion(ctx, meshresource.ConditionRouteKind)) conditionRule.GET("/:ruleName", handler.GetConditionRuleWithRuleName(ctx)) conditionRule.PUT("/:ruleName", handler.PutConditionRuleWithRuleName(ctx)) conditionRule.POST("/:ruleName", handler.PostConditionRuleWithRuleName(ctx)) @@ -130,6 +139,10 @@ func InitRouter(r *gin.Engine, ctx consolectx.Context) { { tagRule := router.Group("/tag-rule") tagRule.GET("/search", handler.TagRuleSearch(ctx)) + tagRule.GET("/:ruleName/versions", handler.ListRuleVersions(ctx, meshresource.TagRouteKind)) + tagRule.GET("/:ruleName/versions/:versionId", handler.GetRuleVersion(ctx, meshresource.TagRouteKind)) + tagRule.GET("/:ruleName/versions/:versionId/diff", handler.DiffRuleVersion(ctx, meshresource.TagRouteKind)) + tagRule.POST("/:ruleName/versions/:versionId/rollback", handler.RollbackRuleVersion(ctx, meshresource.TagRouteKind)) tagRule.GET("/:ruleName", handler.GetTagRuleWithRuleName(ctx)) tagRule.PUT("/:ruleName", handler.PutTagRuleWithRuleName(ctx)) tagRule.POST("/:ruleName", handler.PostTagRuleWithRuleName(ctx)) diff --git a/pkg/console/service/condition_rule.go b/pkg/console/service/condition_rule.go index 9fae15940..8aa7407a6 100644 --- a/pkg/console/service/condition_rule.go +++ b/pkg/console/service/condition_rule.go @@ -18,8 +18,6 @@ package service import ( - "github.com/apache/dubbo-admin/pkg/common/constants" - "github.com/apache/dubbo-admin/pkg/core/lock" "github.com/duke-git/lancet/v2/slice" "github.com/duke-git/lancet/v2/strutil" @@ -108,18 +106,11 @@ func GetConditionRule(ctx context.Context, name string, mesh string) (*meshresou } func UpdateConditionRule(ctx context.Context, res *meshresource.ConditionRouteResource) error { - lockMgr := ctx.LockManager() - if lockMgr == nil { - return updateConditionRuleUnsafe(ctx, res) - } - lockKey := lock.BuildConditionRuleLockKey(res.Mesh, res.Name) - return lockMgr.WithLock(ctx.AppContext(), lockKey, constants.DefaultLockTimeout, func() error { - return updateConditionRuleUnsafe(ctx, res) - }) + return UpdateConditionRuleWithOptions(ctx, res, RuleMutationOptions{}) } -func updateConditionRuleUnsafe(ctx context.Context, res *meshresource.ConditionRouteResource) error { - if err := ctx.ResourceManager().Update(res); err != nil { +func UpdateConditionRuleWithOptions(ctx context.Context, res *meshresource.ConditionRouteResource, opts RuleMutationOptions) error { + if err := updateRule(ctx, res, opts); err != nil { logger.Warnf("update %s condition failed with error: %s", res.Name, err.Error()) return err } @@ -127,18 +118,11 @@ func updateConditionRuleUnsafe(ctx context.Context, res *meshresource.ConditionR } func CreateConditionRule(ctx context.Context, res *meshresource.ConditionRouteResource) error { - lockMgr := ctx.LockManager() - if lockMgr == nil { - return createConditionRuleUnsafe(ctx, res) - } - lockKey := lock.BuildConditionRuleLockKey(res.Mesh, res.Name) - return lockMgr.WithLock(ctx.AppContext(), lockKey, constants.DefaultLockTimeout, func() error { - return createConditionRuleUnsafe(ctx, res) - }) + return CreateConditionRuleWithOptions(ctx, res, RuleMutationOptions{}) } -func createConditionRuleUnsafe(ctx context.Context, res *meshresource.ConditionRouteResource) error { - if err := ctx.ResourceManager().Add(res); err != nil { +func CreateConditionRuleWithOptions(ctx context.Context, res *meshresource.ConditionRouteResource, opts RuleMutationOptions) error { + if err := createRule(ctx, res, opts); err != nil { logger.Warnf("create %s condition failed with error: %s", res.Name, err.Error()) return err } @@ -146,18 +130,13 @@ func createConditionRuleUnsafe(ctx context.Context, res *meshresource.ConditionR } func DeleteConditionRule(ctx context.Context, name string, mesh string) error { - lockMgr := ctx.LockManager() - if lockMgr == nil { - return deleteConditionRuleUnsafe(ctx, name, mesh) - } - lockKey := lock.BuildConditionRuleLockKey(mesh, name) - return lockMgr.WithLock(ctx.AppContext(), lockKey, constants.DefaultLockTimeout, func() error { - return deleteConditionRuleUnsafe(ctx, name, mesh) - }) + return DeleteConditionRuleWithOptions(ctx, name, mesh, RuleMutationOptions{}) } -func deleteConditionRuleUnsafe(ctx context.Context, name string, mesh string) error { - if err := ctx.ResourceManager().DeleteByKey(meshresource.ConditionRouteKind, mesh, coremodel.BuildResourceKey(mesh, name)); err != nil { +func DeleteConditionRuleWithOptions(ctx context.Context, name string, mesh string, opts RuleMutationOptions) error { + kindName := RuleKindName{Kind: meshresource.ConditionRouteKind, Mesh: mesh, Name: name} + if err := deleteRule(ctx, kindName, opts); err != nil { + logger.Warnf("delete %s condition failed with error: %s", name, err.Error()) return err } return nil diff --git a/pkg/console/service/configurator_rule.go b/pkg/console/service/configurator_rule.go index 13dd2284d..fe8c966ba 100644 --- a/pkg/console/service/configurator_rule.go +++ b/pkg/console/service/configurator_rule.go @@ -18,8 +18,6 @@ package service import ( - "github.com/apache/dubbo-admin/pkg/common/constants" - "github.com/apache/dubbo-admin/pkg/core/lock" "github.com/duke-git/lancet/v2/slice" "github.com/apache/dubbo-admin/pkg/common/bizerror" @@ -116,18 +114,11 @@ func GetConfigurator(ctx consolectx.Context, name string, mesh string) (*meshres } func UpdateConfigurator(ctx consolectx.Context, res *meshresource.DynamicConfigResource) error { - lockMgr := ctx.LockManager() - if lockMgr == nil { - return updateConfiguratorUnsafe(ctx, res) - } - lockKey := lock.BuildConfiguratorRuleLockKey(res.Mesh, res.Name) - return lockMgr.WithLock(ctx.AppContext(), lockKey, constants.DefaultLockTimeout, func() error { - return updateConfiguratorUnsafe(ctx, res) - }) + return UpdateConfiguratorWithOptions(ctx, res, RuleMutationOptions{}) } -func updateConfiguratorUnsafe(ctx consolectx.Context, res *meshresource.DynamicConfigResource) error { - if err := ctx.ResourceManager().Update(res); err != nil { +func UpdateConfiguratorWithOptions(ctx consolectx.Context, res *meshresource.DynamicConfigResource, opts RuleMutationOptions) error { + if err := updateRule(ctx, res, opts); err != nil { logger.Warnf("update %s configurator failed with error: %s", res.Name, err.Error()) return err } @@ -135,18 +126,11 @@ func updateConfiguratorUnsafe(ctx consolectx.Context, res *meshresource.DynamicC } func CreateConfigurator(ctx consolectx.Context, res *meshresource.DynamicConfigResource) error { - lockMgr := ctx.LockManager() - if lockMgr == nil { - return createConfiguratorUnsafe(ctx, res) - } - lockKey := lock.BuildConfiguratorRuleLockKey(res.Mesh, res.Name) - return lockMgr.WithLock(ctx.AppContext(), lockKey, constants.DefaultLockTimeout, func() error { - return createConfiguratorUnsafe(ctx, res) - }) + return CreateConfiguratorWithOptions(ctx, res, RuleMutationOptions{}) } -func createConfiguratorUnsafe(ctx consolectx.Context, res *meshresource.DynamicConfigResource) error { - if err := ctx.ResourceManager().Add(res); err != nil { +func CreateConfiguratorWithOptions(ctx consolectx.Context, res *meshresource.DynamicConfigResource, opts RuleMutationOptions) error { + if err := createRule(ctx, res, opts); err != nil { logger.Warnf("create %s configurator failed with error: %s", res.Name, err.Error()) return err } @@ -154,18 +138,12 @@ func createConfiguratorUnsafe(ctx consolectx.Context, res *meshresource.DynamicC } func DeleteConfigurator(ctx consolectx.Context, name string, mesh string) error { - lockMgr := ctx.LockManager() - if lockMgr == nil { - return deleteConfiguratorUnsafe(ctx, name, mesh) - } - lockKey := lock.BuildConfiguratorRuleLockKey(mesh, name) - return lockMgr.WithLock(ctx.AppContext(), lockKey, constants.DefaultLockTimeout, func() error { - return deleteConfiguratorUnsafe(ctx, name, mesh) - }) + return DeleteConfiguratorWithOptions(ctx, name, mesh, RuleMutationOptions{}) } -func deleteConfiguratorUnsafe(ctx consolectx.Context, name string, mesh string) error { - if err := ctx.ResourceManager().DeleteByKey(meshresource.DynamicConfigKind, mesh, coremodel.BuildResourceKey(mesh, name)); err != nil { +func DeleteConfiguratorWithOptions(ctx consolectx.Context, name string, mesh string, opts RuleMutationOptions) error { + kindName := RuleKindName{Kind: meshresource.DynamicConfigKind, Mesh: mesh, Name: name} + if err := deleteRule(ctx, kindName, opts); err != nil { logger.Warnf("delete %s configurator failed with error: %s", name, err.Error()) return err } diff --git a/pkg/console/service/rule_version.go b/pkg/console/service/rule_version.go new file mode 100644 index 000000000..378790ffd --- /dev/null +++ b/pkg/console/service/rule_version.go @@ -0,0 +1,310 @@ +/* + * 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 ( + "fmt" + "strings" + + "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/core/lock" + 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" +) + +type RuleMutationOptions struct { + Author string +} + +type RuleKindName struct { + Kind coremodel.ResourceKind + Mesh string + Name string +} + +func ruleVersioning(ctx consolectx.Context) *versioning.Service { + if ctx == nil { + return nil + } + return ctx.RuleVersioning() +} + +func requiredRuleVersioning(ctx consolectx.Context) (*versioning.Service, error) { + svc := ruleVersioning(ctx) + if svc == nil { + return nil, versioning.ErrVersionStoreError + } + return svc, nil +} + +// getRuleIfExists reads the live ResourceManager state, not recorded history. +func getRuleIfExists(ctx consolectx.Context, kindName RuleKindName) (coremodel.Resource, bool, error) { + key := coremodel.BuildResourceKey(kindName.Mesh, kindName.Name) + res, exists, err := ctx.ResourceManager().GetByKey(kindName.Kind, key) + if err != nil { + return nil, false, err + } + if !exists || res == nil { + return nil, false, nil + } + return res, true, nil +} + +func getExistingRule(ctx consolectx.Context, kindName RuleKindName) (coremodel.Resource, error) { + res, exists, err := getRuleIfExists(ctx, kindName) + if err != nil { + return nil, err + } + if !exists { + key := coremodel.BuildResourceKey(kindName.Mesh, kindName.Name) + return nil, fmt.Errorf("%s %s does not exist", kindName.Kind, key) + } + return res, nil +} + +func appendRuleHistory(ctx consolectx.Context, res coremodel.Resource, op versioning.Operation, source versioning.Source, author, reason string, rolledBackFromID *int64) (*versioning.Version, error) { + svc, err := requiredRuleVersioning(ctx) + if err != nil { + return nil, err + } + return svc.Append(ctx.AppContext(), res, op, source, author, reason, rolledBackFromID) +} + +func ensureBaselineHistory(ctx consolectx.Context, res coremodel.Resource) error { + svc, err := requiredRuleVersioning(ctx) + if err != nil { + return err + } + if res == nil { + return nil + } + hasHistory, err := svc.HasHistory(res.ResourceKind(), res.ResourceMesh(), res.ResourceMeta().Name) + if err != nil { + return err + } + if hasHistory { + return nil + } + if _, err := svc.Append(ctx.AppContext(), res, versioning.OperationCreate, versioning.SourceBootstrap, "system:baseline", "import existing rule before first edit", nil); err != nil { + return err + } + return nil +} + +func withRuleLock(ctx consolectx.Context, kindName RuleKindName, fn func() error) error { + lockMgr := ctx.LockManager() + if lockMgr == nil { + return fn() + } + lockKey := ruleLockKey(kindName) + return lockMgr.WithLock(ctx.AppContext(), lockKey, constants.DefaultLockTimeout, fn) +} + +func ruleLockKey(kindName RuleKindName) string { + switch kindName.Kind { + case meshresource.ConditionRouteKind: + return lock.BuildConditionRuleLockKey(kindName.Mesh, kindName.Name) + case meshresource.TagRouteKind: + return lock.BuildTagRouteLockKey(kindName.Mesh, kindName.Name) + case meshresource.DynamicConfigKind: + return lock.BuildConfiguratorRuleLockKey(kindName.Mesh, kindName.Name) + default: + return lock.BuildLockKey(kindName.Kind.ToString(), kindName.Mesh, kindName.Name) + } +} + +func createRule(ctx consolectx.Context, res coremodel.Resource, opts RuleMutationOptions) error { + kindName := RuleKindName{Kind: res.ResourceKind(), Mesh: res.ResourceMesh(), Name: res.ResourceMeta().Name} + return withRuleLock(ctx, kindName, func() error { + if _, err := appendRuleHistory(ctx, res, versioning.OperationCreate, versioning.SourceAdmin, opts.Author, "", nil); err != nil { + return err + } + if err := ctx.ResourceManager().Add(res); err != nil { + return err + } + return nil + }) +} + +func updateRule(ctx consolectx.Context, res coremodel.Resource, opts RuleMutationOptions) error { + kindName := RuleKindName{Kind: res.ResourceKind(), Mesh: res.ResourceMesh(), Name: res.ResourceMeta().Name} + return withRuleLock(ctx, kindName, func() error { + existing, err := getExistingRule(ctx, kindName) + if err != nil { + return err + } + if err := ensureBaselineHistory(ctx, existing); err != nil { + return err + } + if _, err := appendRuleHistory(ctx, res, versioning.OperationUpdate, versioning.SourceAdmin, opts.Author, "", nil); err != nil { + return err + } + if err := ctx.ResourceManager().Update(res); err != nil { + return err + } + return nil + }) +} + +func deleteRule(ctx consolectx.Context, kindName RuleKindName, opts RuleMutationOptions) error { + return withRuleLock(ctx, kindName, func() error { + resourceKey := coremodel.BuildResourceKey(kindName.Mesh, kindName.Name) + snapshot, exists, err := ctx.ResourceManager().GetByKey(kindName.Kind, resourceKey) + if err != nil { + return err + } + if !exists || snapshot == nil { + return nil + } + if err := ensureBaselineHistory(ctx, snapshot); err != nil { + return err + } + if _, err := appendRuleHistory(ctx, snapshot, versioning.OperationDelete, versioning.SourceAdmin, opts.Author, "", nil); err != nil { + return err + } + if err := ctx.ResourceManager().DeleteByKey(kindName.Kind, kindName.Mesh, resourceKey); err != nil { + return err + } + return nil + }) +} + +func ListRuleVersions(ctx consolectx.Context, kindName RuleKindName) (*versioning.ListResult, error) { + svc := ruleVersioning(ctx) + if svc == nil { + return nil, versioning.ErrVersionStoreError + } + return svc.List(kindName.Kind, kindName.Mesh, kindName.Name) +} + +func GetRuleVersion(ctx consolectx.Context, kindName RuleKindName, versionID int64) (*versioning.Version, error) { + svc := ruleVersioning(ctx) + if svc == nil { + return nil, versioning.ErrVersionStoreError + } + return svc.Get(kindName.Kind, kindName.Mesh, kindName.Name, versionID) +} + +func DiffRuleVersion(ctx consolectx.Context, kindName RuleKindName, versionID int64, against string) (*versioning.DiffResult, error) { + svc := ruleVersioning(ctx) + if svc == nil { + return nil, versioning.ErrVersionStoreError + } + if against != "" && against != "current" { + return svc.DiffHistoryVersions(kindName.Kind, kindName.Mesh, kindName.Name, versionID, against) + } + left, err := svc.Get(kindName.Kind, kindName.Mesh, kindName.Name, versionID) + if err != nil { + return nil, err + } + current, exists, err := getRuleIfExists(ctx, kindName) + if err != nil { + return nil, err + } + specJSON := versioning.DeleteSpecJSON + if exists { + _, specJSON, err = versioning.NormalizeResource(current) + if err != nil { + return nil, err + } + } + return &versioning.DiffResult{ + Left: versioning.DiffSide{ID: left.ID, VersionNo: left.VersionNo, SpecJSON: left.SpecJSON}, + Right: versioning.DiffSide{ID: 0, VersionNo: 0, SpecJSON: specJSON}, + }, nil +} + +// RollbackResult summarizes a rollback write for the API response. +type RollbackResult struct { + RolledBackFromID int64 `json:"rolledBackFromId"` + VersionID int64 `json:"versionId"` + VersionNo int64 `json:"versionNo"` + Source string `json:"source"` +} + +func RollbackRuleVersion(ctx consolectx.Context, kindName RuleKindName, targetVersionID int64, reason string, author string) (*RollbackResult, error) { + svc := ruleVersioning(ctx) + if svc == nil { + return nil, versioning.ErrVersionStoreError + } + reason = strings.TrimSpace(reason) + if reason == "" { + return nil, bizerror.New(bizerror.InvalidArgument, "rollback reason is required") + } + + var result *RollbackResult + err := withRuleLock(ctx, kindName, func() error { + target, err := svc.Get(kindName.Kind, kindName.Mesh, kindName.Name, targetVersionID) + if err != nil { + return err + } + if target.Operation == versioning.OperationDelete { + return versioning.ErrRollbackToDelete + } + if target.Operation != versioning.OperationCreate && target.Operation != versioning.OperationUpdate { + return bizerror.New(bizerror.InvalidArgument, "only CREATE or UPDATE rule versions can be rolled back") + } + + current, exists, err := getRuleIfExists(ctx, kindName) + if err != nil { + return err + } + if exists { + hash, _, err := versioning.NormalizeResource(current) + if err != nil { + return err + } + if hash == target.ContentHash { + return versioning.ErrRollbackToCurrent + } + } + + res, err := versioning.ResourceFromSpecJSON(kindName.Kind, kindName.Mesh, kindName.Name, target.SpecJSON) + if err != nil { + return err + } + + operation := versioning.OperationUpdate + if !exists { + operation = versioning.OperationCreate + } + fromID := target.ID + appended, err := appendRuleHistory(ctx, res, operation, versioning.SourceRollback, author, reason, &fromID) + if err != nil { + return err + } + if err := ctx.ResourceManager().Upsert(res); err != nil { + return err + } + + result = &RollbackResult{ + RolledBackFromID: fromID, + Source: string(versioning.SourceRollback), + VersionID: appended.ID, + VersionNo: appended.VersionNo, + } + return nil + }) + if err != nil { + return nil, err + } + return result, nil +} diff --git a/pkg/console/service/rule_version_rollback_test.go b/pkg/console/service/rule_version_rollback_test.go new file mode 100644 index 000000000..7162e420f --- /dev/null +++ b/pkg/console/service/rule_version_rollback_test.go @@ -0,0 +1,574 @@ +/* + * 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 ( + "context" + "errors" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + meshproto "github.com/apache/dubbo-admin/api/mesh/v1alpha1" + "github.com/apache/dubbo-admin/pkg/common/bizerror" + appcfg "github.com/apache/dubbo-admin/pkg/config/app" + versioningcfg "github.com/apache/dubbo-admin/pkg/config/versioning" + "github.com/apache/dubbo-admin/pkg/console/counter" + "github.com/apache/dubbo-admin/pkg/core/governor" + "github.com/apache/dubbo-admin/pkg/core/lock" + "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" + "github.com/apache/dubbo-admin/pkg/core/versioning" + memoryst "github.com/apache/dubbo-admin/pkg/store/memory" +) + +type testContext struct { + rm manager.ResourceManager + versioningSvc *versioning.Service + adapter *versioning.ResourceStoreAdapter + cfg *appcfg.AdminConfig + stores map[coremodel.ResourceKind]store.ResourceStore + gov *noopGovernor + lockMgr lock.Lock +} + +func (c *testContext) ResourceManager() manager.ResourceManager { return c.rm } +func (c *testContext) CounterManager() counter.CounterManager { return nil } +func (c *testContext) Config() appcfg.AdminConfig { return *c.cfg } +func (c *testContext) AppContext() context.Context { return context.Background() } +func (c *testContext) LockManager() lock.Lock { return c.lockMgr } +func (c *testContext) RuleVersioning() *versioning.Service { return c.versioningSvc } + +type recordingLock struct { + mu sync.Mutex + held bool + events []string +} + +func (l *recordingLock) Lock(context.Context, string, time.Duration) error { return nil } + +func (l *recordingLock) TryLock(context.Context, string, time.Duration) (bool, error) { + return true, nil +} + +func (l *recordingLock) Unlock(context.Context, string) error { return nil } + +func (l *recordingLock) Renew(context.Context, string, time.Duration) error { return nil } + +func (l *recordingLock) IsLocked(context.Context, string) (bool, error) { return false, nil } + +func (l *recordingLock) CleanupExpiredLocks(context.Context) error { return nil } + +func (l *recordingLock) WithLock(_ context.Context, key string, _ time.Duration, fn func() error) error { + l.mu.Lock() + l.events = append(l.events, "lock:"+key) + l.held = true + l.mu.Unlock() + + err := fn() + + l.mu.Lock() + l.held = false + l.events = append(l.events, "unlock:"+key) + l.mu.Unlock() + return err +} + +func (l *recordingLock) record(event string) { + l.mu.Lock() + defer l.mu.Unlock() + if l.held { + l.events = append(l.events, event+":locked") + return + } + l.events = append(l.events, event+":unlocked") +} + +func (l *recordingLock) reset() { + l.mu.Lock() + defer l.mu.Unlock() + l.events = nil +} + +func (l *recordingLock) snapshot() []string { + l.mu.Lock() + defer l.mu.Unlock() + return append([]string(nil), l.events...) +} + +type testRouter struct { + stores map[coremodel.ResourceKind]store.ResourceStore +} + +func (r *testRouter) ResourceRoute(res coremodel.Resource) (store.ResourceStore, error) { + return r.ResourceKindRoute(res.ResourceKind()) +} + +func (r *testRouter) ResourceKindRoute(kind coremodel.ResourceKind) (store.ResourceStore, error) { + s, ok := r.stores[kind] + if !ok { + return nil, bizerror.New(bizerror.InvalidArgument, "store not found for kind") + } + return s, nil +} + +type noopGovernor struct { + stores map[coremodel.ResourceKind]store.ResourceStore + failNextCreate error + failNextUpdate error + failNextDelete error + trace *recordingLock +} + +func (g *noopGovernor) CreateRule(res coremodel.Resource) error { + if g.trace != nil { + g.trace.record("registry:create") + } + if g.failNextCreate != nil { + err := g.failNextCreate + g.failNextCreate = nil + return err + } + s, ok := g.stores[res.ResourceKind()] + if !ok { + return bizerror.New(bizerror.InvalidArgument, "store not found") + } + return s.Add(res) +} + +func (g *noopGovernor) UpdateRule(res coremodel.Resource) error { + if g.trace != nil { + g.trace.record("registry:update") + } + if g.failNextUpdate != nil { + err := g.failNextUpdate + g.failNextUpdate = nil + return err + } + s, ok := g.stores[res.ResourceKind()] + if !ok { + return bizerror.New(bizerror.InvalidArgument, "store not found") + } + return s.Update(res) +} + +func (g *noopGovernor) DeleteRule(res coremodel.Resource) error { + if g.trace != nil { + g.trace.record("registry:delete") + } + if g.failNextDelete != nil { + err := g.failNextDelete + g.failNextDelete = nil + return err + } + s, ok := g.stores[res.ResourceKind()] + if !ok { + return bizerror.New(bizerror.InvalidArgument, "store not found") + } + return s.Delete(res) +} + +type noopGovernorRouter struct { + gov *noopGovernor +} + +func (r *noopGovernorRouter) ResourceRoute(coremodel.Resource) (governor.RuleGovernor, error) { + return r.gov, nil +} + +func (r *noopGovernorRouter) ResourceMeshRoute(string) (governor.RuleGovernor, error) { + return r.gov, nil +} + +type failingResourceStore struct { + store.ResourceStore + failNextAdd bool + err error +} + +type recordingVersionStore struct { + store.ResourceStore + trace *recordingLock +} + +func (s *recordingVersionStore) Add(obj interface{}) error { + if s.trace != nil { + s.trace.record("history:add") + } + return s.ResourceStore.Add(obj) +} + +func (s *failingResourceStore) Add(obj interface{}) error { + if s.failNextAdd { + s.failNextAdd = false + return s.err + } + return s.ResourceStore.Add(obj) +} + +func setupRollbackTestEnv(t *testing.T, wrapVersionStore ...func(store.ResourceStore) store.ResourceStore) *testContext { + conditionStore := memoryst.NewMemoryResourceStore(meshresource.ConditionRouteKind) + dynamicConfigStore := memoryst.NewMemoryResourceStore(meshresource.DynamicConfigKind) + tagStore := memoryst.NewMemoryResourceStore(meshresource.TagRouteKind) + versionStore := memoryst.NewMemoryResourceStore(meshresource.RuleVersionKind) + for _, s := range []store.ManagedResourceStore{conditionStore, dynamicConfigStore, tagStore, versionStore} { + require.NoError(t, s.Init(nil)) + } + + var versioningVersionStore store.ResourceStore = versionStore + if len(wrapVersionStore) > 0 && wrapVersionStore[0] != nil { + versioningVersionStore = wrapVersionStore[0](versionStore) + } + stores := map[coremodel.ResourceKind]store.ResourceStore{ + meshresource.ConditionRouteKind: conditionStore, + meshresource.DynamicConfigKind: dynamicConfigStore, + meshresource.TagRouteKind: tagStore, + meshresource.RuleVersionKind: versioningVersionStore, + } + + gov := &noopGovernor{stores: stores} + rm := manager.NewResourceManager(&testRouter{stores: stores}, &noopGovernorRouter{gov: gov}) + adapter := versioning.NewResourceStoreAdapter(versioningVersionStore) + return &testContext{ + rm: rm, + versioningSvc: versioning.NewService(5, adapter), + adapter: adapter, + cfg: &appcfg.AdminConfig{RuleVersioning: &versioningcfg.Config{MaxVersionsPerRule: 5}}, + stores: stores, + gov: gov, + } +} + +func conditionRule(name, payload string) *meshresource.ConditionRouteResource { + res := meshresource.NewConditionRouteResourceWithAttributes(name, "") + res.Spec = &meshproto.ConditionRoute{Enabled: true, Key: name, Conditions: []string{payload}} + return res +} + +func dynamicConfigRule(name, payload string) *meshresource.DynamicConfigResource { + res := meshresource.NewDynamicConfigResourceWithAttributes(name, "") + res.Spec = &meshproto.DynamicConfig{Enabled: true, Key: name, ConfigVersion: payload} + return res +} + +func tagRule(name, payload string) *meshresource.TagRouteResource { + res := meshresource.NewTagRouteResourceWithAttributes(name, "") + res.Spec = &meshproto.TagRoute{Enabled: true, Key: name, ConfigVersion: payload} + return res +} + +func kindName(name string) RuleKindName { + return RuleKindName{Kind: meshresource.ConditionRouteKind, Name: name} +} + +func TestRuleMutationsUsePerRuleDistributedLock(t *testing.T) { + tests := []struct { + name string + key string + create func(*testContext) error + update func(*testContext) error + remove func(*testContext) error + lockKey string + }{ + { + name: "condition", + key: "condition-rule", + create: func(ctx *testContext) error { + return CreateConditionRuleWithOptions(ctx, conditionRule("condition-rule", "v1"), RuleMutationOptions{Author: "admin"}) + }, + update: func(ctx *testContext) error { + return UpdateConditionRuleWithOptions(ctx, conditionRule("condition-rule", "v2"), RuleMutationOptions{Author: "admin"}) + }, + remove: func(ctx *testContext) error { + return DeleteConditionRuleWithOptions(ctx, "condition-rule", "", RuleMutationOptions{Author: "admin"}) + }, + lockKey: lock.BuildConditionRuleLockKey("", "condition-rule"), + }, + { + name: "dynamic config", + key: "dynamic-rule", + create: func(ctx *testContext) error { + return CreateConfiguratorWithOptions(ctx, dynamicConfigRule("dynamic-rule", "v1"), RuleMutationOptions{Author: "admin"}) + }, + update: func(ctx *testContext) error { + return UpdateConfiguratorWithOptions(ctx, dynamicConfigRule("dynamic-rule", "v2"), RuleMutationOptions{Author: "admin"}) + }, + remove: func(ctx *testContext) error { + return DeleteConfiguratorWithOptions(ctx, "dynamic-rule", "", RuleMutationOptions{Author: "admin"}) + }, + lockKey: lock.BuildConfiguratorRuleLockKey("", "dynamic-rule"), + }, + { + name: "tag", + key: "tag-rule", + create: func(ctx *testContext) error { + return CreateTagRuleWithOptions(ctx, tagRule("tag-rule", "v1"), RuleMutationOptions{Author: "admin"}) + }, + update: func(ctx *testContext) error { + return UpdateTagRuleWithOptions(ctx, tagRule("tag-rule", "v2"), RuleMutationOptions{Author: "admin"}) + }, + remove: func(ctx *testContext) error { + return DeleteTagRuleWithOptions(ctx, "tag-rule", "", RuleMutationOptions{Author: "admin"}) + }, + lockKey: lock.BuildTagRouteLockKey("", "tag-rule"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + trace := &recordingLock{} + ctx := setupRollbackTestEnv(t, func(base store.ResourceStore) store.ResourceStore { + return &recordingVersionStore{ResourceStore: base, trace: trace} + }) + ctx.lockMgr = trace + ctx.gov.trace = trace + + require.NoError(t, tt.create(ctx)) + assert.Contains(t, trace.snapshot(), "lock:"+tt.lockKey) + assert.Contains(t, trace.snapshot(), "history:add:locked") + assert.Contains(t, trace.snapshot(), "registry:create:locked") + + trace.reset() + require.NoError(t, tt.update(ctx)) + assert.Contains(t, trace.snapshot(), "lock:"+tt.lockKey) + assert.Contains(t, trace.snapshot(), "history:add:locked") + assert.Contains(t, trace.snapshot(), "registry:update:locked") + + trace.reset() + require.NoError(t, tt.remove(ctx)) + assert.Contains(t, trace.snapshot(), "lock:"+tt.lockKey) + assert.Contains(t, trace.snapshot(), "history:add:locked") + assert.Contains(t, trace.snapshot(), "registry:delete:locked") + }) + } +} + +func TestRollbackRunsHistoryAppendAndRegistryMutationInsideRuleLock(t *testing.T) { + trace := &recordingLock{} + ctx := setupRollbackTestEnv(t, func(base store.ResourceStore) store.ResourceStore { + return &recordingVersionStore{ResourceStore: base, trace: trace} + }) + ctx.lockMgr = trace + ctx.gov.trace = trace + + require.NoError(t, CreateConditionRuleWithOptions(ctx, conditionRule("demo-rule", "v1"), RuleMutationOptions{Author: "admin"})) + versions, err := ListRuleVersions(ctx, kindName("demo-rule")) + require.NoError(t, err) + targetID := versions.Items[0].ID + require.NoError(t, UpdateConditionRuleWithOptions(ctx, conditionRule("demo-rule", "v2"), RuleMutationOptions{Author: "admin"})) + + trace.reset() + result, err := RollbackRuleVersion(ctx, kindName("demo-rule"), targetID, "restore", "admin") + require.NoError(t, err) + require.NotNil(t, result) + + events := trace.snapshot() + assert.Contains(t, events, "lock:"+lock.BuildConditionRuleLockKey("", "demo-rule")) + assert.Contains(t, events, "history:add:locked") + assert.Contains(t, events, "registry:update:locked") +} + +func TestUpdateAppendsBaselineBeforeFirstHistory(t *testing.T) { + ctx := setupRollbackTestEnv(t) + require.NoError(t, ctx.stores[meshresource.ConditionRouteKind].Add(conditionRule("demo-rule", "v1"))) + + require.NoError(t, UpdateConditionRuleWithOptions(ctx, conditionRule("demo-rule", "v2"), RuleMutationOptions{Author: "admin"})) + + versions, err := ListRuleVersions(ctx, kindName("demo-rule")) + require.NoError(t, err) + require.Len(t, versions.Items, 2) + assert.Equal(t, versioning.OperationUpdate, versions.Items[0].Operation) + assert.Equal(t, versioning.SourceAdmin, versions.Items[0].Source) + assert.Equal(t, versioning.OperationCreate, versions.Items[1].Operation) + assert.Equal(t, versioning.SourceBootstrap, versions.Items[1].Source) + assert.Contains(t, versions.Items[1].SpecJSON, "v1") +} + +func TestCreateUpdateDeleteAppendHistory(t *testing.T) { + ctx := setupRollbackTestEnv(t) + + require.NoError(t, CreateConditionRuleWithOptions(ctx, conditionRule("demo-rule", "v1"), RuleMutationOptions{Author: "admin"})) + require.NoError(t, UpdateConditionRuleWithOptions(ctx, conditionRule("demo-rule", "v2"), RuleMutationOptions{Author: "admin"})) + require.NoError(t, DeleteConditionRuleWithOptions(ctx, "demo-rule", "", RuleMutationOptions{Author: "admin"})) + + versions, err := ListRuleVersions(ctx, kindName("demo-rule")) + require.NoError(t, err) + require.Len(t, versions.Items, 3) + assert.Equal(t, versioning.OperationDelete, versions.Items[0].Operation) + assert.Equal(t, versioning.OperationUpdate, versions.Items[1].Operation) + assert.Equal(t, versioning.OperationCreate, versions.Items[2].Operation) + assert.Equal(t, versioning.SourceAdmin, versions.Items[0].Source) + assert.Equal(t, versioning.DeleteSpecJSON, versions.Items[0].SpecJSON) +} + +func TestMutationFailsClosedWhenHistoryAppendFails(t *testing.T) { + appendErr := errors.New("history append failed") + failingVersionStore := &failingResourceStore{err: appendErr} + ctx := setupRollbackTestEnv(t, func(base store.ResourceStore) store.ResourceStore { + failingVersionStore.ResourceStore = base + return failingVersionStore + }) + failingVersionStore.failNextAdd = true + + err := CreateConditionRuleWithOptions(ctx, conditionRule("demo-rule", "v1"), RuleMutationOptions{Author: "admin"}) + require.Error(t, err) + _, exists, err := ctx.rm.GetByKey(meshresource.ConditionRouteKind, "/demo-rule") + require.NoError(t, err) + require.False(t, exists) + + versions, err := ListRuleVersions(ctx, kindName("demo-rule")) + require.NoError(t, err) + assert.Empty(t, versions.Items) +} + +func TestMutationFailsClosedWhenVersioningUnavailable(t *testing.T) { + ctx := setupRollbackTestEnv(t) + ctx.versioningSvc = nil + + err := CreateConditionRuleWithOptions(ctx, conditionRule("demo-rule", "v1"), RuleMutationOptions{Author: "admin"}) + require.ErrorIs(t, err, versioning.ErrVersionStoreError) + _, exists, err := ctx.rm.GetByKey(meshresource.ConditionRouteKind, "/demo-rule") + require.NoError(t, err) + assert.False(t, exists) +} + +func TestZeroRetentionConfigStillRecordsVersions(t *testing.T) { + ctx := setupRollbackTestEnv(t) + ctx.versioningSvc = versioning.NewService(0, ctx.adapter) + ctx.cfg.RuleVersioning.MaxVersionsPerRule = 0 + + require.NoError(t, CreateConditionRuleWithOptions(ctx, conditionRule("demo-rule", "v1"), RuleMutationOptions{Author: "admin"})) + + versions, err := ListRuleVersions(ctx, kindName("demo-rule")) + require.NoError(t, err) + require.Len(t, versions.Items, 1) + assert.Equal(t, versioning.OperationCreate, versions.Items[0].Operation) +} + +func TestRegistryWriteFailureReturnsErrorAfterLedgerAppend(t *testing.T) { + ctx := setupRollbackTestEnv(t) + ctx.gov.failNextCreate = errors.New("registry write failed") + + err := CreateConditionRuleWithOptions(ctx, conditionRule("demo-rule", "v1"), RuleMutationOptions{Author: "admin"}) + require.Error(t, err) + _, exists, getErr := ctx.rm.GetByKey(meshresource.ConditionRouteKind, "/demo-rule") + require.NoError(t, getErr) + assert.False(t, exists) + versions, listErr := ListRuleVersions(ctx, kindName("demo-rule")) + require.NoError(t, listErr) + require.Len(t, versions.Items, 1) + assert.Equal(t, versioning.OperationCreate, versions.Items[0].Operation) +} + +func TestDeleteMissingRuleDoesNotAppendHistory(t *testing.T) { + ctx := setupRollbackTestEnv(t) + + require.NoError(t, DeleteConditionRuleWithOptions(ctx, "missing-rule", "", RuleMutationOptions{Author: "admin"})) + + versions, err := ListRuleVersions(ctx, kindName("missing-rule")) + require.NoError(t, err) + assert.Empty(t, versions.Items) +} + +func TestRollbackUpsertsAndAppendsRollbackHistory(t *testing.T) { + ctx := setupRollbackTestEnv(t) + require.NoError(t, CreateConditionRuleWithOptions(ctx, conditionRule("demo-rule", "v1"), RuleMutationOptions{Author: "admin"})) + versions, err := ListRuleVersions(ctx, kindName("demo-rule")) + require.NoError(t, err) + targetID := versions.Items[0].ID + require.NoError(t, DeleteConditionRuleWithOptions(ctx, "demo-rule", "", RuleMutationOptions{Author: "admin"})) + + result, err := RollbackRuleVersion(ctx, kindName("demo-rule"), targetID, "restore", "admin") + require.NoError(t, err) + assert.Equal(t, targetID, result.RolledBackFromID) + assert.NotZero(t, result.VersionID) + assert.NotZero(t, result.VersionNo) + + current, exists, err := ctx.rm.GetByKey(meshresource.ConditionRouteKind, "/demo-rule") + require.NoError(t, err) + require.True(t, exists) + assert.Contains(t, current.String(), "v1") + versions, err = ListRuleVersions(ctx, kindName("demo-rule")) + require.NoError(t, err) + assert.Equal(t, versioning.SourceRollback, versions.Items[0].Source) + assert.Equal(t, versioning.OperationCreate, versions.Items[0].Operation) + require.NotNil(t, versions.Items[0].RolledBackFromID) + assert.Equal(t, targetID, *versions.Items[0].RolledBackFromID) +} + +func TestRollbackFailsClosedWhenHistoryAppendFails(t *testing.T) { + appendErr := errors.New("history append failed") + failingVersionStore := &failingResourceStore{err: appendErr} + ctx := setupRollbackTestEnv(t, func(base store.ResourceStore) store.ResourceStore { + failingVersionStore.ResourceStore = base + return failingVersionStore + }) + require.NoError(t, CreateConditionRuleWithOptions(ctx, conditionRule("demo-rule", "v1"), RuleMutationOptions{Author: "admin"})) + require.NoError(t, UpdateConditionRuleWithOptions(ctx, conditionRule("demo-rule", "v2"), RuleMutationOptions{Author: "admin"})) + versions, err := ListRuleVersions(ctx, kindName("demo-rule")) + require.NoError(t, err) + targetID := versions.Items[1].ID + failingVersionStore.failNextAdd = true + + _, err = RollbackRuleVersion(ctx, kindName("demo-rule"), targetID, "restore", "admin") + require.Error(t, err) + current, exists, err := ctx.rm.GetByKey(meshresource.ConditionRouteKind, "/demo-rule") + require.NoError(t, err) + require.True(t, exists) + assert.Contains(t, current.String(), "v2") +} + +func TestRollbackRejectsDeleteMarker(t *testing.T) { + ctx := setupRollbackTestEnv(t) + require.NoError(t, CreateConditionRuleWithOptions(ctx, conditionRule("demo-rule", "v1"), RuleMutationOptions{Author: "admin"})) + require.NoError(t, DeleteConditionRuleWithOptions(ctx, "demo-rule", "", RuleMutationOptions{Author: "admin"})) + versions, err := ListRuleVersions(ctx, kindName("demo-rule")) + require.NoError(t, err) + require.Equal(t, versioning.OperationDelete, versions.Items[0].Operation) + + _, err = RollbackRuleVersion(ctx, kindName("demo-rule"), versions.Items[0].ID, "restore delete marker", "admin") + require.ErrorIs(t, err, versioning.ErrRollbackToDelete) +} + +func TestRollbackNoOpRejectedAgainstActualCurrent(t *testing.T) { + ctx := setupRollbackTestEnv(t) + require.NoError(t, CreateConditionRuleWithOptions(ctx, conditionRule("demo-rule", "v1"), RuleMutationOptions{Author: "admin"})) + versions, err := ListRuleVersions(ctx, kindName("demo-rule")) + require.NoError(t, err) + + _, err = RollbackRuleVersion(ctx, kindName("demo-rule"), versions.Items[0].ID, "same content", "admin") + require.ErrorIs(t, err, versioning.ErrRollbackToCurrent) +} + +func TestDiffAgainstCurrentReadsLiveResourceManagerState(t *testing.T) { + ctx := setupRollbackTestEnv(t) + require.NoError(t, CreateConditionRuleWithOptions(ctx, conditionRule("demo-rule", "v1"), RuleMutationOptions{Author: "admin"})) + versions, err := ListRuleVersions(ctx, kindName("demo-rule")) + require.NoError(t, err) + require.NoError(t, ctx.stores[meshresource.ConditionRouteKind].Update(conditionRule("demo-rule", "v2-outside-history"))) + + diff, err := DiffRuleVersion(ctx, kindName("demo-rule"), versions.Items[0].ID, "current") + require.NoError(t, err) + assert.Contains(t, diff.Left.SpecJSON, "v1") + assert.Contains(t, diff.Right.SpecJSON, "v2-outside-history") +} diff --git a/pkg/console/service/tag_rule.go b/pkg/console/service/tag_rule.go index a051117ca..d81984593 100644 --- a/pkg/console/service/tag_rule.go +++ b/pkg/console/service/tag_rule.go @@ -18,8 +18,6 @@ package service import ( - "github.com/apache/dubbo-admin/pkg/common/constants" - "github.com/apache/dubbo-admin/pkg/core/lock" "github.com/duke-git/lancet/v2/slice" "github.com/apache/dubbo-admin/pkg/common/bizerror" @@ -114,21 +112,11 @@ func GetTagRule(ctx consolectx.Context, name string, mesh string) (*meshresource } func UpdateTagRule(ctx consolectx.Context, res *meshresource.TagRouteResource) error { - lockMgr := ctx.LockManager() - if lockMgr == nil { - return updateTagRuleUnsafe(ctx, res) - } - - lockKey := lock.BuildTagRouteLockKey(res.Mesh, res.Name) - - return lockMgr.WithLock(ctx.AppContext(), lockKey, constants.DefaultLockTimeout, func() error { - return updateTagRuleUnsafe(ctx, res) - }) + return UpdateTagRuleWithOptions(ctx, res, RuleMutationOptions{}) } -func updateTagRuleUnsafe(ctx consolectx.Context, res *meshresource.TagRouteResource) error { - err := ctx.ResourceManager().Update(res) - if err != nil { +func UpdateTagRuleWithOptions(ctx consolectx.Context, res *meshresource.TagRouteResource, opts RuleMutationOptions) error { + if err := updateRule(ctx, res, opts); err != nil { logger.Warnf("update tag rule %s error: %v", res.Name, err) return err } @@ -136,21 +124,11 @@ func updateTagRuleUnsafe(ctx consolectx.Context, res *meshresource.TagRouteResou } func CreateTagRule(ctx consolectx.Context, res *meshresource.TagRouteResource) error { - lockMgr := ctx.LockManager() - if lockMgr == nil { - return createTagRuleUnsafe(ctx, res) - } - - lockKey := lock.BuildTagRouteLockKey(res.Mesh, res.Name) - - return lockMgr.WithLock(ctx.AppContext(), lockKey, constants.DefaultLockTimeout, func() error { - return createTagRuleUnsafe(ctx, res) - }) + return CreateTagRuleWithOptions(ctx, res, RuleMutationOptions{}) } -func createTagRuleUnsafe(ctx consolectx.Context, res *meshresource.TagRouteResource) error { - err := ctx.ResourceManager().Add(res) - if err != nil { +func CreateTagRuleWithOptions(ctx consolectx.Context, res *meshresource.TagRouteResource, opts RuleMutationOptions) error { + if err := createRule(ctx, res, opts); err != nil { logger.Warnf("create tag rule %s error: %v", res.Name, err) return err } @@ -158,19 +136,12 @@ func createTagRuleUnsafe(ctx consolectx.Context, res *meshresource.TagRouteResou } func DeleteTagRule(ctx consolectx.Context, name string, mesh string) error { - lockMgr := ctx.LockManager() - if lockMgr == nil { - return deleteTagRuleUnsafe(ctx, name, mesh) - } - lockKey := lock.BuildTagRouteLockKey(mesh, name) - return lockMgr.WithLock(ctx.AppContext(), lockKey, constants.DefaultLockTimeout, func() error { - return deleteTagRuleUnsafe(ctx, name, mesh) - }) + return DeleteTagRuleWithOptions(ctx, name, mesh, RuleMutationOptions{}) } -func deleteTagRuleUnsafe(ctx consolectx.Context, name string, mesh string) error { - err := ctx.ResourceManager().DeleteByKey(meshresource.TagRouteKind, mesh, coremodel.BuildResourceKey(mesh, name)) - if err != nil { +func DeleteTagRuleWithOptions(ctx consolectx.Context, name string, mesh string, opts RuleMutationOptions) error { + kindName := RuleKindName{Kind: meshresource.TagRouteKind, Mesh: mesh, Name: name} + if err := deleteRule(ctx, kindName, opts); err != nil { logger.Warnf("delete tag rule %s error: %v", name, err) return err } diff --git a/pkg/core/bootstrap/bootstrap.go b/pkg/core/bootstrap/bootstrap.go index d1ee2c0dc..a7b5439e1 100644 --- a/pkg/core/bootstrap/bootstrap.go +++ b/pkg/core/bootstrap/bootstrap.go @@ -27,6 +27,7 @@ import ( "github.com/apache/dubbo-admin/pkg/core/lock" "github.com/apache/dubbo-admin/pkg/core/logger" "github.com/apache/dubbo-admin/pkg/core/runtime" + "github.com/apache/dubbo-admin/pkg/core/versioning" "github.com/apache/dubbo-admin/pkg/diagnostics" ) @@ -130,6 +131,7 @@ func (sb *SmartBootstrapper) gatherComponents() ([]runtime.Component, error) { {"CounterManager", counter.ComponentType}, {"DiagnosticsServer", diagnostics.DiagnosticsServer}, {"DistributedLock", lock.DistributedLockComponent}, + {"RuleVersioning", versioning.ComponentType}, } for _, comp := range optionalComps { diff --git a/pkg/core/bootstrap/init.go b/pkg/core/bootstrap/init.go index c590b84da..0fe16c190 100644 --- a/pkg/core/bootstrap/init.go +++ b/pkg/core/bootstrap/init.go @@ -35,6 +35,7 @@ import ( _ "github.com/apache/dubbo-admin/pkg/governor/mock" _ "github.com/apache/dubbo-admin/pkg/governor/nacos2" _ "github.com/apache/dubbo-admin/pkg/governor/zk" + _ "github.com/apache/dubbo-admin/pkg/lock/gorm" _ "github.com/apache/dubbo-admin/pkg/store/memory" _ "github.com/apache/dubbo-admin/pkg/store/mysql" _ "github.com/apache/dubbo-admin/pkg/store/postgres" diff --git a/pkg/core/discovery/subscriber/zk_config.go b/pkg/core/discovery/subscriber/zk_config.go index 07f9a2620..f9e0eec11 100644 --- a/pkg/core/discovery/subscriber/zk_config.go +++ b/pkg/core/discovery/subscriber/zk_config.go @@ -38,6 +38,13 @@ type ZKConfigEventSubscriber struct { storeRouter store.Router } +// sourceRegistryZookeeper labels rule events coming from ZooKeeper so rule +// history can attribute upstream writes to author "system:zookeeper". Other +// registry subscribers (Nacos, Apollo, ...) should emit the equivalent +// SourceRegistryContextKey on their ResourceChangedEvents; until they do, rule +// history falls back to "system:upstream" for those sources. +const sourceRegistryZookeeper = "zookeeper" + func NewZKConfigEventSubscriber(eventEmitter events.Emitter, storeRouter store.Router) *ZKConfigEventSubscriber { return &ZKConfigEventSubscriber{ emitter: eventEmitter, @@ -127,13 +134,13 @@ func (z *ZKConfigEventSubscriber) processDelete(configRes *meshresource.ZKConfig switch suffix { case constants.TagRuleSuffix: return processConfigDelete[*meshresource.TagRouteResource]( - configRes, meshresource.ToTagRouteResource, z.storeRouter, z.emitter) + configRes, meshresource.TagRouteKind, z.storeRouter, z.emitter) case constants.ConditionRuleSuffix: return processConfigDelete[*meshresource.ConditionRouteResource]( - configRes, meshresource.ToConditionRouteResource, z.storeRouter, z.emitter) + configRes, meshresource.ConditionRouteKind, z.storeRouter, z.emitter) case constants.ConfiguratorsSuffix: return processConfigDelete[*meshresource.DynamicConfigResource]( - configRes, meshresource.ToDynamicConfigResource, z.storeRouter, z.emitter) + configRes, meshresource.DynamicConfigKind, z.storeRouter, z.emitter) default: return bizerror.New(bizerror.UnknownError, fmt.Sprintf("unknown rule type in mesh %s, skipped processing, node: %s", @@ -167,7 +174,9 @@ func processConfigUpsert[T coremodel.Resource]( logger.Errorf("add rule %s to store failed, cause: %s", newRuleRes.ResourceKey(), err.Error()) return err } - emitter.Send(events.NewResourceChangedEvent(cache.Added, nil, newRuleRes)) + emitter.Send(events.NewResourceChangedEventWithContext(cache.Added, nil, newRuleRes, map[string]string{ + events.SourceRegistryContextKey: sourceRegistryZookeeper, + })) return nil } @@ -184,39 +193,43 @@ func processConfigUpsert[T coremodel.Resource]( return bizerror.NewAssertionError(reflect.TypeOf(oldMetadataRes), oldRes) } - emitter.Send(events.NewResourceChangedEvent(cache.Updated, oldMetadataRes, newRuleRes)) + emitter.Send(events.NewResourceChangedEventWithContext(cache.Updated, oldMetadataRes, newRuleRes, map[string]string{ + events.SourceRegistryContextKey: sourceRegistryZookeeper, + })) return nil } func processConfigDelete[T coremodel.Resource]( configRes *meshresource.ZKConfigResource, - toRuleRes meshresource.ToRuleResourceFunc, + ruleKind coremodel.ResourceKind, router store.Router, emitter events.Emitter) error { - ruleRes := toRuleRes(configRes.Mesh, configRes.Name, configRes.Spec.NodeData) - st, err := router.ResourceKindRoute(ruleRes.ResourceKind()) + st, err := router.ResourceKindRoute(ruleKind) if err != nil { - logger.Errorf("get %s store failed, cause: %s", ruleRes.ResourceKind(), err.Error()) + logger.Errorf("get %s store failed, cause: %s", ruleKind, err.Error()) return err } - oldRes, exists, err := st.GetByKey(ruleRes.ResourceKey()) + resourceKey := coremodel.BuildResourceKey(configRes.Mesh, configRes.Name) + oldRes, exists, err := st.GetByKey(resourceKey) if err != nil { - logger.Errorf("get rule %s from store failed, cause: %s", ruleRes.ResourceKey(), err.Error()) + logger.Errorf("get rule %s from store failed, cause: %s", resourceKey, err.Error()) return err } if !exists { - logger.Infof("rule %s not exists in store, skipped deleting", ruleRes.ResourceKey()) + logger.Infof("rule %s not exists in store, skipped deleting", resourceKey) return nil } oldRuleRes, ok := oldRes.(T) if !ok { return bizerror.NewAssertionError(reflect.TypeOf(oldRuleRes), oldRes) } - err = st.Delete(ruleRes) + err = st.Delete(oldRuleRes) if err != nil { - logger.Errorf("delete rule %s from store failed, cause: %s", ruleRes.ResourceKey(), err.Error()) + logger.Errorf("delete rule %s from store failed, cause: %s", resourceKey, err.Error()) return err } - emitter.Send(events.NewResourceChangedEvent(cache.Deleted, oldRuleRes, nil)) + emitter.Send(events.NewResourceChangedEventWithContext(cache.Deleted, oldRuleRes, nil, map[string]string{ + events.SourceRegistryContextKey: sourceRegistryZookeeper, + })) return nil } diff --git a/pkg/core/events/eventbus.go b/pkg/core/events/eventbus.go index 51393c4d2..42c06df00 100644 --- a/pkg/core/events/eventbus.go +++ b/pkg/core/events/eventbus.go @@ -26,6 +26,9 @@ import ( "github.com/apache/dubbo-admin/pkg/core/resource/model" ) +// SourceRegistryContextKey identifies which registry produced a resource event. +const SourceRegistryContextKey = "source-registry" + type Event interface { // Type returns the type of the event, see definitions in cache.DeltaType Type() cache.DeltaType @@ -33,7 +36,7 @@ type Event interface { OldObj() model.Resource // NewObj returns the new object, nil if event type is in [cache.Deleted] NewObj() model.Resource - // Context returns the context of the event, if event provider want to pass extra info to the consumer, just use context + // Context returns read-only event metadata. Subscribers must not mutate the returned map. Context() map[string]string // String returns the string representation of the event String() string diff --git a/pkg/core/resource/apis/mesh/v1alpha1/rule_version_types.go b/pkg/core/resource/apis/mesh/v1alpha1/rule_version_types.go new file mode 100644 index 000000000..2fb516089 --- /dev/null +++ b/pkg/core/resource/apis/mesh/v1alpha1/rule_version_types.go @@ -0,0 +1,154 @@ +/* + * 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 v1alpha1 + +import ( + "encoding/json" + + 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" + "google.golang.org/protobuf/proto" +) + +const ( + RuleVersionKind coremodel.ResourceKind = "RuleVersion" +) + +func init() { + coremodel.RegisterResourceSchema(RuleVersionKind, NewRuleVersionResource, NewRuleVersionResourceList) +} + +// RuleVersionResource stores one immutable history entry for a parent traffic +// rule. The live rule state is always read from ResourceManager. +type RuleVersionResource struct { + metav1.TypeMeta `json:",inline"` + + metav1.ObjectMeta `json:"metadata,omitempty"` + + // Mesh is the name of the dubbo mesh this resource belongs to. + Mesh string `json:"mesh,omitempty"` + + // Spec is the specification of the Dubbo RuleVersion resource. + Spec *meshproto.RuleVersion `json:"spec,omitempty"` +} + +func (r *RuleVersionResource) ResourceKind() coremodel.ResourceKind { + return RuleVersionKind +} + +func (r *RuleVersionResource) ResourceMesh() string { + return r.Mesh +} + +func (r *RuleVersionResource) ResourceKey() string { + return coremodel.BuildResourceKey(r.Mesh, r.Name) +} + +func (r *RuleVersionResource) ResourceMeta() metav1.ObjectMeta { + return r.ObjectMeta +} + +func (r *RuleVersionResource) ResourceSpec() coremodel.ResourceSpec { + return r.Spec +} + +func (r *RuleVersionResource) String() string { + jsonStr, err := json.Marshal(r) + if err != nil { + logger.Errorf("failed to encode RuleVersionResource: %s to json, err: %v", r.ResourceKey(), err) + return "" + } + return string(jsonStr) +} + +func (r *RuleVersionResource) DeepCopyObject() k8sruntime.Object { + out := &RuleVersionResource{ + TypeMeta: r.TypeMeta, + ObjectMeta: *r.ObjectMeta.DeepCopy(), + Mesh: r.Mesh, + } + if r.Spec != nil { + out.Spec = proto.Clone(r.Spec).(*meshproto.RuleVersion) + } + return out +} + +func NewRuleVersionResource() coremodel.Resource { + return &RuleVersionResource{} +} + +func NewRuleVersionResourceWithAttributes(name, mesh string) *RuleVersionResource { + return &RuleVersionResource{ + ObjectMeta: metav1.ObjectMeta{Name: name}, + Mesh: mesh, + Spec: &meshproto.RuleVersion{}, + } +} + +type RuleVersionResourceList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []*RuleVersionResource `json:"items"` +} + +func (r *RuleVersionResourceList) DeepCopyObject() k8sruntime.Object { + out := &RuleVersionResourceList{ + TypeMeta: r.TypeMeta, + } + r.ListMeta.DeepCopyInto(&out.ListMeta) + + if len(r.Items) == 0 { + return out + } + out.Items = make([]*RuleVersionResource, len(r.Items)) + for i := range r.Items { + out.Items[i] = r.Items[i].DeepCopyObject().(*RuleVersionResource) + } + return out +} + +func NewRuleVersionResourceList() coremodel.ResourceList { + return &RuleVersionResourceList{ + TypeMeta: metav1.TypeMeta{ + Kind: string(RuleVersionKind), + APIVersion: "v1alpha1", + }, + Items: make([]*RuleVersionResource, 0), + } +} + +func (r *RuleVersionResourceList) GetItems() []coremodel.Resource { + res := make([]coremodel.Resource, len(r.Items)) + for i := range r.Items { + res[i] = r.Items[i] + } + return res +} + +func (r *RuleVersionResourceList) SetItems(items []coremodel.Resource) { + r.Items = make([]*RuleVersionResource, len(items)) + for i, res := range items { + if typed, ok := res.(*RuleVersionResource); ok { + r.Items[i] = typed + } + } +} diff --git a/pkg/core/resource/model/resource.go b/pkg/core/resource/model/resource.go index 1ff3485b8..d940ca268 100644 --- a/pkg/core/resource/model/resource.go +++ b/pkg/core/resource/model/resource.go @@ -18,6 +18,8 @@ package model import ( + "strings" + k8smeta "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" k8sruntime "k8s.io/apimachinery/pkg/runtime" @@ -61,3 +63,12 @@ type ResourceList interface { func BuildResourceKey(mesh string, name string) string { return mesh + constants.PathSeparator + name } + +// ParseResourceKey is the inverse of BuildResourceKey for resource keys that +// may be stored as either "mesh/name" or just "name". +func ParseResourceKey(resourceKey string) (mesh string, name string) { + if idx := strings.Index(resourceKey, constants.PathSeparator); idx >= 0 { + return resourceKey[:idx], resourceKey[idx+len(constants.PathSeparator):] + } + return "", resourceKey +} diff --git a/pkg/core/resource/model/resource_test.go b/pkg/core/resource/model/resource_test.go new file mode 100644 index 000000000..3353c8891 --- /dev/null +++ b/pkg/core/resource/model/resource_test.go @@ -0,0 +1,47 @@ +/* + * 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 model + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestParseResourceKey(t *testing.T) { + tests := []struct { + name string + resourceKey string + wantMesh string + wantName string + }{ + {name: "mesh and name", resourceKey: "default/demo", wantMesh: "default", wantName: "demo"}, + {name: "name only", resourceKey: "demo", wantMesh: "", wantName: "demo"}, + {name: "empty mesh", resourceKey: "/demo", wantMesh: "", wantName: "demo"}, + {name: "empty name", resourceKey: "default/", wantMesh: "default", wantName: ""}, + {name: "empty key", resourceKey: "", wantMesh: "", wantName: ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotMesh, gotName := ParseResourceKey(tt.resourceKey) + assert.Equal(t, tt.wantMesh, gotMesh) + assert.Equal(t, tt.wantName, gotName) + }) + } +} diff --git a/pkg/core/runtime/builder.go b/pkg/core/runtime/builder.go index 191470b00..8a7933365 100644 --- a/pkg/core/runtime/builder.go +++ b/pkg/core/runtime/builder.go @@ -34,6 +34,7 @@ type BuilderContext interface { Config() app.AdminConfig GetActivatedComponent(typ ComponentType) (Component, error) ActivateComponent(comp Component) error + AppContext() context.Context } var _ BuilderContext = &Builder{} @@ -54,6 +55,10 @@ func (b *Builder) GetActivatedComponent(typ ComponentType) (Component, error) { return comp, nil } +func (b *Builder) AppContext() context.Context { + return b.appCtx +} + func BuilderFor(appCtx context.Context, cfg app.AdminConfig) (*Builder, error) { hostname, err := os.Hostname() if err != nil { diff --git a/pkg/core/store/index/rule_version.go b/pkg/core/store/index/rule_version.go new file mode 100644 index 000000000..778e9ec95 --- /dev/null +++ b/pkg/core/store/index/rule_version.go @@ -0,0 +1,86 @@ +/* + * 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 index + +import ( + "fmt" + "strconv" + + "k8s.io/client-go/tools/cache" + + meshresource "github.com/apache/dubbo-admin/pkg/core/resource/apis/mesh/v1alpha1" +) + +const ( + ByParentRuleIndexName = "ByParentRule" + ByRuleVersionIDIndexName = "ByRuleVersionID" +) + +func init() { + RegisterIndexers(meshresource.RuleVersionKind, map[string]cache.IndexFunc{ + ByParentRuleIndexName: byParentRule, + ByRuleVersionIDIndexName: byRuleVersionID, + }) +} + +// byParentRule indexes RuleVersion resources by their parent rule. +// Index key format: "//" +func byParentRule(obj interface{}) ([]string, error) { + rv, ok := obj.(*meshresource.RuleVersionResource) + if !ok || rv.Spec == nil { + return nil, nil + } + key := fmt.Sprintf("%s/%s/%s", + rv.Spec.ParentRuleKind, + rv.Spec.ParentRuleMesh, + rv.Spec.ParentRuleName, + ) + return []string{key}, nil +} + +func byRuleVersionID(obj interface{}) ([]string, error) { + rv, ok := obj.(*meshresource.RuleVersionResource) + if !ok || rv == nil { + return nil, nil + } + if id := rv.Annotations["dubbo.apache.org/rule-version-id"]; id != "" { + return []string{id}, nil + } + id, err := parseNumericSuffix(rv.Name) + if err != nil { + return nil, nil + } + return []string{id}, nil +} + +func parseNumericSuffix(name string) (string, error) { + for i := len(name) - 1; i >= 0; i-- { + if name[i] != '-' { + continue + } + if i == len(name)-1 { + return "", fmt.Errorf("missing numeric suffix") + } + suffix := name[i+1:] + if _, err := strconv.ParseInt(suffix, 10, 64); err != nil { + return "", err + } + return suffix, nil + } + return "", fmt.Errorf("missing numeric suffix") +} diff --git a/pkg/core/store/store.go b/pkg/core/store/store.go index 8923f6577..2df9fc8a8 100644 --- a/pkg/core/store/store.go +++ b/pkg/core/store/store.go @@ -77,7 +77,9 @@ func ErrorResourceNotFound(rt, name, mesh string) error { return fmt.Errorf("resource not found: type=%q name=%q mesh=%q", rt, name, mesh) } -var ErrorInvalidOffset = errors.New("invalid offset") +var ( + ErrorInvalidOffset = errors.New("invalid offset") +) func IsResourceNotFound(err error) bool { return err != nil && strings.HasPrefix(err.Error(), "Resource not found") diff --git a/pkg/core/versioning/bootstrap.go b/pkg/core/versioning/bootstrap.go new file mode 100644 index 000000000..06c8dfdc4 --- /dev/null +++ b/pkg/core/versioning/bootstrap.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 versioning + +import ( + "context" + "errors" + "fmt" + "time" + + coremodel "github.com/apache/dubbo-admin/pkg/core/resource/model" +) + +// RecordBootstrapState creates a baseline history entry for a rule that already +// exists before rule history starts tracking it. Bootstrap is not reconciliation: +// once any history exists, startup must not record the current registry state as +// an UPDATE. +func RecordBootstrapState(ctx context.Context, store *ResourceStoreAdapter, maxVersions int64, res coremodel.Resource) error { + if store == nil { + return ErrVersionStoreError + } + if res == nil { + return nil + } + latest, err := store.LatestVersion(res.ResourceKind(), res.ResourceKey()) + if err != nil && !errors.Is(err, ErrVersionNotFound) { + return err + } + if latest != nil { + return nil + } + + hash, specJSON, err := NormalizeResource(res) + if err != nil { + return err + } + req := InsertRequest{ + RuleKind: res.ResourceKind(), + Mesh: res.ResourceMesh(), + ResourceKey: res.ResourceKey(), + RuleName: res.ResourceMeta().Name, + SpecJSON: specJSON, + ContentHash: hash, + Source: SourceBootstrap, + Operation: OperationCreate, + Author: "system:bootstrap", + Reason: "initial rule history baseline", + CreatedAt: time.Now(), + } + if _, err := store.InsertVersion(ctx, req, maxVersions); err != nil { + return fmt.Errorf("bootstrap version for %s failed: %w", res.ResourceKey(), err) + } + return nil +} diff --git a/pkg/core/versioning/component.go b/pkg/core/versioning/component.go new file mode 100644 index 000000000..fed475eee --- /dev/null +++ b/pkg/core/versioning/component.go @@ -0,0 +1,160 @@ +/* + * 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 versioning + +import ( + "context" + "fmt" + "math" + "reflect" + + "github.com/apache/dubbo-admin/pkg/common/bizerror" + versioningcfg "github.com/apache/dubbo-admin/pkg/config/versioning" + "github.com/apache/dubbo-admin/pkg/core/governor" + "github.com/apache/dubbo-admin/pkg/core/logger" + meshresource "github.com/apache/dubbo-admin/pkg/core/resource/apis/mesh/v1alpha1" + "github.com/apache/dubbo-admin/pkg/core/runtime" + "github.com/apache/dubbo-admin/pkg/core/store" +) + +const ComponentType runtime.ComponentType = "rule versioning" + +func init() { + runtime.RegisterComponent(&component{}) +} + +type Component interface { + runtime.Component + Service() *Service +} + +type component struct { + service *Service + store *ResourceStoreAdapter +} + +func (c *component) Type() runtime.ComponentType { + return ComponentType +} + +func (c *component) Order() int { + return math.MaxInt - 5 +} + +func (c *component) RequiredDependencies() []runtime.ComponentType { + return []runtime.ComponentType{ + runtime.ResourceStore, + } +} + +func (c *component) Init(ctx runtime.BuilderContext) error { + cfg := ctx.Config().RuleVersioning + if cfg == nil { + cfg = versioningcfg.Default() + } + + storeComponent, err := ctx.GetActivatedComponent(runtime.ResourceStore) + if err != nil { + return err + } + storeRouter, ok := storeComponent.(store.Router) + if !ok { + return bizerror.NewAssertionError("store.Router", reflect.TypeOf(storeComponent).Name()) + } + rvStore, err := storeRouter.ResourceKindRoute(meshresource.RuleVersionKind) + if err != nil { + return fmt.Errorf("failed to get RuleVersion store: %w", err) + } + if rvStore == nil { + return fmt.Errorf("RuleVersion store not available - versioning requires resource store") + } + + store := NewResourceStoreAdapter(rvStore) + if err := store.ensureStores(); err != nil { + return err + } + c.store = store + c.service = NewService(cfg.MaxVersionsPerRule, store) + logger.Infof("Using resource store for rule history (RuleVersion)") + return nil +} + +func (c *component) Start(rt runtime.Runtime, stop <-chan struct{}) error { + cfg := rt.Config().RuleVersioning + if cfg == nil { + cfg = versioningcfg.Default() + } + ctx, cancel := contextWithStop(rt.AppContext(), stop) + defer cancel() + storeComponent, err := rt.GetComponent(runtime.ResourceStore) + if err != nil { + return err + } + storeRouter, ok := storeComponent.(store.Router) + if !ok { + return bizerror.NewAssertionError("store.Router", reflect.TypeOf(storeComponent).Name()) + } + if err := c.bootstrapExistingRules(ctx, storeRouter, cfg.MaxVersionsPerRule); err != nil { + logger.Warnf("rule history bootstrap failed: %v", err) + } + return nil +} + +func (c *component) Service() *Service { + return c.service +} + +func (c *component) bootstrapExistingRules(ctx context.Context, storeRouter store.Router, maxVersions int64) error { + for _, kind := range governor.RuleResourceKinds.Values() { + if err := ctx.Err(); err != nil { + return err + } + rs, err := storeRouter.ResourceKindRoute(kind) + if err != nil { + return err + } + if rs == nil { + continue + } + resources, err := rs.GetByKeys(rs.ListKeys()) + if err != nil { + return err + } + for _, res := range resources { + if err := ctx.Err(); err != nil { + return err + } + if err := RecordBootstrapState(ctx, c.store, maxVersions, res); err != nil { + logger.Warnf("rule history bootstrap skipped for %s: %v", res.ResourceKey(), err) + } + } + } + return nil +} + +func contextWithStop(parent context.Context, stop <-chan struct{}) (context.Context, context.CancelFunc) { + ctx, cancel := context.WithCancel(parent) + go func() { + select { + case <-stop: + cancel() + case <-ctx.Done(): + } + }() + return ctx, cancel +} diff --git a/pkg/core/versioning/id_generator.go b/pkg/core/versioning/id_generator.go new file mode 100644 index 000000000..48370a448 --- /dev/null +++ b/pkg/core/versioning/id_generator.go @@ -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. + */ + +package versioning + +import ( + "crypto/rand" + "encoding/binary" +) + +type idGenerator interface { + Next() (int64, error) +} + +// IDGenerator creates positive int64 identifiers. VersionNo, not this ID, +// defines history ordering, so randomness plus store-level conflict retry is +// enough and avoids clock-coupled ID behavior. +type IDGenerator struct{} + +func NewIDGenerator() *IDGenerator { + return &IDGenerator{} +} + +func (g *IDGenerator) Next() (int64, error) { + var buf [8]byte + if _, err := rand.Read(buf[:]); err != nil { + return 0, err + } + + id := int64(binary.BigEndian.Uint64(buf[:]) & ((uint64(1) << 63) - 1)) + if id == 0 { + id = 1 + } + return id, nil +} diff --git a/pkg/core/versioning/normalize.go b/pkg/core/versioning/normalize.go new file mode 100644 index 000000000..34cfce72b --- /dev/null +++ b/pkg/core/versioning/normalize.go @@ -0,0 +1,130 @@ +/* + * 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 versioning + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" + + meshproto "github.com/apache/dubbo-admin/api/mesh/v1alpha1" + "github.com/apache/dubbo-admin/pkg/common/bizerror" + meshresource "github.com/apache/dubbo-admin/pkg/core/resource/apis/mesh/v1alpha1" + coremodel "github.com/apache/dubbo-admin/pkg/core/resource/model" +) + +// DeleteSpecJSON is the canonical snapshot stored for a rule delete marker. +const DeleteSpecJSON = "{}" + +// NormalizeSpec returns the spec hash followed by canonical JSON for stable comparisons. +// It does not validate whether the spec is acceptable to the registry. +func NormalizeSpec(spec coremodel.ResourceSpec) (specHash string, specJSON string, err error) { + if spec == nil { + return HashSpecJSON(DeleteSpecJSON), DeleteSpecJSON, nil + } + var raw []byte + if msg, ok := spec.(proto.Message); ok { + var err error + raw, err = protojson.MarshalOptions{ + UseProtoNames: false, + EmitUnpopulated: false, + }.Marshal(msg) + if err != nil { + return "", "", err + } + } else { + var err error + raw, err = json.Marshal(spec) + if err != nil { + return "", "", err + } + } + var v any + if err := json.Unmarshal(raw, &v); err != nil { + return "", "", err + } + canonical, err := json.Marshal(v) + if err != nil { + return "", "", err + } + specJSON = string(canonical) + return HashSpecJSON(specJSON), specJSON, nil +} + +// HashSpecJSON hashes canonical spec JSON for comparison and dedup filters. +// It is not sufficient on its own to prove operation source. +func HashSpecJSON(specJSON string) string { + sum := sha256.Sum256([]byte(specJSON)) + return hex.EncodeToString(sum[:]) +} + +func NormalizeResource(res coremodel.Resource) (string, string, error) { + if res == nil { + return "", "", fmt.Errorf("resource is nil") + } + return NormalizeSpec(res.ResourceSpec()) +} + +// 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 +// normalized), falling back to plain JSON for resilience. +func ResourceFromSpecJSON(kind coremodel.ResourceKind, mesh, ruleName, specJSON string) (coremodel.Resource, error) { + switch kind { + case meshresource.ConditionRouteKind: + res := meshresource.NewConditionRouteResourceWithAttributes(ruleName, mesh) + var spec meshproto.ConditionRoute + if err := unmarshalSpec(specJSON, &spec); err != nil { + return nil, err + } + res.Spec = &spec + return res, nil + case meshresource.TagRouteKind: + res := meshresource.NewTagRouteResourceWithAttributes(ruleName, mesh) + var spec meshproto.TagRoute + if err := unmarshalSpec(specJSON, &spec); err != nil { + return nil, err + } + res.Spec = &spec + return res, nil + case meshresource.DynamicConfigKind: + res := meshresource.NewDynamicConfigResourceWithAttributes(ruleName, mesh) + var spec meshproto.DynamicConfig + 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") + } +} + +func unmarshalSpec(specJSON string, spec proto.Message) error { + if err := protojson.Unmarshal([]byte(specJSON), spec); err != nil { + if jsonErr := json.Unmarshal([]byte(specJSON), spec); jsonErr != nil { + return err + } + } + return nil +} diff --git a/pkg/core/versioning/resource_store_adapter.go b/pkg/core/versioning/resource_store_adapter.go new file mode 100644 index 000000000..544f9e300 --- /dev/null +++ b/pkg/core/versioning/resource_store_adapter.go @@ -0,0 +1,79 @@ +/* + * 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 versioning + +import ( + "fmt" + "hash/fnv" + "sync" + + coremodel "github.com/apache/dubbo-admin/pkg/core/resource/model" + "github.com/apache/dubbo-admin/pkg/core/store" +) + +const parentLockStripes = 256 +const maxIDGenerateAttempts = 16 + +// ResourceStoreAdapter routes RuleVersion resources through the existing +// resource store. The striped mutexes only keep a single adapter instance +// internally consistent while it derives monotonically increasing version +// numbers for audit entries. +type ResourceStoreAdapter struct { + versionStore store.ResourceStore + idGenerator idGenerator + parentLocks [parentLockStripes]sync.Mutex +} + +func NewResourceStoreAdapter(versionStore store.ResourceStore) *ResourceStoreAdapter { + return &ResourceStoreAdapter{ + versionStore: versionStore, + idGenerator: NewIDGenerator(), + } +} + +func (a *ResourceStoreAdapter) ensureStores() error { + if a == nil || a.versionStore == nil { + return fmt.Errorf("%w: RuleVersion store is required", ErrVersionStoreError) + } + return nil +} + +func (a *ResourceStoreAdapter) LatestVersion(kind coremodel.ResourceKind, resourceKey string) (*Version, error) { + snapshot, err := a.HistorySnapshot(kind, resourceKey) + if err != nil { + return nil, err + } + if snapshot.Head == nil { + return nil, ErrVersionNotFound + } + return snapshot.Head, nil +} + +func (a *ResourceStoreAdapter) withParentLock(kind coremodel.ResourceKind, resourceKey string, fn func() error) error { + key := fmt.Sprintf("%s/%s", kind, resourceKey) + mu := &a.parentLocks[parentLockIndex(key)] + mu.Lock() + defer mu.Unlock() + return fn() +} + +func parentLockIndex(key string) uint32 { + h := fnv.New32a() + _, _ = h.Write([]byte(key)) + return h.Sum32() % parentLockStripes +} diff --git a/pkg/core/versioning/resource_store_adapter_test.go b/pkg/core/versioning/resource_store_adapter_test.go new file mode 100644 index 000000000..72b5df6fa --- /dev/null +++ b/pkg/core/versioning/resource_store_adapter_test.go @@ -0,0 +1,121 @@ +/* + * 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 versioning + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + meshproto "github.com/apache/dubbo-admin/api/mesh/v1alpha1" + meshresource "github.com/apache/dubbo-admin/pkg/core/resource/apis/mesh/v1alpha1" + "github.com/apache/dubbo-admin/pkg/core/store" + memoryst "github.com/apache/dubbo-admin/pkg/store/memory" +) + +func TestResourceStoreAdapter_AppendsAndListsByParentRule(t *testing.T) { + versionStore := newVersionStore(t) + adapter := NewResourceStoreAdapter(versionStore) + res := conditionRouteForVersionTest("demo-rule", "v1") + + v1, err := adapter.InsertVersion(context.Background(), insertRequestForTest(t, res, OperationCreate), 10) + require.NoError(t, err) + updated := conditionRouteForVersionTest("demo-rule", "v2") + v2, err := adapter.InsertVersion(context.Background(), insertRequestForTest(t, updated, OperationUpdate), 10) + require.NoError(t, err) + + snapshot, err := adapter.HistorySnapshot(meshresource.ConditionRouteKind, res.ResourceKey()) + require.NoError(t, err) + require.Len(t, snapshot.Versions, 2) + assert.Equal(t, v2.ID, snapshot.Head.ID) + assert.Equal(t, int64(2), snapshot.Head.VersionNo) + assert.True(t, snapshot.Versions[0].IsLatestRecorded) + assert.False(t, snapshot.Versions[1].IsLatestRecorded) + assert.NotEqual(t, v1.ID, v2.ID) +} + +func TestResourceStoreAdapter_DeleteVersionStoresAbsenceMarker(t *testing.T) { + versionStore := newVersionStore(t) + adapter := NewResourceStoreAdapter(versionStore) + res := conditionRouteForVersionTest("demo-rule", "v1") + _, err := adapter.InsertVersion(context.Background(), insertRequestForTest(t, res, OperationCreate), 10) + require.NoError(t, err) + deleteReq := insertRequestForTest(t, res, OperationDelete) + _, err = adapter.InsertVersion(context.Background(), deleteReq, 10) + require.NoError(t, err) + + snapshot, err := adapter.HistorySnapshot(meshresource.ConditionRouteKind, res.ResourceKey()) + require.NoError(t, err) + require.True(t, snapshot.Deleted) + require.NotNil(t, snapshot.Head) + assert.Equal(t, OperationDelete, snapshot.Head.Operation) + assert.Equal(t, DeleteSpecJSON, snapshot.Head.SpecJSON) +} + +func TestRecordBootstrapStateCreatesOnlyInitialBaseline(t *testing.T) { + versionStore := newVersionStore(t) + adapter := NewResourceStoreAdapter(versionStore) + res := conditionRouteForVersionTest("demo-rule", "v1") + + require.NoError(t, RecordBootstrapState(context.Background(), adapter, 10, res)) + + snapshot, err := adapter.HistorySnapshot(meshresource.ConditionRouteKind, res.ResourceKey()) + require.NoError(t, err) + require.Len(t, snapshot.Versions, 1) + assert.Equal(t, OperationCreate, snapshot.Versions[0].Operation) + assert.Equal(t, SourceBootstrap, snapshot.Versions[0].Source) + assert.Contains(t, snapshot.Versions[0].SpecJSON, "v1") +} + +func TestRecordBootstrapStateDoesNotReconcileWhenHistoryExists(t *testing.T) { + versionStore := newVersionStore(t) + adapter := NewResourceStoreAdapter(versionStore) + res := conditionRouteForVersionTest("demo-rule", "v1") + require.NoError(t, RecordBootstrapState(context.Background(), adapter, 10, res)) + + require.NoError(t, RecordBootstrapState(context.Background(), adapter, 10, conditionRouteForVersionTest("demo-rule", "v2-outside-history"))) + + snapshot, err := adapter.HistorySnapshot(meshresource.ConditionRouteKind, res.ResourceKey()) + require.NoError(t, err) + require.Len(t, snapshot.Versions, 1) + assert.Equal(t, OperationCreate, snapshot.Versions[0].Operation) + assert.Contains(t, snapshot.Versions[0].SpecJSON, "v1") +} + +func newVersionStore(t *testing.T) store.ManagedResourceStore { + t.Helper() + s := memoryst.NewMemoryResourceStore(meshresource.RuleVersionKind) + require.NoError(t, s.Init(nil)) + return s +} + +func conditionRouteForVersionTest(name, payload string) *meshresource.ConditionRouteResource { + res := meshresource.NewConditionRouteResourceWithAttributes(name, "") + res.Spec = &meshproto.ConditionRoute{Enabled: true, Key: name, Conditions: []string{payload}} + return res +} + +func insertRequestForTest(t *testing.T, res *meshresource.ConditionRouteResource, op Operation) InsertRequest { + t.Helper() + req, err := BuildInsertRequest(res, op, SourceAdmin, "admin", "", nil, time.Now()) + require.NoError(t, err) + return req +} diff --git a/pkg/core/versioning/resource_store_convert.go b/pkg/core/versioning/resource_store_convert.go new file mode 100644 index 000000000..f3e2f218e --- /dev/null +++ b/pkg/core/versioning/resource_store_convert.go @@ -0,0 +1,161 @@ +/* + * 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 versioning + +import ( + "fmt" + "strconv" + "strings" + "time" + + "google.golang.org/protobuf/types/known/timestamppb" + + meshproto "github.com/apache/dubbo-admin/api/mesh/v1alpha1" + "github.com/apache/dubbo-admin/pkg/common/bizerror" + meshresource "github.com/apache/dubbo-admin/pkg/core/resource/apis/mesh/v1alpha1" + coremodel "github.com/apache/dubbo-admin/pkg/core/resource/model" +) + +const ruleVersionIDAnnotation = "dubbo.apache.org/rule-version-id" + +func buildVersionName(kind coremodel.ResourceKind, resourceKey string, id int64) string { + return fmt.Sprintf("%s-%s-%d", kind, extractName(resourceKey), id) +} + +func buildVersionNoName(kind coremodel.ResourceKind, resourceKey string, versionNo int64) string { + return fmt.Sprintf("%s-%s-version-%d", kind, extractName(resourceKey), versionNo) +} + +func buildParentIndexKey(kind coremodel.ResourceKind, resourceKey string) string { + mesh := extractMesh(resourceKey) + name := extractName(resourceKey) + return fmt.Sprintf("%s/%s/%s", kind, mesh, name) +} + +func extractMesh(resourceKey string) string { + mesh, _ := coremodel.ParseResourceKey(resourceKey) + return mesh +} + +func extractName(resourceKey string) string { + _, name := coremodel.ParseResourceKey(resourceKey) + return name +} + +func extractIDFromName(name string) (int64, error) { + idx := strings.LastIndex(name, "-") + if idx == -1 || idx == len(name)-1 { + return 0, fmt.Errorf("invalid version name format: %s", name) + } + id, err := strconv.ParseInt(name[idx+1:], 10, 64) + if err != nil { + return 0, fmt.Errorf("invalid version name format: %s", name) + } + return id, nil +} + +func versionIDFromResource(rv *meshresource.RuleVersionResource) (int64, error) { + if rv == nil { + return 0, fmt.Errorf("RuleVersion resource is nil") + } + if rv.Annotations != nil { + if raw := rv.Annotations[ruleVersionIDAnnotation]; raw != "" { + id, err := strconv.ParseInt(raw, 10, 64) + if err != nil { + return 0, fmt.Errorf("invalid RuleVersion id annotation for %s: %w", rv.Name, err) + } + return id, nil + } + } + return extractIDFromName(rv.Name) +} + +func protoToVersion(spec *meshproto.RuleVersion, id int64) (*Version, error) { + if spec == nil { + return nil, bizerror.New(bizerror.InvalidArgument, "RuleVersion spec is nil") + } + + var rolledBackFromID *int64 + if spec.RolledBackFromId != 0 { + v := spec.RolledBackFromId + rolledBackFromID = &v + } + + createdAt := timestampAsTime(spec.CreatedAt) + recordedAt := timestampAsTime(spec.RecordedAt) + if recordedAt.IsZero() { + recordedAt = createdAt + } + + return &Version{ + ID: id, + RuleKind: coremodel.ResourceKind(spec.ParentRuleKind), + Mesh: spec.ParentRuleMesh, + ResourceKey: coremodel.BuildResourceKey(spec.ParentRuleMesh, spec.ParentRuleName), + RuleName: spec.ParentRuleName, + VersionNo: spec.VersionNo, + ContentHash: spec.ContentHash, + SpecJSON: spec.SpecJson, + Operation: Operation(spec.Operation), + Source: Source(spec.Source), + Author: spec.Author, + Reason: spec.Reason, + RolledBackFromID: rolledBackFromID, + CreatedAt: createdAt, + RecordedAt: recordedAt, + IsLatestRecorded: false, + }, nil +} + +func historySnapshotFromState(state *historyState) *HistorySnapshot { + if state == nil { + return &HistorySnapshot{} + } + snapshot := &HistorySnapshot{ + Versions: append([]Version(nil), state.Versions...), + } + if len(snapshot.Versions) == 0 { + return snapshot + } + head := snapshot.Versions[0] + snapshot.Head = &head + snapshot.Deleted = head.Operation == OperationDelete + for i := range snapshot.Versions { + snapshot.Versions[i].IsLatestRecorded = snapshot.Versions[i].ID == head.ID + } + return snapshot +} + +func duplicateVersionNoError(kind coremodel.ResourceKind, resourceKey string, versionNo, firstID, secondID int64) error { + return fmt.Errorf("%w: duplicate version number for kind=%s mesh=%s rule=%s versionNo=%d conflictingVersionIDs=%d,%d", + ErrVersionStoreError, + kind, + extractMesh(resourceKey), + extractName(resourceKey), + versionNo, + firstID, + secondID, + ) +} + +func timestampAsTime(ts *timestamppb.Timestamp) time.Time { + if ts == nil { + return time.Time{} + } + return ts.AsTime() +} diff --git a/pkg/core/versioning/resource_store_version.go b/pkg/core/versioning/resource_store_version.go new file mode 100644 index 000000000..c0fe61039 --- /dev/null +++ b/pkg/core/versioning/resource_store_version.go @@ -0,0 +1,447 @@ +/* + * 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 versioning + +import ( + "context" + "errors" + "fmt" + "sort" + "strconv" + "time" + + "google.golang.org/protobuf/types/known/timestamppb" + + meshproto "github.com/apache/dubbo-admin/api/mesh/v1alpha1" + "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" + "github.com/apache/dubbo-admin/pkg/core/store/index" +) + +func (a *ResourceStoreAdapter) GetVersion(kind coremodel.ResourceKind, resourceKey string, id int64) (*Version, error) { + if err := a.ensureStores(); err != nil { + return nil, err + } + rv, err := a.getVersionResourceForRule(kind, resourceKey, id) + if err != nil { + return nil, err + } + return protoToVersion(rv.Spec, id) +} + +func (a *ResourceStoreAdapter) ListVersions(kind coremodel.ResourceKind, resourceKey string) ([]Version, error) { + if err := a.ensureStores(); err != nil { + return nil, err + } + snapshot, err := a.HistorySnapshot(kind, resourceKey) + if err != nil { + return nil, err + } + return snapshot.Versions, nil +} + +func (a *ResourceStoreAdapter) ListLatestVersions(kind coremodel.ResourceKind) ([]Version, error) { + if err := a.ensureStores(); err != nil { + return nil, err + } + // ListLatestVersions builds the global latest list, so it scans all stored + // RuleVersion resources and groups by parent. ByParentRule is only suitable + // for a single parent query; this pass intentionally avoids a new index. + keys := a.versionStore.ListKeys() + objs, err := a.versionStore.GetByKeys(keys) + if err != nil { + return nil, err + } + versions, err := versionsFromResources(objs, kind, "", false, "") + if err != nil { + return nil, err + } + byParent := make(map[string][]Version) + for _, version := range versions { + byParent[version.ResourceKey] = append(byParent[version.ResourceKey], version) + } + + latest := make([]Version, 0, len(byParent)) + for resourceKey, versions := range byParent { + if err := validateAndSortVersions(kind, resourceKey, versions); err != nil { + return nil, err + } + if len(versions) > 0 { + latest = append(latest, versions[0]) + } + } + sort.Slice(latest, func(i, j int) bool { + if latest[i].ResourceKey == latest[j].ResourceKey { + return latest[i].VersionNo > latest[j].VersionNo + } + return latest[i].ResourceKey < latest[j].ResourceKey + }) + return latest, nil +} + +func (a *ResourceStoreAdapter) HistorySnapshot(kind coremodel.ResourceKind, resourceKey string) (*HistorySnapshot, error) { + if err := a.ensureStores(); err != nil { + return nil, err + } + var snapshot *HistorySnapshot + err := a.withParentLock(kind, resourceKey, func() error { + state, err := a.historyState(kind, resourceKey) + if err != nil { + return err + } + snapshot = historySnapshotFromState(state) + return nil + }) + return snapshot, err +} + +func (a *ResourceStoreAdapter) latestVersionLocked(kind coremodel.ResourceKind, resourceKey string) (*Version, error) { + state, err := a.historyState(kind, resourceKey) + if err != nil { + return nil, err + } + if state.Latest == nil { + return nil, ErrVersionNotFound + } + return state.Latest, nil +} + +func (a *ResourceStoreAdapter) historyState(kind coremodel.ResourceKind, resourceKey string) (*historyState, error) { + parentKey := buildParentIndexKey(kind, resourceKey) + objs, err := a.versionStore.ByIndex(index.ByParentRuleIndexName, parentKey) + if err != nil { + return nil, err + } + + versions, err := versionsFromIndexObjects(objs, kind, resourceKey, true, "parent "+parentKey) + if err != nil { + return nil, err + } + if err := validateAndSortVersions(kind, resourceKey, versions); err != nil { + return nil, err + } + + state := &historyState{Versions: versions} + if len(versions) > 0 { + state.Latest = &versions[0] + state.MaxVersionNo = versions[0].VersionNo + } + return state, nil +} + +func versionsFromResources(objs []coremodel.Resource, kind coremodel.ResourceKind, resourceKey string, filterParent bool, specContext string) ([]Version, error) { + versions := make([]Version, 0, len(objs)) + for _, obj := range objs { + version, include, err := versionFromObject(obj, kind, resourceKey, filterParent, specContext) + if err != nil { + return nil, err + } + if include { + versions = append(versions, version) + } + } + return versions, nil +} + +func versionsFromIndexObjects(objs []interface{}, kind coremodel.ResourceKind, resourceKey string, filterParent bool, specContext string) ([]Version, error) { + versions := make([]Version, 0, len(objs)) + for _, obj := range objs { + version, include, err := versionFromObject(obj, kind, resourceKey, filterParent, specContext) + if err != nil { + return nil, err + } + if include { + versions = append(versions, version) + } + } + return versions, nil +} + +func versionFromObject(obj interface{}, kind coremodel.ResourceKind, resourceKey string, filterParent bool, specContext string) (Version, bool, error) { + rv, ok := obj.(*meshresource.RuleVersionResource) + if !ok { + return Version{}, false, fmt.Errorf("%w: expected RuleVersionResource, got %T", ErrVersionStoreError, obj) + } + if rv.Spec == nil { + if specContext == "" { + specContext = rv.ResourceKey() + } + return Version{}, false, fmt.Errorf("%w: RuleVersion spec is nil for %s", ErrVersionStoreError, specContext) + } + if rv.Spec.ParentRuleKind != string(kind) { + return Version{}, false, nil + } + if filterParent && !versionResourceMatchesParent(rv, kind, resourceKey) { + return Version{}, false, nil + } + id, err := versionIDFromResource(rv) + if err != nil { + return Version{}, false, fmt.Errorf("%w: %v", ErrVersionStoreError, err) + } + v, err := protoToVersion(rv.Spec, id) + if err != nil { + return Version{}, false, err + } + return *v, true, nil +} + +func validateAndSortVersions(kind coremodel.ResourceKind, resourceKey string, versions []Version) error { + seenVersionNo := make(map[int64]int64, len(versions)) + for _, version := range versions { + if previousID, ok := seenVersionNo[version.VersionNo]; ok && previousID != version.ID { + return duplicateVersionNoError(kind, resourceKey, version.VersionNo, previousID, version.ID) + } + seenVersionNo[version.VersionNo] = version.ID + } + sort.Slice(versions, func(i, j int) bool { + return versions[i].VersionNo > versions[j].VersionNo + }) + return nil +} + +func (a *ResourceStoreAdapter) InsertVersion(ctx context.Context, req InsertRequest, maxVersions int64) (*Version, error) { + if err := a.ensureStores(); err != nil { + return nil, err + } + var version *Version + err := a.withParentLock(req.RuleKind, req.ResourceKey, func() error { + var inner error + version, inner = a.insertVersionLocked(ctx, req, maxVersions) + return inner + }) + return version, err +} + +func (a *ResourceStoreAdapter) insertVersionLocked(ctx context.Context, req InsertRequest, maxVersions int64) (*Version, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + state, err := a.historyState(req.RuleKind, req.ResourceKey) + if err != nil { + return nil, err + } + if err := ctx.Err(); err != nil { + return nil, err + } + + versionNo := state.MaxVersionNo + 1 + + createdAt := req.CreatedAt + if createdAt.IsZero() { + createdAt = time.Now() + } + recordedAt := time.Now() + + var rv *meshresource.RuleVersionResource + var id int64 + + if err := ctx.Err(); err != nil { + return nil, err + } + + attempts := maxIDGenerateAttempts + var addErr error + for attempt := 0; attempt < attempts; attempt++ { + generated, err := a.idGenerator.Next() + if err != nil { + return nil, err + } + id = generated + if _, err := a.getVersionResourceByGlobalID(id); err == nil { + addErr = store.ErrorResourceAlreadyExists(meshresource.RuleVersionKind.ToString(), buildVersionName(req.RuleKind, req.ResourceKey, id), extractMesh(req.ResourceKey)) + continue + } else if !errors.Is(err, ErrVersionNotFound) { + return nil, err + } + rv = newRuleVersionResource(req, id, versionNo, createdAt, recordedAt) + if err := ctx.Err(); err != nil { + return nil, err + } + addErr = a.versionStore.Add(rv) + if addErr == nil { + break + } + if !isAddConflict(addErr) { + return nil, fmt.Errorf("failed to add version resource id=%d: %w", id, addErr) + } + if err := ctx.Err(); err != nil { + return nil, err + } + state, err = a.historyState(req.RuleKind, req.ResourceKey) + if err != nil { + return nil, err + } + if state.MaxVersionNo+1 <= versionNo { + return nil, fmt.Errorf("failed to allocate unique rule version number %d: %w", versionNo, addErr) + } + versionNo = state.MaxVersionNo + 1 + } + if addErr != nil { + return nil, fmt.Errorf("failed to allocate unique rule version id after %d attempts: %w", attempts, addErr) + } + + if err := ctx.Err(); err != nil { + return nil, err + } + if maxVersions > 0 { + if err := a.trimVersionsLocked(ctx, req.RuleKind, req.ResourceKey, maxVersions); err != nil { + logger.Warnf("rule version retention cleanup failed for kind=%s resourceKey=%s recordedVersion=%d: %v", req.RuleKind, req.ResourceKey, id, err) + } + } + + return protoToVersion(rv.Spec, id) +} + +func (a *ResourceStoreAdapter) trimVersionsLocked(ctx context.Context, kind coremodel.ResourceKind, resourceKey string, keep int64) error { + state, err := a.historyState(kind, resourceKey) + if err != nil { + return err + } + versions := state.Versions + if int64(len(versions)) <= keep { + return nil + } + + // Retention runs after the new version is durable and only removes entries + // beyond the configured window. Cleanup failure is reported to logs by the + // caller and does not roll back the already-written rule mutation. + toDelete := versions[int(keep):] + for _, v := range toDelete { + if err := ctx.Err(); err != nil { + return err + } + rv, err := a.getVersionResourceForRule(kind, resourceKey, v.ID) + if err != nil { + return err + } + if err := a.versionStore.Delete(rv); err != nil { + return err + } + } + + return nil +} + +func (a *ResourceStoreAdapter) getVersionResourceByGlobalID(id int64) (*meshresource.RuleVersionResource, error) { + objects, err := a.versionStore.ByIndex(index.ByRuleVersionIDIndexName, strconv.FormatInt(id, 10)) + if err != nil { + return nil, err + } + if len(objects) == 0 { + return nil, ErrVersionNotFound + } + if len(objects) > 1 { + return nil, fmt.Errorf("%w: multiple RuleVersion resources indexed by id %d", ErrVersionStoreError, id) + } + rv, ok := objects[0].(*meshresource.RuleVersionResource) + if !ok { + return nil, fmt.Errorf("%w: expected RuleVersionResource, got %T", ErrVersionStoreError, objects[0]) + } + if rv.Spec == nil { + return nil, fmt.Errorf("%w: RuleVersion spec is nil for id %d", ErrVersionStoreError, id) + } + indexedID, err := versionIDFromResource(rv) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrVersionStoreError, err) + } + if indexedID != id { + return nil, fmt.Errorf("%w: RuleVersion id index mismatch: requested %d, got %d", ErrVersionStoreError, id, indexedID) + } + return rv, nil +} + +func (a *ResourceStoreAdapter) getVersionResourceForRule(kind coremodel.ResourceKind, resourceKey string, id int64) (*meshresource.RuleVersionResource, error) { + rv, err := a.getVersionResourceByGlobalID(id) + if err != nil { + return nil, err + } + if !versionResourceMatchesParent(rv, kind, resourceKey) { + return nil, ErrVersionNotFound + } + return rv, nil +} + +func versionResourceMatchesParent(rv *meshresource.RuleVersionResource, kind coremodel.ResourceKind, resourceKey string) bool { + return rv != nil && + rv.Spec != nil && + rv.Spec.ParentRuleKind == string(kind) && + rv.Spec.ParentRuleMesh == extractMesh(resourceKey) && + rv.Spec.ParentRuleName == extractName(resourceKey) +} + +func validateExistingVersionForRequest(existing *meshresource.RuleVersionResource, existingID int64, req InsertRequest) error { + spec := existing.Spec + if spec.ParentRuleKind != string(req.RuleKind) || + spec.ParentRuleMesh != extractMesh(req.ResourceKey) || + spec.ParentRuleName != extractName(req.ResourceKey) || + spec.ContentHash != req.ContentHash || + spec.SpecJson != req.SpecJSON || + spec.Source != string(req.Source) || + spec.Operation != string(req.Operation) || + spec.Author != req.Author || + spec.Reason != req.Reason || + spec.RolledBackFromId != rolledBackFromIDValue(req.RolledBackFromID) { + return fmt.Errorf("%w: RuleVersion id %d already exists with different content", ErrVersionStoreError, existingID) + } + if !req.CreatedAt.IsZero() && !timestampAsTime(spec.CreatedAt).Equal(req.CreatedAt) { + return fmt.Errorf("%w: RuleVersion id %d already exists with different content", ErrVersionStoreError, existingID) + } + return nil +} + +func newRuleVersionResource(req InsertRequest, id, versionNo int64, createdAt, recordedAt time.Time) *meshresource.RuleVersionResource { + rv := meshresource.NewRuleVersionResourceWithAttributes( + buildVersionNoName(req.RuleKind, req.ResourceKey, versionNo), + extractMesh(req.ResourceKey), + ) + rv.Annotations = map[string]string{ + ruleVersionIDAnnotation: strconv.FormatInt(id, 10), + } + rv.Spec = &meshproto.RuleVersion{ + ParentRuleKind: string(req.RuleKind), + ParentRuleMesh: extractMesh(req.ResourceKey), + ParentRuleName: extractName(req.ResourceKey), + VersionNo: versionNo, + ContentHash: req.ContentHash, + SpecJson: req.SpecJSON, + Operation: string(req.Operation), + Source: string(req.Source), + Author: req.Author, + Reason: req.Reason, + CreatedAt: timestamppb.New(createdAt), + RecordedAt: timestamppb.New(recordedAt), + } + if req.RolledBackFromID != nil { + rv.Spec.RolledBackFromId = *req.RolledBackFromID + } + return rv +} + +func isAddConflict(err error) bool { + var conflict *store.ResourceConflictError + return errors.As(err, &conflict) +} + +func rolledBackFromIDValue(id *int64) int64 { + if id == nil { + return 0 + } + return *id +} diff --git a/pkg/core/versioning/service.go b/pkg/core/versioning/service.go new file mode 100644 index 000000000..b303fdc67 --- /dev/null +++ b/pkg/core/versioning/service.go @@ -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. + */ + +package versioning + +import ( + "context" + "errors" + "sort" + "strconv" + "strings" + "time" + + "github.com/apache/dubbo-admin/pkg/common/bizerror" + coremodel "github.com/apache/dubbo-admin/pkg/core/resource/model" +) + +// Service provides rule history operations. RuleVersion entries are audit +// records and rollback material; live current state is still owned by +// ResourceManager and the backing registry. +type Service struct { + maxVersions int64 + store *ResourceStoreAdapter +} + +func NewService(maxVersions int64, store *ResourceStoreAdapter) *Service { + return &Service{ + maxVersions: maxVersions, + store: store, + } +} + +func (s *Service) ensureAvailable() error { + if s == nil || s.store == nil { + return ErrVersionStoreError + } + return nil +} + +func (s *Service) List(kind coremodel.ResourceKind, mesh, ruleName string) (*ListResult, error) { + if err := s.ensureAvailable(); err != nil { + return nil, err + } + resourceKey := coremodel.BuildResourceKey(mesh, ruleName) + snapshot, err := s.store.HistorySnapshot(kind, resourceKey) + if err != nil { + return nil, err + } + result := &ListResult{Items: snapshot.Versions, Total: int64(len(snapshot.Versions)), LatestRecordedDeleted: snapshot.Deleted} + if snapshot.Head != nil { + latestID := snapshot.Head.ID + result.LatestRecordedVersionID = &latestID + result.LatestRecordedVersionNo = snapshot.Head.VersionNo + } + return result, nil +} + +func (s *Service) Get(kind coremodel.ResourceKind, mesh, ruleName string, id int64) (*Version, error) { + if err := s.ensureAvailable(); err != nil { + return nil, err + } + resourceKey := coremodel.BuildResourceKey(mesh, ruleName) + version, err := s.store.GetVersion(kind, resourceKey, id) + if err != nil { + return nil, err + } + snapshot, err := s.store.HistorySnapshot(kind, resourceKey) + if err != nil { + return nil, err + } + if snapshot.Head != nil { + version.IsLatestRecorded = version.ID == snapshot.Head.ID + } + return version, nil +} + +func (s *Service) DiffHistoryVersions(kind coremodel.ResourceKind, mesh, ruleName string, id int64, against string) (*DiffResult, error) { + if err := s.ensureAvailable(); err != nil { + return nil, err + } + left, err := s.Get(kind, mesh, ruleName, id) + if err != nil { + return nil, err + } + right, err := s.diffRight(kind, mesh, ruleName, id, against) + if err != nil { + return nil, err + } + return &DiffResult{ + Left: DiffSide{ID: left.ID, VersionNo: left.VersionNo, SpecJSON: left.SpecJSON}, + Right: DiffSide{ID: right.ID, VersionNo: right.VersionNo, SpecJSON: right.SpecJSON}, + }, nil +} + +func (s *Service) diffRight(kind coremodel.ResourceKind, mesh, ruleName string, id int64, against string) (*Version, error) { + switch against { + case "previous": + list, err := s.store.ListVersions(kind, coremodel.BuildResourceKey(mesh, ruleName)) + if err != nil { + return nil, err + } + sort.Slice(list, func(i, j int) bool { + return list[i].VersionNo > list[j].VersionNo + }) + for i := range list { + if list[i].ID != id { + continue + } + if i+1 >= len(list) { + return nil, ErrVersionNotFound + } + return &list[i+1], nil + } + return nil, ErrVersionNotFound + case "", "current": + return nil, bizerror.New(bizerror.InvalidArgument, "current diff requires live ResourceManager state and is implemented by the console service") + default: + againstID, err := strconv.ParseInt(against, 10, 64) + if err != nil { + return nil, bizerror.New(bizerror.InvalidArgument, "against must be 'current', 'previous', or a version ID") + } + return s.Get(kind, mesh, ruleName, againstID) + } +} + +func (s *Service) Append(ctx context.Context, res coremodel.Resource, op Operation, source Source, author, reason string, rolledBackFromID *int64) (*Version, error) { + if err := s.ensureAvailable(); err != nil { + return nil, err + } + req, err := BuildInsertRequest(res, op, source, author, reason, rolledBackFromID, time.Now()) + if err != nil { + return nil, err + } + return s.store.InsertVersion(ctx, req, s.maxVersions) +} + +func (s *Service) LatestVersion(kind coremodel.ResourceKind, mesh, ruleName string) (*Version, error) { + if err := s.ensureAvailable(); err != nil { + return nil, err + } + return s.store.LatestVersion(kind, coremodel.BuildResourceKey(mesh, ruleName)) +} + +func (s *Service) HasHistory(kind coremodel.ResourceKind, mesh, ruleName string) (bool, error) { + if err := s.ensureAvailable(); err != nil { + return false, err + } + _, err := s.store.LatestVersion(kind, coremodel.BuildResourceKey(mesh, ruleName)) + if err == nil { + return true, nil + } + if errors.Is(err, ErrVersionNotFound) { + return false, nil + } + return false, err +} + +func BuildInsertRequest(res coremodel.Resource, op Operation, source Source, author, reason string, rolledBackFromID *int64, createdAt time.Time) (InsertRequest, error) { + if res == nil { + return InsertRequest{}, bizerror.New(bizerror.InvalidArgument, "rule resource is required") + } + hash, specJSON, err := NormalizeResource(res) + if err != nil { + return InsertRequest{}, err + } + if op == OperationDelete { + specJSON = DeleteSpecJSON + hash = HashSpecJSON(specJSON) + } + if strings.TrimSpace(author) == "" { + author = "system:unknown" + } else { + author = strings.TrimSpace(author) + } + if source == "" { + source = SourceAdmin + } + return InsertRequest{ + RuleKind: res.ResourceKind(), + Mesh: res.ResourceMesh(), + ResourceKey: res.ResourceKey(), + RuleName: res.ResourceMeta().Name, + SpecJSON: specJSON, + ContentHash: hash, + Source: source, + Operation: op, + Author: author, + Reason: reason, + RolledBackFromID: rolledBackFromID, + CreatedAt: createdAt, + }, nil +} diff --git a/pkg/core/versioning/service_test.go b/pkg/core/versioning/service_test.go new file mode 100644 index 000000000..e4aa2b44d --- /dev/null +++ b/pkg/core/versioning/service_test.go @@ -0,0 +1,60 @@ +/* + * 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 versioning + +import ( + "strconv" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + meshresource "github.com/apache/dubbo-admin/pkg/core/resource/apis/mesh/v1alpha1" +) + +func TestDiffHistoryVersionsPreviousUsesPriorRecordedVersion(t *testing.T) { + adapter := NewResourceStoreAdapter(newVersionStore(t)) + svc := NewService(10, adapter) + v1, err := adapter.InsertVersion(t.Context(), insertRequestForTest(t, conditionRouteForVersionTest("demo-rule", "v1"), OperationCreate), 10) + require.NoError(t, err) + v2, err := adapter.InsertVersion(t.Context(), insertRequestForTest(t, conditionRouteForVersionTest("demo-rule", "v2"), OperationUpdate), 10) + require.NoError(t, err) + v3, err := adapter.InsertVersion(t.Context(), insertRequestForTest(t, conditionRouteForVersionTest("demo-rule", "v3"), OperationUpdate), 10) + require.NoError(t, err) + require.NotEqual(t, v1.ID, v2.ID) + + diff, err := svc.DiffHistoryVersions(meshresource.ConditionRouteKind, "", "demo-rule", v3.ID, "previous") + require.NoError(t, err) + assert.Equal(t, v3.ID, diff.Left.ID) + assert.Equal(t, v2.ID, diff.Right.ID) + assert.Contains(t, diff.Right.SpecJSON, "v2") +} + +func TestDiffHistoryVersionsUsesExplicitVersionID(t *testing.T) { + adapter := NewResourceStoreAdapter(newVersionStore(t)) + svc := NewService(10, adapter) + v1, err := adapter.InsertVersion(t.Context(), insertRequestForTest(t, conditionRouteForVersionTest("demo-rule", "v1"), OperationCreate), 10) + require.NoError(t, err) + v2, err := adapter.InsertVersion(t.Context(), insertRequestForTest(t, conditionRouteForVersionTest("demo-rule", "v2"), OperationUpdate), 10) + require.NoError(t, err) + + diff, err := svc.DiffHistoryVersions(meshresource.ConditionRouteKind, "", "demo-rule", v2.ID, strconv.FormatInt(v1.ID, 10)) + require.NoError(t, err) + assert.Equal(t, v2.ID, diff.Left.ID) + assert.Equal(t, v1.ID, diff.Right.ID) +} diff --git a/pkg/core/versioning/types.go b/pkg/core/versioning/types.go new file mode 100644 index 000000000..729f953b5 --- /dev/null +++ b/pkg/core/versioning/types.go @@ -0,0 +1,127 @@ +/* + * 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 versioning + +import ( + "errors" + "time" + + coremodel "github.com/apache/dubbo-admin/pkg/core/resource/model" +) + +// Source identifies where a rule mutation originated. +// Used for auditing and distinguishing user-initiated changes from system-generated ones. +type Source string + +const ( + SourceAdmin Source = "ADMIN" // User edit via Admin UI/API + // SourceUpstream marks a version imported from an upstream registry event. + SourceUpstream Source = "UPSTREAM" + // SourceBootstrap represents a baseline record for an existing rule when it + // is first brought under version tracking. + SourceBootstrap Source = "BOOTSTRAP" + // SourceRollback marks a version produced by re-publishing a historical + // snapshot. Rollback records a new version and does not rewrite history. + SourceRollback Source = "ROLLBACK" +) + +type Operation string + +const ( + OperationCreate Operation = "CREATE" + OperationUpdate Operation = "UPDATE" + OperationDelete Operation = "DELETE" +) + +var ( + ErrVersionNotFound = errors.New("rule version not found") + ErrVersionStoreError = errors.New("rule version store error") + ErrRollbackToDelete = errors.New("cannot roll back to a DELETE marker") + ErrRollbackToCurrent = errors.New("cannot roll back to a version identical to current") +) + +// Version represents a snapshot of a rule's spec at a point in time. Version +// entries are immutable after creation. Rollback appends a new version, while +// retention may delete the oldest entries. IsLatestRecorded is derived from +// history only; it is not proof that this snapshot equals the live rule. +type Version struct { + ID int64 `json:"id"` + RuleKind coremodel.ResourceKind `json:"ruleKind"` + Mesh string `json:"mesh"` + ResourceKey string `json:"resourceKey"` + RuleName string `json:"ruleName"` + VersionNo int64 `json:"versionNo"` + ContentHash string `json:"contentHash"` + SpecJSON string `json:"specJson"` + Source Source `json:"source"` + Operation Operation `json:"operation"` + Author string `json:"author"` + Reason string `json:"reason,omitempty"` + // RolledBackFromID records the historical version whose snapshot was + // re-published to produce this version. It is audit metadata only. + RolledBackFromID *int64 `json:"rolledBackFromId,omitempty"` + CreatedAt time.Time `json:"createdAt"` + RecordedAt time.Time `json:"recordedAt"` + IsLatestRecorded bool `json:"isLatestRecorded"` +} + +type historyState struct { + Versions []Version + Latest *Version + MaxVersionNo int64 +} + +type HistorySnapshot struct { + Versions []Version + Head *Version + Deleted bool +} + +type InsertRequest struct { + RuleKind coremodel.ResourceKind + Mesh string + ResourceKey string + RuleName string + SpecJSON string + ContentHash string + Source Source + Operation Operation + Author string + Reason string + RolledBackFromID *int64 + CreatedAt time.Time +} + +type ListResult struct { + Items []Version `json:"items"` + Total int64 `json:"total"` + LatestRecordedVersionID *int64 `json:"latestRecordedVersionId,omitempty"` + LatestRecordedVersionNo int64 `json:"latestRecordedVersionNo,omitempty"` + LatestRecordedDeleted bool `json:"latestRecordedDeleted"` +} + +type DiffResult struct { + Left DiffSide `json:"left"` + Right DiffSide `json:"right"` +} + +type DiffSide struct { + ID int64 `json:"id"` + VersionNo int64 `json:"versionNo"` + SpecJSON string `json:"specJson"` +} diff --git a/pkg/store/dbcommon/gorm_store.go b/pkg/store/dbcommon/gorm_store.go index cedc20144..ccf54cf2c 100644 --- a/pkg/store/dbcommon/gorm_store.go +++ b/pkg/store/dbcommon/gorm_store.go @@ -594,7 +594,7 @@ func (gs *GormStore) findByIndex(indexName, indexedValue string) ([]interface{}, func (gs *GormStore) getKeysByIndexes(indexes []index.IndexCondition) ([]string, error) { if len(indexes) == 0 { - return gs.ListKeys(), nil + return []string{}, nil } var keySet map[string]struct{} diff --git a/pkg/store/dbcommon/gorm_store_test.go b/pkg/store/dbcommon/gorm_store_test.go index b1dd1b8c6..238113c11 100644 --- a/pkg/store/dbcommon/gorm_store_test.go +++ b/pkg/store/dbcommon/gorm_store_test.go @@ -21,11 +21,13 @@ import ( "encoding/json" "fmt" "os" + "strings" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gorm.io/driver/sqlite" + "gorm.io/gorm" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" @@ -118,12 +120,26 @@ func (m mockResourceList) SetItems(items []model.Resource) { // setupTestStore creates a new GormStore with an in-memory SQLite database for testing func setupTestStore(t *testing.T) (*GormStore, func()) { // Create temporary SQLite database file for better isolation and reliability - tmpFile, err := os.CreateTemp("", fmt.Sprintf("test-db-%s-*.db", t.Name())) + dbPath := tempSQLitePath(t) + dialector := sqlite.Open(dbPath) + return setupTestStoreWithDialector(t, dialector) +} + +func tempSQLitePath(t *testing.T) string { + t.Helper() + safeName := strings.NewReplacer("/", "_", "\\", "_").Replace(t.Name()) + tmpFile, err := os.CreateTemp("", fmt.Sprintf("test-db-%s-*.db", safeName)) require.NoError(t, err) dbPath := tmpFile.Name() - tmpFile.Close() + require.NoError(t, tmpFile.Close()) + t.Cleanup(func() { + _ = os.Remove(dbPath) + }) + return dbPath +} - dialector := sqlite.Open(dbPath) +func setupTestStoreWithDialector(t *testing.T, dialector gorm.Dialector) (*GormStore, func()) { + t.Helper() pool, err := NewConnectionPool(dialector, storecfg.MySQL, t.Name(), DefaultConnectionPoolConfig()) require.NoError(t, err) @@ -146,8 +162,7 @@ func setupTestStore(t *testing.T) (*GormStore, func()) { // Cleanup function cleanup := func() { - pool.Close() - os.Remove(dbPath) + _ = pool.Close() } return store, cleanup @@ -802,10 +817,46 @@ func TestGormStore_ListByIndexesEmpty(t *testing.T) { err = store.Add(mockRes) require.NoError(t, err) - // List with empty indexes should return all resources + // Empty index conditions preserve memory-store semantics: no indexed query means no results. resources, err := store.ListByIndexes([]index.IndexCondition{}) assert.NoError(t, err) - assert.Len(t, resources, 1) + assert.Empty(t, resources) +} + +func TestGormStore_ListResourcesSorted(t *testing.T) { + store, cleanup := setupTestStore(t) + defer cleanup() + + err := store.Init(nil) + require.NoError(t, err) + + mockRes1 := &mockResource{ + Kind: "TestResource", + Key: "mesh/test-key-2", + Mesh: "mesh", + Meta: metav1.ObjectMeta{Name: "test-resource-2"}, + } + mockRes2 := &mockResource{ + Kind: "TestResource", + Key: "mesh/test-key-1", + Mesh: "mesh", + Meta: metav1.ObjectMeta{Name: "test-resource-1"}, + } + + err = store.Add(mockRes1) + require.NoError(t, err) + err = store.Add(mockRes2) + require.NoError(t, err) + + resources := store.List() + require.Len(t, resources, 2) + // List() returns resources in arbitrary order, so check both are present + keys := []string{ + resources[0].(model.Resource).ResourceKey(), + resources[1].(model.Resource).ResourceKey(), + } + assert.Contains(t, keys, "mesh/test-key-1") + assert.Contains(t, keys, "mesh/test-key-2") } func TestGormStore_PageListByIndexes(t *testing.T) { diff --git a/pkg/store/memory/store.go b/pkg/store/memory/store.go index 0b392bd58..0726b8cca 100644 --- a/pkg/store/memory/store.go +++ b/pkg/store/memory/store.go @@ -40,6 +40,7 @@ type resourceStore struct { rk coremodel.ResourceKind storeProxy cache.Indexer prefixTrees map[string]*radix.Tree + mu sync.Mutex treesMu sync.RWMutex } @@ -74,6 +75,15 @@ func (rs *resourceStore) Start(_ runtime.Runtime, _ <-chan struct{}) error { } func (rs *resourceStore) Add(obj interface{}) error { + rs.mu.Lock() + defer rs.mu.Unlock() + if r, ok := obj.(coremodel.Resource); ok { + if _, exists, err := rs.storeProxy.GetByKey(r.ResourceKey()); err != nil { + return err + } else if exists { + return store.ErrorResourceAlreadyExists(r.ResourceKind().ToString(), r.ResourceMeta().Name, r.ResourceMesh()) + } + } if err := rs.storeProxy.Add(obj); err != nil { return err } @@ -85,6 +95,8 @@ func (rs *resourceStore) Add(obj interface{}) error { } func (rs *resourceStore) Update(obj interface{}) error { + rs.mu.Lock() + defer rs.mu.Unlock() r, ok := obj.(coremodel.Resource) var oldRes coremodel.Resource if ok { @@ -108,6 +120,8 @@ func (rs *resourceStore) Update(obj interface{}) error { } func (rs *resourceStore) Delete(obj interface{}) error { + rs.mu.Lock() + defer rs.mu.Unlock() if err := rs.storeProxy.Delete(obj); err != nil { return err } @@ -134,6 +148,8 @@ func (rs *resourceStore) GetByKey(key string) (item interface{}, exists bool, er } func (rs *resourceStore) Replace(i []interface{}, s string) error { + rs.mu.Lock() + defer rs.mu.Unlock() // Clear all trees before replace rs.treesMu.Lock() for indexName := range rs.prefixTrees { diff --git a/pkg/store/memory/store_test.go b/pkg/store/memory/store_test.go index 8ad401995..9bb51af66 100644 --- a/pkg/store/memory/store_test.go +++ b/pkg/store/memory/store_test.go @@ -227,6 +227,44 @@ func TestResourceStore_List(t *testing.T) { assert.Contains(t, list, mockRes2) } +func TestResourceStore_ListResourcesSortedAndEmptyIndexes(t *testing.T) { + store := NewMemoryResourceStore("TestResource") + err := store.Init(nil) + assert.NoError(t, err) + + mockRes1 := &mockResource{ + kind: "TestResource", + key: "mesh/test-key-2", + mesh: "mesh", + meta: metav1.ObjectMeta{Name: "test-resource-2"}, + } + mockRes2 := &mockResource{ + kind: "TestResource", + key: "mesh/test-key-1", + mesh: "mesh", + meta: metav1.ObjectMeta{Name: "test-resource-1"}, + } + + err = store.Add(mockRes1) + assert.NoError(t, err) + err = store.Add(mockRes2) + assert.NoError(t, err) + + resources := store.List() + assert.Len(t, resources, 2) + // List() returns resources in arbitrary order, so check both are present + keys := []string{ + resources[0].(model.Resource).ResourceKey(), + resources[1].(model.Resource).ResourceKey(), + } + assert.Contains(t, keys, "mesh/test-key-1") + assert.Contains(t, keys, "mesh/test-key-2") + + indexed, err := store.ListByIndexes([]index.IndexCondition{}) + assert.NoError(t, err) + assert.Empty(t, indexed) +} + func TestResourceStore_ListKeys(t *testing.T) { store := NewMemoryResourceStore("TestResource") err := store.Init(nil) diff --git a/release/kubernetes/dubbo-system/dubbo-admin.yaml b/release/kubernetes/dubbo-system/dubbo-admin.yaml index 54216c4f2..517cdfeb4 100644 --- a/release/kubernetes/dubbo-system/dubbo-admin.yaml +++ b/release/kubernetes/dubbo-system/dubbo-admin.yaml @@ -96,12 +96,14 @@ data: observability: grafana: http://grafana.monitoringg.svc:3000 prometheus: http://prometheus-k8s.monitoring.svc:9090/ - console: - auth: - user: admin - password: dubbo@2025 - expirationTime: 3600 - discovery: + console: + auth: + user: admin + password: dubbo@2025 + expirationTime: 3600 + ruleVersioning: + maxVersionsPerRule: 20 + discovery: - type: nacos2 name: nacos2.5-standalone id: nacos2.5 diff --git a/ui-vue3/package.json b/ui-vue3/package.json index 70b06d7c1..4eec7fee3 100644 --- a/ui-vue3/package.json +++ b/ui-vue3/package.json @@ -8,6 +8,7 @@ "dev:mock": "vite --mode mock", "check:i18n": "node --loader ts-node/esm src/base/i18n/sortI18n.ts", "preview": "vite preview", + "test": "vitest run", "test:unit": "vitest", "build": "prettier --write src/ && vite build", "format": "prettier --write src/", diff --git a/ui-vue3/src/api/service/traffic.ts b/ui-vue3/src/api/service/traffic.ts index 9d4ff0a29..e9c5fd273 100644 --- a/ui-vue3/src/api/service/traffic.ts +++ b/ui-vue3/src/api/service/traffic.ts @@ -17,6 +17,106 @@ import request from '@/base/http/request' +export type TrafficRuleKind = 'condition-rule' | 'tag-rule' | 'configurator' + +// Version IDs are int64 values serialized as decimal strings by the API. Keep +// them as strings in the UI to avoid JavaScript number precision loss. +export interface RuleVersion { + id: string + ruleKind: string + mesh: string + resourceKey: string + ruleName: string + versionNo: number + contentHash: string + specJson: string + source: 'ADMIN' | 'UPSTREAM' | 'BOOTSTRAP' | 'ROLLBACK' | string + operation: 'CREATE' | 'UPDATE' | 'DELETE' | string + author: string + reason?: string + rolledBackFromId?: string + createdAt: string + recordedAt?: string + isLatestRecorded: boolean +} + +export interface RuleVersionList { + items: RuleVersion[] + total: number + latestRecordedVersionId?: string + latestRecordedVersionNo?: number + latestRecordedDeleted?: boolean +} + +export interface RuleVersionDiffSide { + id: string + versionNo: number + specJson: string +} + +export interface RuleVersionDiff { + left: RuleVersionDiffSide + right: RuleVersionDiffSide +} + +export interface RollbackRuleVersionResult { + rolledBackFromId: string + versionId: string + versionNo: number + source: 'ROLLBACK' | string +} + +const ruleNameForPath = (kind: TrafficRuleKind, ruleName: string): string => { + return kind === 'configurator' ? encodeURIComponent(ruleName) : ruleName +} + +export const listRuleVersionsAPI = ( + kind: TrafficRuleKind, + ruleName: string +): Promise<{ code: string; data: RuleVersionList }> => { + return request({ + url: `/${kind}/${ruleNameForPath(kind, ruleName)}/versions`, + method: 'get' + }) +} + +export const getRuleVersionAPI = ( + kind: TrafficRuleKind, + ruleName: string, + versionId: string +): Promise<{ code: string; data: RuleVersion }> => { + return request({ + url: `/${kind}/${ruleNameForPath(kind, ruleName)}/versions/${versionId}`, + method: 'get' + }) +} + +export const diffRuleVersionAPI = ( + kind: TrafficRuleKind, + ruleName: string, + versionId: string, + against = 'current' +): Promise<{ code: string; data: RuleVersionDiff }> => { + return request({ + url: `/${kind}/${ruleNameForPath(kind, ruleName)}/versions/${versionId}/diff`, + method: 'get', + params: { against } + }) +} + +export const rollbackRuleVersionAPI = ( + kind: TrafficRuleKind, + ruleName: string, + versionId: string, + reason: string +): Promise<{ code: string; data: RollbackRuleVersionResult }> => { + return request({ + url: `/${kind}/${ruleNameForPath(kind, ruleName)}/versions/${versionId}/rollback`, + method: 'post', + data: { reason } + }) +} + export const searchRoutingRule = (params: any): Promise => { return request({ url: '/condition-rule/search', @@ -149,5 +249,3 @@ export const delConfiguratorDetail = (params: any): Promise => { method: 'delete' }) } - -// TODO Perform front-end and back-end joint debugging diff --git a/ui-vue3/src/base/http/request.ts b/ui-vue3/src/base/http/request.ts index 094a76cdd..bc5956eca 100644 --- a/ui-vue3/src/base/http/request.ts +++ b/ui-vue3/src/base/http/request.ts @@ -39,6 +39,10 @@ const isSilentErrorUrl = (url?: string): boolean => { return SILENT_ERROR_URLS.some((silentUrl) => url.includes(silentUrl)) } +const shouldShowErrorMessage = (url?: string): boolean => { + return !isSilentErrorUrl(url) +} + const service: AxiosInstance = axios.create({ baseURL: '/api/v1', timeout: 30 * 1000 @@ -82,7 +86,7 @@ response.use( // Show error toast message const errorMsg = `${response.data.code}:${response.data.message}` - if (!isSilentErrorUrl(response.config.url)) { + if (shouldShowErrorMessage(response.config.url)) { message.error(errorMsg) } console.error(errorMsg) @@ -120,7 +124,7 @@ response.use( } if (response?.data) { const errorMsg = `${response.data?.code}:${response.data?.message}` - if (!isSilentErrorUrl(error.config?.url)) { + if (shouldShowErrorMessage(error.config?.url)) { message.error(errorMsg) } console.error(errorMsg) diff --git a/ui-vue3/src/base/i18n/en.ts b/ui-vue3/src/base/i18n/en.ts index 1ae6ec15a..ebdf3443d 100644 --- a/ui-vue3/src/base/i18n/en.ts +++ b/ui-vue3/src/base/i18n/en.ts @@ -110,7 +110,7 @@ const words: I18nType = { flowControlDomain: { actuatingRange: 'Actuating range', notSet: 'Not set', - versionRecords: 'Version records', + versionRecords: 'Version history', YAMLView: 'YAML View', addConfiguration: 'Add configuration', addConfigurationItem: 'Add configurationItem', @@ -554,6 +554,44 @@ const words: I18nType = { copy: 'You have successfully copied a piece of information' } }, + ruleVersionDomain: { + versionRecords: 'Version history', + versionJson: 'Version JSON', + versionDiff: 'Version Diff', + currentRule: 'Current rule', + latestRecorded: 'latest recorded', + latestRecordedVersionBadge: 'latest recorded v{versionNo}', + latestRecordedVersion: 'Latest recorded version', + targetVersion: 'Target version', + empty: 'No version records', + view: 'View', + diffCurrent: 'Diff current', + rollback: 'Rollback', + rollbackConfirmTitle: 'Confirm rollback', + rollbackAppendHint: + 'Rollback creates a new version from the historical snapshot. Existing history is not modified.', + rollbackReason: 'Rollback reason', + rollbackReasonPlaceholder: 'Enter a rollback reason', + rollbackReasonRequired: 'Rollback reason is required', + rollbackSuccess: 'Rollback succeeded and created a new version', + rollbackSuccessWithVersion: 'Rollback succeeded and created v{versionNo}', + rollbackFailed: 'Rollback failed', + diffFailed: 'Failed to load version diff', + rollbackDeleteDisabled: 'Delete markers cannot be rolled back', + source: 'Source', + author: 'Author', + createdAt: 'Created at', + modifiedAt: 'Modified at', + sourceAdmin: 'Console', + sourceUpstream: 'Upstream', + sourceBootstrap: 'Baseline', + sourceRollback: 'Rollback', + changeReason: 'Change reason', + reason: 'Reason', + none: 'None', + reload: 'Reload', + cancel: 'Cancel' + }, backHome: 'Back Home', noPageTip: 'Sorry, the page you visited does not exist.', globalSearchTip: 'Search ip, application, instance, service', diff --git a/ui-vue3/src/base/i18n/zh.ts b/ui-vue3/src/base/i18n/zh.ts index c355a42c9..776cf5958 100644 --- a/ui-vue3/src/base/i18n/zh.ts +++ b/ui-vue3/src/base/i18n/zh.ts @@ -530,6 +530,44 @@ const words: I18nType = { copy: '您已经成功复制一条信息' } }, + ruleVersionDomain: { + versionRecords: '版本记录', + versionJson: '版本 JSON', + versionDiff: '版本差异', + currentRule: '当前规则', + latestRecorded: '最新记录', + latestRecordedVersionBadge: '最新记录 v{versionNo}', + latestRecordedVersion: '最新记录版本', + targetVersion: '目标版本', + empty: '暂无版本记录', + view: '查看', + diffCurrent: '对比当前', + rollback: '回滚', + rollbackConfirmTitle: '确认回滚', + rollbackDeletedWarning: '当前规则已删除,回滚会重新创建该规则', + rollbackAppendHint: '回滚会基于该历史版本创建一条新的版本记录,已有历史记录不会被修改。', + rollbackReason: '回滚原因', + rollbackReasonPlaceholder: '请输入回滚原因(必填)', + rollbackReasonRequired: '请输入回滚原因', + rollbackSuccess: '回滚成功,已创建新版本', + rollbackSuccessWithVersion: '回滚成功,已创建 v{versionNo}', + rollbackFailed: '回滚失败', + diffFailed: '加载版本差异失败', + rollbackDeleteDisabled: '删除标记不能回滚', + source: '来源', + author: '作者', + createdAt: '创建时间', + modifiedAt: '修改时间', + sourceAdmin: '控制台', + sourceUpstream: '上游', + sourceBootstrap: '基线', + sourceRollback: '回滚', + changeReason: '变更原因', + reason: '原因', + none: '暂无', + reload: '重新加载', + cancel: '取消' + }, backHome: '回到首页', noPageTip: '抱歉,你访问的页面不存在', globalSearchTip: '搜索ip,应用,实例,服务', diff --git a/ui-vue3/src/mocks/handlers.ts b/ui-vue3/src/mocks/handlers.ts index f46ff3bda..8691eb460 100644 --- a/ui-vue3/src/mocks/handlers.ts +++ b/ui-vue3/src/mocks/handlers.ts @@ -27,6 +27,7 @@ import { versionHandlers } from './handlers/version' import { dynamicConfigHandlers } from './handlers/dynamicConfig' import { routingRuleHandlers } from './handlers/routingRule' import { tagRuleHandlers } from './handlers/tagRule' +import { ruleVersionHandlers } from './handlers/ruleVersion' import { destinationRuleHandlers, virtualServiceHandlers } from './handlers/istio' import { promQLHandlers } from './handlers/promQL' import { serverHandlers } from './handlers/server' @@ -46,6 +47,7 @@ export const handlers: HttpHandler[] = [ ...dynamicConfigHandlers, ...routingRuleHandlers, ...tagRuleHandlers, + ...ruleVersionHandlers, ...destinationRuleHandlers, ...virtualServiceHandlers, ...promQLHandlers, diff --git a/ui-vue3/src/mocks/handlers/dynamicConfig.ts b/ui-vue3/src/mocks/handlers/dynamicConfig.ts index 3e5b355af..83ed38a22 100644 --- a/ui-vue3/src/mocks/handlers/dynamicConfig.ts +++ b/ui-vue3/src/mocks/handlers/dynamicConfig.ts @@ -28,6 +28,14 @@ function randomString(min: number, max: number): string { return Array.from({ length: len }, () => String.fromCharCode(97 + randomInt(0, 25))).join('') } +const decodeRuleName = (raw: string) => { + try { + return decodeURIComponent(raw) + } catch { + return raw + } +} + export const dynamicConfigHandlers: HttpHandler[] = [ http.get(`${base}/configurator/search`, () => { const total = randomInt(8, 1000) @@ -45,7 +53,7 @@ export const dynamicConfigHandlers: HttpHandler[] = [ http.get(`${base}/configurator/:ruleName`, ({ params }) => { const detail: ConfiguratorDetail = { - name: params.ruleName as string, + name: decodeRuleName(params.ruleName as string), configs: [{ side: 'provider', timeout: 3000, retries: 2, loadbalance: 'roundrobin' }] } return success(detail) diff --git a/ui-vue3/src/mocks/handlers/ruleVersion.ts b/ui-vue3/src/mocks/handlers/ruleVersion.ts new file mode 100644 index 000000000..9e1d80a3b --- /dev/null +++ b/ui-vue3/src/mocks/handlers/ruleVersion.ts @@ -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 + * (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 { http, HttpResponse, type HttpHandler } from 'msw' +import { success, base } from '../utils' +import type { + RuleVersion, + RuleVersionDiff, + RuleVersionList, + TrafficRuleKind +} from '@/api/service/traffic' + +const KINDS: TrafficRuleKind[] = ['condition-rule', 'tag-rule', 'configurator'] +const SCENARIOS = ['normal', 'deleted', 'empty', 'backend-error', 'diff'] as const + +type Scenario = (typeof SCENARIOS)[number] + +const scenarioOf = (ruleName: string): Scenario => + SCENARIOS.find((scenario) => ruleName.includes(`-${scenario}`)) ?? 'normal' + +const decodeName = (raw: string) => { + try { + return decodeURIComponent(raw) + } catch { + return raw + } +} + +const spec = (ruleName: string, marker: string) => + JSON.stringify({ configVersion: 'v3.0', key: ruleName, marker }) + +const version = ( + kind: TrafficRuleKind, + ruleName: string, + id: string, + versionNo: number, + operation: RuleVersion['operation'], + source: RuleVersion['source'], + isLatestRecorded: boolean, + marker = `v${versionNo}` +): RuleVersion => ({ + id, + ruleKind: kind, + mesh: 'default', + resourceKey: `/${ruleName}`, + ruleName, + versionNo, + contentHash: `sha256:${ruleName}:${marker}`, + specJson: spec(ruleName, marker), + source, + operation, + author: 'user name', + createdAt: `2026-05-${(20 + versionNo).toString().padStart(2, '0')}T08:00:00Z`, + recordedAt: `2026-05-${(20 + versionNo).toString().padStart(2, '0')}T08:01:00Z`, + isLatestRecorded +}) + +const fixtureVersions = (kind: TrafficRuleKind, ruleName: string): RuleVersion[] => { + switch (scenarioOf(ruleName)) { + case 'empty': + return [] + case 'deleted': + return [ + version(kind, ruleName, '2003', 3, 'DELETE', 'ADMIN', false, 'deleted'), + version(kind, ruleName, '2002', 2, 'UPDATE', 'ADMIN', false), + version(kind, ruleName, '2001', 1, 'CREATE', 'BOOTSTRAP', false) + ] + case 'diff': + return [ + version(kind, ruleName, '4002', 2, 'UPDATE', 'ADMIN', true, 'right'), + version(kind, ruleName, '4001', 1, 'CREATE', 'BOOTSTRAP', false, 'left') + ] + default: + return [ + version(kind, ruleName, '1005', 5, 'UPDATE', 'ADMIN', true), + version(kind, ruleName, '1004', 4, 'UPDATE', 'ADMIN', false), + version(kind, ruleName, '1003', 3, 'UPDATE', 'ADMIN', false), + version(kind, ruleName, '1002', 2, 'UPDATE', 'ADMIN', false), + version(kind, ruleName, '1001', 1, 'CREATE', 'BOOTSTRAP', false) + ] + } +} + +const latestRecordedVersionOf = (versions: RuleVersion[]) => + versions.find((version) => version.isLatestRecorded) + +const versionList = (versions: RuleVersion[]): RuleVersionList => { + const latestRecorded = latestRecordedVersionOf(versions) + const head = versions[0] + return { + items: versions, + total: versions.length, + latestRecordedVersionId: latestRecorded?.id, + latestRecordedVersionNo: latestRecorded?.versionNo, + latestRecordedDeleted: Boolean(head?.operation === 'DELETE') + } +} + +const bizError = (code: string, message: string, status = 200) => + HttpResponse.json({ code, message, data: null }, { status }) + +const notFoundResp = (message: string) => bizError('NotFoundError', message, 404) + +const readJsonBody = async (request: Request): Promise> => { + try { + const body = (await request.json()) as Record | null + return body ?? {} + } catch { + return {} + } +} + +const validateReason = (reason: string) => { + const trimmed = reason.trim() + if (!trimmed) return bizError('InvalidArgument', 'reason must not be empty', 400) + if (trimmed.length > 1024) + return bizError('InvalidArgument', 'reason must be at most 1024 characters', 400) + return null +} + +const buildVersionHandlersForKind = (kind: TrafficRuleKind): HttpHandler[] => [ + http.get(`${base}/${kind}/:ruleName/versions`, ({ params }) => { + const ruleName = decodeName(params.ruleName as string) + if (scenarioOf(ruleName) === 'backend-error') + return bizError('InternalError', 'backend error', 500) + const versions = fixtureVersions(kind, ruleName) + return success(versionList(versions)) + }), + + http.get(`${base}/${kind}/:ruleName/versions/:versionId`, ({ params }) => { + const ruleName = decodeName(params.ruleName as string) + const versionId = String(params.versionId || '').trim() + if (!versionId) return bizError('InvalidArgument', 'versionId must be an integer', 400) + const found = fixtureVersions(kind, ruleName).find((item) => item.id === versionId) + return found ? success(found) : notFoundResp('rule version not found') + }), + + http.get(`${base}/${kind}/:ruleName/versions/:versionId/diff`, ({ params, request }) => { + const ruleName = decodeName(params.ruleName as string) + const versionId = String(params.versionId || '').trim() + if (!versionId) return bizError('InvalidArgument', 'versionId must be an integer', 400) + const versions = fixtureVersions(kind, ruleName) + const left = versions.find((item) => item.id === versionId) + if (!left) return notFoundResp('rule version not found') + const against = new URL(request.url).searchParams.get('against') || 'current' + if (against !== 'current' && against !== 'previous' && !/^\d+$/.test(against)) { + return bizError( + 'InvalidArgument', + "against must be 'current', 'previous', or a version ID", + 400 + ) + } + const leftIndex = versions.findIndex((item) => item.id === versionId) + const right = + against === 'current' + ? latestRecordedVersionOf(versions) + : against === 'previous' + ? versions[leftIndex + 1] + : versions.find((item) => item.id === against) + if (!right) return notFoundResp('rule version not found') + return success({ + left: { id: left.id, versionNo: left.versionNo, specJson: left.specJson }, + right: { id: right.id, versionNo: right.versionNo, specJson: right.specJson } + }) + }), + + http.post( + `${base}/${kind}/:ruleName/versions/:versionId/rollback`, + async ({ params, request }) => { + const ruleName = decodeName(params.ruleName as string) + const body = await readJsonBody(request) + const reasonErr = validateReason(typeof body.reason === 'string' ? body.reason : '') + if (reasonErr) return reasonErr + + const versions = fixtureVersions(kind, ruleName) + const target = versions.find((item) => item.id === String(params.versionId || '').trim()) + if (!target) return notFoundResp('rule version not found') + if (target.operation === 'DELETE') + return bizError('InvalidArgument', 'cannot roll back to a DELETE marker', 400) + const latestRecorded = latestRecordedVersionOf(versions) + + return success({ + rolledBackFromId: target.id, + versionId: '9901', + versionNo: (latestRecorded?.versionNo ?? 0) + 1, + source: 'ROLLBACK' + }) + } + ) +] + +export const ruleVersionHandlers: HttpHandler[] = KINDS.flatMap(buildVersionHandlersForKind) + +export const ruleVersionMock = { + scenarios: SCENARIOS, + scenarioOf, + reset() {} +} diff --git a/ui-vue3/src/views/traffic/_shared/RuleDiffEditor.vue b/ui-vue3/src/views/traffic/_shared/RuleDiffEditor.vue new file mode 100644 index 000000000..e57b45050 --- /dev/null +++ b/ui-vue3/src/views/traffic/_shared/RuleDiffEditor.vue @@ -0,0 +1,116 @@ + + + + + + + diff --git a/ui-vue3/src/views/traffic/_shared/RuleHistoryDrawer.vue b/ui-vue3/src/views/traffic/_shared/RuleHistoryDrawer.vue new file mode 100644 index 000000000..a5a418c00 --- /dev/null +++ b/ui-vue3/src/views/traffic/_shared/RuleHistoryDrawer.vue @@ -0,0 +1,162 @@ + + + + + + + diff --git a/ui-vue3/src/views/traffic/_shared/RuleHistoryPanel.spec.ts b/ui-vue3/src/views/traffic/_shared/RuleHistoryPanel.spec.ts new file mode 100644 index 000000000..d398e0c35 --- /dev/null +++ b/ui-vue3/src/views/traffic/_shared/RuleHistoryPanel.spec.ts @@ -0,0 +1,403 @@ +/* + * 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, nextTick } from 'vue' +import { message } from 'ant-design-vue' +import { HTTP_STATUS } from '@/base/http/constants' +import type { RuleVersion } from '@/api/service/traffic' +import type RuleHistoryDrawerType from './RuleHistoryDrawer.vue' +import type RuleHistoryPanelType from './RuleHistoryPanel.vue' + +const mocks = vi.hoisted(() => { + Object.defineProperty(globalThis, 'localStorage', { + value: { + getItem: () => null, + setItem: () => undefined, + removeItem: () => undefined + }, + configurable: true + }) + + return { + listRuleVersionsAPI: vi.fn(), + rollbackRuleVersionAPI: vi.fn(), + diffRuleVersionAPI: vi.fn() + } +}) + +vi.mock('@/api/service/traffic', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + listRuleVersionsAPI: mocks.listRuleVersionsAPI, + rollbackRuleVersionAPI: mocks.rollbackRuleVersionAPI, + diffRuleVersionAPI: mocks.diffRuleVersionAPI + } +}) + +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('@/components/editor/MonacoEditor.vue', () => ({ + default: { + name: 'MonacoEditor', + template: '
' + } +})) + +vi.mock('./RuleDiffEditor.vue', () => ({ + default: { + name: 'RuleDiffEditor', + template: '
' + } +})) + +const version = ( + id: string, + versionNo: number, + isLatestRecorded: boolean, + overrides: Partial = {} +): RuleVersion => ({ + id, + ruleKind: 'ConditionRoute', + mesh: '', + resourceKey: '/demo-rule', + ruleName: 'demo-rule', + versionNo, + contentHash: `hash-${id}`, + specJson: '{"key":"demo-rule"}', + source: 'ADMIN', + operation: 'UPDATE', + author: 'admin', + createdAt: '2026-06-19T00:00:00Z', + isLatestRecorded, + ...overrides +}) + +const drawerStub = defineComponent({ + props: ['items'], + emits: ['rollback'], + setup(props, { emit }) { + return () => + h( + 'div', + { 'data-test': 'history-drawer' }, + (props.items as RuleVersion[]).map((item) => + h( + 'button', + { + type: 'button', + 'data-test': `rollback-${item.id}`, + onClick: () => emit('rollback', item) + }, + `rollback-${item.id}` + ) + ) + ) + } +}) + +const modalStub = defineComponent({ + props: ['open'], + emits: ['ok'], + setup(props, { emit, slots }) { + return () => + props.open + ? h('div', { 'data-test': 'modal' }, [ + slots.default?.(), + h( + 'button', + { type: 'button', 'data-test': 'modal-ok', onClick: () => emit('ok') }, + 'ok' + ) + ]) + : null + } +}) + +const textAreaStub = defineComponent({ + emits: ['update:value'], + setup(_props, { emit }) { + return () => + h('textarea', { + 'data-test': 'rollback-reason', + onInput: (event: Event) => { + emit('update:value', (event.target as HTMLTextAreaElement).value) + } + }) + } +}) + +const mountPanel = (props: Partial['$props']> = {}) => + mount(RuleHistoryPanel, { + props: { + open: true, + kind: 'condition-rule', + ruleName: 'demo-rule', + title: 'History', + ...props + }, + global: { + plugins: [i18n], + stubs: { + RuleHistoryDrawer: drawerStub, + MonacoEditor: true, + RuleDiffEditor: true, + AModal: modalStub, + 'a-modal': modalStub, + AAlert: { template: '
' }, + 'a-alert': { template: '
' }, + ATypographyText: { template: '' }, + 'a-typography-text': { template: '' }, + AForm: { template: '
' }, + 'a-form': { template: '
' }, + AFormItem: { template: '' }, + 'a-form-item': { template: '' }, + ATextarea: textAreaStub, + 'a-textarea': textAreaStub + } + } + }) + +let i18n: typeof import('@/base/i18n').i18n +let RuleHistoryDrawer: typeof RuleHistoryDrawerType +let RuleHistoryPanel: typeof RuleHistoryPanelType + +beforeAll(async () => { + i18n = (await import('@/base/i18n')).i18n + RuleHistoryDrawer = (await import('./RuleHistoryDrawer.vue')).default + RuleHistoryPanel = (await import('./RuleHistoryPanel.vue')).default +}) + +beforeEach(() => { + mocks.listRuleVersionsAPI.mockReset() + mocks.rollbackRuleVersionAPI.mockReset() + mocks.diffRuleVersionAPI.mockReset() + vi.mocked(message.error).mockClear() + vi.mocked(message.success).mockClear() + vi.mocked(message.warning).mockClear() +}) + +const mountDrawer = (items: RuleVersion[]) => + mount(RuleHistoryDrawer, { + props: { + open: true, + title: 'History', + items + }, + global: { + plugins: [i18n], + stubs: { + ADrawer: { + props: ['open'], + template: '
' + }, + 'a-drawer': { + props: ['open'], + template: '
' + }, + ASpin: { template: '
' }, + 'a-spin': { template: '
' }, + AEmpty: { template: '
' }, + 'a-empty': { template: '
' }, + ASpace: { template: '
' }, + 'a-space': { template: '
' }, + ATag: { template: '' }, + 'a-tag': { template: '' }, + ATooltip: { template: '
' }, + 'a-tooltip': { template: '
' }, + AButton: { + props: { + disabled: { + type: Boolean, + default: false + } + }, + emits: ['click'], + template: + '' + }, + 'a-button': { + props: { + disabled: { + type: Boolean, + default: false + } + }, + emits: ['click'], + template: + '' + }, + ATypographyText: { template: '' }, + 'a-typography-text': { template: '' } + } + } + }) + +const rollbackButton = (wrapper: ReturnType) => wrapper.findAll('button').at(-1) + +describe('RuleHistoryPanel', () => { + it('allows rollback for latest recorded non-delete versions', () => { + const wrapper = mountDrawer([version('latest-update', 3, true)]) + + rollbackButton(wrapper)?.trigger('click') + + expect(wrapper.emitted('rollback')?.[0][0]).toMatchObject({ id: 'latest-update' }) + }) + + it('disables rollback for delete markers', () => { + const wrapper = mountDrawer([ + version('delete-marker', 4, true, { operation: 'DELETE', specJson: '' }) + ]) + + rollbackButton(wrapper)?.trigger('click') + + expect(wrapper.emitted('rollback')).toBeUndefined() + }) + + it('ignores stale history responses after ruleName changes', async () => { + let resolveFirst: (value: unknown) => void = () => undefined + mocks.listRuleVersionsAPI + .mockReturnValueOnce(new Promise((resolve) => (resolveFirst = resolve))) + .mockResolvedValueOnce({ + code: HTTP_STATUS.SUCCESS, + data: { + items: [version('new-latest-recorded', 7, true)], + total: 1, + latestRecordedVersionId: 'new-latest-recorded', + latestRecordedVersionNo: 7, + latestRecordedDeleted: false + } + }) + + const wrapper = mountPanel({ ruleName: 'old-rule' }) + await wrapper.setProps({ ruleName: 'new-rule' }) + await flushPromises() + + resolveFirst({ + code: HTTP_STATUS.SUCCESS, + data: { + items: [version('old-latest-recorded', 3, true)], + total: 1, + latestRecordedVersionId: 'old-latest-recorded', + latestRecordedVersionNo: 3, + latestRecordedDeleted: false + } + }) + await flushPromises() + + expect(wrapper.emitted('latest-recorded-version-change')?.at(-1)).toEqual([ + 'new-latest-recorded' + ]) + expect(wrapper.emitted('latest-recorded-version-no-change')?.at(-1)).toEqual([7]) + expect(wrapper.text()).toContain('rollback-new-latest-recorded') + expect(wrapper.text()).not.toContain('rollback-old-latest-recorded') + }) + + it('ignores stale rollback success after ruleName changes', async () => { + mocks.listRuleVersionsAPI + .mockResolvedValueOnce({ + code: HTTP_STATUS.SUCCESS, + data: { + items: [version('old-target', 1, false)], + total: 1, + latestRecordedVersionId: 'old-latest-recorded', + latestRecordedVersionNo: 2, + latestRecordedDeleted: false + } + }) + .mockResolvedValueOnce({ + code: HTTP_STATUS.SUCCESS, + data: { + items: [version('new-target', 3, false)], + total: 1, + latestRecordedVersionId: 'new-latest-recorded', + latestRecordedVersionNo: 4, + latestRecordedDeleted: false + } + }) + let resolveRollback: (value: unknown) => void = () => undefined + mocks.rollbackRuleVersionAPI.mockReturnValueOnce( + new Promise((resolve) => (resolveRollback = resolve)) + ) + + const wrapper = mountPanel({ ruleName: 'old-rule' }) + await flushPromises() + await wrapper.get('[data-test="rollback-old-target"]').trigger('click') + await nextTick() + await wrapper.get('[data-test="rollback-reason"]').setValue('restore old') + await wrapper.get('[data-test="modal-ok"]').trigger('click') + + await wrapper.setProps({ ruleName: 'new-rule' }) + await flushPromises() + await wrapper.get('[data-test="rollback-new-target"]').trigger('click') + await nextTick() + + resolveRollback({ + code: HTTP_STATUS.SUCCESS, + data: { + rolledBackFromId: 'old-target', + versionId: 'old-rollback', + versionNo: 5, + source: 'ROLLBACK' + } + }) + await flushPromises() + + expect(wrapper.text()).toContain('rollback-new-target') + expect(wrapper.find('[data-test="modal"]').exists()).toBe(true) + expect(mocks.listRuleVersionsAPI).toHaveBeenCalledTimes(2) + }) + + it('shows backend rollback rejection errors', async () => { + mocks.listRuleVersionsAPI.mockResolvedValue({ + code: HTTP_STATUS.SUCCESS, + data: { + items: [version('already-current', 1, true)], + total: 1, + latestRecordedVersionId: 'already-current', + latestRecordedVersionNo: 1, + latestRecordedDeleted: false + } + }) + mocks.rollbackRuleVersionAPI.mockRejectedValue({ + code: 'InvalidArgument', + message: 'cannot roll back to a version identical to current' + }) + + const wrapper = mountPanel() + await flushPromises() + await wrapper.get('[data-test="rollback-already-current"]').trigger('click') + await nextTick() + await wrapper.get('[data-test="rollback-reason"]').setValue('same content') + await wrapper.get('[data-test="modal-ok"]').trigger('click') + await flushPromises() + + expect(message.error).toHaveBeenCalledWith('cannot roll back to a version identical to current') + }) +}) diff --git a/ui-vue3/src/views/traffic/_shared/RuleHistoryPanel.vue b/ui-vue3/src/views/traffic/_shared/RuleHistoryPanel.vue new file mode 100644 index 000000000..9f22a17e6 --- /dev/null +++ b/ui-vue3/src/views/traffic/_shared/RuleHistoryPanel.vue @@ -0,0 +1,365 @@ + + + + + + + diff --git a/ui-vue3/src/views/traffic/_shared/ruleVersion.ts b/ui-vue3/src/views/traffic/_shared/ruleVersion.ts new file mode 100644 index 000000000..5b9dffbbc --- /dev/null +++ b/ui-vue3/src/views/traffic/_shared/ruleVersion.ts @@ -0,0 +1,87 @@ +/* + * 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 { + listRuleVersionsAPI, + type RuleVersion, + type RuleVersionList, + type TrafficRuleKind +} from '@/api/service/traffic' +import { HTTP_STATUS } from '@/base/http/constants' + +export interface LatestRecordedState { + id?: string + versionNo?: number + latestRecordedDeleted: boolean +} + +export const latestRecordedStateFromItems = (items: RuleVersion[]): LatestRecordedState => { + const latestRecorded = items.find((item) => item.isLatestRecorded) + const head = items[0] + return { + id: latestRecorded?.id, + versionNo: latestRecorded?.versionNo, + latestRecordedDeleted: Boolean(head?.operation === 'DELETE') + } +} + +export const latestRecordedStateFromList = (list?: RuleVersionList): LatestRecordedState => { + if (!list) { + return { latestRecordedDeleted: false } + } + if (list.latestRecordedVersionId !== undefined || list.latestRecordedDeleted !== undefined) { + return { + id: list.latestRecordedVersionId, + versionNo: list.latestRecordedVersionNo, + latestRecordedDeleted: Boolean(list.latestRecordedDeleted) + } + } + return latestRecordedStateFromItems(list.items || []) +} + +export const versionDiffLabel = (prefix: string, versionNo?: number): string => + typeof versionNo === 'number' && versionNo > 0 ? `${prefix} v${versionNo}` : prefix + +export const isLatestRecordedHistoryRequest = ( + requestSeq: number, + latestSeq: number, + disposed: boolean +) => { + return !disposed && requestSeq === latestSeq +} + +export const fetchLatestRecordedState = async ( + kind: TrafficRuleKind, + ruleName: string +): Promise => { + const res = await listRuleVersionsAPI(kind, ruleName) + if (res.code === HTTP_STATUS.SUCCESS) { + return latestRecordedStateFromList(res.data) + } + return { latestRecordedDeleted: false } +} + +export const formatRuleSpec = (specJson?: string): string => { + if (!specJson) { + return '' + } + try { + return JSON.stringify(JSON.parse(specJson), null, 2) + } catch (e) { + return specJson + } +} diff --git a/ui-vue3/src/views/traffic/dynamicConfig/index.vue b/ui-vue3/src/views/traffic/dynamicConfig/index.vue index baad34d02..6194c5680 100644 --- a/ui-vue3/src/views/traffic/dynamicConfig/index.vue +++ b/ui-vue3/src/views/traffic/dynamicConfig/index.vue @@ -73,6 +73,7 @@ import { PROVIDE_INJECT_KEY } from '@/base/enums/ProvideInject' import { useRouter } from 'vue-router' import { PRIMARY_COLOR } from '@/base/constants' import { Icon } from '@iconify/vue' +import { message } from 'ant-design-vue' const router = useRouter() @@ -143,8 +144,12 @@ onMounted(async () => { }) const delDynamicConfig = async (record: any) => { - await delConfiguratorDetail({ name: record.ruleName }) - await searchDomain.onSearch() + try { + await delConfiguratorDetail({ name: record.ruleName }) + await searchDomain.onSearch() + } catch (e: any) { + message.error(e?.message || String(e)) + } } provide(PROVIDE_INJECT_KEY.SEARCH_DOMAIN, searchDomain) diff --git a/ui-vue3/src/views/traffic/dynamicConfig/model/ConfigModel.spec.ts b/ui-vue3/src/views/traffic/dynamicConfig/model/ConfigModel.spec.ts new file mode 100644 index 000000000..de13c4fbd --- /dev/null +++ b/ui-vue3/src/views/traffic/dynamicConfig/model/ConfigModel.spec.ts @@ -0,0 +1,66 @@ +/* + * 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, vi } from 'vitest' +import { ViewDataModel } from './ConfigModel' + +vi.hoisted(() => { + Object.defineProperty(globalThis, 'localStorage', { + value: { + getItem: () => null, + setItem: () => undefined, + removeItem: () => undefined + }, + configurable: true + }) +}) + +describe('ViewDataModel traffic form compatibility', () => { + it('round-trips dynamic config configVersion and config row fields', () => { + const model = new ViewDataModel() + model.fromApiOutput({ + ruleName: 'demo.configurators', + scope: 'service', + key: 'demo', + enabled: true, + configVersion: 'v3.1', + configs: [ + { + enabled: false, + side: 'consumer', + match: { + application: { + oneof: [{ exact: 'shop-web' }] + } + }, + parameters: { + timeout: '3000', + retries: '2' + } + } + ] + }) + + const payload = model.toApiInput() + + expect(payload.configVersion).toBe('v3.1') + expect(payload.enabled).toBe(true) + expect(payload.configs[0].enabled).toBe(false) + expect(payload.configs[0].side).toBe('consumer') + expect(payload.configs[0].parameters).toEqual({ timeout: '3000', retries: '2' }) + }) +}) diff --git a/ui-vue3/src/views/traffic/dynamicConfig/model/ConfigModel.ts b/ui-vue3/src/views/traffic/dynamicConfig/model/ConfigModel.ts index ec9e1c81b..61e4cfde8 100644 --- a/ui-vue3/src/views/traffic/dynamicConfig/model/ConfigModel.ts +++ b/ui-vue3/src/views/traffic/dynamicConfig/model/ConfigModel.ts @@ -18,6 +18,8 @@ import { i18n } from '@/base/i18n' export class ConfigModel { + [key: string]: any + enabled: boolean = true hasMatch: boolean = false side: string = 'provider' @@ -103,7 +105,7 @@ export class ConfigModel { constructor(obj: any) { if (obj) { for (const key of Object.keys(this)) { - if (obj[key]) { + if (obj[key] !== undefined) { this[key] = obj[key] } } @@ -112,17 +114,17 @@ export class ConfigModel { } descMatches() { - const desc = [] + const desc: string[] = [] for (const key in this.matchesValue) { const tmp = this.matchesValue[key] if (!this.matchesKeys.includes(key)) continue if (tmp.type === 'obj') { desc.push(`${key} ${tmp.relation} ${tmp.value}`) } else if (tmp.type === 'arr') { - const oneof = tmp.arr.map((x) => `${x.relation} ${x.value}`).join(', ') + const oneof = tmp.arr.map((x: any) => `${x.relation} ${x.value}`).join(', ') desc.push(`${key} oneof [${oneof}]`) } else { - const allof = tmp.arr.map((x) => `${x.key} ${x.relation} ${x.value}`).join(', ') + const allof = tmp.arr.map((x: any) => `${x.key} ${x.relation} ${x.value}`).join(', ') desc.push(`${key} allof [${allof}]`) } } @@ -130,14 +132,14 @@ export class ConfigModel { } descParameters() { - const desc = [] + const desc: string[] = [] for (const key in this.parametersValue) { const tmp = this.parametersValue[key] if (!this.parametersKeys.includes(key)) continue if (tmp.type === 'obj') { desc.push(`${key} = ${tmp.value}`) } else { - desc.push(...tmp.arr.map((x) => `${x.key} ${x.relation} ${x.value}`)) + desc.push(...tmp.arr.map((x: any) => `${x.key} ${x.relation} ${x.value}`)) } } return desc @@ -147,7 +149,7 @@ export class ConfigModel { obj[key].arr.splice(idx, 1) } - addArrConfig(obj: any, key: string, idx: number, val: any | {}) { + addArrConfig(obj: any, key: string, idx: number, val: any = {}) { obj[key].arr.splice(idx + 1, 0, { key: '', relation: val?.relation || '', @@ -217,7 +219,7 @@ export class ConfigModel { } } - checkArrConfig(prefix: string, keys: [any], obj: any, errorMsg: []) { + checkArrConfig(prefix: string, keys: any[], obj: any, errorMsg: string[]) { for (const key of keys) { const item = obj[key] if (item.type === 'obj') { @@ -273,18 +275,18 @@ export class ConfigModel { } export class DynamicConfigBasicInfo { - ruleName: 'org.apache.dubbo.samples.UserService::.configurator' - scope: '服务' - configVersion: 'v3.0' - key: 'org.apache.dubbo.samples.UserService' - effectTime: '20230/12/19 22:09:34' - enabled: true + ruleName: string = 'org.apache.dubbo.samples.UserService::.configurator' + scope: string = '服务' + configVersion: string = 'v3.0' + key: string = 'org.apache.dubbo.samples.UserService' + effectTime: string = '20230/12/19 22:09:34' + enabled: boolean = true } export class ViewDataModel { basicInfo: DynamicConfigBasicInfo = new DynamicConfigBasicInfo() config: ConfigModel[] = [] - errorMsg = [] + errorMsg: string[] = [] isAdd: boolean = false constructor() {} @@ -296,7 +298,7 @@ export class ViewDataModel { } fromApiOutput(data: any) { - this.basicInfo.configVerison = data.configVerison || 'v3.0' + this.basicInfo.configVersion = data.configVersion || 'v3.0' this.basicInfo.scope = data.scope this.basicInfo.key = data.key this.basicInfo.enabled = data.enabled || false @@ -325,8 +327,8 @@ export class ViewDataModel { scope: this.basicInfo.scope, key: this.basicInfo.key, enabled: this.basicInfo.enabled, - configVersion: this.basicInfo.configVerison || 'v3.0', - configs: this.config.map((x: configModel, idx: number) => { + configVersion: this.basicInfo.configVersion || 'v3.0', + configs: this.config.map((x: ConfigModel, idx: number) => { const match: any = {} const parameters: any = {} if (check) { @@ -334,7 +336,6 @@ export class ViewDataModel { this.errorMsg.push( `配置 ${idx + 1}${i18n.global.t('dynamicConfigDomain.configType')} 不能为空` ) - loading.value = false throw new Error('数据检查失败') } if ( diff --git a/ui-vue3/src/views/traffic/dynamicConfig/tabs/YAMLView.vue b/ui-vue3/src/views/traffic/dynamicConfig/tabs/YAMLView.vue index df8ea5257..35d7bcd12 100644 --- a/ui-vue3/src/views/traffic/dynamicConfig/tabs/YAMLView.vue +++ b/ui-vue3/src/views/traffic/dynamicConfig/tabs/YAMLView.vue @@ -21,19 +21,20 @@ - - - - - - - - - - - - - + + + + + {{ + $t('ruleVersionDomain.latestRecordedVersionBadge', { + versionNo: latestRecordedVersionNo + }) + }} + + + {{ $t('flowControlDomain.versionRecords') }} + + @@ -71,11 +72,19 @@ 保存 重置 + + diff --git a/ui-vue3/src/views/traffic/dynamicConfig/tabs/addByFormView.vue b/ui-vue3/src/views/traffic/dynamicConfig/tabs/addByFormView.vue index f01a11f72..71ed5b045 100644 --- a/ui-vue3/src/views/traffic/dynamicConfig/tabs/addByFormView.vue +++ b/ui-vue3/src/views/traffic/dynamicConfig/tabs/addByFormView.vue @@ -372,7 +372,7 @@ const handleChange = (index: number, name: string, keys: string) => { function transApiData(data: any) { if (data) { - formViewData.basicInfo.configVerison = data.configVerison + formViewData.basicInfo.configVersion = data.configVersion formViewData.basicInfo.scope = data.scope formViewData.basicInfo.key = data.key formViewData.basicInfo.enabled = data.enabled diff --git a/ui-vue3/src/views/traffic/dynamicConfig/tabs/formView.vue b/ui-vue3/src/views/traffic/dynamicConfig/tabs/formView.vue index bc4595e0b..4ecd41d3d 100644 --- a/ui-vue3/src/views/traffic/dynamicConfig/tabs/formView.vue +++ b/ui-vue3/src/views/traffic/dynamicConfig/tabs/formView.vue @@ -55,13 +55,35 @@ :disabled="!isEdit" /> + + + + {{ + $t('ruleVersionDomain.latestRecordedVersionBadge', { + versionNo: latestRecordedVersionNo + }) + }} + + + {{ $t('flowControlDomain.versionRecords') }} + + +
- + @@ -376,14 +404,21 @@ }} + +
diff --git a/ui-vue3/src/views/traffic/routingRule/tabs/formView.vue b/ui-vue3/src/views/traffic/routingRule/tabs/formView.vue index fb42ce908..ae87a4b80 100644 --- a/ui-vue3/src/views/traffic/routingRule/tabs/formView.vue +++ b/ui-vue3/src/views/traffic/routingRule/tabs/formView.vue @@ -18,15 +18,22 @@ @@ -208,23 +209,32 @@ import { import { CopyOutlined } from '@ant-design/icons-vue' import useClipboard from 'vue-clipboard3' import { message } from 'ant-design-vue' -import { PRIMARY_COLOR } from '@/base/constants' import { getConditionRuleDetailAPI } from '@/api/service/traffic' import { useRoute } from 'vue-router' import { HTTP_STATUS } from '@/base/http/constants' +import RuleHistoryPanel from '../../_shared/RuleHistoryPanel.vue' + +interface ConditionRuleDetail { + key: string + scope: string + version: string + group: string + force?: boolean + enabled?: boolean + runtime?: boolean + conditions: string[] +} const { appContext: { config: { globalProperties } } -} = getCurrentInstance() +} = getCurrentInstance() as ComponentInternalInstance const route = useRoute() +const ruleName = computed(() => String(route.params?.ruleName || '')) -const isDrawerOpened = ref(false) - -const sliderSpan = ref(8) - -let __ = PRIMARY_COLOR +const isHistoryOpen = ref(false) +const latestRecordedVersionNo = ref(undefined) const toClipboard = useClipboard().toClipboard @@ -234,13 +244,17 @@ function copyIt(v: string) { } // Condition routing details -const conditionRuleDetail = reactive({}) +const conditionRuleDetail = reactive({ + key: '', + scope: '', + version: '', + group: '', + conditions: [] +}) const actionObj = computed(() => { const key = conditionRuleDetail.key || '' const arr = typeof key === 'string' ? key.split(':') : [] - conditionRuleDetail.version = arr[1] || '' - conditionRuleDetail.group = arr[2] || '' return arr[0] || '' }) @@ -252,11 +266,13 @@ const addressSubsetMatch = ref([]) // Get condition routing details async function getRoutingRuleDetail() { - let res = await getConditionRuleDetailAPI(route.params?.ruleName) + let res = await getConditionRuleDetailAPI(ruleName.value) if (res?.code === HTTP_STATUS.SUCCESS) { Object.assign(conditionRuleDetail, res?.data || {}) - conditionRuleDetail.conditions.forEach((item: any, index: number) => { + requestParameterMatch.value = [] + addressSubsetMatch.value = [] + conditionRuleDetail.conditions.forEach((item: any) => { const arr = item.split(' => ') const addressArr = arr[1]?.split(' & ') const requestMatchArr = arr[0]?.split(' & ') @@ -267,11 +283,11 @@ async function getRoutingRuleDetail() { } const getVersionAndGroup = () => { - const conditionName = route.params?.ruleName + const conditionName = ruleName.value if (conditionName && conditionRuleDetail.scope === 'service') { const arr = conditionName?.split(':') - conditionRuleDetail.version = arr[1] - conditionRuleDetail.group = arr[2].split('.')[0] + conditionRuleDetail.version = arr[1] || '' + conditionRuleDetail.group = arr[2]?.split('.')[0] || '' } } diff --git a/ui-vue3/src/views/traffic/routingRule/tabs/updateByFormView.spec.ts b/ui-vue3/src/views/traffic/routingRule/tabs/updateByFormView.spec.ts new file mode 100644 index 000000000..e5830eb96 --- /dev/null +++ b/ui-vue3/src/views/traffic/routingRule/tabs/updateByFormView.spec.ts @@ -0,0 +1,170 @@ +/* + * 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 UpdateByFormViewType from './updateByFormView.vue' + +const mocks = vi.hoisted(() => ({ + updateConditionRuleAPI: vi.fn(), + getConditionRuleDetailAPI: 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, + useRoute: () => ({ params: { ruleName: 'demo-rule' } }) + } +}) + +vi.mock('@/api/service/traffic', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + updateConditionRuleAPI: mocks.updateConditionRuleAPI, + getConditionRuleDetailAPI: mocks.getConditionRuleDetailAPI + } +}) + +vi.mock('ant-design-vue', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + message: { + success: vi.fn(), + warning: vi.fn(), + error: 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 UpdateByFormView: typeof UpdateByFormViewType + +beforeAll(async () => { + i18n = (await import('@/base/i18n')).i18n + UpdateByFormView = (await import('./updateByFormView.vue')).default +}) + +beforeEach(() => { + mocks.updateConditionRuleAPI.mockReset() + mocks.getConditionRuleDetailAPI.mockReset() +}) + +describe('condition route form compatibility', () => { + it('preserves priority force and configVersion during edit round-trip', async () => { + mocks.updateConditionRuleAPI.mockResolvedValue({ code: HTTP_STATUS.SUCCESS }) + mocks.getConditionRuleDetailAPI.mockResolvedValue({ code: HTTP_STATUS.SUCCESS, data: {} }) + const tabState = { + conditionRule: { + configVersion: 'v3.1', + priority: 12, + enabled: true, + force: true, + key: 'demo-service', + scope: 'service', + runtime: false, + conditions: ['host=1.1.1.1 => host=2.2.2.2'] + } + } + + const wrapper = mount(UpdateByFormView, { + 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 + } + } + }) + await flushPromises() + + const submitButton = wrapper.findAll('button')[1] + await submitButton.trigger('click') + await flushPromises() + + expect(mocks.updateConditionRuleAPI).toHaveBeenCalledWith( + 'demo-rule', + expect.objectContaining({ + configVersion: 'v3.1', + priority: 12, + force: true, + runtime: false + }) + ) + }) +}) diff --git a/ui-vue3/src/views/traffic/routingRule/tabs/updateByFormView.vue b/ui-vue3/src/views/traffic/routingRule/tabs/updateByFormView.vue index 42795adef..f455c7de1 100644 --- a/ui-vue3/src/views/traffic/routingRule/tabs/updateByFormView.vue +++ b/ui-vue3/src/views/traffic/routingRule/tabs/updateByFormView.vue @@ -144,19 +144,9 @@