diff --git a/pkg/config/app/admin.go b/pkg/config/app/admin.go index 71cf3f95f..1a1970941 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,8 @@ type AdminConfig struct { Engine *engine.Config `json:"engine" yaml:"engine"` // EventBus configuration EventBus *eventbus.Config `json:"eventBus,omitempty" yaml:"eventBus,omitempty"` + // Versioning configuration for governor-managed traffic rules. + Versioning *versioning.Config `json:"versioning,omitempty" yaml:"versioning,omitempty"` } var _ = &AdminConfig{} @@ -65,10 +68,12 @@ var DefaultAdminConfig = func() AdminConfig { Diagnostics: diagnostics.DefaultDiagnosticsConfig(), Console: console.DefaultConsoleConfig(), EventBus: &eventBusCfg, + Versioning: versioning.Default(), } } -func (c AdminConfig) Sanitize() { +func (c *AdminConfig) Sanitize() { + c.ensureDefaults() c.Engine.Sanitize() for _, d := range c.Discovery { d.Sanitize() @@ -78,9 +83,11 @@ func (c AdminConfig) Sanitize() { c.Observability.Sanitize() c.Diagnostics.Sanitize() c.Log.Sanitize() + c.Versioning.Sanitize() } -func (c AdminConfig) PreProcess() error { +func (c *AdminConfig) PreProcess() error { + c.ensureDefaults() discoveryPreProcess := func() error { for _, d := range c.Discovery { if err := d.PreProcess(); err != nil { @@ -97,10 +104,12 @@ func (c AdminConfig) PreProcess() error { c.Observability.PreProcess(), c.Diagnostics.PreProcess(), c.Log.PreProcess(), + c.Versioning.PreProcess(), ) } -func (c AdminConfig) PostProcess() error { +func (c *AdminConfig) PostProcess() error { + c.ensureDefaults() discoveryPostProcess := func() error { for _, d := range c.Discovery { if err := d.PostProcess(); err != nil { @@ -117,10 +126,12 @@ func (c AdminConfig) PostProcess() error { c.Observability.PostProcess(), c.Diagnostics.PostProcess(), c.Log.PostProcess(), + c.Versioning.PostProcess(), ) } -func (c AdminConfig) Validate() error { +func (c *AdminConfig) Validate() error { + c.ensureDefaults() if c.Log == nil { c.Log = log.DefaultLogConfig() } else if err := c.Log.Validate(); err != nil { @@ -171,9 +182,42 @@ 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.Versioning == nil { + c.Versioning = versioning.Default() + } else if err := c.Versioning.Validate(); err != nil { + return bizerror.Wrap(err, bizerror.ConfigError, "versioning config validation failed") + } return nil } +func (c *AdminConfig) ensureDefaults() { + if c.Log == nil { + c.Log = log.DefaultLogConfig() + } + if c.Store == nil { + c.Store = store.DefaultStoreConfig() + } + if c.Diagnostics == nil { + c.Diagnostics = diagnostics.DefaultDiagnosticsConfig() + } + if c.Console == nil { + c.Console = console.DefaultConsoleConfig() + } + if c.Observability == nil { + c.Observability = observability.DefaultObservabilityConfig() + } + if c.Engine == nil { + c.Engine = engine.DefaultResourceEngineConfig() + } + if c.EventBus == nil { + cfg := eventbus.Default() + c.EventBus = &cfg + } + if c.Versioning == nil { + c.Versioning = versioning.Default() + } +} + // FindDiscovery finds the DiscoveryConfig by id, returns nil if not found func (c AdminConfig) FindDiscovery(id string) *discovery.Config { for _, d := range c.Discovery { diff --git a/pkg/config/app/admin_test.go b/pkg/config/app/admin_test.go new file mode 100644 index 000000000..87a305403 --- /dev/null +++ b/pkg/config/app/admin_test.go @@ -0,0 +1,44 @@ +/* + * 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 app + +import ( + "testing" + + "github.com/apache/dubbo-admin/pkg/config/versioning" + "github.com/stretchr/testify/require" +) + +func TestAdminConfigVersioningDefaultsWhenMissing(t *testing.T) { + cfg := DefaultAdminConfig() + cfg.Versioning = nil + + require.NotPanics(t, func() { + cfg.Sanitize() + }) + require.NotNil(t, cfg.Versioning) + require.Equal(t, versioning.DefaultMaxVersionsPerRule, cfg.Versioning.MaxVersionsPerRule) + + cfg.Versioning = nil + require.NoError(t, cfg.PreProcess()) + require.NotNil(t, cfg.Versioning) + + cfg.Versioning = nil + require.NoError(t, cfg.PostProcess()) + require.NotNil(t, cfg.Versioning) +} diff --git a/pkg/config/versioning/config.go b/pkg/config/versioning/config.go new file mode 100644 index 000000000..6796ba39b --- /dev/null +++ b/pkg/config/versioning/config.go @@ -0,0 +1,63 @@ +/* + * 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 ( + DefaultEnabled = false + DefaultMaxVersionsPerRule = int64(5) +) + +type Config struct { + config.BaseConfig + Enabled bool `json:"enabled" yaml:"enabled"` + 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)) +} + +func Default() *Config { + return &Config{ + Enabled: DefaultEnabled, + MaxVersionsPerRule: DefaultMaxVersionsPerRule, + } +} + +func (c *Config) Sanitize() { + if c.MaxVersionsPerRule <= 0 { + c.MaxVersionsPerRule = DefaultMaxVersionsPerRule + } +} + +func (c *Config) Validate() error { + if c.MaxVersionsPerRule <= 0 { + return bizerror.New(bizerror.ConfigError, "versioning.maxVersionsPerRule must be greater than 0") + } + return nil +} diff --git a/pkg/config/versioning/config_test.go b/pkg/config/versioning/config_test.go new file mode 100644 index 000000000..c1ca0ca7e --- /dev/null +++ b/pkg/config/versioning/config_test.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 ( + "testing" + + "github.com/stretchr/testify/require" + "sigs.k8s.io/yaml" +) + +func TestConfigDefaultsOnYAMLUnmarshal(t *testing.T) { + var cfg Config + require.NoError(t, yaml.Unmarshal([]byte("enabled: false\n"), &cfg)) + + require.False(t, cfg.Enabled) + require.Equal(t, DefaultMaxVersionsPerRule, cfg.MaxVersionsPerRule) +} + +func TestConfigValidate(t *testing.T) { + cfg := Default() + require.NoError(t, cfg.Validate()) + + cfg.MaxVersionsPerRule = 0 + require.ErrorContains(t, cfg.Validate(), "versioning.maxVersionsPerRule") +} + +func TestConfigSanitizeRestoresDefaults(t *testing.T) { + cfg := Default() + cfg.MaxVersionsPerRule = 0 + cfg.Sanitize() + + require.Equal(t, DefaultMaxVersionsPerRule, cfg.MaxVersionsPerRule) +} diff --git a/pkg/console/component_test.go b/pkg/console/component_test.go new file mode 100644 index 000000000..447034f97 --- /dev/null +++ b/pkg/console/component_test.go @@ -0,0 +1,46 @@ +/* + * 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 console + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-contrib/sessions" + "github.com/gin-contrib/sessions/cookie" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestAuthMiddlewareGatesRollbackWithoutSession(t *testing.T) { + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(sessions.Sessions("session", cookie.NewStore([]byte("secret")))) + r.Use((&consoleWebServer{}).authMiddleware()) + r.POST("/api/v1/condition-rule/:ruleName/versions/:versionId/rollback", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"ok": true}) + }) + + recorder := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/condition-rule/demo/versions/1/rollback", nil) + r.ServeHTTP(recorder, req) + + require.Equal(t, http.StatusUnauthorized, recorder.Code) + require.JSONEq(t, `{"code":"Unauthorized","message":"no access, please login","data":null}`, recorder.Body.String()) +} diff --git a/pkg/console/context/context.go b/pkg/console/context/context.go index f4c64ce5b..593b714cb 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..626683dc6 100644 --- a/pkg/console/handler/condition_rule.go +++ b/pkg/console/handler/condition_rule.go @@ -94,8 +94,14 @@ func PutConditionRuleWithRuleName(cs consolectx.Context) gin.HandlerFunc { util.HandleArgumentError(c, err) return } - - if err := service.UpdateConditionRule(cs, res); err != nil { + opts, ok := mutationOptions(c) + if !ok { + return + } + if err := service.UpdateConditionRuleWithOptions(cs, res, opts); err != nil { + if writeVersioningMutationError(c, err) { + return + } util.HandleServiceError(c, err) return } else { @@ -118,8 +124,14 @@ func PostConditionRuleWithRuleName(cs consolectx.Context) gin.HandlerFunc { util.HandleArgumentError(c, err) return } - - if err := service.CreateConditionRule(cs, res); err != nil { + opts, ok := mutationOptions(c) + if !ok { + return + } + if err := service.CreateConditionRuleWithOptions(cs, res, opts); err != nil { + if writeVersioningMutationError(c, err) { + return + } c.JSON(http.StatusOK, model.NewErrorResp(err.Error())) return } else { @@ -137,7 +149,14 @@ 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, ok := mutationOptions(c) + if !ok { + return + } + if err := service.DeleteConditionRuleWithOptions(cs, ruleName, mesh, opts); err != nil { + if writeVersioningMutationError(c, err) { + return + } 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..15318a9d4 100644 --- a/pkg/console/handler/configurator_rule.go +++ b/pkg/console/handler/configurator_rule.go @@ -105,7 +105,14 @@ 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, ok := mutationOptions(c) + if !ok { + return + } + if err = service.UpdateConfiguratorWithOptions(ctx, res, opts); err != nil { + if writeVersioningMutationError(c, err) { + return + } c.JSON(http.StatusOK, model.NewErrorResp(err.Error())) return } @@ -128,7 +135,14 @@ func PostConfiguratorWithRuleName(ctx consolectx.Context) gin.HandlerFunc { util.HandleArgumentError(c, err) return } - if err = service.CreateConfigurator(ctx, res); err != nil { + opts, ok := mutationOptions(c) + if !ok { + return + } + if err = service.CreateConfiguratorWithOptions(ctx, res, opts); err != nil { + if writeVersioningMutationError(c, err) { + return + } c.JSON(http.StatusOK, model.NewErrorResp(err.Error())) return } @@ -146,7 +160,14 @@ 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, ok := mutationOptions(c) + if !ok { + return + } + if err := service.DeleteConfiguratorWithOptions(ctx, ruleName, mesh, opts); err != nil { + if writeVersioningMutationError(c, err) { + return + } 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..b91a54934 --- /dev/null +++ b/pkg/console/handler/rule_version.go @@ -0,0 +1,264 @@ +/* + * 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" + "net/http" + "strconv" + "strings" + + "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" + 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"` + ExpectedVersionID *int64 `json:"expectedVersionId"` +} + +type abandonIntentReq struct { + Reason string `json:"reason"` +} + +const maxRuleVersionReasonLength = 1024 + +func ListRuleVersions(cs consolectx.Context, kind coremodel.ResourceKind) gin.HandlerFunc { + return func(c *gin.Context) { + if !ensureVersioningEnabled(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 !ensureVersioningEnabled(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 !ensureVersioningEnabled(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 !ensureVersioningEnabled(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, req.ExpectedVersionID, currentUser(c)) + writeVersioningResp(c, resp, err) + } +} + +func RepairRuleVersionIntent(cs consolectx.Context) gin.HandlerFunc { + return func(c *gin.Context) { + if !ensureVersioningEnabled(c, cs) { + return + } + id, ok := parseIntentID(c) + if !ok { + return + } + resp, err := service.RepairRuleVersionIntent(cs, id) + writeVersioningResp(c, resp, err) + } +} + +func AbandonRuleVersionIntent(cs consolectx.Context) gin.HandlerFunc { + return func(c *gin.Context) { + if !ensureVersioningEnabled(c, cs) { + return + } + id, ok := parseIntentID(c) + if !ok { + return + } + req := abandonIntentReq{} + if err := c.ShouldBindJSON(&req); err != nil { + writeVersioningInvalidArgument(c, err.Error()) + return + } + if !validateRuleVersionReasonLength(c, req.Reason) { + return + } + err := service.AbandonRuleVersionIntent(cs, id, req.Reason) + writeVersioningResp(c, "", 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 parseExpectedVersionID(c *gin.Context) (*int64, bool) { + raw := strings.TrimSpace(c.Query("expectedVersionId")) + if raw == "" { + return nil, true + } + id, err := strconv.ParseInt(raw, 10, 64) + if err != nil { + writeVersioningInvalidArgument(c, "expectedVersionId must be an integer") + return nil, false + } + return &id, true +} + +func mutationOptions(c *gin.Context) (service.RuleMutationOptions, bool) { + expected, ok := parseExpectedVersionID(c) + if !ok { + return service.RuleMutationOptions{}, false + } + return service.RuleMutationOptions{ExpectedVersionID: expected, Author: currentUser(c)}, true +} + +func parseVersionID(c *gin.Context) (int64, bool) { + id, err := strconv.ParseInt(c.Param("versionId"), 10, 64) + if err != nil { + writeVersioningInvalidArgument(c, "versionId must be an integer") + return 0, false + } + return id, true +} + +func parseIntentID(c *gin.Context) (int64, bool) { + id, err := strconv.ParseInt(c.Param("intentId"), 10, 64) + if err != nil { + writeVersioningInvalidArgument(c, "intentId must be an integer") + return 0, false + } + return id, true +} + +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 ensureVersioningEnabled(c *gin.Context, cs consolectx.Context) bool { + if cs.RuleVersioning() != nil && cs.Config().Versioning != nil && cs.Config().Versioning.Enabled { + return true + } + c.JSON(http.StatusServiceUnavailable, &model.CommonResp{ + Code: "FEATURE_DISABLED", + Message: versioning.ErrFeatureDisabled.Error(), + }) + return false +} + +func writeVersioningResp(c *gin.Context, data any, err error) { + if err == nil { + c.JSON(http.StatusOK, model.NewSuccessResp(data)) + return + } + var conflict *versioning.ConflictError + var pending *versioning.IntentPendingError + var bizErr bizerror.Error + switch { + case errors.As(err, &conflict): + // Conflict and pending responses intentionally use flat fields because + // the frontend interceptor reads currentVersionId/intentId at top level. + c.JSON(http.StatusConflict, gin.H{ + "code": "VERSION_CONFLICT", + "message": versioning.ErrVersionConflict.Error(), + "currentVersionId": conflict.CurrentVersionID, + }) + case errors.As(err, &pending): + c.JSON(http.StatusConflict, gin.H{ + "code": "VERSION_LEDGER_PENDING", + "message": versioning.ErrVersionIntentPending.Error(), + "intentId": pending.IntentID, + }) + case errors.Is(err, versioning.ErrVersionIntentPending): + c.JSON(http.StatusConflict, gin.H{ + "code": "VERSION_LEDGER_PENDING", + "message": versioning.ErrVersionIntentPending.Error(), + }) + case errors.Is(err, versioning.ErrFeatureDisabled): + c.JSON(http.StatusServiceUnavailable, gin.H{"code": "FEATURE_DISABLED", "message": err.Error()}) + case errors.Is(err, versioning.ErrVersionNotFound), errors.Is(err, versioning.ErrVersionIntentNotFound): + c.JSON(http.StatusOK, model.NewBizErrorResp(bizerror.New(bizerror.NotFoundError, err.Error()))) + case errors.Is(err, versioning.ErrRollbackToDelete), errors.Is(err, versioning.ErrRollbackToCurrent), errors.Is(err, versioning.ErrVersionIntentNotOpen): + c.JSON(http.StatusOK, model.NewBizErrorResp(bizerror.New(bizerror.InvalidArgument, err.Error()))) + case errors.As(err, &bizErr) && bizErr.Code() == bizerror.InvalidArgument: + c.JSON(http.StatusBadRequest, model.NewBizErrorResp(bizErr)) + default: + c.JSON(http.StatusOK, model.NewBizErrorResp(bizerror.New(bizerror.UnknownError, err.Error()))) + } +} + +func writeVersioningInvalidArgument(c *gin.Context, message string) { + writeVersioningResp(c, nil, bizerror.New(bizerror.InvalidArgument, message)) +} + +func writeVersioningMutationError(c *gin.Context, err error) bool { + var conflict *versioning.ConflictError + var pending *versioning.IntentPendingError + if errors.As(err, &conflict) || errors.As(err, &pending) || + errors.Is(err, versioning.ErrVersionIntentPending) || errors.Is(err, versioning.ErrFeatureDisabled) { + writeVersioningResp(c, nil, err) + return true + } + return false +} diff --git a/pkg/console/handler/rule_version_test.go b/pkg/console/handler/rule_version_test.go new file mode 100644 index 000000000..ad8f4779c --- /dev/null +++ b/pkg/console/handler/rule_version_test.go @@ -0,0 +1,185 @@ +/* + * 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" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + + "github.com/apache/dubbo-admin/pkg/common/bizerror" + appconfig "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/lock" + "github.com/apache/dubbo-admin/pkg/core/manager" + meshresource "github.com/apache/dubbo-admin/pkg/core/resource/apis/mesh/v1alpha1" + "github.com/apache/dubbo-admin/pkg/core/versioning" +) + +func TestWriteVersioningRespMapsInvalidArgumentToBadRequest(t *testing.T) { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + + writeVersioningResp(c, nil, bizerror.New(bizerror.InvalidArgument, "rollback reason is required")) + + require.Equal(t, http.StatusBadRequest, recorder.Code) + require.JSONEq(t, `{"code":"InvalidArgument","message":"rollback reason is required","data":null}`, recorder.Body.String()) +} + +func TestWriteVersioningRespIncludesPendingIntentID(t *testing.T) { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + + writeVersioningResp(c, nil, &versioning.IntentPendingError{IntentID: 42}) + + require.Equal(t, http.StatusConflict, recorder.Code) + require.JSONEq(t, `{"code":"VERSION_LEDGER_PENDING","message":"rule version intent is pending","intentId":42}`, recorder.Body.String()) +} + +func TestValidateRuleVersionReasonLengthRejectsTooLongReason(t *testing.T) { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + + ok := validateRuleVersionReasonLength(c, strings.Repeat("x", maxRuleVersionReasonLength+1)) + + require.False(t, ok) + require.Equal(t, http.StatusBadRequest, recorder.Code) + require.JSONEq(t, `{"code":"InvalidArgument","message":"reason must be at most 1024 characters","data":null}`, recorder.Body.String()) +} + +func TestRuleVersionMutationHandlersReturnBadRequestForMalformedJSON(t *testing.T) { + tests := []struct { + name string + handler gin.HandlerFunc + params gin.Params + }{ + { + name: "rollback", + handler: RollbackRuleVersion(ruleVersionHandlerTestContext{}, meshresource.ConditionRouteKind), + params: gin.Params{ + {Key: "ruleName", Value: "demo.condition-router"}, + {Key: "versionId", Value: "1"}, + }, + }, + { + name: "abandon intent", + handler: AbandonRuleVersionIntent(ruleVersionHandlerTestContext{}), + params: gin.Params{{Key: "intentId", Value: "1"}}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/", strings.NewReader("{")) + c.Params = tt.params + + tt.handler(c) + + require.Equal(t, http.StatusBadRequest, recorder.Code) + require.Contains(t, recorder.Body.String(), `"code":"InvalidArgument"`) + }) + } +} + +func TestRuleVersionInvalidIDsReturnBadRequest(t *testing.T) { + tests := []struct { + name string + run func(c *gin.Context) bool + }{ + { + name: "version id", + run: func(c *gin.Context) bool { + c.Params = gin.Params{{Key: "versionId", Value: "bad"}} + _, ok := parseVersionID(c) + return ok + }, + }, + { + name: "intent id", + run: func(c *gin.Context) bool { + c.Params = gin.Params{{Key: "intentId", Value: "bad"}} + _, ok := parseIntentID(c) + return ok + }, + }, + { + name: "expected version id", + run: func(c *gin.Context) bool { + c.Request = httptest.NewRequest(http.MethodPost, "/?expectedVersionId=bad", nil) + _, ok := parseExpectedVersionID(c) + return ok + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/", nil) + + require.False(t, tt.run(c)) + require.Equal(t, http.StatusBadRequest, recorder.Code) + require.Contains(t, recorder.Body.String(), `"code":"InvalidArgument"`) + }) + } +} + +type ruleVersionHandlerTestContext struct{} + +func (ruleVersionHandlerTestContext) ResourceManager() manager.ResourceManager { + return nil +} + +func (ruleVersionHandlerTestContext) CounterManager() counter.CounterManager { + return nil +} + +func (ruleVersionHandlerTestContext) Config() appconfig.AdminConfig { + return appconfig.AdminConfig{ + Versioning: &versioningcfg.Config{ + Enabled: true, + MaxVersionsPerRule: 5, + }, + } +} + +func (ruleVersionHandlerTestContext) AppContext() context.Context { + return context.Background() +} + +func (ruleVersionHandlerTestContext) LockManager() lock.Lock { + return nil +} + +func (ruleVersionHandlerTestContext) RuleVersioning() versioning.Service { + return versioning.NewService(true, 5, versioning.NewMemoryStore()) +} diff --git a/pkg/console/handler/tag_rule.go b/pkg/console/handler/tag_rule.go index a6fe3637c..563b1baa2 100644 --- a/pkg/console/handler/tag_rule.go +++ b/pkg/console/handler/tag_rule.go @@ -103,7 +103,14 @@ 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, ok := mutationOptions(c) + if !ok { + return + } + if err = service.UpdateTagRuleWithOptions(ctx, res, opts); err != nil { + if writeVersioningMutationError(c, err) { + return + } c.JSON(http.StatusOK, model.NewErrorResp(err.Error())) return } else { @@ -127,7 +134,14 @@ 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, ok := mutationOptions(c) + if !ok { + return + } + if err = service.CreateTagRuleWithOptions(ctx, res, opts); err != nil { + if writeVersioningMutationError(c, err) { + return + } c.JSON(http.StatusOK, model.NewErrorResp(err.Error())) return } else { @@ -145,7 +159,14 @@ 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, ok := mutationOptions(c) + if !ok { + return + } + if err := service.DeleteTagRuleWithOptions(ctx, ruleName, mesh, opts); err != nil { + if writeVersioningMutationError(c, err) { + return + } 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..d80b3e357 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,12 +139,22 @@ 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)) tagRule.DELETE("/:ruleName", handler.DeleteTagRuleWithRuleName(ctx)) } + { + ruleVersionIntent := router.Group("/rule-version-intents") + ruleVersionIntent.POST("/:intentId/repair", handler.RepairRuleVersionIntent(ctx)) + ruleVersionIntent.POST("/:intentId/abandon", handler.AbandonRuleVersionIntent(ctx)) + } + router.GET("/prometheus", handler.GetPrometheus(ctx)) router.GET("/search", handler.BannerGlobalSearch(ctx)) router.GET("/overview", handler.ClusterOverview(ctx)) diff --git a/pkg/console/service/condition_rule.go b/pkg/console/service/condition_rule.go index 9fae15940..f7d1b3e61 100644 --- a/pkg/console/service/condition_rule.go +++ b/pkg/console/service/condition_rule.go @@ -31,6 +31,7 @@ import ( meshresource "github.com/apache/dubbo-admin/pkg/core/resource/apis/mesh/v1alpha1" coremodel "github.com/apache/dubbo-admin/pkg/core/resource/model" "github.com/apache/dubbo-admin/pkg/core/store/index" + "github.com/apache/dubbo-admin/pkg/core/versioning" ) func SearchConditionRules(ctx context.Context, req *model.SearchConditionRuleReq) (*model.SearchPaginationResult, error) { @@ -108,57 +109,94 @@ func GetConditionRule(ctx context.Context, name string, mesh string) (*meshresou } func UpdateConditionRule(ctx context.Context, res *meshresource.ConditionRouteResource) error { + return UpdateConditionRuleWithOptions(ctx, res, RuleMutationOptions{}) +} + +func UpdateConditionRuleWithOptions(ctx context.Context, res *meshresource.ConditionRouteResource, opts RuleMutationOptions) error { lockMgr := ctx.LockManager() if lockMgr == nil { - return updateConditionRuleUnsafe(ctx, res) + return updateConditionRuleUnsafe(ctx, res, opts) } lockKey := lock.BuildConditionRuleLockKey(res.Mesh, res.Name) return lockMgr.WithLock(ctx.AppContext(), lockKey, constants.DefaultLockTimeout, func() error { - return updateConditionRuleUnsafe(ctx, res) + return updateConditionRuleUnsafe(ctx, res, opts) }) } -func updateConditionRuleUnsafe(ctx context.Context, res *meshresource.ConditionRouteResource) error { - if err := ctx.ResourceManager().Update(res); err != nil { - logger.Warnf("update %s condition failed with error: %s", res.Name, err.Error()) +func updateConditionRuleUnsafe(ctx context.Context, res *meshresource.ConditionRouteResource, opts RuleMutationOptions) error { + kindName := RuleKindName{Kind: meshresource.ConditionRouteKind, Mesh: res.Mesh, Name: res.Name} + if err := prepareRuleMutation(ctx, kindName, opts); err != nil { return err } - return nil + return applyAdminMutation(ctx, res, versioning.OperationUpdate, opts, func() error { + if err := ctx.ResourceManager().Update(res); err != nil { + logger.Warnf("update %s condition failed with error: %s", res.Name, err.Error()) + return err + } + return nil + }) } func CreateConditionRule(ctx context.Context, res *meshresource.ConditionRouteResource) error { + return CreateConditionRuleWithOptions(ctx, res, RuleMutationOptions{}) +} + +func CreateConditionRuleWithOptions(ctx context.Context, res *meshresource.ConditionRouteResource, opts RuleMutationOptions) error { lockMgr := ctx.LockManager() if lockMgr == nil { - return createConditionRuleUnsafe(ctx, res) + return createConditionRuleUnsafe(ctx, res, opts) } lockKey := lock.BuildConditionRuleLockKey(res.Mesh, res.Name) return lockMgr.WithLock(ctx.AppContext(), lockKey, constants.DefaultLockTimeout, func() error { - return createConditionRuleUnsafe(ctx, res) + return createConditionRuleUnsafe(ctx, res, opts) }) } -func createConditionRuleUnsafe(ctx context.Context, res *meshresource.ConditionRouteResource) error { - if err := ctx.ResourceManager().Add(res); err != nil { - logger.Warnf("create %s condition failed with error: %s", res.Name, err.Error()) +func createConditionRuleUnsafe(ctx context.Context, res *meshresource.ConditionRouteResource, opts RuleMutationOptions) error { + kindName := RuleKindName{Kind: meshresource.ConditionRouteKind, Mesh: res.Mesh, Name: res.Name} + if err := prepareRuleMutation(ctx, kindName, opts); err != nil { return err } - return nil + return applyAdminMutation(ctx, res, versioning.OperationCreate, opts, func() error { + if err := ctx.ResourceManager().Add(res); err != nil { + logger.Warnf("create %s condition failed with error: %s", res.Name, err.Error()) + return err + } + return nil + }) } func DeleteConditionRule(ctx context.Context, name string, mesh string) error { + return DeleteConditionRuleWithOptions(ctx, name, mesh, RuleMutationOptions{}) +} + +func DeleteConditionRuleWithOptions(ctx context.Context, name string, mesh string, opts RuleMutationOptions) error { lockMgr := ctx.LockManager() if lockMgr == nil { - return deleteConditionRuleUnsafe(ctx, name, mesh) + return deleteConditionRuleUnsafe(ctx, name, mesh, opts) } lockKey := lock.BuildConditionRuleLockKey(mesh, name) return lockMgr.WithLock(ctx.AppContext(), lockKey, constants.DefaultLockTimeout, func() error { - return deleteConditionRuleUnsafe(ctx, name, mesh) + return deleteConditionRuleUnsafe(ctx, name, mesh, opts) }) } -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 deleteConditionRuleUnsafe(ctx context.Context, name string, mesh string, opts RuleMutationOptions) error { + kindName := RuleKindName{Kind: meshresource.ConditionRouteKind, Mesh: mesh, Name: name} + if err := repairPendingIntent(ctx, kindName); err != nil { + return err + } + res, err := getExistingRule(ctx, kindName) + if err != nil { return err } - return nil + if err := checkExpectedVersion(ctx, kindName, opts); err != nil { + return err + } + return applyAdminMutation(ctx, res, versioning.OperationDelete, opts, func() error { + if err := ctx.ResourceManager().DeleteByKey(meshresource.ConditionRouteKind, mesh, coremodel.BuildResourceKey(mesh, name)); err != nil { + return err + } + return nil + }) } diff --git a/pkg/console/service/configurator_rule.go b/pkg/console/service/configurator_rule.go index 13dd2284d..5fa992298 100644 --- a/pkg/console/service/configurator_rule.go +++ b/pkg/console/service/configurator_rule.go @@ -30,6 +30,7 @@ import ( meshresource "github.com/apache/dubbo-admin/pkg/core/resource/apis/mesh/v1alpha1" coremodel "github.com/apache/dubbo-admin/pkg/core/resource/model" "github.com/apache/dubbo-admin/pkg/core/store/index" + "github.com/apache/dubbo-admin/pkg/core/versioning" ) func PageListConfiguratorRule(ctx consolectx.Context, req *model.SearchReq) (*model.SearchPaginationResult, error) { @@ -116,58 +117,95 @@ func GetConfigurator(ctx consolectx.Context, name string, mesh string) (*meshres } func UpdateConfigurator(ctx consolectx.Context, res *meshresource.DynamicConfigResource) error { + return UpdateConfiguratorWithOptions(ctx, res, RuleMutationOptions{}) +} + +func UpdateConfiguratorWithOptions(ctx consolectx.Context, res *meshresource.DynamicConfigResource, opts RuleMutationOptions) error { lockMgr := ctx.LockManager() if lockMgr == nil { - return updateConfiguratorUnsafe(ctx, res) + return updateConfiguratorUnsafe(ctx, res, opts) } lockKey := lock.BuildConfiguratorRuleLockKey(res.Mesh, res.Name) return lockMgr.WithLock(ctx.AppContext(), lockKey, constants.DefaultLockTimeout, func() error { - return updateConfiguratorUnsafe(ctx, res) + return updateConfiguratorUnsafe(ctx, res, opts) }) } -func updateConfiguratorUnsafe(ctx consolectx.Context, res *meshresource.DynamicConfigResource) error { - if err := ctx.ResourceManager().Update(res); err != nil { - logger.Warnf("update %s configurator failed with error: %s", res.Name, err.Error()) +func updateConfiguratorUnsafe(ctx consolectx.Context, res *meshresource.DynamicConfigResource, opts RuleMutationOptions) error { + kindName := RuleKindName{Kind: meshresource.DynamicConfigKind, Mesh: res.Mesh, Name: res.Name} + if err := prepareRuleMutation(ctx, kindName, opts); err != nil { return err } - return nil + return applyAdminMutation(ctx, res, versioning.OperationUpdate, opts, func() error { + if err := ctx.ResourceManager().Update(res); err != nil { + logger.Warnf("update %s configurator failed with error: %s", res.Name, err.Error()) + return err + } + return nil + }) } func CreateConfigurator(ctx consolectx.Context, res *meshresource.DynamicConfigResource) error { + return CreateConfiguratorWithOptions(ctx, res, RuleMutationOptions{}) +} + +func CreateConfiguratorWithOptions(ctx consolectx.Context, res *meshresource.DynamicConfigResource, opts RuleMutationOptions) error { lockMgr := ctx.LockManager() if lockMgr == nil { - return createConfiguratorUnsafe(ctx, res) + return createConfiguratorUnsafe(ctx, res, opts) } lockKey := lock.BuildConfiguratorRuleLockKey(res.Mesh, res.Name) return lockMgr.WithLock(ctx.AppContext(), lockKey, constants.DefaultLockTimeout, func() error { - return createConfiguratorUnsafe(ctx, res) + return createConfiguratorUnsafe(ctx, res, opts) }) } -func createConfiguratorUnsafe(ctx consolectx.Context, res *meshresource.DynamicConfigResource) error { - if err := ctx.ResourceManager().Add(res); err != nil { - logger.Warnf("create %s configurator failed with error: %s", res.Name, err.Error()) +func createConfiguratorUnsafe(ctx consolectx.Context, res *meshresource.DynamicConfigResource, opts RuleMutationOptions) error { + kindName := RuleKindName{Kind: meshresource.DynamicConfigKind, Mesh: res.Mesh, Name: res.Name} + if err := prepareRuleMutation(ctx, kindName, opts); err != nil { return err } - return nil + return applyAdminMutation(ctx, res, versioning.OperationCreate, opts, func() error { + if err := ctx.ResourceManager().Add(res); err != nil { + logger.Warnf("create %s configurator failed with error: %s", res.Name, err.Error()) + return err + } + return nil + }) } func DeleteConfigurator(ctx consolectx.Context, name string, mesh string) error { + return DeleteConfiguratorWithOptions(ctx, name, mesh, RuleMutationOptions{}) +} + +func DeleteConfiguratorWithOptions(ctx consolectx.Context, name string, mesh string, opts RuleMutationOptions) error { lockMgr := ctx.LockManager() if lockMgr == nil { - return deleteConfiguratorUnsafe(ctx, name, mesh) + return deleteConfiguratorUnsafe(ctx, name, mesh, opts) } lockKey := lock.BuildConfiguratorRuleLockKey(mesh, name) return lockMgr.WithLock(ctx.AppContext(), lockKey, constants.DefaultLockTimeout, func() error { - return deleteConfiguratorUnsafe(ctx, name, mesh) + return deleteConfiguratorUnsafe(ctx, name, mesh, opts) }) } -func deleteConfiguratorUnsafe(ctx consolectx.Context, name string, mesh string) error { - if err := ctx.ResourceManager().DeleteByKey(meshresource.DynamicConfigKind, mesh, coremodel.BuildResourceKey(mesh, name)); err != nil { - logger.Warnf("delete %s configurator failed with error: %s", name, err.Error()) +func deleteConfiguratorUnsafe(ctx consolectx.Context, name string, mesh string, opts RuleMutationOptions) error { + kindName := RuleKindName{Kind: meshresource.DynamicConfigKind, Mesh: mesh, Name: name} + if err := repairPendingIntent(ctx, kindName); err != nil { + return err + } + res, err := getExistingRule(ctx, kindName) + if err != nil { return err } - return nil + if err := checkExpectedVersion(ctx, kindName, opts); err != nil { + return err + } + return applyAdminMutation(ctx, res, versioning.OperationDelete, opts, func() error { + if err := ctx.ResourceManager().DeleteByKey(meshresource.DynamicConfigKind, mesh, coremodel.BuildResourceKey(mesh, name)); err != nil { + logger.Warnf("delete %s configurator failed with error: %s", name, err.Error()) + return err + } + return nil + }) } diff --git a/pkg/console/service/rule_version.go b/pkg/console/service/rule_version.go new file mode 100644 index 000000000..6d16250de --- /dev/null +++ b/pkg/console/service/rule_version.go @@ -0,0 +1,291 @@ +/* + * 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 { + ExpectedVersionID *int64 + Author string +} + +func ruleVersioning(ctx consolectx.Context) versioning.Service { + if ctx == nil { + return nil + } + cfg := ctx.Config().Versioning + if cfg == nil || !cfg.Enabled { + return nil + } + return ctx.RuleVersioning() +} + +func checkExpectedVersion(ctx consolectx.Context, kindName RuleKindName, opts RuleMutationOptions) error { + svc := ruleVersioning(ctx) + if svc == nil { + return nil + } + return svc.CheckExpected(kindName.Kind, kindName.Mesh, kindName.Name, opts.ExpectedVersionID) +} + +func prepareRuleMutation(ctx consolectx.Context, kindName RuleKindName, opts RuleMutationOptions) error { + if err := repairPendingIntent(ctx, kindName); err != nil { + return err + } + return checkExpectedVersion(ctx, kindName, opts) +} + +func repairPendingIntent(ctx consolectx.Context, kindName RuleKindName) error { + svc := ruleVersioning(ctx) + if svc == nil { + return nil + } + resourceKey := coremodel.BuildResourceKey(kindName.Mesh, kindName.Name) + current, exists, err := ctx.ResourceManager().GetByKey(kindName.Kind, resourceKey) + if err != nil { + return err + } + _, err = svc.RepairIntent(kindName.Kind, resourceKey, current, !exists) + return err +} + +type RuleKindName struct { + Kind coremodel.ResourceKind + Mesh string + Name string +} + +func getExistingRule(ctx consolectx.Context, kindName RuleKindName) (coremodel.Resource, error) { + key := coremodel.BuildResourceKey(kindName.Mesh, kindName.Name) + res, exists, err := ctx.ResourceManager().GetByKey(kindName.Kind, key) + if err != nil { + return nil, err + } + if !exists { + return nil, fmt.Errorf("%s %s does not exist", kindName.Kind, key) + } + return res, nil +} + +func applyAdminMutation(ctx consolectx.Context, res coremodel.Resource, op versioning.Operation, opts RuleMutationOptions, mutate func() error) error { + _, err := applyRuleMutationIntent(ctx, res, op, versioning.SourceAdmin, opts.Author, "", opts.ExpectedVersionID, nil, mutate) + return err +} + +func applyRuleMutationIntent(ctx consolectx.Context, res coremodel.Resource, op versioning.Operation, source versioning.Source, author, reason string, expected *int64, rolledBackFromID *int64, mutate func() error) (*versioning.Version, error) { + svc := ruleVersioning(ctx) + if svc == nil { + return nil, mutate() + } + intent, err := svc.BeginMutationIntent(res, op, source, author, reason, expected, rolledBackFromID) + if err != nil { + return nil, err + } + if intent == nil { + return nil, mutate() + } + if err := mutate(); err != nil { + if markErr := svc.FailMutationIntent(intent.ID, err.Error()); markErr != nil { + return nil, fmt.Errorf("%w; failed to mark version intent failed: %v", err, markErr) + } + return nil, err + } + if err := svc.MarkMutationIntentApplied(intent.ID); err != nil { + return nil, err + } + return svc.CommitMutationIntent(intent.ID) +} + +func ListRuleVersions(ctx consolectx.Context, kindName RuleKindName) (*versioning.ListResult, error) { + return ctx.RuleVersioning().List(kindName.Kind, kindName.Mesh, kindName.Name) +} + +func GetRuleVersion(ctx consolectx.Context, kindName RuleKindName, versionID int64) (*versioning.Version, error) { + return ctx.RuleVersioning().Get(kindName.Kind, kindName.Mesh, kindName.Name, versionID) +} + +func DiffRuleVersion(ctx consolectx.Context, kindName RuleKindName, versionID int64, against string) (*versioning.DiffResult, error) { + return ctx.RuleVersioning().Diff(kindName.Kind, kindName.Mesh, kindName.Name, versionID, against) +} + +func RepairRuleVersionIntent(ctx consolectx.Context, intentID int64) (*versioning.Version, error) { + svc := ruleVersioning(ctx) + if svc == nil { + return nil, versioning.ErrFeatureDisabled + } + intent, err := svc.Store().GetIntent(intentID) + if err != nil { + return nil, err + } + kindName := ruleKindNameFromIntent(intent) + var repaired *versioning.Version + err = withRuleLock(ctx, kindName, func() error { + current, deleted, err := currentResourceForIntent(ctx, intentID) + if err != nil { + return err + } + repaired, err = svc.RepairIntentByID(intentID, current, deleted) + return err + }) + return repaired, err +} + +func AbandonRuleVersionIntent(ctx consolectx.Context, intentID int64, reason string) error { + svc := ruleVersioning(ctx) + if svc == nil { + return versioning.ErrFeatureDisabled + } + reason = strings.TrimSpace(reason) + if reason == "" { + return bizerror.New(bizerror.InvalidArgument, "abandon reason is required") + } + intent, err := svc.Store().GetIntent(intentID) + if err != nil { + return err + } + return withRuleLock(ctx, ruleKindNameFromIntent(intent), func() error { + intent, err := svc.Store().GetIntent(intentID) + if err != nil { + return err + } + if intent.Status != versioning.IntentStatusPending { + return bizerror.New(bizerror.InvalidArgument, "only pending rule version intent can be abandoned") + } + current, exists, err := ctx.ResourceManager().GetByKey(intent.RuleKind, intent.ResourceKey) + if err != nil { + return err + } + if versioning.IntentMatchesResource(intent, current, !exists) { + return bizerror.New(bizerror.InvalidArgument, "rule version intent matches the current resource; repair it instead") + } + return svc.Store().MarkIntentFailedWithReason(intent.ID, reason) + }) +} + +func RollbackRuleVersion(ctx consolectx.Context, kindName RuleKindName, versionID int64, reason string, expected *int64, author string) (*versioning.Version, error) { + var rollback *versioning.Version + err := withRuleLock(ctx, kindName, func() error { + var err error + rollback, err = rollbackRuleVersionUnsafe(ctx, kindName, versionID, reason, expected, author) + return err + }) + return rollback, err +} + +func rollbackRuleVersionUnsafe(ctx consolectx.Context, kindName RuleKindName, versionID int64, reason string, expected *int64, author string) (*versioning.Version, error) { + svc := ruleVersioning(ctx) + if svc == nil { + return nil, versioning.ErrFeatureDisabled + } + reason = strings.TrimSpace(reason) + if reason == "" { + return nil, bizerror.New(bizerror.InvalidArgument, "rollback reason is required") + } + if err := prepareRuleMutation(ctx, kindName, RuleMutationOptions{ExpectedVersionID: expected, Author: author}); err != nil { + return nil, err + } + target, err := svc.Get(kindName.Kind, kindName.Mesh, kindName.Name, versionID) + if err != nil { + return nil, err + } + if target.Operation == versioning.OperationDelete { + return nil, versioning.ErrRollbackToDelete + } + if target.IsCurrent { + return nil, versioning.ErrRollbackToCurrent + } + resourceKey := coremodel.BuildResourceKey(kindName.Mesh, kindName.Name) + meta, err := svc.Store().CurrentMeta(kindName.Kind, resourceKey) + if err != nil { + return nil, err + } + if meta != nil && meta.CurrentVersion != nil { + current, err := svc.Store().GetVersion(kindName.Kind, resourceKey, *meta.CurrentVersion) + if err != nil { + return nil, err + } + if current.ID == target.ID || current.ContentHash == target.ContentHash { + return nil, versioning.ErrRollbackToCurrent + } + } + res, err := versioning.ResourceFromSpecJSON(kindName.Kind, kindName.Mesh, kindName.Name, target.SpecJSON) + if err != nil { + return nil, err + } + fromID := target.ID + return applyRuleMutationIntent(ctx, res, versioning.OperationUpdate, versioning.SourceRollback, author, reason, expected, &fromID, func() error { + return ctx.ResourceManager().Upsert(res) + }) +} + +func ruleKindNameFromIntent(intent *versioning.Intent) RuleKindName { + if intent == nil { + return RuleKindName{} + } + return RuleKindName{ + Kind: intent.RuleKind, + Mesh: intent.Mesh, + Name: intent.RuleName, + } +} + +func currentResourceForIntent(ctx consolectx.Context, intentID int64) (coremodel.Resource, bool, error) { + intent, err := ctx.RuleVersioning().Store().GetIntent(intentID) + if err != nil { + return nil, false, err + } + current, exists, err := ctx.ResourceManager().GetByKey(intent.RuleKind, intent.ResourceKey) + // Repair APIs pass deleted=true when the resource manager no longer has the rule. + return current, !exists, err +} + +func withRuleLock(ctx consolectx.Context, kindName RuleKindName, fn func() error) error { + lockMgr := ctx.LockManager() + if lockMgr == nil { + return fn() + } + lockKey, err := ruleLockKey(kindName) + if err != nil { + return err + } + return lockMgr.WithLock(ctx.AppContext(), lockKey, constants.DefaultLockTimeout, fn) +} + +func ruleLockKey(kindName RuleKindName) (string, error) { + switch kindName.Kind { + case meshresource.ConditionRouteKind: + return lock.BuildConditionRuleLockKey(kindName.Mesh, kindName.Name), nil + case meshresource.TagRouteKind: + return lock.BuildTagRouteLockKey(kindName.Mesh, kindName.Name), nil + case meshresource.DynamicConfigKind: + return lock.BuildConfiguratorRuleLockKey(kindName.Mesh, kindName.Name), nil + default: + return "", bizerror.New(bizerror.InvalidArgument, "unsupported rule kind") + } +} diff --git a/pkg/console/service/rule_version_test.go b/pkg/console/service/rule_version_test.go new file mode 100644 index 000000000..d954eefe8 --- /dev/null +++ b/pkg/console/service/rule_version_test.go @@ -0,0 +1,626 @@ +/* + * 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/require" + + meshproto "github.com/apache/dubbo-admin/api/mesh/v1alpha1" + "github.com/apache/dubbo-admin/pkg/common/bizerror" + appconfig "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" + corelock "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/index" + "github.com/apache/dubbo-admin/pkg/core/versioning" +) + +func TestAdminMutationRecordsSynchronouslyAndConflictsOnStaleExpected(t *testing.T) { + ctx, store := newRuleVersionTestContext() + initial := newTestConditionRule(1) + ctx.rm.Put(initial) + current := insertTestVersion(t, store, initial, versioning.OperationCreate, versioning.SourceBootstrap, "system:bootstrap", "", nil) + + expected := current.ID + firstUpdate := newTestConditionRule(2) + err := UpdateConditionRuleWithOptions(ctx, firstUpdate, RuleMutationOptions{ + ExpectedVersionID: &expected, + Author: "alice", + }) + require.NoError(t, err) + + secondUpdate := newTestConditionRule(3) + err = UpdateConditionRuleWithOptions(ctx, secondUpdate, RuleMutationOptions{ + ExpectedVersionID: &expected, + Author: "bob", + }) + var conflict *versioning.ConflictError + require.ErrorAs(t, err, &conflict) + + items, err := store.ListVersions(meshresource.ConditionRouteKind, initial.ResourceKey()) + require.NoError(t, err) + require.Len(t, items, 2) + require.Equal(t, versioning.SourceAdmin, items[0].Source) + require.Equal(t, "alice", items[0].Author) +} + +func TestConcurrentAdminWritesWithStaleExpectedHitOpenIntent(t *testing.T) { + ctx, store := newRuleVersionTestContext() + ctx.lock = nil + currentRes := newTestConditionRule(1) + ctx.rm.Put(currentRes) + current := insertTestVersion(t, store, currentRes, versioning.OperationCreate, versioning.SourceAdmin, "alice", "", nil) + + started := make(chan struct{}) + release := make(chan struct{}) + ctx.rm.BlockNextUpdate(started, release) + firstErr := make(chan error, 1) + go func() { + firstErr <- UpdateConditionRuleWithOptions(ctx, newTestConditionRule(2), RuleMutationOptions{ + ExpectedVersionID: ¤t.ID, + Author: "alice", + }) + }() + + require.Eventually(t, func() bool { + select { + case <-started: + return true + default: + return false + } + }, time.Second, 10*time.Millisecond) + + err := UpdateConditionRuleWithOptions(ctx, newTestConditionRule(3), RuleMutationOptions{ + ExpectedVersionID: ¤t.ID, + Author: "bob", + }) + require.ErrorIs(t, err, versioning.ErrVersionIntentPending) + + close(release) + require.NoError(t, <-firstErr) + items, err := store.ListVersions(meshresource.ConditionRouteKind, currentRes.ResourceKey()) + require.NoError(t, err) + require.Len(t, items, 2) + require.Equal(t, "alice", items[0].Author) +} + +func TestRollbackAndUpdateShareRuleLockAndStaleExpectedConflicts(t *testing.T) { + ctx, store := newRuleVersionTestContext() + original := newTestConditionRule(1) + currentRes := newTestConditionRule(2) + ctx.rm.Put(currentRes) + target := insertTestVersion(t, store, original, versioning.OperationCreate, versioning.SourceBootstrap, "system:bootstrap", "", nil) + current := insertTestVersion(t, store, currentRes, versioning.OperationUpdate, versioning.SourceAdmin, "alice", "", nil) + + expected := current.ID + start := make(chan struct{}) + errs := make(chan error, 2) + go func() { + <-start + _, err := RollbackRuleVersion(ctx, conditionKindName(), target.ID, "restore baseline", &expected, "bob") + errs <- err + }() + go func() { + <-start + errs <- UpdateConditionRuleWithOptions(ctx, newTestConditionRule(3), RuleMutationOptions{ + ExpectedVersionID: &expected, + Author: "carol", + }) + }() + close(start) + + var successCount, conflictCount int + for i := 0; i < 2; i++ { + err := <-errs + if err == nil { + successCount++ + continue + } + var conflict *versioning.ConflictError + if errors.As(err, &conflict) { + conflictCount++ + continue + } + require.NoError(t, err) + } + require.Equal(t, 1, successCount) + require.Equal(t, 1, conflictCount) + + items, err := store.ListVersions(meshresource.ConditionRouteKind, original.ResourceKey()) + require.NoError(t, err) + require.Len(t, items, 3) + require.True(t, items[0].Source == versioning.SourceAdmin || items[0].Source == versioning.SourceRollback) +} + +func TestRollbackCurrentVersionReturnsImmediately(t *testing.T) { + ctx, store := newRuleVersionTestContext() + currentRes := newTestConditionRule(1) + ctx.rm.Put(currentRes) + current := insertTestVersion(t, store, currentRes, versioning.OperationCreate, versioning.SourceAdmin, "alice", "", nil) + + _, err := RollbackRuleVersion(ctx, conditionKindName(), current.ID, "restore current", ¤t.ID, "bob") + require.ErrorIs(t, err, versioning.ErrRollbackToCurrent) + + items, err := store.ListVersions(meshresource.ConditionRouteKind, currentRes.ResourceKey()) + require.NoError(t, err) + require.Len(t, items, 1) +} + +func TestPendingIntentRepairPreventsStaleExpectedReuse(t *testing.T) { + ctx, store := newRuleVersionTestContext() + currentRes := newTestConditionRule(1) + ctx.rm.Put(currentRes) + current := insertTestVersion(t, store, currentRes, versioning.OperationCreate, versioning.SourceAdmin, "alice", "", nil) + + appliedRes := newTestConditionRule(2) + _, err := ctx.versioning.BeginMutationIntent(appliedRes, versioning.OperationUpdate, versioning.SourceAdmin, "bob", "", ¤t.ID, nil) + require.NoError(t, err) + ctx.rm.Put(appliedRes) + + err = UpdateConditionRuleWithOptions(ctx, newTestConditionRule(3), RuleMutationOptions{ + ExpectedVersionID: ¤t.ID, + Author: "carol", + }) + var conflict *versioning.ConflictError + require.ErrorAs(t, err, &conflict) + + items, err := store.ListVersions(meshresource.ConditionRouteKind, currentRes.ResourceKey()) + require.NoError(t, err) + require.Len(t, items, 2) + require.Equal(t, "bob", items[0].Author) + require.Equal(t, versioning.SourceAdmin, items[0].Source) + intent, err := store.OpenIntent(meshresource.ConditionRouteKind, currentRes.ResourceKey()) + require.NoError(t, err) + require.Nil(t, intent) +} + +func TestPendingIntentWithoutAppliedResourceBlocksMutation(t *testing.T) { + ctx, store := newRuleVersionTestContext() + currentRes := newTestConditionRule(1) + ctx.rm.Put(currentRes) + current := insertTestVersion(t, store, currentRes, versioning.OperationCreate, versioning.SourceAdmin, "alice", "", nil) + + pendingRes := newTestConditionRule(2) + _, err := ctx.versioning.BeginMutationIntent(pendingRes, versioning.OperationUpdate, versioning.SourceAdmin, "bob", "", ¤t.ID, nil) + require.NoError(t, err) + + err = UpdateConditionRuleWithOptions(ctx, newTestConditionRule(3), RuleMutationOptions{ + ExpectedVersionID: ¤t.ID, + Author: "carol", + }) + require.ErrorIs(t, err, versioning.ErrVersionIntentPending) + + items, err := store.ListVersions(meshresource.ConditionRouteKind, currentRes.ResourceKey()) + require.NoError(t, err) + require.Len(t, items, 1) +} + +func TestRepairRuleVersionIntentCommitsMatchingPendingIntent(t *testing.T) { + ctx, store := newRuleVersionTestContext() + currentRes := newTestConditionRule(1) + ctx.rm.Put(currentRes) + current := insertTestVersion(t, store, currentRes, versioning.OperationCreate, versioning.SourceAdmin, "alice", "", nil) + + appliedRes := newTestConditionRule(2) + intent, err := ctx.versioning.BeginMutationIntent(appliedRes, versioning.OperationUpdate, versioning.SourceAdmin, "bob", "admin edit", ¤t.ID, nil) + require.NoError(t, err) + ctx.rm.Put(appliedRes) + + repaired, err := RepairRuleVersionIntent(ctx, intent.ID) + require.NoError(t, err) + require.NotNil(t, repaired) + require.Equal(t, versioning.SourceAdmin, repaired.Source) + require.Equal(t, "bob", repaired.Author) + repairedIntent, err := store.GetIntent(intent.ID) + require.NoError(t, err) + require.Equal(t, versioning.IntentStatusCommitted, repairedIntent.Status) + require.NotNil(t, repairedIntent.VersionID) + open, err := store.OpenIntent(meshresource.ConditionRouteKind, currentRes.ResourceKey()) + require.NoError(t, err) + require.Nil(t, open) +} + +func TestRepairRuleVersionIntentBlocksMismatchedPendingIntent(t *testing.T) { + ctx, store := newRuleVersionTestContext() + currentRes := newTestConditionRule(1) + ctx.rm.Put(currentRes) + current := insertTestVersion(t, store, currentRes, versioning.OperationCreate, versioning.SourceAdmin, "alice", "", nil) + + pendingRes := newTestConditionRule(2) + intent, err := ctx.versioning.BeginMutationIntent(pendingRes, versioning.OperationUpdate, versioning.SourceAdmin, "bob", "admin edit", ¤t.ID, nil) + require.NoError(t, err) + + _, err = RepairRuleVersionIntent(ctx, intent.ID) + require.ErrorIs(t, err, versioning.ErrVersionIntentPending) + repairedIntent, err := store.GetIntent(intent.ID) + require.NoError(t, err) + require.Equal(t, versioning.IntentStatusPending, repairedIntent.Status) + items, err := store.ListVersions(meshresource.ConditionRouteKind, currentRes.ResourceKey()) + require.NoError(t, err) + require.Len(t, items, 1) +} + +func TestAbandonRuleVersionIntentFailsMismatchedPendingAndUnblocksMutation(t *testing.T) { + ctx, store := newRuleVersionTestContext() + currentRes := newTestConditionRule(1) + ctx.rm.Put(currentRes) + current := insertTestVersion(t, store, currentRes, versioning.OperationCreate, versioning.SourceAdmin, "alice", "", nil) + + pendingRes := newTestConditionRule(2) + intent, err := ctx.versioning.BeginMutationIntent(pendingRes, versioning.OperationUpdate, versioning.SourceAdmin, "bob", "admin edit", ¤t.ID, nil) + require.NoError(t, err) + + err = AbandonRuleVersionIntent(ctx, intent.ID, "registry rejected mutation") + require.NoError(t, err) + abandoned, err := store.GetIntent(intent.ID) + require.NoError(t, err) + require.Equal(t, versioning.IntentStatusFailed, abandoned.Status) + require.Equal(t, "registry rejected mutation", abandoned.LastError) + open, err := store.OpenIntent(meshresource.ConditionRouteKind, currentRes.ResourceKey()) + require.NoError(t, err) + require.Nil(t, open) + + err = UpdateConditionRuleWithOptions(ctx, newTestConditionRule(3), RuleMutationOptions{ + ExpectedVersionID: ¤t.ID, + Author: "carol", + }) + require.NoError(t, err) + items, err := store.ListVersions(meshresource.ConditionRouteKind, currentRes.ResourceKey()) + require.NoError(t, err) + require.Len(t, items, 2) + require.Equal(t, "carol", items[0].Author) +} + +func TestAbandonRuleVersionIntentRejectsAppliedAndMatchingPending(t *testing.T) { + ctx, store := newRuleVersionTestContext() + currentRes := newTestConditionRule(1) + current := insertTestVersion(t, store, currentRes, versioning.OperationCreate, versioning.SourceAdmin, "alice", "", nil) + appliedRes := newTestConditionRule(2) + applied, err := ctx.versioning.BeginMutationIntent(appliedRes, versioning.OperationUpdate, versioning.SourceAdmin, "bob", "admin edit", ¤t.ID, nil) + require.NoError(t, err) + require.NoError(t, ctx.versioning.MarkMutationIntentApplied(applied.ID)) + + err = AbandonRuleVersionIntent(ctx, applied.ID, "operator abandon") + requireInvalidArgument(t, err) + unchanged, err := store.GetIntent(applied.ID) + require.NoError(t, err) + require.Equal(t, versioning.IntentStatusApplied, unchanged.Status) + + ctx, store = newRuleVersionTestContext() + matchingRes := newTestConditionRule(2) + intent, err := ctx.versioning.BeginMutationIntent(matchingRes, versioning.OperationUpdate, versioning.SourceAdmin, "bob", "admin edit", nil, nil) + require.NoError(t, err) + ctx.rm.Put(matchingRes) + + err = AbandonRuleVersionIntent(ctx, intent.ID, "operator abandon") + requireInvalidArgument(t, err) + unchanged, err = store.GetIntent(intent.ID) + require.NoError(t, err) + require.Equal(t, versioning.IntentStatusPending, unchanged.Status) +} + +func TestDeleteRecordsMarkerAndClearsCurrent(t *testing.T) { + ctx, store := newRuleVersionTestContext() + currentRes := newTestConditionRule(1) + ctx.rm.Put(currentRes) + current := insertTestVersion(t, store, currentRes, versioning.OperationCreate, versioning.SourceAdmin, "alice", "", nil) + + err := DeleteConditionRuleWithOptions(ctx, currentRes.Name, currentRes.Mesh, RuleMutationOptions{ + ExpectedVersionID: ¤t.ID, + Author: "alice", + }) + require.NoError(t, err) + + meta, err := store.CurrentMeta(meshresource.ConditionRouteKind, currentRes.ResourceKey()) + require.NoError(t, err) + require.Nil(t, meta.CurrentVersion) + items, err := store.ListVersions(meshresource.ConditionRouteKind, currentRes.ResourceKey()) + require.NoError(t, err) + require.Len(t, items, 2) + require.Equal(t, versioning.OperationDelete, items[0].Operation) + require.False(t, items[0].IsCurrent) +} + +func TestFailedAdminMutationDoesNotRecordOrPolluteNextUpstreamEvent(t *testing.T) { + ctx, store := newRuleVersionTestContext() + currentRes := newTestConditionRule(1) + ctx.rm.Put(currentRes) + current := insertTestVersion(t, store, currentRes, versioning.OperationCreate, versioning.SourceAdmin, "alice", "", nil) + + failedUpdate := newTestConditionRule(2) + updateErr := errors.New("update failed") + ctx.rm.FailUpdate(updateErr) + err := UpdateConditionRuleWithOptions(ctx, failedUpdate, RuleMutationOptions{ + ExpectedVersionID: ¤t.ID, + Author: "alice", + }) + require.ErrorIs(t, err, updateErr) + items, err := store.ListVersions(meshresource.ConditionRouteKind, currentRes.ResourceKey()) + require.NoError(t, err) + require.Len(t, items, 1) + open, err := store.OpenIntent(meshresource.ConditionRouteKind, currentRes.ResourceKey()) + require.NoError(t, err) + require.Nil(t, open) +} + +func requireInvalidArgument(t *testing.T, err error) { + t.Helper() + var bizErr bizerror.Error + require.ErrorAs(t, err, &bizErr) + require.Equal(t, bizerror.InvalidArgument, bizErr.Code()) +} + +func newRuleVersionTestContext() (*ruleVersionTestContext, *versioning.MemoryStore) { + store := versioning.NewMemoryStore() + return &ruleVersionTestContext{ + rm: newTestResourceManager(), + lock: &serialTestLock{}, + versioning: versioning.NewService(true, 5, store), + }, store +} + +func insertTestVersion(t *testing.T, store *versioning.MemoryStore, res coremodel.Resource, op versioning.Operation, source versioning.Source, author, reason string, rolledBackFromID *int64) *versioning.Version { + t.Helper() + hash, specJSON, err := versioning.NormalizeResource(res) + require.NoError(t, err) + if op == versioning.OperationDelete { + hash = versioning.HashSpecJSON(versioning.DeleteSpecJSON) + specJSON = versioning.DeleteSpecJSON + } + v, err := store.InsertVersion(versioning.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: time.Now(), + }, 5) + require.NoError(t, err) + return v +} + +func newTestConditionRule(priority int32) *meshresource.ConditionRouteResource { + res := meshresource.NewConditionRouteResourceWithAttributes("demo.condition-router", "mesh") + res.Spec = &meshproto.ConditionRoute{ + Key: "demo", + Enabled: true, + Priority: priority, + Conditions: []string{"host = 127.0.0.1"}, + } + return res +} + +func conditionKindName() RuleKindName { + return RuleKindName{ + Kind: meshresource.ConditionRouteKind, + Mesh: "mesh", + Name: "demo.condition-router", + } +} + +type ruleVersionTestContext struct { + rm *testResourceManager + lock corelock.Lock + versioning versioning.Service +} + +func (c *ruleVersionTestContext) ResourceManager() manager.ResourceManager { + return c.rm +} + +func (c *ruleVersionTestContext) CounterManager() counter.CounterManager { + return nil +} + +func (c *ruleVersionTestContext) Config() appconfig.AdminConfig { + return appconfig.AdminConfig{Versioning: &versioningcfg.Config{Enabled: true}} +} + +func (c *ruleVersionTestContext) AppContext() context.Context { + return context.Background() +} + +func (c *ruleVersionTestContext) LockManager() corelock.Lock { + return c.lock +} + +func (c *ruleVersionTestContext) RuleVersioning() versioning.Service { + return c.versioning +} + +type serialTestLock struct { + mu sync.Mutex +} + +func (l *serialTestLock) Lock(context.Context, string, time.Duration) error { + l.mu.Lock() + return nil +} + +func (l *serialTestLock) TryLock(context.Context, string, time.Duration) (bool, error) { + l.mu.Lock() + return true, nil +} + +func (l *serialTestLock) Unlock(context.Context, string) error { + l.mu.Unlock() + return nil +} + +func (l *serialTestLock) Renew(context.Context, string, time.Duration) error { + return nil +} + +func (l *serialTestLock) IsLocked(context.Context, string) (bool, error) { + return false, nil +} + +func (l *serialTestLock) WithLock(_ context.Context, _ string, _ time.Duration, fn func() error) error { + l.mu.Lock() + defer l.mu.Unlock() + return fn() +} + +func (l *serialTestLock) CleanupExpiredLocks(context.Context) error { + return nil +} + +type testResourceManager struct { + mu sync.Mutex + resources map[coremodel.ResourceKind]map[string]coremodel.Resource + updateFail error + updateStarted chan struct{} + updateRelease chan struct{} +} + +func newTestResourceManager() *testResourceManager { + return &testResourceManager{ + resources: make(map[coremodel.ResourceKind]map[string]coremodel.Resource), + } +} + +func (m *testResourceManager) Put(res coremodel.Resource) { + m.mu.Lock() + defer m.mu.Unlock() + m.putLocked(res) +} + +func (m *testResourceManager) FailUpdate(err error) { + m.mu.Lock() + defer m.mu.Unlock() + m.updateFail = err +} + +func (m *testResourceManager) BlockNextUpdate(started, release chan struct{}) { + m.mu.Lock() + defer m.mu.Unlock() + m.updateStarted = started + m.updateRelease = release +} + +func (m *testResourceManager) GetByKey(kind coremodel.ResourceKind, key string) (coremodel.Resource, bool, error) { + m.mu.Lock() + defer m.mu.Unlock() + byKind := m.resources[kind] + if byKind == nil { + return nil, false, nil + } + res, ok := byKind[key] + return res, ok, nil +} + +func (m *testResourceManager) GetByKeys(kind coremodel.ResourceKind, keys []string) ([]coremodel.Resource, error) { + m.mu.Lock() + defer m.mu.Unlock() + items := make([]coremodel.Resource, 0, len(keys)) + for _, key := range keys { + if res := m.resources[kind][key]; res != nil { + items = append(items, res) + } + } + return items, nil +} + +func (m *testResourceManager) List(kind coremodel.ResourceKind) ([]coremodel.Resource, error) { + m.mu.Lock() + defer m.mu.Unlock() + items := make([]coremodel.Resource, 0, len(m.resources[kind])) + for _, res := range m.resources[kind] { + items = append(items, res) + } + return items, nil +} + +func (m *testResourceManager) ListByIndexes(kind coremodel.ResourceKind, _ []index.IndexCondition) ([]coremodel.Resource, error) { + return m.List(kind) +} + +func (m *testResourceManager) PageListByIndexes(kind coremodel.ResourceKind, _ []index.IndexCondition, page coremodel.PageReq) (*coremodel.PageData[coremodel.Resource], error) { + items, err := m.List(kind) + if err != nil { + return nil, err + } + return coremodel.NewPageData(len(items), page.PageOffset, page.PageSize, items), nil +} + +func (m *testResourceManager) Add(res coremodel.Resource) error { + m.mu.Lock() + defer m.mu.Unlock() + m.putLocked(res) + return nil +} + +func (m *testResourceManager) Update(res coremodel.Resource) error { + m.mu.Lock() + if m.updateFail != nil { + m.mu.Unlock() + return m.updateFail + } + started := m.updateStarted + release := m.updateRelease + m.updateStarted = nil + m.updateRelease = nil + m.mu.Unlock() + if started != nil { + close(started) + <-release + } + m.mu.Lock() + defer m.mu.Unlock() + m.putLocked(res) + return nil +} + +func (m *testResourceManager) Upsert(res coremodel.Resource) error { + m.mu.Lock() + defer m.mu.Unlock() + m.putLocked(res) + return nil +} + +func (m *testResourceManager) DeleteByKey(kind coremodel.ResourceKind, _ string, key string) error { + m.mu.Lock() + defer m.mu.Unlock() + delete(m.resources[kind], key) + return nil +} + +func (m *testResourceManager) putLocked(res coremodel.Resource) { + byKind := m.resources[res.ResourceKind()] + if byKind == nil { + byKind = make(map[string]coremodel.Resource) + m.resources[res.ResourceKind()] = byKind + } + byKind[res.ResourceKey()] = res +} diff --git a/pkg/console/service/tag_rule.go b/pkg/console/service/tag_rule.go index a051117ca..ff3fd7366 100644 --- a/pkg/console/service/tag_rule.go +++ b/pkg/console/service/tag_rule.go @@ -30,6 +30,7 @@ import ( meshresource "github.com/apache/dubbo-admin/pkg/core/resource/apis/mesh/v1alpha1" coremodel "github.com/apache/dubbo-admin/pkg/core/resource/model" "github.com/apache/dubbo-admin/pkg/core/store/index" + "github.com/apache/dubbo-admin/pkg/core/versioning" ) func PageListTagRule(ctx consolectx.Context, req *model.SearchReq) (*model.SearchPaginationResult, error) { @@ -114,65 +115,101 @@ func GetTagRule(ctx consolectx.Context, name string, mesh string) (*meshresource } func UpdateTagRule(ctx consolectx.Context, res *meshresource.TagRouteResource) error { + return UpdateTagRuleWithOptions(ctx, res, RuleMutationOptions{}) +} + +func UpdateTagRuleWithOptions(ctx consolectx.Context, res *meshresource.TagRouteResource, opts RuleMutationOptions) error { lockMgr := ctx.LockManager() if lockMgr == nil { - return updateTagRuleUnsafe(ctx, res) + return updateTagRuleUnsafe(ctx, res, opts) } lockKey := lock.BuildTagRouteLockKey(res.Mesh, res.Name) return lockMgr.WithLock(ctx.AppContext(), lockKey, constants.DefaultLockTimeout, func() error { - return updateTagRuleUnsafe(ctx, res) + return updateTagRuleUnsafe(ctx, res, opts) }) } -func updateTagRuleUnsafe(ctx consolectx.Context, res *meshresource.TagRouteResource) error { - err := ctx.ResourceManager().Update(res) - if err != nil { - logger.Warnf("update tag rule %s error: %v", res.Name, err) +func updateTagRuleUnsafe(ctx consolectx.Context, res *meshresource.TagRouteResource, opts RuleMutationOptions) error { + kindName := RuleKindName{Kind: meshresource.TagRouteKind, Mesh: res.Mesh, Name: res.Name} + if err := prepareRuleMutation(ctx, kindName, opts); err != nil { return err } - return nil + return applyAdminMutation(ctx, res, versioning.OperationUpdate, opts, func() error { + err := ctx.ResourceManager().Update(res) + if err != nil { + logger.Warnf("update tag rule %s error: %v", res.Name, err) + return err + } + return nil + }) } func CreateTagRule(ctx consolectx.Context, res *meshresource.TagRouteResource) error { + return CreateTagRuleWithOptions(ctx, res, RuleMutationOptions{}) +} + +func CreateTagRuleWithOptions(ctx consolectx.Context, res *meshresource.TagRouteResource, opts RuleMutationOptions) error { lockMgr := ctx.LockManager() if lockMgr == nil { - return createTagRuleUnsafe(ctx, res) + return createTagRuleUnsafe(ctx, res, opts) } lockKey := lock.BuildTagRouteLockKey(res.Mesh, res.Name) return lockMgr.WithLock(ctx.AppContext(), lockKey, constants.DefaultLockTimeout, func() error { - return createTagRuleUnsafe(ctx, res) + return createTagRuleUnsafe(ctx, res, opts) }) } -func createTagRuleUnsafe(ctx consolectx.Context, res *meshresource.TagRouteResource) error { - err := ctx.ResourceManager().Add(res) - if err != nil { - logger.Warnf("create tag rule %s error: %v", res.Name, err) +func createTagRuleUnsafe(ctx consolectx.Context, res *meshresource.TagRouteResource, opts RuleMutationOptions) error { + kindName := RuleKindName{Kind: meshresource.TagRouteKind, Mesh: res.Mesh, Name: res.Name} + if err := prepareRuleMutation(ctx, kindName, opts); err != nil { return err } - return nil + return applyAdminMutation(ctx, res, versioning.OperationCreate, opts, func() error { + err := ctx.ResourceManager().Add(res) + if err != nil { + logger.Warnf("create tag rule %s error: %v", res.Name, err) + return err + } + return nil + }) } func DeleteTagRule(ctx consolectx.Context, name string, mesh string) error { + return DeleteTagRuleWithOptions(ctx, name, mesh, RuleMutationOptions{}) +} + +func DeleteTagRuleWithOptions(ctx consolectx.Context, name string, mesh string, opts RuleMutationOptions) error { lockMgr := ctx.LockManager() if lockMgr == nil { - return deleteTagRuleUnsafe(ctx, name, mesh) + return deleteTagRuleUnsafe(ctx, name, mesh, opts) } lockKey := lock.BuildTagRouteLockKey(mesh, name) return lockMgr.WithLock(ctx.AppContext(), lockKey, constants.DefaultLockTimeout, func() error { - return deleteTagRuleUnsafe(ctx, name, mesh) + return deleteTagRuleUnsafe(ctx, name, mesh, opts) }) } -func deleteTagRuleUnsafe(ctx consolectx.Context, name string, mesh string) error { - err := ctx.ResourceManager().DeleteByKey(meshresource.TagRouteKind, mesh, coremodel.BuildResourceKey(mesh, name)) +func deleteTagRuleUnsafe(ctx consolectx.Context, name string, mesh string, opts RuleMutationOptions) error { + kindName := RuleKindName{Kind: meshresource.TagRouteKind, Mesh: mesh, Name: name} + if err := repairPendingIntent(ctx, kindName); err != nil { + return err + } + res, err := getExistingRule(ctx, kindName) if err != nil { - logger.Warnf("delete tag rule %s error: %v", name, err) return err } - return nil + if err := checkExpectedVersion(ctx, kindName, opts); err != nil { + return err + } + return applyAdminMutation(ctx, res, versioning.OperationDelete, opts, func() error { + if err := ctx.ResourceManager().DeleteByKey(meshresource.TagRouteKind, mesh, coremodel.BuildResourceKey(mesh, name)); err != nil { + logger.Warnf("delete tag rule %s error: %v", name, err) + return err + } + return nil + }) } 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/discovery/subscriber/zk_config.go b/pkg/core/discovery/subscriber/zk_config.go index 07f9a2620..9a442b2b6 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 the +// versioning ledger can attribute upstream writes to author "system:zookeeper". +// Other registry subscribers (Nacos, Apollo, ...) should emit the equivalent +// SourceRegistryContextKey on their ResourceChangedEvents — until they do, +// the ledger 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.Warnf("rule %s not exists in store for zk delete event, skipped deleting; node data may be unavailable", 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/discovery/subscriber/zk_config_test.go b/pkg/core/discovery/subscriber/zk_config_test.go new file mode 100644 index 000000000..42e880efb --- /dev/null +++ b/pkg/core/discovery/subscriber/zk_config_test.go @@ -0,0 +1,97 @@ +/* + * 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 subscriber + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" + "k8s.io/client-go/tools/cache" + + meshproto "github.com/apache/dubbo-admin/api/mesh/v1alpha1" + "github.com/apache/dubbo-admin/pkg/core/events" + meshresource "github.com/apache/dubbo-admin/pkg/core/resource/apis/mesh/v1alpha1" + "github.com/apache/dubbo-admin/pkg/core/resource/model" + corestore "github.com/apache/dubbo-admin/pkg/core/store" + memorystore "github.com/apache/dubbo-admin/pkg/store/memory" +) + +func TestZKConfigDeleteUsesLocalOldRule(t *testing.T) { + ruleStore := memorystore.NewMemoryResourceStore(meshresource.TagRouteKind) + require.NoError(t, ruleStore.Init(nil)) + oldRule := meshresource.NewTagRouteResourceWithAttributes("demo.tag-router", "mesh") + oldRule.Spec = &meshproto.TagRoute{Key: "demo", Priority: 7} + require.NoError(t, ruleStore.Add(oldRule)) + + emitter := &capturingEmitter{} + sub := NewZKConfigEventSubscriber(emitter, singleStoreRouter{store: ruleStore}) + zkConfig := newZKRuleConfig("demo.tag-router", "mesh", "") + + require.NoError(t, sub.ProcessEvent(events.NewResourceChangedEvent(cache.Deleted, zkConfig, nil))) + + _, exists, err := ruleStore.GetByKey(oldRule.ResourceKey()) + require.NoError(t, err) + require.False(t, exists) + require.Len(t, emitter.events, 1) + require.Equal(t, cache.Deleted, emitter.events[0].Type()) + require.Equal(t, oldRule.ResourceKey(), emitter.events[0].OldObj().ResourceKey()) + require.Nil(t, emitter.events[0].NewObj()) +} + +func TestZKConfigDeleteMissingLocalRuleIsNoop(t *testing.T) { + ruleStore := memorystore.NewMemoryResourceStore(meshresource.TagRouteKind) + require.NoError(t, ruleStore.Init(nil)) + + emitter := &capturingEmitter{} + sub := NewZKConfigEventSubscriber(emitter, singleStoreRouter{store: ruleStore}) + zkConfig := newZKRuleConfig("demo.tag-router", "mesh", "") + + require.NoError(t, sub.ProcessEvent(events.NewResourceChangedEvent(cache.Deleted, zkConfig, nil))) + require.Empty(t, emitter.events) +} + +type singleStoreRouter struct { + store corestore.ResourceStore +} + +func (r singleStoreRouter) ResourceRoute(model.Resource) (corestore.ResourceStore, error) { + return r.store, nil +} + +func (r singleStoreRouter) ResourceKindRoute(model.ResourceKind) (corestore.ResourceStore, error) { + if r.store == nil { + return nil, fmt.Errorf("no store configured") + } + return r.store, nil +} + +type capturingEmitter struct { + events []events.Event +} + +func (e *capturingEmitter) Send(event events.Event) { + e.events = append(e.events, event) +} + +func newZKRuleConfig(name, mesh, nodeData string) *meshresource.ZKConfigResource { + res := meshresource.NewZKConfigResourceWithAttributes(name, mesh) + res.Spec.NodeName = name + res.Spec.NodeData = nodeData + return res +} diff --git a/pkg/core/events/eventbus.go b/pkg/core/events/eventbus.go index 51393c4d2..fe98c7a1e 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" ) +// TODO(@mochengqian): wire this context from Nacos and Apollo subscribers too. +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/manager/manager.go b/pkg/core/manager/manager.go index 3e4ce0942..27fc14697 100644 --- a/pkg/core/manager/manager.go +++ b/pkg/core/manager/manager.go @@ -33,6 +33,8 @@ type ReadOnlyResourceManager interface { GetByKey(rk model.ResourceKind, key string) (r model.Resource, exist bool, err error) // GetByKeys returns the resources with the given resource keys GetByKeys(rk model.ResourceKind, keys []string) ([]model.Resource, error) + // List returns all resources for the given resource kind. + List(rk model.ResourceKind) ([]model.Resource, error) // ListByIndexes returns the resources with the given index conditions ListByIndexes(rk model.ResourceKind, indexes []index.IndexCondition) ([]model.Resource, error) // PageListByIndexes page list the resources with the given index conditions @@ -98,6 +100,14 @@ func (rm *resourcesManager) GetByKeys(rk model.ResourceKind, keys []string) ([]m return resources, nil } +func (rm *resourcesManager) List(rk model.ResourceKind) ([]model.Resource, error) { + rs, err := rm.storeRouter.ResourceKindRoute(rk) + if err != nil { + return nil, err + } + return rs.ListResources() +} + func (rm *resourcesManager) ListByIndexes(rk model.ResourceKind, indexes []index.IndexCondition) ([]model.Resource, error) { rs, err := rm.storeRouter.ResourceKindRoute(rk) if err != nil { diff --git a/pkg/core/manager/manager_test.go b/pkg/core/manager/manager_test.go new file mode 100644 index 000000000..d985be5c8 --- /dev/null +++ b/pkg/core/manager/manager_test.go @@ -0,0 +1,124 @@ +/* + * 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 manager + +import ( + "testing" + + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + + "github.com/apache/dubbo-admin/pkg/core/governor" + "github.com/apache/dubbo-admin/pkg/core/resource/model" + corestore "github.com/apache/dubbo-admin/pkg/core/store" + memorystore "github.com/apache/dubbo-admin/pkg/store/memory" +) + +func TestResourceManagerListUsesStoreFullList(t *testing.T) { + const kind model.ResourceKind = "TestManagerResource" + st := memorystore.NewMemoryResourceStore(kind) + require.NoError(t, st.Init(nil)) + res1 := &managerTestResource{kind: kind, key: "mesh/rule-b", mesh: "mesh", meta: metav1.ObjectMeta{Name: "rule-b"}} + res2 := &managerTestResource{kind: kind, key: "mesh/rule-a", mesh: "mesh", meta: metav1.ObjectMeta{Name: "rule-a"}} + require.NoError(t, st.Add(res1)) + require.NoError(t, st.Add(res2)) + + rm := NewResourceManager(singleStoreRouter{store: st}, noopGovernorRouter{}) + resources, err := rm.List(kind) + require.NoError(t, err) + require.Len(t, resources, 2) + require.Equal(t, "mesh/rule-a", resources[0].ResourceKey()) + require.Equal(t, "mesh/rule-b", resources[1].ResourceKey()) +} + +type managerTestResource struct { + kind model.ResourceKind + key string + mesh string + meta metav1.ObjectMeta +} + +func (r *managerTestResource) ResourceMesh() string { + return r.mesh +} + +func (r *managerTestResource) GetObjectKind() schema.ObjectKind { + return schema.EmptyObjectKind +} + +func (r *managerTestResource) DeepCopyObject() runtime.Object { + return r +} + +func (r *managerTestResource) ResourceKind() model.ResourceKind { + return r.kind +} + +func (r *managerTestResource) ResourceKey() string { + return r.key +} + +func (r *managerTestResource) ResourceMeta() metav1.ObjectMeta { + return r.meta +} + +func (r *managerTestResource) ResourceSpec() model.ResourceSpec { + return nil +} + +func (r *managerTestResource) String() string { + return r.key +} + +type singleStoreRouter struct { + store corestore.ResourceStore +} + +func (r singleStoreRouter) ResourceRoute(model.Resource) (corestore.ResourceStore, error) { + return r.store, nil +} + +func (r singleStoreRouter) ResourceKindRoute(model.ResourceKind) (corestore.ResourceStore, error) { + return r.store, nil +} + +type noopGovernorRouter struct{} + +func (noopGovernorRouter) ResourceRoute(model.Resource) (governor.RuleGovernor, error) { + return noopGovernor{}, nil +} + +func (noopGovernorRouter) ResourceMeshRoute(string) (governor.RuleGovernor, error) { + return noopGovernor{}, nil +} + +type noopGovernor struct{} + +func (noopGovernor) CreateRule(model.Resource) error { + return nil +} + +func (noopGovernor) UpdateRule(model.Resource) error { + return nil +} + +func (noopGovernor) DeleteRule(model.Resource) error { + return nil +} diff --git a/pkg/core/store/store.go b/pkg/core/store/store.go index 8923f6577..69053796a 100644 --- a/pkg/core/store/store.go +++ b/pkg/core/store/store.go @@ -34,6 +34,8 @@ import ( // ResourceStore expanded the interface of cache.Indexer and cache.Store type ResourceStore interface { Indexer + // ListResources lists all resources in this store with error propagation. + ListResources() ([]model.Resource, error) // GetByKeys get resources by keys, return list of resource. // if a resource of specified key doesn't exist in the store, resource list will not include it GetByKeys(keys []string) ([]model.Resource, error) diff --git a/pkg/core/versioning/component.go b/pkg/core/versioning/component.go new file mode 100644 index 000000000..0c715627e --- /dev/null +++ b/pkg/core/versioning/component.go @@ -0,0 +1,185 @@ +/* + * 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" + "fmt" + "math" + + versioningcfg "github.com/apache/dubbo-admin/pkg/config/versioning" + "github.com/apache/dubbo-admin/pkg/core/events" + "github.com/apache/dubbo-admin/pkg/core/governor" + "github.com/apache/dubbo-admin/pkg/core/logger" + "github.com/apache/dubbo-admin/pkg/core/manager" + "github.com/apache/dubbo-admin/pkg/core/runtime" + "gorm.io/gorm" +) + +const ComponentType runtime.ComponentType = "rule versioning" + +func init() { + runtime.RegisterComponent(&component{}) +} + +type Component interface { + runtime.Component + Service() Service +} + +type component struct { + service Service + store Store + subscribers []*Subscriber +} + +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.EventBus, + runtime.ResourceStore, + runtime.ResourceManager, + } +} + +func (c *component) Init(ctx runtime.BuilderContext) error { + cfg := ctx.Config().Versioning + if cfg == nil { + cfg = versioningcfg.Default() + } + store := Store(NewMemoryStore()) + c.store = store + c.service = NewService( + cfg.Enabled, + cfg.MaxVersionsPerRule, + store, + ) + if !cfg.Enabled { + return nil + } + storeComponent, err := ctx.GetActivatedComponent(runtime.ResourceStore) + if err != nil { + return err + } + if sc, ok := storeComponent.(interface { + GetDB() (*gorm.DB, bool) + }); ok { + if db, exists := sc.GetDB(); exists { + gormStore := NewGormStore(db) + if err := gormStore.AutoMigrate(); err != nil { + return err + } + store = gormStore + } + } + c.store = store + c.service = NewService( + cfg.Enabled, + cfg.MaxVersionsPerRule, + store, + ) + if !cfg.Enabled { + return nil + } + eventBusComponent, err := ctx.GetActivatedComponent(runtime.EventBus) + if err != nil { + return err + } + bus, ok := eventBusComponent.(events.EventBus) + if !ok { + return fmt.Errorf("component %s does not implement events.EventBus", runtime.EventBus) + } + for _, kind := range governor.RuleResourceKinds.Values() { + sub := NewSubscriber(kind, store, cfg.MaxVersionsPerRule) + if err := bus.Subscribe(sub); err != nil { + return err + } + c.subscribers = append(c.subscribers, sub) + } + return nil +} + +func (c *component) Start(rt runtime.Runtime, stop <-chan struct{}) error { + cfg := rt.Config().Versioning + if cfg == nil { + cfg = versioningcfg.Default() + } + if !cfg.Enabled { + return nil + } + rmComp, err := rt.GetComponent(runtime.ResourceManager) + if err != nil { + return err + } + rm := rmComp.(manager.ResourceManagerComponent).ResourceManager() + if err := c.repairOpenIntents(rm); err != nil { + return err + } + for _, kind := range governor.RuleResourceKinds.Values() { + resources, err := rm.List(kind) + if err != nil { + return err + } + for _, res := range resources { + if err := RecordBootstrap(c.store, cfg.MaxVersionsPerRule, res); err != nil { + return err + } + } + } + if stop != nil { + go func() { + <-stop + for _, sub := range c.subscribers { + sub.FlushAll() + } + }() + } + return nil +} + +func (c *component) Service() Service { + return c.service +} + +func (c *component) repairOpenIntents(rm manager.ResourceManager) error { + intents, err := c.store.ListOpenIntents() + if err != nil { + return err + } + for _, intent := range intents { + current, exists, err := rm.GetByKey(intent.RuleKind, intent.ResourceKey) + if err != nil { + return err + } + if _, err := c.service.RepairIntentByID(intent.ID, current, !exists); err != nil { + if errors.Is(err, ErrVersionIntentPending) { + logger.Warnf("rule version intent %d is still pending for %s", intent.ID, intent.ResourceKey) + continue + } + return err + } + } + return nil +} diff --git a/pkg/core/versioning/e2e_rollback_drill_test.go b/pkg/core/versioning/e2e_rollback_drill_test.go new file mode 100644 index 000000000..6c1d0ef25 --- /dev/null +++ b/pkg/core/versioning/e2e_rollback_drill_test.go @@ -0,0 +1,153 @@ +/* + * 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 ( + "testing" + "time" + + "github.com/stretchr/testify/require" + "k8s.io/client-go/tools/cache" + + meshproto "github.com/apache/dubbo-admin/api/mesh/v1alpha1" + "github.com/apache/dubbo-admin/pkg/core/events" + meshresource "github.com/apache/dubbo-admin/pkg/core/resource/apis/mesh/v1alpha1" +) + +func TestE2ERollbackDrill(t *testing.T) { + store := NewMemoryStore() + maxVersions := int64(5) + svc := NewService(true, maxVersions, store) + sub := NewSubscriber(meshresource.ConditionRouteKind, store, maxVersions) + bus := newTestEventBus(t) + defer bus.WaitForDone() + require.NoError(t, bus.Subscribe(sub)) + require.NoError(t, bus.Start(nil, nil)) + + original := newE2EConditionRoute(1) + require.NoError(t, RecordBootstrap(store, maxVersions, original)) + items := requireVersions(t, store, original.ResourceKey(), 1) + require.Equal(t, SourceBootstrap, items[0].Source) + require.Equal(t, OperationCreate, items[0].Operation) + require.Equal(t, int64(1), items[0].VersionNo) + require.Equal(t, "system:bootstrap", items[0].Author) + bootstrapID := items[0].ID + + adminEdit := newE2EConditionRoute(2) + applyE2EIntentMutation(t, svc, adminEdit, OperationUpdate, SourceAdmin, "alice", "raise priority", nil) + bus.Send(events.NewResourceChangedEvent(cache.Updated, original, adminEdit)) + items = requireVersions(t, store, original.ResourceKey(), 2) + require.Equal(t, SourceAdmin, items[0].Source) + require.Equal(t, "alice", items[0].Author) + require.Equal(t, int64(2), items[0].VersionNo) + + upstreamPush := newE2EConditionRoute(3) + bus.Send(events.NewResourceChangedEventWithContext(cache.Updated, adminEdit, upstreamPush, map[string]string{ + events.SourceRegistryContextKey: "zookeeper", + })) + items = requireVersions(t, store, original.ResourceKey(), 3) + require.Equal(t, SourceUpstream, items[0].Source) + require.Equal(t, "system:zookeeper", items[0].Author) + require.Equal(t, int64(3), items[0].VersionNo) + + fromID := bootstrapID + rollback := applyE2EIntentMutation(t, svc, original, OperationUpdate, SourceRollback, "bob", "restore bootstrap baseline", &fromID) + bus.Send(events.NewResourceChangedEvent(cache.Updated, upstreamPush, original)) + require.Equal(t, SourceRollback, rollback.Source) + require.Equal(t, OperationUpdate, rollback.Operation) + require.NotNil(t, rollback.RolledBackFromID) + require.Equal(t, bootstrapID, *rollback.RolledBackFromID) + require.Equal(t, "bob", rollback.Author) + require.Equal(t, int64(4), rollback.VersionNo) + + items = requireVersions(t, store, original.ResourceKey(), 4) + requireAuditChainReadable(t, items, []Source{SourceRollback, SourceUpstream, SourceAdmin, SourceBootstrap}) + + previous := newE2EConditionRoute(1) + for priority := int32(4); priority <= 9; priority++ { + next := newE2EConditionRoute(priority) + applyE2EIntentMutation(t, svc, next, OperationUpdate, SourceAdmin, "alice", "bulk edit", nil) + bus.Send(events.NewResourceChangedEvent(cache.Updated, previous, next)) + previous = next + require.Eventually(t, func() bool { + latest, err := store.LatestVersion(meshresource.ConditionRouteKind, original.ResourceKey()) + return err == nil && latest != nil && latest.VersionNo == int64(priority+1) + }, time.Second, 10*time.Millisecond) + } + + items = requireVersions(t, store, original.ResourceKey(), int(maxVersions)) + require.Equal(t, []int64{10, 9, 8, 7, 6}, versionNumbers(items)) + for _, item := range items { + require.NotEmpty(t, item.Author) + require.False(t, item.CreatedAt.IsZero()) + } +} + +func applyE2EIntentMutation(t *testing.T, svc Service, res *meshresource.ConditionRouteResource, op Operation, source Source, author, reason string, rolledBackFromID *int64) *Version { + t.Helper() + intent, err := svc.BeginMutationIntent(res, op, source, author, reason, nil, rolledBackFromID) + require.NoError(t, err) + require.NotNil(t, intent) + require.NoError(t, svc.MarkMutationIntentApplied(intent.ID)) + version, err := svc.CommitMutationIntent(intent.ID) + require.NoError(t, err) + return version +} + +func newE2EConditionRoute(priority int32) *meshresource.ConditionRouteResource { + res := meshresource.NewConditionRouteResourceWithAttributes("demo.condition-router", "mesh") + res.Spec = &meshproto.ConditionRoute{ + Key: "demo", + Enabled: true, + Priority: priority, + Conditions: []string{"host = 127.0.0.1"}, + } + return res +} + +func requireVersions(t *testing.T, store Store, resourceKey string, count int) []Version { + t.Helper() + var items []Version + require.Eventually(t, func() bool { + var err error + items, err = store.ListVersions(meshresource.ConditionRouteKind, resourceKey) + return err == nil && len(items) == count + }, time.Second, 10*time.Millisecond) + return items +} + +func requireAuditChainReadable(t *testing.T, items []Version, sources []Source) { + t.Helper() + require.Len(t, items, len(sources)) + for i, item := range items { + require.Equal(t, sources[i], item.Source) + require.NotEmpty(t, item.Author) + require.False(t, item.CreatedAt.IsZero()) + if i > 0 { + require.False(t, items[i-1].CreatedAt.Before(item.CreatedAt)) + } + } +} + +func versionNumbers(items []Version) []int64 { + numbers := make([]int64, 0, len(items)) + for _, item := range items { + numbers = append(numbers, item.VersionNo) + } + return numbers +} diff --git a/pkg/core/versioning/normalize.go b/pkg/core/versioning/normalize.go new file mode 100644 index 000000000..5a380f94a --- /dev/null +++ b/pkg/core/versioning/normalize.go @@ -0,0 +1,77 @@ +/* + * 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" + + coremodel "github.com/apache/dubbo-admin/pkg/core/resource/model" +) + +const DeleteSpecJSON = "{}" + +func NormalizeSpec(spec coremodel.ResourceSpec) (string, string, 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 +} + +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()) +} diff --git a/pkg/core/versioning/service.go b/pkg/core/versioning/service.go new file mode 100644 index 000000000..24eed586d --- /dev/null +++ b/pkg/core/versioning/service.go @@ -0,0 +1,311 @@ +/* + * 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" + "strconv" + "strings" + "time" + + 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" + "google.golang.org/protobuf/encoding/protojson" +) + +type Service interface { + Store() Store + List(kind coremodel.ResourceKind, mesh, ruleName string) (*ListResult, error) + Get(kind coremodel.ResourceKind, mesh, ruleName string, id int64) (*Version, error) + Diff(kind coremodel.ResourceKind, mesh, ruleName string, id int64, against string) (*DiffResult, error) + CheckExpected(kind coremodel.ResourceKind, mesh, ruleName string, expected *int64) error + BeginMutationIntent(res coremodel.Resource, op Operation, source Source, author, reason string, expected *int64, rolledBackFromID *int64) (*Intent, error) + MarkMutationIntentApplied(id int64) error + FailMutationIntent(id int64, message string) error + CommitMutationIntent(id int64) (*Version, error) + RepairIntent(kind coremodel.ResourceKind, resourceKey string, current coremodel.Resource, deleted bool) (*Version, error) + RepairIntentByID(id int64, current coremodel.Resource, deleted bool) (*Version, error) +} + +type service struct { + enabled bool + maxVersions int64 + store Store +} + +func NewService(enabled bool, maxVersions int64, store Store) Service { + return &service{ + enabled: enabled, + maxVersions: maxVersions, + store: store, + } +} + +func (s *service) Store() Store { + return s.store +} + +func (s *service) ensureEnabled() error { + if !s.enabled { + return ErrFeatureDisabled + } + return nil +} + +func (s *service) List(kind coremodel.ResourceKind, mesh, ruleName string) (*ListResult, error) { + if err := s.ensureEnabled(); err != nil { + return nil, err + } + items, err := s.store.ListVersions(kind, coremodel.BuildResourceKey(mesh, ruleName)) + if err != nil { + return nil, err + } + return &ListResult{Items: items, Total: int64(len(items))}, nil +} + +func (s *service) Get(kind coremodel.ResourceKind, mesh, ruleName string, id int64) (*Version, error) { + if err := s.ensureEnabled(); err != nil { + return nil, err + } + return s.store.GetVersion(kind, coremodel.BuildResourceKey(mesh, ruleName), id) +} + +func (s *service) Diff(kind coremodel.ResourceKind, mesh, ruleName string, id int64, against string) (*DiffResult, error) { + if err := s.ensureEnabled(); err != nil { + return nil, err + } + left, err := s.Get(kind, mesh, ruleName, id) + if err != nil { + return nil, err + } + var right *Version + if against == "" || against == "current" { + meta, err := s.store.CurrentMeta(kind, coremodel.BuildResourceKey(mesh, ruleName)) + if err != nil { + return nil, err + } + if meta == nil || meta.CurrentVersion == nil { + return nil, ErrVersionNotFound + } + right, err = s.store.GetVersion(kind, coremodel.BuildResourceKey(mesh, ruleName), *meta.CurrentVersion) + if err != nil { + return nil, err + } + } else { + var againstID int64 + if parsed, err := strconv.ParseInt(against, 10, 64); err != nil { + return nil, bizerror.New(bizerror.InvalidArgument, "against must be a version id or current") + } else { + againstID = parsed + } + right, err = s.Get(kind, mesh, ruleName, againstID) + 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) CheckExpected(kind coremodel.ResourceKind, mesh, ruleName string, expected *int64) error { + if err := s.ensureEnabled(); err != nil { + return nil + } + resourceKey := coremodel.BuildResourceKey(mesh, ruleName) + // The expected-version guard first checks for open intents so a second + // writer in the same rule-lock window gets a deterministic 409 before the + // meta current-version pointer has a chance to lag behind the first write. + intent, err := s.store.OpenIntent(kind, resourceKey) + if err != nil { + return err + } + if intent != nil { + return &IntentPendingError{IntentID: intent.ID} + } + return s.store.CheckExpectedVersion(kind, resourceKey, expected) +} + +func (s *service) BeginMutationIntent(res coremodel.Resource, op Operation, source Source, author, reason string, expected *int64, rolledBackFromID *int64) (*Intent, error) { + if err := s.ensureEnabled(); err != nil { + return nil, nil + } + req, err := buildMutationInsertRequest(res, op, source, author, reason, rolledBackFromID, time.Now()) + if err != nil { + return nil, err + } + return s.store.CreateIntent(req, expected) +} + +func (s *service) MarkMutationIntentApplied(id int64) error { + if err := s.ensureEnabled(); err != nil { + return nil + } + return s.store.MarkIntentApplied(id) +} + +func (s *service) FailMutationIntent(id int64, message string) error { + if err := s.ensureEnabled(); err != nil { + return nil + } + return s.store.MarkIntentFailed(id, message) +} + +func (s *service) CommitMutationIntent(id int64) (*Version, error) { + if err := s.ensureEnabled(); err != nil { + return nil, nil + } + return s.store.CommitIntent(id, s.maxVersions) +} + +func (s *service) RepairIntent(kind coremodel.ResourceKind, resourceKey string, current coremodel.Resource, deleted bool) (*Version, error) { + if err := s.ensureEnabled(); err != nil { + return nil, nil + } + intent, err := s.store.OpenIntent(kind, resourceKey) + if err != nil || intent == nil { + return nil, err + } + return s.repairIntent(intent, current, deleted) +} + +func (s *service) RepairIntentByID(id int64, current coremodel.Resource, deleted bool) (*Version, error) { + if err := s.ensureEnabled(); err != nil { + return nil, nil + } + intent, err := s.store.GetIntent(id) + if err != nil { + return nil, err + } + return s.repairIntent(intent, current, deleted) +} + +func (s *service) repairIntent(intent *Intent, current coremodel.Resource, deleted bool) (*Version, error) { + if intent == nil { + return nil, nil + } + if intent.Status == IntentStatusCommitted { + if intent.VersionID == nil { + return nil, ErrVersionIntentNotOpen + } + return s.store.GetVersionByID(*intent.VersionID) + } + if intent.Status == IntentStatusFailed { + return nil, ErrVersionIntentNotOpen + } + if intent.Status != IntentStatusPending && intent.Status != IntentStatusApplied { + return nil, ErrVersionIntentNotOpen + } + if intent.Status == IntentStatusApplied || IntentMatchesResource(intent, current, deleted) { + if intent.Status == IntentStatusPending { + if err := s.store.MarkIntentApplied(intent.ID); err != nil { + return nil, err + } + } + return s.store.CommitIntent(intent.ID, s.maxVersions) + } + return nil, &IntentPendingError{IntentID: intent.ID} +} + +func buildMutationInsertRequest(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 op == OperationDelete { + hash = HashSpecJSON(DeleteSpecJSON) + specJSON = DeleteSpecJSON + err = nil + } + if err != nil { + return InsertRequest{}, err + } + 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 +} + +func IntentMatchesResource(intent *Intent, current coremodel.Resource, deleted bool) bool { + if intent == nil { + return false + } + if deleted || current == nil { + return intent.Operation == OperationDelete && intent.ContentHash == HashSpecJSON(DeleteSpecJSON) + } + hash, _, err := NormalizeResource(current) + return err == nil && hash == intent.ContentHash +} + +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 := protojson.Unmarshal([]byte(specJSON), &spec); err != nil { + if err := json.Unmarshal([]byte(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 := protojson.Unmarshal([]byte(specJSON), &spec); err != nil { + if err := json.Unmarshal([]byte(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 := protojson.Unmarshal([]byte(specJSON), &spec); err != nil { + if err := json.Unmarshal([]byte(specJSON), &spec); err != nil { + return nil, err + } + } + res.Spec = &spec + return res, nil + default: + return nil, bizerror.New(bizerror.InvalidArgument, "unsupported rule kind") + } +} diff --git a/pkg/core/versioning/store.go b/pkg/core/versioning/store.go new file mode 100644 index 000000000..7d4cb841c --- /dev/null +++ b/pkg/core/versioning/store.go @@ -0,0 +1,422 @@ +/* + * 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 ( + "sort" + "sync" + "time" + + coremodel "github.com/apache/dubbo-admin/pkg/core/resource/model" +) + +type Store interface { + InsertVersion(req InsertRequest, maxVersions int64) (*Version, error) + CreateIntent(req InsertRequest, expected *int64) (*Intent, error) + GetIntent(id int64) (*Intent, error) + OpenIntent(kind coremodel.ResourceKind, resourceKey string) (*Intent, error) + FindOpenIntentByHash(kind coremodel.ResourceKind, resourceKey, contentHash string) (*Intent, error) + MarkIntentApplied(id int64) error + MarkIntentFailed(id int64, message string) error + MarkIntentFailedWithReason(id int64, reason string) error + CommitIntent(id int64, maxVersions int64) (*Version, error) + ListOpenIntents() ([]Intent, error) + ListVersions(kind coremodel.ResourceKind, resourceKey string) ([]Version, error) + GetVersion(kind coremodel.ResourceKind, resourceKey string, id int64) (*Version, error) + GetVersionByID(id int64) (*Version, error) + CurrentMeta(kind coremodel.ResourceKind, resourceKey string) (*Meta, error) + LatestVersion(kind coremodel.ResourceKind, resourceKey string) (*Version, error) + CheckExpectedVersion(kind coremodel.ResourceKind, resourceKey string, expected *int64) error +} + +type MemoryStore struct { + mu sync.Mutex + nextID int64 + nextIntentID int64 + versions map[int64]*Version + byRule map[ruleKey][]int64 + meta map[ruleKey]*Meta + intents map[int64]*Intent + byIntentRule map[ruleKey][]int64 +} + +type ruleKey struct { + kind coremodel.ResourceKind + resourceKey string +} + +func NewMemoryStore() *MemoryStore { + return &MemoryStore{ + nextID: 1, + nextIntentID: 1, + versions: make(map[int64]*Version), + byRule: make(map[ruleKey][]int64), + meta: make(map[ruleKey]*Meta), + intents: make(map[int64]*Intent), + byIntentRule: make(map[ruleKey][]int64), + } +} + +func (s *MemoryStore) InsertVersion(req InsertRequest, maxVersions int64) (*Version, error) { + s.mu.Lock() + defer s.mu.Unlock() + return s.insertVersionLocked(req, maxVersions) +} + +func (s *MemoryStore) insertVersionLocked(req InsertRequest, maxVersions int64) (*Version, error) { + key := ruleKey{kind: req.RuleKind, resourceKey: req.ResourceKey} + meta := s.meta[key] + if meta == nil { + meta = &Meta{RuleKind: req.RuleKind, ResourceKey: req.ResourceKey} + s.meta[key] = meta + } + if ids := s.byRule[key]; len(ids) > 0 { + latest := s.versions[ids[len(ids)-1]] + if shouldDedupVersion(latest, req) { + cp := *latest + if meta.CurrentVersion != nil && *meta.CurrentVersion == cp.ID { + cp.IsCurrent = true + } + return &cp, nil + } + } + now := req.CreatedAt + meta.LastVersionNo++ + id := s.nextID + s.nextID++ + v := &Version{ + ID: id, + RuleKind: req.RuleKind, + Mesh: req.Mesh, + ResourceKey: req.ResourceKey, + RuleName: req.RuleName, + VersionNo: meta.LastVersionNo, + ContentHash: req.ContentHash, + SpecJSON: req.SpecJSON, + Source: req.Source, + Operation: req.Operation, + Author: req.Author, + Reason: req.Reason, + RolledBackFromID: req.RolledBackFromID, + CreatedAt: now, + } + s.versions[id] = v + s.byRule[key] = append(s.byRule[key], id) + if req.Operation == OperationDelete { + meta.CurrentVersion = nil + } else { + current := id + meta.CurrentVersion = ¤t + } + meta.UpdatedAt = now + s.trimLocked(key, maxVersions) + cp := *v + cp.IsCurrent = meta.CurrentVersion != nil && *meta.CurrentVersion == cp.ID + return &cp, nil +} + +func (s *MemoryStore) CreateIntent(req InsertRequest, expected *int64) (*Intent, error) { + s.mu.Lock() + defer s.mu.Unlock() + now := req.CreatedAt + if now.IsZero() { + now = time.Now() + } + id := s.nextIntentID + s.nextIntentID++ + intent := &Intent{ + ID: id, + RuleKind: req.RuleKind, + Mesh: req.Mesh, + ResourceKey: req.ResourceKey, + RuleName: req.RuleName, + ContentHash: req.ContentHash, + SpecJSON: req.SpecJSON, + Source: req.Source, + Operation: req.Operation, + Author: req.Author, + Reason: req.Reason, + RolledBackFromID: req.RolledBackFromID, + ExpectedVersionID: expected, + Status: IntentStatusPending, + CreatedAt: now, + UpdatedAt: now, + } + s.intents[id] = intent + key := ruleKey{kind: req.RuleKind, resourceKey: req.ResourceKey} + s.byIntentRule[key] = append(s.byIntentRule[key], id) + return copyIntent(intent), nil +} + +func (s *MemoryStore) GetIntent(id int64) (*Intent, error) { + s.mu.Lock() + defer s.mu.Unlock() + intent := s.intents[id] + if intent == nil { + return nil, ErrVersionIntentNotFound + } + return copyIntent(intent), nil +} + +func (s *MemoryStore) OpenIntent(kind coremodel.ResourceKind, resourceKey string) (*Intent, error) { + s.mu.Lock() + defer s.mu.Unlock() + return copyIntent(s.openIntentLocked(ruleKey{kind: kind, resourceKey: resourceKey})), nil +} + +func (s *MemoryStore) FindOpenIntentByHash(kind coremodel.ResourceKind, resourceKey, contentHash string) (*Intent, error) { + s.mu.Lock() + defer s.mu.Unlock() + key := ruleKey{kind: kind, resourceKey: resourceKey} + for _, id := range s.byIntentRule[key] { + intent := s.intents[id] + if isOpenIntent(intent) && intent.ContentHash == contentHash { + return copyIntent(intent), nil + } + } + return nil, nil +} + +func (s *MemoryStore) MarkIntentApplied(id int64) error { + s.mu.Lock() + defer s.mu.Unlock() + intent := s.intents[id] + if intent == nil { + return ErrVersionIntentNotFound + } + if intent.Status == IntentStatusPending { + intent.Status = IntentStatusApplied + intent.UpdatedAt = time.Now() + } + return nil +} + +func (s *MemoryStore) MarkIntentFailed(id int64, message string) error { + return s.MarkIntentFailedWithReason(id, message) +} + +func (s *MemoryStore) MarkIntentFailedWithReason(id int64, reason string) error { + s.mu.Lock() + defer s.mu.Unlock() + intent := s.intents[id] + if intent == nil { + return ErrVersionIntentNotFound + } + if intent.Status != IntentStatusPending { + return ErrVersionIntentNotOpen + } + intent.Status = IntentStatusFailed + intent.LastError = reason + intent.UpdatedAt = time.Now() + return nil +} + +func (s *MemoryStore) CommitIntent(id int64, maxVersions int64) (*Version, error) { + s.mu.Lock() + defer s.mu.Unlock() + intent := s.intents[id] + if intent == nil || !isOpenIntent(intent) { + if intent != nil && intent.Status == IntentStatusCommitted && intent.VersionID != nil { + return s.copyVersionLocked(*intent.VersionID) + } + return nil, ErrVersionIntentPending + } + version, err := s.insertVersionLocked(intentInsertRequest(intent), maxVersions) + if err != nil { + return nil, err + } + intent.Status = IntentStatusCommitted + intent.VersionID = &version.ID + intent.UpdatedAt = time.Now() + return version, nil +} + +func (s *MemoryStore) ListOpenIntents() ([]Intent, error) { + s.mu.Lock() + defer s.mu.Unlock() + items := make([]Intent, 0) + for _, intent := range s.intents { + if isOpenIntent(intent) { + items = append(items, *copyIntent(intent)) + } + } + sort.Slice(items, func(i, j int) bool { + return items[i].ID < items[j].ID + }) + return items, nil +} + +// shouldDedupVersion collapses identical consecutive writes onto the latest row +// instead of producing a new ledger entry. Two writes are considered identical +// when their canonical content hashes match AND their operations are compatible: +// an existing Delete row is only deduped against another Delete (since all +// deletes share HashSpecJSON("{}")), and non-Delete writes are deduped whenever +// the hashes match. Trade-off: the ledger is not a verbatim API-call log; it is +// an "effective state change" log. This avoids ZK push-burst flooding the +// history while preserving every distinct content snapshot. +func shouldDedupVersion(latest *Version, req InsertRequest) bool { + if latest == nil || latest.ContentHash != req.ContentHash { + return false + } + if latest.Operation == OperationDelete || req.Operation == OperationDelete { + return latest.Operation == req.Operation + } + return true +} + +func (s *MemoryStore) ListVersions(kind coremodel.ResourceKind, resourceKey string) ([]Version, error) { + s.mu.Lock() + defer s.mu.Unlock() + key := ruleKey{kind: kind, resourceKey: resourceKey} + ids := append([]int64(nil), s.byRule[key]...) + sort.Slice(ids, func(i, j int) bool { + return s.versions[ids[i]].VersionNo > s.versions[ids[j]].VersionNo + }) + meta := s.meta[key] + items := make([]Version, 0, len(ids)) + for _, id := range ids { + if v := s.versions[id]; v != nil { + cp := *v + cp.IsCurrent = meta != nil && meta.CurrentVersion != nil && *meta.CurrentVersion == cp.ID + items = append(items, cp) + } + } + return items, nil +} + +func (s *MemoryStore) GetVersion(kind coremodel.ResourceKind, resourceKey string, id int64) (*Version, error) { + s.mu.Lock() + defer s.mu.Unlock() + v, err := s.copyVersionLocked(id) + if err != nil { + return nil, err + } + if v.RuleKind != kind || v.ResourceKey != resourceKey { + return nil, ErrVersionNotFound + } + return v, nil +} + +func (s *MemoryStore) copyVersionLocked(id int64) (*Version, error) { + v := s.versions[id] + if v == nil { + return nil, ErrVersionNotFound + } + cp := *v + if meta := s.meta[ruleKey{kind: v.RuleKind, resourceKey: v.ResourceKey}]; meta != nil && meta.CurrentVersion != nil { + cp.IsCurrent = *meta.CurrentVersion == cp.ID + } + return &cp, nil +} + +func (s *MemoryStore) GetVersionByID(id int64) (*Version, error) { + s.mu.Lock() + defer s.mu.Unlock() + return s.copyVersionLocked(id) +} + +func (s *MemoryStore) CurrentMeta(kind coremodel.ResourceKind, resourceKey string) (*Meta, error) { + s.mu.Lock() + defer s.mu.Unlock() + meta := s.meta[ruleKey{kind: kind, resourceKey: resourceKey}] + if meta == nil { + return nil, nil + } + cp := *meta + return &cp, nil +} + +func (s *MemoryStore) LatestVersion(kind coremodel.ResourceKind, resourceKey string) (*Version, error) { + items, err := s.ListVersions(kind, resourceKey) + if err != nil || len(items) == 0 { + return nil, err + } + return &items[0], nil +} + +func (s *MemoryStore) CheckExpectedVersion(kind coremodel.ResourceKind, resourceKey string, expected *int64) error { + if expected == nil { + return nil + } + meta, err := s.CurrentMeta(kind, resourceKey) + if err != nil { + return err + } + if meta == nil || meta.CurrentVersion == nil || *meta.CurrentVersion != *expected { + var current *int64 + if meta != nil { + current = meta.CurrentVersion + } + return &ConflictError{CurrentVersionID: current} + } + return nil +} + +func (s *MemoryStore) trimLocked(key ruleKey, maxVersions int64) { + if maxVersions <= 0 { + return + } + ids := s.byRule[key] + if int64(len(ids)) <= maxVersions { + return + } + remove := ids[:int64(len(ids))-maxVersions] + s.byRule[key] = ids[int64(len(ids))-maxVersions:] + for _, id := range remove { + delete(s.versions, id) + } +} + +func (s *MemoryStore) openIntentLocked(key ruleKey) *Intent { + for _, id := range s.byIntentRule[key] { + intent := s.intents[id] + if isOpenIntent(intent) { + return intent + } + } + return nil +} + +func isOpenIntent(intent *Intent) bool { + return intent != nil && (intent.Status == IntentStatusPending || intent.Status == IntentStatusApplied) +} + +func copyIntent(intent *Intent) *Intent { + if intent == nil { + return nil + } + cp := *intent + return &cp +} + +func intentInsertRequest(intent *Intent) InsertRequest { + return InsertRequest{ + RuleKind: intent.RuleKind, + Mesh: intent.Mesh, + ResourceKey: intent.ResourceKey, + RuleName: intent.RuleName, + SpecJSON: intent.SpecJSON, + ContentHash: intent.ContentHash, + Source: intent.Source, + Operation: intent.Operation, + Author: intent.Author, + Reason: intent.Reason, + RolledBackFromID: intent.RolledBackFromID, + CreatedAt: intent.CreatedAt, + } +} diff --git a/pkg/core/versioning/store_gorm.go b/pkg/core/versioning/store_gorm.go new file mode 100644 index 000000000..93a1855cc --- /dev/null +++ b/pkg/core/versioning/store_gorm.go @@ -0,0 +1,411 @@ +/* + * 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" + "sync" + + "gorm.io/gorm" + "gorm.io/gorm/clause" + + coremodel "github.com/apache/dubbo-admin/pkg/core/resource/model" +) + +type GormStore struct { + db *gorm.DB + mu sync.Mutex +} + +func NewGormStore(db *gorm.DB) *GormStore { + return &GormStore{db: db} +} + +func (s *GormStore) AutoMigrate() error { + return s.db.AutoMigrate(&Version{}, &Meta{}, &Intent{}) +} + +func (s *GormStore) InsertVersion(req InsertRequest, maxVersions int64) (*Version, error) { + s.mu.Lock() + defer s.mu.Unlock() + var inserted Version + err := s.db.Transaction(func(tx *gorm.DB) error { + version, err := insertVersionTx(tx, req, maxVersions) + if err != nil { + return err + } + inserted = *version + return nil + }) + if err != nil { + return nil, err + } + meta, err := s.CurrentMeta(inserted.RuleKind, inserted.ResourceKey) + if err == nil && meta != nil && meta.CurrentVersion != nil { + inserted.IsCurrent = *meta.CurrentVersion == inserted.ID + } + return &inserted, nil +} + +func (s *GormStore) CreateIntent(req InsertRequest, expected *int64) (*Intent, error) { + now := req.CreatedAt + if now.IsZero() { + now = s.db.NowFunc() + } + intent := Intent{ + RuleKind: req.RuleKind, + Mesh: req.Mesh, + ResourceKey: req.ResourceKey, + RuleName: req.RuleName, + ContentHash: req.ContentHash, + SpecJSON: req.SpecJSON, + Source: req.Source, + Operation: req.Operation, + Author: req.Author, + Reason: req.Reason, + RolledBackFromID: req.RolledBackFromID, + ExpectedVersionID: expected, + Status: IntentStatusPending, + CreatedAt: now, + UpdatedAt: now, + } + if err := s.db.Create(&intent).Error; err != nil { + return nil, err + } + return &intent, nil +} + +func (s *GormStore) GetIntent(id int64) (*Intent, error) { + var intent Intent + err := s.db.Where("id = ?", id).First(&intent).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrVersionIntentNotFound + } + if err != nil { + return nil, err + } + return &intent, nil +} + +func (s *GormStore) OpenIntent(kind coremodel.ResourceKind, resourceKey string) (*Intent, error) { + var intent Intent + err := s.db.Where("rule_kind = ? AND resource_key = ? AND status IN ?", kind, resourceKey, openIntentStatuses()). + Order("id ASC"). + First(&intent).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + return &intent, nil +} + +func (s *GormStore) FindOpenIntentByHash(kind coremodel.ResourceKind, resourceKey, contentHash string) (*Intent, error) { + var intent Intent + err := s.db.Where("rule_kind = ? AND resource_key = ? AND content_hash = ? AND status IN ?", kind, resourceKey, contentHash, openIntentStatuses()). + Order("id ASC"). + First(&intent).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + return &intent, nil +} + +func (s *GormStore) MarkIntentApplied(id int64) error { + result := s.db.Model(&Intent{}). + Where("id = ? AND status = ?", id, IntentStatusPending). + Updates(map[string]any{ + "status": IntentStatusApplied, + "updated_at": s.db.NowFunc(), + }) + if result.Error != nil { + return result.Error + } + if result.RowsAffected > 0 { + return nil + } + if _, err := s.GetIntent(id); err != nil { + return err + } + return nil +} + +func (s *GormStore) MarkIntentFailed(id int64, message string) error { + return s.MarkIntentFailedWithReason(id, message) +} + +func (s *GormStore) MarkIntentFailedWithReason(id int64, reason string) error { + result := s.db.Model(&Intent{}). + Where("id = ? AND status = ?", id, IntentStatusPending). + Updates(map[string]any{ + "status": IntentStatusFailed, + "last_error": reason, + "updated_at": s.db.NowFunc(), + }) + if result.Error != nil { + return result.Error + } + if result.RowsAffected > 0 { + return nil + } + if _, err := s.GetIntent(id); err != nil { + return err + } + return ErrVersionIntentNotOpen +} + +func (s *GormStore) CommitIntent(id int64, maxVersions int64) (*Version, error) { + s.mu.Lock() + defer s.mu.Unlock() + var inserted Version + err := s.db.Transaction(func(tx *gorm.DB) error { + var intent Intent + err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("id = ?", id). + First(&intent).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrVersionIntentPending + } + if err != nil { + return err + } + if intent.Status == IntentStatusCommitted { + if intent.VersionID == nil { + return ErrVersionIntentPending + } + return tx.Where("id = ?", *intent.VersionID).First(&inserted).Error + } + if !isOpenIntent(&intent) { + return ErrVersionIntentPending + } + version, err := insertVersionTx(tx, intentInsertRequest(&intent), maxVersions) + if err != nil { + return err + } + inserted = *version + versionID := version.ID + intent.Status = IntentStatusCommitted + intent.VersionID = &versionID + intent.UpdatedAt = tx.NowFunc() + return tx.Save(&intent).Error + }) + if err != nil { + return nil, err + } + meta, err := s.CurrentMeta(inserted.RuleKind, inserted.ResourceKey) + if err == nil && meta != nil && meta.CurrentVersion != nil { + inserted.IsCurrent = *meta.CurrentVersion == inserted.ID + } + return &inserted, nil +} + +func (s *GormStore) ListOpenIntents() ([]Intent, error) { + var items []Intent + if err := s.db.Where("status IN ?", openIntentStatuses()). + Order("id ASC"). + Find(&items).Error; err != nil { + return nil, err + } + return items, nil +} + +func (s *GormStore) ListVersions(kind coremodel.ResourceKind, resourceKey string) ([]Version, error) { + var items []Version + if err := s.db.Where("rule_kind = ? AND resource_key = ?", kind, resourceKey). + Order("version_no DESC"). + Find(&items).Error; err != nil { + return nil, err + } + meta, err := s.CurrentMeta(kind, resourceKey) + if err != nil { + return nil, err + } + for i := range items { + items[i].IsCurrent = meta != nil && meta.CurrentVersion != nil && *meta.CurrentVersion == items[i].ID + } + return items, nil +} + +func (s *GormStore) GetVersion(kind coremodel.ResourceKind, resourceKey string, id int64) (*Version, error) { + var v Version + err := s.db.Where("id = ? AND rule_kind = ? AND resource_key = ?", id, kind, resourceKey).First(&v).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrVersionNotFound + } + if err != nil { + return nil, err + } + meta, err := s.CurrentMeta(kind, resourceKey) + if err != nil { + return nil, err + } + v.IsCurrent = meta != nil && meta.CurrentVersion != nil && *meta.CurrentVersion == v.ID + return &v, nil +} + +func (s *GormStore) GetVersionByID(id int64) (*Version, error) { + var v Version + err := s.db.Where("id = ?", id).First(&v).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrVersionNotFound + } + if err != nil { + return nil, err + } + meta, err := s.CurrentMeta(v.RuleKind, v.ResourceKey) + if err != nil { + return nil, err + } + v.IsCurrent = meta != nil && meta.CurrentVersion != nil && *meta.CurrentVersion == v.ID + return &v, nil +} + +func (s *GormStore) CurrentMeta(kind coremodel.ResourceKind, resourceKey string) (*Meta, error) { + var meta Meta + err := s.db.Where("rule_kind = ? AND resource_key = ?", kind, resourceKey).First(&meta).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + return &meta, nil +} + +func (s *GormStore) LatestVersion(kind coremodel.ResourceKind, resourceKey string) (*Version, error) { + var v Version + err := s.db.Where("rule_kind = ? AND resource_key = ?", kind, resourceKey). + Order("version_no DESC"). + First(&v).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + return &v, nil +} + +func (s *GormStore) CheckExpectedVersion(kind coremodel.ResourceKind, resourceKey string, expected *int64) error { + if expected == nil { + return nil + } + meta, err := s.CurrentMeta(kind, resourceKey) + if err != nil { + return err + } + if meta == nil || meta.CurrentVersion == nil || *meta.CurrentVersion != *expected { + var current *int64 + if meta != nil { + current = meta.CurrentVersion + } + return &ConflictError{CurrentVersionID: current} + } + return nil +} + +func insertVersionTx(tx *gorm.DB, req InsertRequest, maxVersions int64) (*Version, error) { + var meta Meta + err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("rule_kind = ? AND resource_key = ?", req.RuleKind, req.ResourceKey). + First(&meta).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + newMeta := Meta{RuleKind: req.RuleKind, ResourceKey: req.ResourceKey, UpdatedAt: req.CreatedAt} + if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&newMeta).Error; err != nil { + return nil, err + } + // Re-select with FOR UPDATE so concurrent inserts converge on the same row. + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("rule_kind = ? AND resource_key = ?", req.RuleKind, req.ResourceKey). + First(&meta).Error; err != nil { + return nil, err + } + } else if err != nil { + return nil, err + } + var latest Version + err = tx.Where("rule_kind = ? AND resource_key = ?", req.RuleKind, req.ResourceKey). + Order("version_no DESC"). + First(&latest).Error + if err == nil && shouldDedupVersion(&latest, req) { + return &latest, nil + } + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + meta.LastVersionNo++ + inserted := Version{ + RuleKind: req.RuleKind, + Mesh: req.Mesh, + ResourceKey: req.ResourceKey, + RuleName: req.RuleName, + VersionNo: meta.LastVersionNo, + ContentHash: req.ContentHash, + SpecJSON: req.SpecJSON, + Source: req.Source, + Operation: req.Operation, + Author: req.Author, + Reason: req.Reason, + RolledBackFromID: req.RolledBackFromID, + CreatedAt: req.CreatedAt, + } + if err := tx.Create(&inserted).Error; err != nil { + return nil, err + } + if req.Operation == OperationDelete { + meta.CurrentVersion = nil + } else { + current := inserted.ID + meta.CurrentVersion = ¤t + } + meta.UpdatedAt = req.CreatedAt + if err := tx.Save(&meta).Error; err != nil { + return nil, err + } + if err := trimGorm(tx, req.RuleKind, req.ResourceKey, maxVersions); err != nil { + return nil, err + } + return &inserted, nil +} + +func openIntentStatuses() []IntentStatus { + return []IntentStatus{IntentStatusPending, IntentStatusApplied} +} + +func trimGorm(tx *gorm.DB, kind coremodel.ResourceKind, resourceKey string, maxVersions int64) error { + if maxVersions <= 0 { + return nil + } + var keepIDs []int64 + if err := tx.Model(&Version{}). + Where("rule_kind = ? AND resource_key = ?", kind, resourceKey). + Order("version_no DESC"). + Limit(int(maxVersions)). + Pluck("id", &keepIDs).Error; err != nil { + return err + } + if len(keepIDs) == 0 { + return nil + } + return tx.Where("rule_kind = ? AND resource_key = ? AND id NOT IN ?", kind, resourceKey, keepIDs). + Delete(&Version{}).Error +} diff --git a/pkg/core/versioning/store_gorm_test.go b/pkg/core/versioning/store_gorm_test.go new file mode 100644 index 000000000..67723b9ff --- /dev/null +++ b/pkg/core/versioning/store_gorm_test.go @@ -0,0 +1,261 @@ +/* + * 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" + "strings" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + + meshresource "github.com/apache/dubbo-admin/pkg/core/resource/apis/mesh/v1alpha1" +) + +func setupGormVersionStore(t *testing.T) *GormStore { + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{}) + require.NoError(t, err) + store := NewGormStore(db) + require.NoError(t, store.AutoMigrate()) + require.NoError(t, store.AutoMigrate()) + return store +} + +func TestGormStoreAutoMigrateSpecJSONUsesPortableText(t *testing.T) { + store := setupGormVersionStore(t) + + columns, err := store.db.Migrator().ColumnTypes(&Version{}) + require.NoError(t, err) + for _, column := range columns { + if column.Name() != "spec_json" { + continue + } + require.Equal(t, "text", strings.ToLower(column.DatabaseTypeName())) + return + } + require.Fail(t, "spec_json column was not migrated") +} + +func TestGormStoreInsertListGetAndTrim(t *testing.T) { + store := setupGormVersionStore(t) + key := "mesh/demo.condition-router" + for i := 0; i < 4; i++ { + _, err := store.InsertVersion(InsertRequest{ + RuleKind: meshresource.ConditionRouteKind, + Mesh: "mesh", + ResourceKey: key, + RuleName: "demo.condition-router", + SpecJSON: fmt.Sprintf(`{"priority":%d}`, i+1), + ContentHash: fmt.Sprintf("hash-%d", i+1), + Source: SourceAdmin, + Operation: OperationUpdate, + Author: "alice", + CreatedAt: time.Now().Add(time.Duration(i) * time.Second), + }, 2) + require.NoError(t, err) + } + items, err := store.ListVersions(meshresource.ConditionRouteKind, key) + require.NoError(t, err) + require.Len(t, items, 2) + require.Equal(t, int64(4), items[0].VersionNo) + require.True(t, items[0].IsCurrent) + _, err = store.GetVersion(meshresource.ConditionRouteKind, key, items[1].ID) + require.NoError(t, err) + meta, err := store.CurrentMeta(meshresource.ConditionRouteKind, key) + require.NoError(t, err) + require.Equal(t, int64(4), meta.LastVersionNo) + + _, err = store.InsertVersion(InsertRequest{ + RuleKind: meshresource.ConditionRouteKind, + Mesh: "mesh", + ResourceKey: key, + RuleName: "demo.condition-router", + SpecJSON: `{"priority":5}`, + ContentHash: "hash-5", + Source: SourceAdmin, + Operation: OperationUpdate, + Author: "alice", + CreatedAt: time.Now().Add(5 * time.Second), + }, 2) + require.NoError(t, err) + meta, err = store.CurrentMeta(meshresource.ConditionRouteKind, key) + require.NoError(t, err) + require.Equal(t, int64(5), meta.LastVersionNo) +} + +func TestGormStoreDeleteIsNotDedupedAgainstEmptyCurrentSpec(t *testing.T) { + store := setupGormVersionStore(t) + key := "mesh/demo.condition-router" + created, err := store.InsertVersion(InsertRequest{ + RuleKind: meshresource.ConditionRouteKind, + Mesh: "mesh", + ResourceKey: key, + RuleName: "demo.condition-router", + SpecJSON: DeleteSpecJSON, + ContentHash: HashSpecJSON(DeleteSpecJSON), + Source: SourceAdmin, + Operation: OperationCreate, + Author: "alice", + CreatedAt: time.Now(), + }, 5) + require.NoError(t, err) + deleted, err := store.InsertVersion(InsertRequest{ + RuleKind: meshresource.ConditionRouteKind, + Mesh: "mesh", + ResourceKey: key, + RuleName: "demo.condition-router", + SpecJSON: DeleteSpecJSON, + ContentHash: HashSpecJSON(DeleteSpecJSON), + Source: SourceAdmin, + Operation: OperationDelete, + Author: "alice", + CreatedAt: time.Now().Add(time.Second), + }, 5) + require.NoError(t, err) + + require.NotEqual(t, created.ID, deleted.ID) + require.Equal(t, int64(2), deleted.VersionNo) + items, err := store.ListVersions(meshresource.ConditionRouteKind, key) + require.NoError(t, err) + require.Len(t, items, 2) + require.Equal(t, OperationDelete, items[0].Operation) + meta, err := store.CurrentMeta(meshresource.ConditionRouteKind, key) + require.NoError(t, err) + require.Nil(t, meta.CurrentVersion) +} + +func TestGormStoreIntentCommit(t *testing.T) { + store := setupGormVersionStore(t) + key := "mesh/demo.condition-router" + expected := int64(7) + intent, err := store.CreateIntent(InsertRequest{ + RuleKind: meshresource.ConditionRouteKind, + Mesh: "mesh", + ResourceKey: key, + RuleName: "demo.condition-router", + SpecJSON: `{"priority":1}`, + ContentHash: "hash-1", + Source: SourceAdmin, + Operation: OperationUpdate, + Author: "alice", + Reason: "admin edit", + CreatedAt: time.Now(), + }, &expected) + require.NoError(t, err) + require.Equal(t, IntentStatusPending, intent.Status) + + open, err := store.FindOpenIntentByHash(meshresource.ConditionRouteKind, key, "hash-1") + require.NoError(t, err) + require.NotNil(t, open) + require.Equal(t, expected, *open.ExpectedVersionID) + + require.NoError(t, store.MarkIntentApplied(intent.ID)) + version, err := store.CommitIntent(intent.ID, 5) + require.NoError(t, err) + require.Equal(t, SourceAdmin, version.Source) + require.True(t, version.IsCurrent) + + open, err = store.OpenIntent(meshresource.ConditionRouteKind, key) + require.NoError(t, err) + require.Nil(t, open) + committed, err := store.CommitIntent(intent.ID, 5) + require.NoError(t, err) + require.Equal(t, version.ID, committed.ID) +} + +func TestGormStoreIntentGetListOpenAndFailWithReason(t *testing.T) { + store := setupGormVersionStore(t) + key := "mesh/demo.condition-router" + intent, err := store.CreateIntent(InsertRequest{ + RuleKind: meshresource.ConditionRouteKind, + Mesh: "mesh", + ResourceKey: key, + RuleName: "demo.condition-router", + SpecJSON: `{"priority":1}`, + ContentHash: "hash-1", + Source: SourceAdmin, + Operation: OperationUpdate, + Author: "alice", + CreatedAt: time.Now(), + }, nil) + require.NoError(t, err) + + got, err := store.GetIntent(intent.ID) + require.NoError(t, err) + require.Equal(t, IntentStatusPending, got.Status) + open, err := store.ListOpenIntents() + require.NoError(t, err) + require.Len(t, open, 1) + require.Equal(t, intent.ID, open[0].ID) + + require.NoError(t, store.MarkIntentFailedWithReason(intent.ID, "registry rejected mutation")) + got, err = store.GetIntent(intent.ID) + require.NoError(t, err) + require.Equal(t, IntentStatusFailed, got.Status) + require.Equal(t, "registry rejected mutation", got.LastError) + open, err = store.ListOpenIntents() + require.NoError(t, err) + require.Empty(t, open) + require.ErrorIs(t, store.MarkIntentFailedWithReason(intent.ID, "again"), ErrVersionIntentNotOpen) + _, err = store.GetIntent(404) + require.ErrorIs(t, err, ErrVersionIntentNotFound) +} + +func TestGormStoreMetaCounterConcurrencyMonotonic(t *testing.T) { + store := setupGormVersionStore(t) + key := "mesh/concurrent.condition-router" + var wg sync.WaitGroup + errCh := make(chan error, 6) + for i := 0; i < 6; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + _, err := store.InsertVersion(InsertRequest{ + RuleKind: meshresource.ConditionRouteKind, + Mesh: "mesh", + ResourceKey: key, + RuleName: "concurrent.condition-router", + SpecJSON: fmt.Sprintf(`{"priority":%d}`, i), + ContentHash: fmt.Sprintf("hash-concurrent-%d", i), + Source: SourceAdmin, + Operation: OperationUpdate, + Author: "alice", + CreatedAt: time.Now().Add(time.Duration(i) * time.Millisecond), + }, 10) + errCh <- err + }(i) + } + wg.Wait() + close(errCh) + for err := range errCh { + require.NoError(t, err) + } + items, err := store.ListVersions(meshresource.ConditionRouteKind, key) + require.NoError(t, err) + require.Len(t, items, 6) + seen := map[int64]bool{} + for _, item := range items { + require.False(t, seen[item.VersionNo]) + seen[item.VersionNo] = true + } + require.Len(t, seen, 6) +} diff --git a/pkg/core/versioning/subscriber.go b/pkg/core/versioning/subscriber.go new file mode 100644 index 000000000..388644f45 --- /dev/null +++ b/pkg/core/versioning/subscriber.go @@ -0,0 +1,205 @@ +/* + * 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" + "fmt" + "time" + + "k8s.io/client-go/tools/cache" + + "github.com/apache/dubbo-admin/pkg/core/events" + "github.com/apache/dubbo-admin/pkg/core/logger" + coremodel "github.com/apache/dubbo-admin/pkg/core/resource/model" +) + +type Subscriber struct { + kind coremodel.ResourceKind + store Store + maxVersions int64 +} + +func NewSubscriber(kind coremodel.ResourceKind, store Store, maxVersions int64) *Subscriber { + return &Subscriber{ + kind: kind, + store: store, + maxVersions: maxVersions, + } +} + +func (s *Subscriber) ResourceKind() coremodel.ResourceKind { + return s.kind +} + +func (s *Subscriber) Name() string { + return "rule-version-" + s.kind.ToString() +} + +func (s *Subscriber) AsyncEnabled() bool { + return false +} + +func (s *Subscriber) ProcessEvent(event events.Event) error { + res := event.NewObj() + if event.Type() == cache.Deleted { + res = event.OldObj() + } + if res == nil { + return nil + } + return s.record(event) +} + +func (s *Subscriber) FlushAll() { + // Synchronous subscribers do not buffer events. +} + +func (s *Subscriber) record(event events.Event) error { + res := event.NewObj() + op := OperationUpdate + switch event.Type() { + case cache.Added: + op = OperationCreate + case cache.Updated: + op = OperationUpdate + case cache.Deleted: + op = OperationDelete + res = event.OldObj() + default: + return nil + } + if res == nil { + return nil + } + hash, specJSON, err := NormalizeResource(res) + if op == OperationDelete { + hash = HashSpecJSON(DeleteSpecJSON) + specJSON = DeleteSpecJSON + err = nil + } + if err != nil { + return err + } + ruleKind := res.ResourceKind() + mesh := res.ResourceMesh() + resourceKey := res.ResourceKey() + ruleName := res.ResourceMeta().Name + committed, err := s.tryCommitMatchingIntent(ruleKind, resourceKey, hash) + if err != nil { + return err + } + if committed { + return nil + } + source := SourceUpstream + author := "system:upstream" + reason := "" + if ctx := event.Context(); ctx != nil { + if registry := ctx[events.SourceRegistryContextKey]; registry != "" { + author = "system:" + registry + } + } + if author == "" { + author = "system:unknown" + } + _, err = s.store.InsertVersion(InsertRequest{ + RuleKind: ruleKind, + Mesh: mesh, + ResourceKey: resourceKey, + RuleName: ruleName, + SpecJSON: specJSON, + ContentHash: hash, + Source: source, + Operation: op, + Author: author, + Reason: reason, + CreatedAt: time.Now(), + }, s.maxVersions) + return err +} + +// tryCommitMatchingIntent attempts to attach the incoming event to an open admin +// intent with the same content hash. Returns committed=true when an intent was +// committed (so the caller should not insert another version row). When the +// intent has been concurrently closed (failed/committed/removed) the helper +// logs a warning and returns committed=false so the caller falls back to the +// normal UPSTREAM insert path — the event must never be silently dropped. +func (s *Subscriber) tryCommitMatchingIntent(kind coremodel.ResourceKind, resourceKey, hash string) (bool, error) { + intent, err := s.store.FindOpenIntentByHash(kind, resourceKey, hash) + if err != nil { + return false, err + } + if intent == nil { + return false, nil + } + if intent.Status == IntentStatusPending { + if err := s.store.MarkIntentApplied(intent.ID); err != nil { + if !isIntentClosedErr(err) { + return false, err + } + logger.Warnf("rule version intent %d for %s no longer pending (%v); falling back to upstream record", intent.ID, resourceKey, err) + return false, nil + } + } + if _, err := s.store.CommitIntent(intent.ID, s.maxVersions); err != nil { + if !isIntentClosedErr(err) { + return false, err + } + logger.Warnf("rule version intent %d for %s could not be committed (%v); falling back to upstream record", intent.ID, resourceKey, err) + return false, nil + } + return true, nil +} + +func isIntentClosedErr(err error) bool { + return errors.Is(err, ErrVersionIntentPending) || + errors.Is(err, ErrVersionIntentNotOpen) || + errors.Is(err, ErrVersionIntentNotFound) +} + +func RecordBootstrap(store Store, maxVersions int64, res coremodel.Resource) error { + // TODO: batch insert when rule count gets large. + meta, err := store.CurrentMeta(res.ResourceKind(), res.ResourceKey()) + if err != nil { + return err + } + if meta != nil { + return nil + } + hash, specJSON, err := NormalizeResource(res) + if err != nil { + return err + } + _, err = store.InsertVersion(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", + CreatedAt: time.Now(), + }, maxVersions) + if err != nil { + return fmt.Errorf("bootstrap version for %s failed: %w", res.ResourceKey(), err) + } + return nil +} diff --git a/pkg/core/versioning/types.go b/pkg/core/versioning/types.go new file mode 100644 index 000000000..2a35be809 --- /dev/null +++ b/pkg/core/versioning/types.go @@ -0,0 +1,172 @@ +/* + * 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" +) + +type Source string + +const ( + SourceAdmin Source = "ADMIN" + SourceUpstream Source = "UPSTREAM" + SourceRollback Source = "ROLLBACK" + SourceBootstrap Source = "BOOTSTRAP" +) + +type Operation string + +const ( + OperationCreate Operation = "CREATE" + OperationUpdate Operation = "UPDATE" + OperationDelete Operation = "DELETE" +) + +type IntentStatus string + +const ( + IntentStatusPending IntentStatus = "PENDING" + IntentStatusApplied IntentStatus = "APPLIED" + IntentStatusCommitted IntentStatus = "COMMITTED" + IntentStatusFailed IntentStatus = "FAILED" +) + +var ( + ErrFeatureDisabled = errors.New("rule versioning is disabled") + ErrVersionConflict = errors.New("rule version conflict") + ErrVersionNotFound = errors.New("rule version not found") + ErrVersionIntentNotFound = errors.New("rule version intent not found") + ErrVersionIntentNotOpen = errors.New("rule version intent is not open") + ErrVersionIntentPending = errors.New("rule version intent is pending") + ErrRollbackToDelete = errors.New("cannot roll back to a deleted rule version") + ErrRollbackToCurrent = errors.New("cannot roll back to a version identical to current") +) + +type Version struct { + ID int64 `json:"id" gorm:"primaryKey;autoIncrement"` + RuleKind coremodel.ResourceKind `json:"ruleKind" gorm:"type:varchar(64);not null;index:idx_rk_key_created,priority:1;uniqueIndex:uk_rk_key_ver,priority:1;index:idx_rk_hash,priority:1"` + Mesh string `json:"mesh" gorm:"type:varchar(128);not null"` + ResourceKey string `json:"resourceKey" gorm:"type:varchar(512);not null;index:idx_rk_key_created,priority:2;uniqueIndex:uk_rk_key_ver,priority:2"` + RuleName string `json:"ruleName" gorm:"type:varchar(256);not null"` + VersionNo int64 `json:"versionNo" gorm:"not null;uniqueIndex:uk_rk_key_ver,priority:3"` + ContentHash string `json:"contentHash" gorm:"type:char(64);not null;index:idx_rk_hash,priority:2"` + SpecJSON string `json:"specJson" gorm:"type:text;not null"` + Source Source `json:"source" gorm:"type:varchar(16);not null"` + Operation Operation `json:"operation" gorm:"type:varchar(16);not null"` + Author string `json:"author" gorm:"type:varchar(128);not null"` + Reason string `json:"reason,omitempty" gorm:"type:varchar(1024)"` + RolledBackFromID *int64 `json:"rolledBackFromId,omitempty"` + CreatedAt time.Time `json:"createdAt" gorm:"not null;index:idx_rk_key_created,priority:3,sort:desc"` + IsCurrent bool `json:"isCurrent" gorm:"-"` +} + +func (Version) TableName() string { + return "rule_version" +} + +type Meta struct { + RuleKind coremodel.ResourceKind `json:"ruleKind" gorm:"type:varchar(64);primaryKey"` + ResourceKey string `json:"resourceKey" gorm:"type:varchar(512);primaryKey"` + CurrentVersion *int64 `json:"currentVersion"` + LastVersionNo int64 `json:"lastVersionNo" gorm:"not null;default:0"` + UpdatedAt time.Time `json:"updatedAt" gorm:"not null"` +} + +func (Meta) TableName() string { + return "rule_version_meta" +} + +type Intent struct { + ID int64 `json:"id" gorm:"primaryKey;autoIncrement"` + RuleKind coremodel.ResourceKind `json:"ruleKind" gorm:"type:varchar(64);not null;index:idx_intent_rule_status,priority:1;index:idx_intent_hash,priority:1"` + Mesh string `json:"mesh" gorm:"type:varchar(128);not null"` + ResourceKey string `json:"resourceKey" gorm:"type:varchar(512);not null;index:idx_intent_rule_status,priority:2;index:idx_intent_hash,priority:2"` + RuleName string `json:"ruleName" gorm:"type:varchar(256);not null"` + ContentHash string `json:"contentHash" gorm:"type:char(64);not null;index:idx_intent_hash,priority:3"` + SpecJSON string `json:"specJson" gorm:"type:text;not null"` + Source Source `json:"source" gorm:"type:varchar(16);not null"` + Operation Operation `json:"operation" gorm:"type:varchar(16);not null"` + Author string `json:"author" gorm:"type:varchar(128);not null"` + Reason string `json:"reason,omitempty" gorm:"type:varchar(1024)"` + RolledBackFromID *int64 `json:"rolledBackFromId,omitempty"` + ExpectedVersionID *int64 `json:"expectedVersionId,omitempty"` + Status IntentStatus `json:"status" gorm:"type:varchar(16);not null;index:idx_intent_rule_status,priority:3"` + VersionID *int64 `json:"versionId,omitempty"` + LastError string `json:"lastError,omitempty" gorm:"type:varchar(1024)"` + CreatedAt time.Time `json:"createdAt" gorm:"not null"` + UpdatedAt time.Time `json:"updatedAt" gorm:"not null"` +} + +func (Intent) TableName() string { + return "rule_version_intent" +} + +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"` +} + +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"` +} + +type ConflictError struct { + CurrentVersionID *int64 +} + +func (e *ConflictError) Error() string { + return ErrVersionConflict.Error() +} + +type IntentPendingError struct { + IntentID int64 +} + +func (e *IntentPendingError) Error() string { + return ErrVersionIntentPending.Error() +} + +func (e *IntentPendingError) Is(target error) bool { + return target == ErrVersionIntentPending +} diff --git a/pkg/core/versioning/versioning_test.go b/pkg/core/versioning/versioning_test.go new file mode 100644 index 000000000..39d277cb3 --- /dev/null +++ b/pkg/core/versioning/versioning_test.go @@ -0,0 +1,727 @@ +/* + * 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" + "reflect" + "testing" + "time" + + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "k8s.io/client-go/tools/cache" + + meshproto "github.com/apache/dubbo-admin/api/mesh/v1alpha1" + "github.com/apache/dubbo-admin/pkg/common/bizerror" + appconfig "github.com/apache/dubbo-admin/pkg/config/app" + eventbusconfig "github.com/apache/dubbo-admin/pkg/config/eventbus" + "github.com/apache/dubbo-admin/pkg/config/mode" + versioningcfg "github.com/apache/dubbo-admin/pkg/config/versioning" + "github.com/apache/dubbo-admin/pkg/core/events" + "github.com/apache/dubbo-admin/pkg/core/manager" + meshresource "github.com/apache/dubbo-admin/pkg/core/resource/apis/mesh/v1alpha1" + "github.com/apache/dubbo-admin/pkg/core/resource/model" + coreruntime "github.com/apache/dubbo-admin/pkg/core/runtime" + "github.com/apache/dubbo-admin/pkg/core/store/index" +) + +func TestNormalizeSpecHashStable(t *testing.T) { + hash1, spec1, err := NormalizeSpec(&meshproto.ConditionRoute{ + Enabled: true, + Conditions: []string{"host = 127.0.0.1"}, + Key: "demo", + }) + require.NoError(t, err) + hash2, spec2, err := NormalizeSpec(&meshproto.ConditionRoute{ + Key: "demo", + Conditions: []string{"host = 127.0.0.1"}, + Enabled: true, + }) + require.NoError(t, err) + require.Equal(t, spec1, spec2) + require.Equal(t, hash1, hash2) + require.NotEmpty(t, hash1) +} + +func TestNormalizeSpecHashStableForProtoWithMaps(t *testing.T) { + hash1, spec1, err := NormalizeSpec(&meshproto.DynamicConfig{ + Key: "demo", + ConfigVersion: "v3.0", + Configs: []*meshproto.OverrideConfig{{ + Parameters: map[string]string{"timeout": "1000", "retries": "2"}, + }}, + }) + require.NoError(t, err) + hash2, spec2, err := NormalizeSpec(&meshproto.DynamicConfig{ + ConfigVersion: "v3.0", + Key: "demo", + Configs: []*meshproto.OverrideConfig{{ + Parameters: map[string]string{"retries": "2", "timeout": "1000"}, + }}, + }) + require.NoError(t, err) + require.Equal(t, spec1, spec2) + require.Equal(t, hash1, hash2) + require.NotEmpty(t, hash1) +} + +func TestDiffRejectsTrailingGarbageInAgainstVersionID(t *testing.T) { + store := NewMemoryStore() + svc := NewService(true, 5, store) + res := meshresource.NewConditionRouteResourceWithAttributes("demo.condition-router", "mesh") + res.Spec = &meshproto.ConditionRoute{Key: "demo", Priority: 1} + _, err := store.InsertVersion(InsertRequest{ + RuleKind: res.ResourceKind(), + Mesh: res.ResourceMesh(), + ResourceKey: res.ResourceKey(), + RuleName: res.ResourceMeta().Name, + SpecJSON: `{"priority":1}`, + ContentHash: "hash-1", + Source: SourceAdmin, + Operation: OperationUpdate, + Author: "alice", + CreatedAt: time.Now(), + }, 5) + require.NoError(t, err) + + _, err = svc.Diff(meshresource.ConditionRouteKind, "mesh", res.Name, 1, "2junk") + require.Error(t, err) + var bizErr bizerror.Error + require.ErrorAs(t, err, &bizErr) + require.Equal(t, bizerror.InvalidArgument, bizErr.Code()) +} + +func TestMemoryStoreRetentionCurrentPointerAndDelete(t *testing.T) { + store := NewMemoryStore() + key := "mesh/demo.condition-router" + for i := 0; i < 4; i++ { + hash, specJSON, err := NormalizeSpec(&meshproto.ConditionRoute{Key: "demo", Priority: int32(i + 1)}) + require.NoError(t, err) + _, err = store.InsertVersion(InsertRequest{ + RuleKind: meshresource.ConditionRouteKind, + Mesh: "mesh", + ResourceKey: key, + RuleName: "demo.condition-router", + SpecJSON: specJSON, + ContentHash: hash, + Source: SourceAdmin, + Operation: OperationUpdate, + Author: "alice", + CreatedAt: time.Now().Add(time.Duration(i) * time.Second), + }, 2) + require.NoError(t, err) + } + items, err := store.ListVersions(meshresource.ConditionRouteKind, key) + require.NoError(t, err) + require.Len(t, items, 2) + require.Equal(t, int64(4), items[0].VersionNo) + require.Equal(t, int64(3), items[1].VersionNo) + require.True(t, items[0].IsCurrent) + meta, err := store.CurrentMeta(meshresource.ConditionRouteKind, key) + require.NoError(t, err) + require.Equal(t, int64(4), meta.LastVersionNo) + + _, err = store.InsertVersion(InsertRequest{ + RuleKind: meshresource.ConditionRouteKind, + Mesh: "mesh", + ResourceKey: key, + RuleName: "demo.condition-router", + SpecJSON: DeleteSpecJSON, + ContentHash: HashSpecJSON(DeleteSpecJSON), + Source: SourceAdmin, + Operation: OperationDelete, + Author: "alice", + CreatedAt: time.Now().Add(5 * time.Second), + }, 2) + require.NoError(t, err) + meta, err = store.CurrentMeta(meshresource.ConditionRouteKind, key) + require.NoError(t, err) + require.Nil(t, meta.CurrentVersion) + require.Equal(t, int64(5), meta.LastVersionNo) +} + +func TestMemoryStoreDeleteIsNotDedupedAgainstEmptyCurrentSpec(t *testing.T) { + store := NewMemoryStore() + key := "mesh/demo.condition-router" + created, err := store.InsertVersion(InsertRequest{ + RuleKind: meshresource.ConditionRouteKind, + Mesh: "mesh", + ResourceKey: key, + RuleName: "demo.condition-router", + SpecJSON: DeleteSpecJSON, + ContentHash: HashSpecJSON(DeleteSpecJSON), + Source: SourceAdmin, + Operation: OperationCreate, + Author: "alice", + CreatedAt: time.Now(), + }, 5) + require.NoError(t, err) + deleted, err := store.InsertVersion(InsertRequest{ + RuleKind: meshresource.ConditionRouteKind, + Mesh: "mesh", + ResourceKey: key, + RuleName: "demo.condition-router", + SpecJSON: DeleteSpecJSON, + ContentHash: HashSpecJSON(DeleteSpecJSON), + Source: SourceAdmin, + Operation: OperationDelete, + Author: "alice", + CreatedAt: time.Now().Add(time.Second), + }, 5) + require.NoError(t, err) + + require.NotEqual(t, created.ID, deleted.ID) + require.Equal(t, int64(2), deleted.VersionNo) + items, err := store.ListVersions(meshresource.ConditionRouteKind, key) + require.NoError(t, err) + require.Len(t, items, 2) + require.Equal(t, OperationDelete, items[0].Operation) + meta, err := store.CurrentMeta(meshresource.ConditionRouteKind, key) + require.NoError(t, err) + require.Nil(t, meta.CurrentVersion) +} + +func TestMemoryStoreIntentGetListOpenAndFailWithReason(t *testing.T) { + store := NewMemoryStore() + key := "mesh/demo.condition-router" + intent, err := store.CreateIntent(InsertRequest{ + RuleKind: meshresource.ConditionRouteKind, + Mesh: "mesh", + ResourceKey: key, + RuleName: "demo.condition-router", + SpecJSON: `{"priority":1}`, + ContentHash: "hash-1", + Source: SourceAdmin, + Operation: OperationUpdate, + Author: "alice", + CreatedAt: time.Now(), + }, nil) + require.NoError(t, err) + + got, err := store.GetIntent(intent.ID) + require.NoError(t, err) + require.Equal(t, IntentStatusPending, got.Status) + open, err := store.ListOpenIntents() + require.NoError(t, err) + require.Len(t, open, 1) + require.Equal(t, intent.ID, open[0].ID) + + require.NoError(t, store.MarkIntentFailedWithReason(intent.ID, "registry rejected mutation")) + got, err = store.GetIntent(intent.ID) + require.NoError(t, err) + require.Equal(t, IntentStatusFailed, got.Status) + require.Equal(t, "registry rejected mutation", got.LastError) + open, err = store.ListOpenIntents() + require.NoError(t, err) + require.Empty(t, open) + require.ErrorIs(t, store.MarkIntentFailedWithReason(intent.ID, "again"), ErrVersionIntentNotOpen) + _, err = store.GetIntent(404) + require.ErrorIs(t, err, ErrVersionIntentNotFound) +} + +func TestSubscriberRecordsBurstsLosslessly(t *testing.T) { + store := NewMemoryStore() + sub := NewSubscriber(meshresource.ConditionRouteKind, store, 5) + key := "mesh/demo.condition-router" + first := meshresource.NewConditionRouteResourceWithAttributes("demo.condition-router", "mesh") + first.Spec = &meshproto.ConditionRoute{Key: "demo", Priority: 1} + second := meshresource.NewConditionRouteResourceWithAttributes("demo.condition-router", "mesh") + second.Spec = &meshproto.ConditionRoute{Key: "demo", Priority: 2} + require.NoError(t, sub.ProcessEvent(events.NewResourceChangedEvent(cache.Updated, nil, first))) + require.NoError(t, sub.ProcessEvent(events.NewResourceChangedEvent(cache.Updated, first, second))) + items, err := store.ListVersions(meshresource.ConditionRouteKind, key) + require.NoError(t, err) + require.Len(t, items, 2) + require.Contains(t, items[0].SpecJSON, `"priority":2`) + require.Contains(t, items[1].SpecJSON, `"priority":1`) +} + +func TestSubscriberCommitsIntentAndCapturesUpstreamSource(t *testing.T) { + store := NewMemoryStore() + svc := NewService(true, 5, store) + sub := NewSubscriber(meshresource.ConditionRouteKind, store, 5) + adminRes := meshresource.NewConditionRouteResourceWithAttributes("demo.condition-router", "mesh") + adminRes.Spec = &meshproto.ConditionRoute{Key: "demo", Priority: 1} + _, err := svc.BeginMutationIntent(adminRes, OperationCreate, SourceAdmin, "alice", "", nil, nil) + require.NoError(t, err) + require.NoError(t, sub.ProcessEvent(events.NewResourceChangedEvent(cache.Added, nil, adminRes))) + upstreamRes := meshresource.NewConditionRouteResourceWithAttributes("demo.condition-router", "mesh") + upstreamRes.Spec = &meshproto.ConditionRoute{Key: "demo", Priority: 2} + require.NoError(t, sub.ProcessEvent(events.NewResourceChangedEvent(cache.Updated, adminRes, upstreamRes))) + items, err := store.ListVersions(meshresource.ConditionRouteKind, adminRes.ResourceKey()) + require.NoError(t, err) + require.Len(t, items, 2) + require.Equal(t, SourceUpstream, items[0].Source) + require.Equal(t, SourceAdmin, items[1].Source) + require.Equal(t, "alice", items[1].Author) +} + +func TestSubscriberRespectsRegistrySourceContext(t *testing.T) { + store := NewMemoryStore() + sub := NewSubscriber(meshresource.ConditionRouteKind, store, 5) + + upstreamRes := meshresource.NewConditionRouteResourceWithAttributes("demo.condition-router", "mesh") + upstreamRes.Spec = &meshproto.ConditionRoute{Key: "demo", Priority: 2} + require.NoError(t, sub.ProcessEvent(events.NewResourceChangedEventWithContext( + cache.Updated, + nil, + upstreamRes, + map[string]string{events.SourceRegistryContextKey: "zookeeper"}, + ))) + + items, err := store.ListVersions(meshresource.ConditionRouteKind, upstreamRes.ResourceKey()) + require.NoError(t, err) + require.Len(t, items, 1) + require.Equal(t, SourceUpstream, items[0].Source) + require.Equal(t, "system:zookeeper", items[0].Author) +} + +func TestSubscriberSkipsNoopUpstreamEchoAfterBootstrap(t *testing.T) { + store := NewMemoryStore() + sub := NewSubscriber(meshresource.ConditionRouteKind, store, 5) + + original := meshresource.NewConditionRouteResourceWithAttributes("demo.condition-router", "mesh") + original.Spec = &meshproto.ConditionRoute{Key: "demo", Priority: 1} + require.NoError(t, RecordBootstrap(store, 5, original)) + require.NoError(t, sub.ProcessEvent(events.NewResourceChangedEventWithContext( + cache.Updated, + nil, + original, + map[string]string{events.SourceRegistryContextKey: "zookeeper"}, + ))) + + items, err := store.ListVersions(meshresource.ConditionRouteKind, original.ResourceKey()) + require.NoError(t, err) + require.Len(t, items, 1) + require.Equal(t, SourceBootstrap, items[0].Source) + require.True(t, items[0].IsCurrent) + + changed := meshresource.NewConditionRouteResourceWithAttributes("demo.condition-router", "mesh") + changed.Spec = &meshproto.ConditionRoute{Key: "demo", Priority: 2} + require.NoError(t, sub.ProcessEvent(events.NewResourceChangedEventWithContext( + cache.Updated, + original, + changed, + map[string]string{events.SourceRegistryContextKey: "zookeeper"}, + ))) + + items, err = store.ListVersions(meshresource.ConditionRouteKind, original.ResourceKey()) + require.NoError(t, err) + require.Len(t, items, 2) + require.Equal(t, SourceUpstream, items[0].Source) + require.Equal(t, "system:zookeeper", items[0].Author) + require.True(t, items[0].IsCurrent) +} + +func TestSubscriberRecordsEmptyCreateAfterDelete(t *testing.T) { + store := NewMemoryStore() + sub := NewSubscriber(meshresource.ConditionRouteKind, store, 5) + res := meshresource.NewConditionRouteResourceWithAttributes("demo.condition-router", "mesh") + res.Spec = &meshproto.ConditionRoute{} + + require.NoError(t, sub.ProcessEvent(events.NewResourceChangedEvent(cache.Added, nil, res))) + require.NoError(t, sub.ProcessEvent(events.NewResourceChangedEvent(cache.Deleted, res, nil))) + require.NoError(t, sub.ProcessEvent(events.NewResourceChangedEvent(cache.Added, nil, res))) + + items, err := store.ListVersions(meshresource.ConditionRouteKind, res.ResourceKey()) + require.NoError(t, err) + require.Len(t, items, 3) + require.Equal(t, OperationCreate, items[0].Operation) + require.True(t, items[0].IsCurrent) + require.Equal(t, OperationDelete, items[1].Operation) + require.False(t, items[1].IsCurrent) +} + +func TestSubscriberCommitsMatchingAdminIntent(t *testing.T) { + store := NewMemoryStore() + svc := NewService(true, 5, store) + sub := NewSubscriber(meshresource.ConditionRouteKind, store, 5) + + res := meshresource.NewConditionRouteResourceWithAttributes("demo.condition-router", "mesh") + res.Spec = &meshproto.ConditionRoute{Key: "demo", Priority: 1} + intent, err := svc.BeginMutationIntent(res, OperationUpdate, SourceAdmin, "alice", "admin edit", nil, nil) + require.NoError(t, err) + require.Equal(t, IntentStatusPending, intent.Status) + + require.NoError(t, sub.ProcessEvent(events.NewResourceChangedEvent(cache.Updated, nil, res))) + items, err := store.ListVersions(meshresource.ConditionRouteKind, res.ResourceKey()) + require.NoError(t, err) + require.Len(t, items, 1) + require.Equal(t, SourceAdmin, items[0].Source) + require.Equal(t, "alice", items[0].Author) + require.Equal(t, "admin edit", items[0].Reason) + open, err := store.OpenIntent(meshresource.ConditionRouteKind, res.ResourceKey()) + require.NoError(t, err) + require.Nil(t, open) +} + +func TestServiceRepairIntentByIDCommitsOnlyMatchingPendingIntent(t *testing.T) { + store := NewMemoryStore() + svc := NewService(true, 5, store) + res := meshresource.NewConditionRouteResourceWithAttributes("demo.condition-router", "mesh") + res.Spec = &meshproto.ConditionRoute{Key: "demo", Priority: 1} + intent, err := svc.BeginMutationIntent(res, OperationUpdate, SourceAdmin, "alice", "admin edit", nil, nil) + require.NoError(t, err) + + _, err = svc.RepairIntentByID(intent.ID, nil, false) + require.ErrorIs(t, err, ErrVersionIntentPending) + + version, err := svc.RepairIntentByID(intent.ID, res, false) + require.NoError(t, err) + require.Equal(t, SourceAdmin, version.Source) + require.Equal(t, "alice", version.Author) + repaired, err := store.GetIntent(intent.ID) + require.NoError(t, err) + require.Equal(t, IntentStatusCommitted, repaired.Status) + require.NotNil(t, repaired.VersionID) +} + +func TestDisabledServiceHistoryReturnsFeatureDisabled(t *testing.T) { + svc := NewService(false, 5, NewMemoryStore()) + _, err := svc.List(meshresource.ConditionRouteKind, "mesh", "demo.condition-router") + require.ErrorIs(t, err, ErrFeatureDisabled) +} + +func TestComponentAutoMigrateOnlyWhenEnabled(t *testing.T) { + tests := []struct { + name string + enabled bool + wantTables bool + }{ + {name: "disabled", enabled: false, wantTables: false}, + {name: "enabled", enabled: true, wantTables: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{}) + require.NoError(t, err) + + components := map[coreruntime.ComponentType]coreruntime.Component{ + coreruntime.ResourceStore: testGormStoreComponent{db: db}, + } + if tt.enabled { + components[coreruntime.EventBus] = newTestEventBus(t) + } + + comp := &component{} + require.NoError(t, comp.Init(testBuilderContext{ + cfg: appconfig.AdminConfig{ + Versioning: &versioningcfg.Config{ + Enabled: tt.enabled, + MaxVersionsPerRule: 5, + }, + }, + components: components, + })) + + require.Equal(t, tt.wantTables, db.Migrator().HasTable(&Version{})) + require.Equal(t, tt.wantTables, db.Migrator().HasTable(&Meta{})) + require.Equal(t, tt.wantTables, db.Migrator().HasTable(&Intent{})) + }) + } +} + +func TestComponentFlushesPendingVersionsOnStop(t *testing.T) { + store := NewMemoryStore() + sub := NewSubscriber(meshresource.ConditionRouteKind, store, 5) + comp := &component{ + store: store, + subscribers: []*Subscriber{sub}, + } + res := meshresource.NewConditionRouteResourceWithAttributes("demo.condition-router", "mesh") + res.Spec = &meshproto.ConditionRoute{Key: "demo", Priority: 1} + require.NoError(t, sub.ProcessEvent(events.NewResourceChangedEvent(cache.Added, nil, res))) + + stop := make(chan struct{}) + require.NoError(t, comp.Start(testRuntime{ + cfg: appconfig.AdminConfig{ + Versioning: &versioningcfg.Config{ + Enabled: true, + MaxVersionsPerRule: 5, + }, + }, + components: map[coreruntime.ComponentType]coreruntime.Component{ + coreruntime.ResourceManager: testRMComponent{rm: fakeNoopResourceManager{}}, + }, + }, stop)) + close(stop) + + require.Eventually(t, func() bool { + items, err := store.ListVersions(meshresource.ConditionRouteKind, res.ResourceKey()) + return err == nil && len(items) == 1 + }, time.Second, 10*time.Millisecond) +} + +type fakeVersionResourceManager struct { + subscriber *Subscriber +} + +func (f fakeVersionResourceManager) GetByKey(model.ResourceKind, string) (model.Resource, bool, error) { + return nil, false, nil +} + +func (f fakeVersionResourceManager) GetByKeys(model.ResourceKind, []string) ([]model.Resource, error) { + return nil, nil +} + +func (f fakeVersionResourceManager) List(model.ResourceKind) ([]model.Resource, error) { + return nil, nil +} + +func (f fakeVersionResourceManager) ListByIndexes(model.ResourceKind, []index.IndexCondition) ([]model.Resource, error) { + return nil, nil +} + +func (f fakeVersionResourceManager) PageListByIndexes(model.ResourceKind, []index.IndexCondition, model.PageReq) (*model.PageData[model.Resource], error) { + return nil, nil +} + +func (f fakeVersionResourceManager) Add(r model.Resource) error { + return f.subscriber.ProcessEvent(events.NewResourceChangedEvent(cache.Added, nil, r)) +} + +func (f fakeVersionResourceManager) Update(r model.Resource) error { + return f.subscriber.ProcessEvent(events.NewResourceChangedEvent(cache.Updated, nil, r)) +} + +func (f fakeVersionResourceManager) Upsert(r model.Resource) error { + return f.Update(r) +} + +func (f fakeVersionResourceManager) DeleteByKey(model.ResourceKind, string, string) error { + return nil +} + +type fakeNoopResourceManager struct{} + +func (f fakeNoopResourceManager) GetByKey(model.ResourceKind, string) (model.Resource, bool, error) { + return nil, false, nil +} + +func (f fakeNoopResourceManager) GetByKeys(model.ResourceKind, []string) ([]model.Resource, error) { + return nil, nil +} + +func (f fakeNoopResourceManager) List(model.ResourceKind) ([]model.Resource, error) { + return nil, nil +} + +func (f fakeNoopResourceManager) ListByIndexes(model.ResourceKind, []index.IndexCondition) ([]model.Resource, error) { + return nil, nil +} + +func (f fakeNoopResourceManager) PageListByIndexes(model.ResourceKind, []index.IndexCondition, model.PageReq) (*model.PageData[model.Resource], error) { + return nil, nil +} + +func (f fakeNoopResourceManager) Add(model.Resource) error { + return nil +} + +func (f fakeNoopResourceManager) Update(model.Resource) error { + return nil +} + +func (f fakeNoopResourceManager) Upsert(model.Resource) error { + return nil +} + +func (f fakeNoopResourceManager) DeleteByKey(model.ResourceKind, string, string) error { + return nil +} + +type eventBusVersionResourceManager struct { + emitter events.Emitter +} + +func (f eventBusVersionResourceManager) GetByKey(model.ResourceKind, string) (model.Resource, bool, error) { + return nil, false, nil +} + +func (f eventBusVersionResourceManager) GetByKeys(model.ResourceKind, []string) ([]model.Resource, error) { + return nil, nil +} + +func (f eventBusVersionResourceManager) List(model.ResourceKind) ([]model.Resource, error) { + return nil, nil +} + +func (f eventBusVersionResourceManager) ListByIndexes(model.ResourceKind, []index.IndexCondition) ([]model.Resource, error) { + return nil, nil +} + +func (f eventBusVersionResourceManager) PageListByIndexes(model.ResourceKind, []index.IndexCondition, model.PageReq) (*model.PageData[model.Resource], error) { + return nil, nil +} + +func (f eventBusVersionResourceManager) Add(r model.Resource) error { + f.emitter.Send(events.NewResourceChangedEvent(cache.Added, nil, r)) + return nil +} + +func (f eventBusVersionResourceManager) Update(r model.Resource) error { + f.emitter.Send(events.NewResourceChangedEvent(cache.Updated, nil, r)) + return nil +} + +func (f eventBusVersionResourceManager) Upsert(r model.Resource) error { + return f.Update(r) +} + +func (f eventBusVersionResourceManager) DeleteByKey(model.ResourceKind, string, string) error { + return nil +} + +type testEventBus interface { + events.EventBusComponent + coreruntime.GracefulComponent +} + +func newTestEventBus(t *testing.T) testEventBus { + t.Helper() + prototype, err := coreruntime.ComponentRegistry().EventBus() + require.NoError(t, err) + bus := reflect.New(reflect.TypeOf(prototype).Elem()).Interface().(testEventBus) + bufferSize := uint(1) + require.NoError(t, bus.Init(testBuilderContext{ + cfg: appconfig.AdminConfig{ + EventBus: &eventbusconfig.Config{BufferSize: bufferSize}, + }, + })) + return bus +} + +type testBuilderContext struct { + cfg appconfig.AdminConfig + components map[coreruntime.ComponentType]coreruntime.Component +} + +func (c testBuilderContext) Config() appconfig.AdminConfig { + return c.cfg +} + +func (c testBuilderContext) GetActivatedComponent(typ coreruntime.ComponentType) (coreruntime.Component, error) { + if c.components != nil { + if comp, ok := c.components[typ]; ok { + return comp, nil + } + } + return nil, nil +} + +func (c testBuilderContext) ActivateComponent(coreruntime.Component) error { + return nil +} + +type testGormStoreComponent struct { + db *gorm.DB +} + +func (c testGormStoreComponent) Type() coreruntime.ComponentType { + return coreruntime.ResourceStore +} + +func (c testGormStoreComponent) Order() int { + return 0 +} + +func (c testGormStoreComponent) RequiredDependencies() []coreruntime.ComponentType { + return nil +} + +func (c testGormStoreComponent) Init(coreruntime.BuilderContext) error { + return nil +} + +func (c testGormStoreComponent) Start(coreruntime.Runtime, <-chan struct{}) error { + return nil +} + +func (c testGormStoreComponent) GetDB() (*gorm.DB, bool) { + return c.db, c.db != nil +} + +type testRuntime struct { + cfg appconfig.AdminConfig + components map[coreruntime.ComponentType]coreruntime.Component +} + +func (r testRuntime) GetInstanceId() string { + return "test-instance" +} + +func (r testRuntime) GetClusterId() string { + return "test-cluster" +} + +func (r testRuntime) GetStartTime() time.Time { + return time.Now() +} + +func (r testRuntime) GetMode() mode.Mode { + return mode.Test +} + +func (r testRuntime) Config() appconfig.AdminConfig { + return r.cfg +} + +func (r testRuntime) GetComponent(typ coreruntime.ComponentType) (coreruntime.Component, error) { + return r.components[typ], nil +} + +func (r testRuntime) AppContext() context.Context { + return context.Background() +} + +func (r testRuntime) Add(...coreruntime.Component) {} + +func (r testRuntime) Start(<-chan struct{}) error { + return nil +} + +type testRMComponent struct { + rm manager.ResourceManager +} + +func (c testRMComponent) Type() coreruntime.ComponentType { + return coreruntime.ResourceManager +} + +func (c testRMComponent) Order() int { + return 0 +} + +func (c testRMComponent) RequiredDependencies() []coreruntime.ComponentType { + return nil +} + +func (c testRMComponent) Init(coreruntime.BuilderContext) error { + return nil +} + +func (c testRMComponent) Start(coreruntime.Runtime, <-chan struct{}) error { + return nil +} + +func (c testRMComponent) ResourceManager() manager.ResourceManager { + return c.rm +} diff --git a/pkg/store/dbcommon/gorm_store.go b/pkg/store/dbcommon/gorm_store.go index cedc20144..1a0819727 100644 --- a/pkg/store/dbcommon/gorm_store.go +++ b/pkg/store/dbcommon/gorm_store.go @@ -267,6 +267,26 @@ func (gs *GormStore) List() []interface{} { return result } +func (gs *GormStore) ListResources() ([]model.Resource, error) { + var models []ResourceModel + db := gs.pool.GetDB() + if err := db.Scopes(TableScope(gs.kind.ToString())).Model(&ResourceModel{}). + Order("resource_key ASC"). + Find(&models).Error; err != nil { + return nil, err + } + + resources := make([]model.Resource, 0, len(models)) + for _, m := range models { + resource, err := m.ToResource() + if err != nil { + return nil, err + } + resources = append(resources, resource) + } + return resources, nil +} + // ListKeys returns all resource keys of the configured kind from the database func (gs *GormStore) ListKeys() []string { var keys []string @@ -594,7 +614,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..d6b23b706 100644 --- a/pkg/store/dbcommon/gorm_store_test.go +++ b/pkg/store/dbcommon/gorm_store_test.go @@ -802,10 +802,42 @@ 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, err := store.ListResources() + require.NoError(t, err) + require.Len(t, resources, 2) + assert.Equal(t, "mesh/test-key-1", resources[0].ResourceKey()) + assert.Equal(t, "mesh/test-key-2", resources[1].ResourceKey()) } func TestGormStore_PageListByIndexes(t *testing.T) { diff --git a/pkg/store/memory/store.go b/pkg/store/memory/store.go index 0b392bd58..bcdb7bd45 100644 --- a/pkg/store/memory/store.go +++ b/pkg/store/memory/store.go @@ -121,6 +121,22 @@ func (rs *resourceStore) List() []interface{} { return rs.storeProxy.List() } +func (rs *resourceStore) ListResources() ([]coremodel.Resource, error) { + items := rs.storeProxy.List() + resources := make([]coremodel.Resource, 0, len(items)) + for _, item := range items { + res, ok := item.(coremodel.Resource) + if !ok { + return nil, bizerror.NewAssertionError("Resource", reflect.TypeOf(item).Name()) + } + resources = append(resources, res) + } + slice.SortBy(resources, func(r1 coremodel.Resource, r2 coremodel.Resource) bool { + return r1.ResourceKey() < r2.ResourceKey() + }) + return resources, nil +} + func (rs *resourceStore) ListKeys() []string { return rs.storeProxy.ListKeys() } diff --git a/pkg/store/memory/store_test.go b/pkg/store/memory/store_test.go index 8ad401995..9e251ccbb 100644 --- a/pkg/store/memory/store_test.go +++ b/pkg/store/memory/store_test.go @@ -227,6 +227,40 @@ 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, err := store.ListResources() + assert.NoError(t, err) + assert.Len(t, resources, 2) + assert.Equal(t, "mesh/test-key-1", resources[0].ResourceKey()) + assert.Equal(t, "mesh/test-key-2", resources[1].ResourceKey()) + + 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/ui-vue3/src/api/service/traffic.ts b/ui-vue3/src/api/service/traffic.ts index 9d4ff0a29..792bb3a4e 100644 --- a/ui-vue3/src/api/service/traffic.ts +++ b/ui-vue3/src/api/service/traffic.ts @@ -17,6 +17,132 @@ import request from '@/base/http/request' +export type TrafficRuleKind = 'condition-rule' | 'tag-rule' | 'configurator' + +export interface RuleVersion { + id: number + ruleKind: string + mesh: string + resourceKey: string + ruleName: string + versionNo: number + contentHash: string + specJson: string + source: 'ADMIN' | 'UPSTREAM' | 'ROLLBACK' | 'BOOTSTRAP' | string + operation: 'CREATE' | 'UPDATE' | 'DELETE' | string + author: string + reason?: string + rolledBackFromId?: number + createdAt: string + isCurrent: boolean +} + +export interface RuleVersionList { + items: RuleVersion[] + total: number +} + +export interface RuleVersionDiffSide { + id: number + versionNo: number + specJson: string +} + +export interface RuleVersionDiff { + left: RuleVersionDiffSide + right: RuleVersionDiffSide +} + +export interface RuleMutationOptions { + expectedVersionId?: number +} + +export interface RuleRollbackRequest extends RuleMutationOptions { + reason: string +} + +export interface VersionConflictError { + code: 'VERSION_CONFLICT' | 'VERSION_LEDGER_PENDING' + message: string + currentVersionId?: number | null + intentId?: number +} + +const ruleNameForPath = (kind: TrafficRuleKind, ruleName: string): string => { + return kind === 'configurator' ? encodeURIComponent(ruleName) : ruleName +} + +const withExpectedVersion = (options?: RuleMutationOptions) => { + return options?.expectedVersionId ? { expectedVersionId: options.expectedVersionId } : undefined +} + +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: number +): Promise<{ code: string; data: RuleVersion }> => { + return request({ + url: `/${kind}/${ruleNameForPath(kind, ruleName)}/versions/${versionId}`, + method: 'get' + }) +} + +export const diffRuleVersionAPI = ( + kind: TrafficRuleKind, + ruleName: string, + versionId: number, + 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: number, + data: RuleRollbackRequest +): Promise<{ code: string; data: RuleVersion }> => { + return request({ + url: `/${kind}/${ruleNameForPath(kind, ruleName)}/versions/${versionId}/rollback`, + method: 'post', + data + }) +} + +export const repairRuleVersionIntentAPI = ( + intentId: number +): Promise<{ code: string; data: RuleVersion }> => { + return request({ + url: `/rule-version-intents/${intentId}/repair`, + method: 'post' + }) +} + +export const abandonRuleVersionIntentAPI = ( + intentId: number, + reason: string +): Promise<{ code: string; data: string }> => { + return request({ + url: `/rule-version-intents/${intentId}/abandon`, + method: 'post', + data: { reason } + }) +} + export const searchRoutingRule = (params: any): Promise => { return request({ url: '/condition-rule/search', @@ -34,28 +160,42 @@ export const getConditionRuleDetailAPI = (ruleName: string): Promise => { } // Delete condition routing. -export const deleteConditionRuleAPI = (ruleName: string): Promise => { +export const deleteConditionRuleAPI = ( + ruleName: string, + options?: RuleMutationOptions +): Promise => { return request({ url: `/condition-rule/${ruleName}`, - method: 'delete' + method: 'delete', + params: withExpectedVersion(options) }) } // update condition routing. -export const updateConditionRuleAPI = (ruleName: string, data: any): Promise => { +export const updateConditionRuleAPI = ( + ruleName: string, + data: any, + options?: RuleMutationOptions +): Promise => { return request({ url: `/condition-rule/${ruleName}`, method: 'put', - data + data, + params: withExpectedVersion(options) }) } // add condition routing. -export const addConditionRuleAPI = (ruleName: string, data: any): Promise => { +export const addConditionRuleAPI = ( + ruleName: string, + data: any, + options?: RuleMutationOptions +): Promise => { return request({ url: `/condition-rule/${ruleName}`, method: 'post', - data + data, + params: withExpectedVersion(options) }) } @@ -68,10 +208,11 @@ export const searchTagRule = (params: any): Promise => { } // Delete tag routing. -export const deleteTagRuleAPI = (ruleName: string): Promise => { +export const deleteTagRuleAPI = (ruleName: string, options?: RuleMutationOptions): Promise => { return request({ url: `/tag-rule/${ruleName}`, - method: 'delete' + method: 'delete', + params: withExpectedVersion(options) }) } @@ -83,19 +224,29 @@ export const getTagRuleDetailAPI = (ruleName: string): Promise => { }) } -export const updateTagRuleAPI = (ruleName: string, data: any): Promise => { +export const updateTagRuleAPI = ( + ruleName: string, + data: any, + options?: RuleMutationOptions +): Promise => { return request({ url: `/tag-rule/${ruleName}`, method: 'put', - data + data, + params: withExpectedVersion(options) }) } -export const addTagRuleAPI = (ruleName: string, data: any): Promise => { +export const addTagRuleAPI = ( + ruleName: string, + data: any, + options?: RuleMutationOptions +): Promise => { return request({ url: `/tag-rule/${ruleName}`, method: 'post', - data + data, + params: withExpectedVersion(options) }) } @@ -129,24 +280,35 @@ export const getConfiguratorDetail = (params: any): Promise => { method: 'get' }) } -export const saveConfiguratorDetail = (params: any, data: any): Promise => { +export const saveConfiguratorDetail = ( + params: any, + data: any, + options?: RuleMutationOptions +): Promise => { return request({ url: `/configurator/${encodeURIComponent(params.name)}`, method: 'put', - data + data, + params: withExpectedVersion(options) }) } -export const addConfiguratorDetail = (params: any, data: any): Promise => { +export const addConfiguratorDetail = ( + params: any, + data: any, + options?: RuleMutationOptions +): Promise => { return request({ url: `/configurator/${encodeURIComponent(params.name)}`, method: 'post', - data + data, + params: withExpectedVersion(options) }) } -export const delConfiguratorDetail = (params: any): Promise => { +export const delConfiguratorDetail = (params: any, options?: RuleMutationOptions): Promise => { return request({ url: `/configurator/${encodeURIComponent(params.name)}`, - method: 'delete' + method: 'delete', + params: withExpectedVersion(options) }) } diff --git a/ui-vue3/src/base/http/request.ts b/ui-vue3/src/base/http/request.ts index 094a76cdd..eb39c4cf4 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, code?: string): boolean => { + return !isSilentErrorUrl(url) && code !== 'VERSION_CONFLICT' && code !== 'VERSION_LEDGER_PENDING' +} + 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, response.data.code)) { 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, response.data?.code)) { message.error(errorMsg) } console.error(errorMsg) 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..1b3d5d8fd 100644 --- a/ui-vue3/src/mocks/handlers/dynamicConfig.ts +++ b/ui-vue3/src/mocks/handlers/dynamicConfig.ts @@ -17,6 +17,7 @@ import { http, type HttpHandler } from 'msw' import { success, base } from '../utils' +import { ruleVersionMock } from './ruleVersion' import type { ConfiguratorRule, ConfiguratorDetail, PaginatedData } from '@/types/api' function randomInt(min: number, max: number): number { @@ -28,6 +29,24 @@ 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 + } +} + +const writeOrConflict = (rawName: string, operation: 'CREATE' | 'UPDATE' | 'DELETE') => { + const ruleName = decodeRuleName(rawName) + if (ruleVersionMock.shouldConflict(ruleName)) + return ruleVersionMock.conflictResponse('configurator', ruleName) + if (ruleVersionMock.shouldPend(ruleName)) + return ruleVersionMock.pendingResponse('configurator', ruleName) + ruleVersionMock.recordAdminWrite('configurator', ruleName, operation) + return success(null) +} + export const dynamicConfigHandlers: HttpHandler[] = [ http.get(`${base}/configurator/search`, () => { const total = randomInt(8, 1000) @@ -45,15 +64,21 @@ 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) }), - http.delete(`${base}/configurator/:ruleName`, () => success(null)), + http.delete(`${base}/configurator/:ruleName`, ({ params }) => + writeOrConflict(params.ruleName as string, 'DELETE') + ), - http.put(`${base}/configurator/:ruleName`, () => success(null)), + http.put(`${base}/configurator/:ruleName`, ({ params }) => + writeOrConflict(params.ruleName as string, 'UPDATE') + ), - http.post(`${base}/configurator/:ruleName`, () => success(null)) + http.post(`${base}/configurator/:ruleName`, ({ params }) => + writeOrConflict(params.ruleName as string, 'CREATE') + ) ] diff --git a/ui-vue3/src/mocks/handlers/routingRule.ts b/ui-vue3/src/mocks/handlers/routingRule.ts index 7dad6b644..75c809efe 100644 --- a/ui-vue3/src/mocks/handlers/routingRule.ts +++ b/ui-vue3/src/mocks/handlers/routingRule.ts @@ -17,6 +17,7 @@ import { http, type HttpHandler } from 'msw' import { success, base } from '../utils' +import { ruleVersionMock } from './ruleVersion' import type { RoutingRule, RoutingRuleDetail, PaginatedData } from '@/types/api' function randomInt(min: number, max: number): number { @@ -28,6 +29,15 @@ function randomString(min: number, max: number): string { return Array.from({ length: len }, () => String.fromCharCode(97 + randomInt(0, 25))).join('') } +const writeOrConflict = (ruleName: string, operation: 'CREATE' | 'UPDATE' | 'DELETE') => { + if (ruleVersionMock.shouldConflict(ruleName)) + return ruleVersionMock.conflictResponse('condition-rule', ruleName) + if (ruleVersionMock.shouldPend(ruleName)) + return ruleVersionMock.pendingResponse('condition-rule', ruleName) + ruleVersionMock.recordAdminWrite('condition-rule', ruleName, operation) + return success(null) +} + export const routingRuleHandlers: HttpHandler[] = [ http.get(`${base}/condition-rule/search`, () => { const total = randomInt(8, 1000) @@ -59,9 +69,15 @@ export const routingRuleHandlers: HttpHandler[] = [ return success(detail) }), - http.delete(`${base}/condition-rule/:ruleName`, () => success(null)), + http.delete(`${base}/condition-rule/:ruleName`, ({ params }) => + writeOrConflict(params.ruleName as string, 'DELETE') + ), - http.put(`${base}/condition-rule/:ruleName`, () => success(null)), + http.put(`${base}/condition-rule/:ruleName`, ({ params }) => + writeOrConflict(params.ruleName as string, 'UPDATE') + ), - http.post(`${base}/condition-rule/:ruleName`, () => success(null)) + http.post(`${base}/condition-rule/:ruleName`, ({ params }) => + writeOrConflict(params.ruleName as string, 'CREATE') + ) ] diff --git a/ui-vue3/src/mocks/handlers/ruleVersion.ts b/ui-vue3/src/mocks/handlers/ruleVersion.ts new file mode 100644 index 000000000..c0ca25c0a --- /dev/null +++ b/ui-vue3/src/mocks/handlers/ruleVersion.ts @@ -0,0 +1,345 @@ +/* + * 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. + */ + +// Rule-name conventions that drive the error paths the round-3/4 review patched: +// *-conflict → writes / rollback return HTTP 409 VERSION_CONFLICT +// *-pending → writes / rollback return HTTP 409 VERSION_LEDGER_PENDING (intentId from ledger); +// cleared by intent repair / abandon +// *-disabled → /versions endpoints return HTTP 503 FEATURE_DISABLED (writes are unaffected, +// matching backend behaviour where the gate is on the versioning routes only) + +import { http, HttpResponse, type HttpHandler } from 'msw' +import { success, base } from '../utils' +import type { + RuleVersion, + RuleVersionDiff, + RuleVersionList, + TrafficRuleKind +} from '@/api/service/traffic' + +interface Ledger { + versions: RuleVersion[] + pendingIntentId?: number + nextVersionId: number + nextVersionNo: number +} + +const KINDS: TrafficRuleKind[] = ['condition-rule', 'tag-rule', 'configurator'] +// Mirrors backend DefaultMaxVersionsPerRule (pkg/config/versioning/config.go). +// Backend trims to this many rows per rule on every InsertVersion; the mock +// matches that so retention demos in the browser don't diverge. +const MAX_VERSIONS_PER_RULE = 5 +const MOCK_CONSOLE_USER = 'user name' +const MOCK_UPSTREAM_USER = 'upstream' +const ledgers = new Map() +let nextIntentId = 9001 + +const ledgerKey = (kind: TrafficRuleKind, ruleName: string) => `${kind}:${ruleName}` + +const isDisabledName = (name: string) => name.includes('-disabled') +const isConflictName = (name: string) => name.includes('-conflict') +const isPendingName = (name: string) => name.includes('-pending') + +const decodeName = (raw: string) => { + try { + return decodeURIComponent(raw) + } catch { + return raw + } +} + +const sampleSpec = (ruleName: string, versionNo: number, operation: RuleVersion['operation']) => + JSON.stringify({ + configVersion: 'v3.0', + key: ruleName, + enabled: versionNo % 2 === 0, + note: `mock-${operation.toLowerCase()}-v${versionNo}` + }) + +const seedLedger = (kind: TrafficRuleKind, ruleName: string): Ledger => { + const ops: RuleVersion['operation'][] = ['CREATE', 'UPDATE', 'UPDATE', 'UPDATE', 'UPDATE'] + const sources: RuleVersion['source'][] = ['ADMIN', 'ADMIN', 'UPSTREAM', 'ADMIN', 'ADMIN'] + const authors = [ + MOCK_CONSOLE_USER, + MOCK_CONSOLE_USER, + MOCK_UPSTREAM_USER, + MOCK_CONSOLE_USER, + MOCK_CONSOLE_USER + ] + const versions: RuleVersion[] = ops.map((operation, i) => ({ + id: 1000 + i, + ruleKind: kind, + mesh: 'default', + resourceKey: `/${ruleName}`, + ruleName, + versionNo: i + 1, + contentHash: `sha256:mock-${1000 + i}`, + specJson: sampleSpec(ruleName, i + 1, operation), + source: sources[i], + operation, + author: authors[i], + createdAt: `2026-05-${(20 + i).toString().padStart(2, '0')}T08:00:00Z`, + isCurrent: i === ops.length - 1 + })) + const ledger: Ledger = { + versions, + nextVersionId: 1000 + ops.length - 1, + nextVersionNo: ops.length + 1 + } + if (isPendingName(ruleName)) { + ledger.pendingIntentId = nextIntentId++ + } + return ledger +} + +const getOrSeed = (kind: TrafficRuleKind, ruleName: string): Ledger => { + const key = ledgerKey(kind, ruleName) + const existing = ledgers.get(key) + if (existing) return existing + const ledger = seedLedger(kind, ruleName) + ledgers.set(key, ledger) + return ledger +} + +const currentVersionOf = (ledger: Ledger) => ledger.versions.find((v) => v.isCurrent) + +const featureDisabledResp = () => + HttpResponse.json( + { code: 'FEATURE_DISABLED', message: 'rule versioning is disabled' }, + { status: 503 } + ) + +const conflictResp = (currentVersionId?: number | null) => + HttpResponse.json( + { + code: 'VERSION_CONFLICT', + message: 'rule version conflict', + currentVersionId: currentVersionId ?? null + }, + { status: 409 } + ) + +const pendingResp = (intentId: number) => + HttpResponse.json( + { + code: 'VERSION_LEDGER_PENDING', + message: 'rule version intent is pending', + intentId + }, + { status: 409 } + ) + +const bizError = (code: string, message: string, status = 200) => + HttpResponse.json({ code, message, data: null }, { status }) + +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 readJsonBody = async (request: Request): Promise> => { + try { + const body = (await request.json()) as Record | null + return body ?? {} + } catch { + return {} + } +} + +const appendVersion = ( + ledger: Ledger, + kind: TrafficRuleKind, + ruleName: string, + override: Partial & Pick +): RuleVersion => { + const prev = currentVersionOf(ledger) + const id = ++ledger.nextVersionId + const versionNo = ledger.nextVersionNo++ + const newVer: RuleVersion = { + ...override, + id, + versionNo, + ruleKind: kind, + ruleName, + mesh: override.mesh ?? 'default', + resourceKey: override.resourceKey ?? `/${ruleName}`, + contentHash: override.contentHash ?? `sha256:mock-${id}`, + specJson: override.specJson ?? prev?.specJson ?? '{}', + author: override.author ?? MOCK_CONSOLE_USER, + createdAt: override.createdAt ?? new Date().toISOString(), + isCurrent: true + } + ledger.versions.forEach((v) => (v.isCurrent = false)) + ledger.versions.push(newVer) + if (ledger.versions.length > MAX_VERSIONS_PER_RULE) { + ledger.versions.splice(0, ledger.versions.length - MAX_VERSIONS_PER_RULE) + } + return newVer +} + +const buildVersionHandlersForKind = (kind: TrafficRuleKind): HttpHandler[] => [ + http.get(`${base}/${kind}/:ruleName/versions`, ({ params }) => { + const ruleName = decodeName(params.ruleName as string) + if (isDisabledName(ruleName)) return featureDisabledResp() + const ledger = getOrSeed(kind, ruleName) + return success({ + items: ledger.versions.slice().reverse(), + total: ledger.versions.length + }) + }), + + http.get(`${base}/${kind}/:ruleName/versions/:versionId`, ({ params }) => { + const ruleName = decodeName(params.ruleName as string) + if (isDisabledName(ruleName)) return featureDisabledResp() + const versionId = Number(params.versionId) + if (!Number.isFinite(versionId)) + return bizError('InvalidArgument', 'versionId must be an integer', 400) + const ledger = getOrSeed(kind, ruleName) + const version = ledger.versions.find((v) => v.id === versionId) + if (!version) return bizError('NotFoundError', 'rule version not found') + return success(version) + }), + + http.get(`${base}/${kind}/:ruleName/versions/:versionId/diff`, ({ params, request }) => { + const ruleName = decodeName(params.ruleName as string) + if (isDisabledName(ruleName)) return featureDisabledResp() + const versionId = Number(params.versionId) + if (!Number.isFinite(versionId)) + return bizError('InvalidArgument', 'versionId must be an integer', 400) + const ledger = getOrSeed(kind, ruleName) + const left = ledger.versions.find((v) => v.id === versionId) + if (!left) return bizError('NotFoundError', 'rule version not found') + const against = new URL(request.url).searchParams.get('against') || 'current' + const right = + against === 'current' + ? currentVersionOf(ledger) + : ledger.versions.find((v) => v.id === Number(against)) + if (!right) return bizError('NotFoundError', '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) + if (isDisabledName(ruleName)) return featureDisabledResp() + const versionId = Number(params.versionId) + if (!Number.isFinite(versionId)) + return bizError('InvalidArgument', 'versionId must be an integer', 400) + const body = await readJsonBody(request) + const reasonErr = validateReason(typeof body.reason === 'string' ? body.reason : '') + if (reasonErr) return reasonErr + const ledger = getOrSeed(kind, ruleName) + if (isConflictName(ruleName)) return conflictResp(currentVersionOf(ledger)?.id) + if (isPendingName(ruleName) && ledger.pendingIntentId) + return pendingResp(ledger.pendingIntentId) + const target = ledger.versions.find((v) => v.id === versionId) + if (!target) return bizError('NotFoundError', 'rule version not found') + if (target.isCurrent) + return bizError('InvalidArgument', 'cannot rollback to current version', 400) + if (target.operation === 'DELETE') + return bizError('InvalidArgument', 'cannot rollback to deleted version', 400) + const newVer = appendVersion(ledger, kind, ruleName, { + source: 'ROLLBACK', + operation: target.operation, + specJson: target.specJson, + rolledBackFromId: target.id, + reason: (body.reason as string).trim() + }) + return success(newVer) + } + ) +] + +const intentHandlers: HttpHandler[] = [ + http.post(`${base}/rule-version-intents/:intentId/repair`, ({ params }) => { + const intentId = Number(params.intentId) + if (!Number.isFinite(intentId)) + return bizError('InvalidArgument', 'intentId must be an integer', 400) + let recovered: RuleVersion | null = null + ledgers.forEach((ledger, key) => { + if (ledger.pendingIntentId !== intentId) return + ledger.pendingIntentId = undefined + const [kindStr, ruleName] = key.split(':') as [TrafficRuleKind, string] + recovered = appendVersion(ledger, kindStr, ruleName, { + source: 'ADMIN', + operation: 'UPDATE', + reason: 'intent repaired' + }) + }) + if (!recovered) return bizError('NotFoundError', 'rule version intent not found') + return success(recovered) + }), + + http.post(`${base}/rule-version-intents/:intentId/abandon`, async ({ params, request }) => { + const intentId = Number(params.intentId) + if (!Number.isFinite(intentId)) + return bizError('InvalidArgument', 'intentId must be an integer', 400) + const body = await readJsonBody(request) + const reasonErr = validateReason(typeof body.reason === 'string' ? body.reason : '') + if (reasonErr) return reasonErr + let cleared = false + ledgers.forEach((ledger) => { + if (ledger.pendingIntentId === intentId) { + ledger.pendingIntentId = undefined + cleared = true + } + }) + if (!cleared) return bizError('NotFoundError', 'rule version intent not found') + return success('') + }) +] + +export const ruleVersionHandlers: HttpHandler[] = [ + ...KINDS.flatMap(buildVersionHandlersForKind), + ...intentHandlers +] + +// Helper surface for the rule-write handlers (routingRule / tagRule / dynamicConfig) +// so they can mirror the round-3/4 review fixes without re-implementing the rules. +export const ruleVersionMock = { + shouldConflict(ruleName: string) { + return isConflictName(ruleName) + }, + shouldPend(ruleName: string) { + return isPendingName(ruleName) + }, + conflictResponse(kind: TrafficRuleKind, ruleName: string) { + const ledger = ledgers.get(ledgerKey(kind, ruleName)) + return conflictResp(ledger ? currentVersionOf(ledger)?.id : null) + }, + pendingResponse(kind: TrafficRuleKind, ruleName: string) { + const ledger = getOrSeed(kind, ruleName) + if (!ledger.pendingIntentId) ledger.pendingIntentId = nextIntentId++ + return pendingResp(ledger.pendingIntentId) + }, + recordAdminWrite(kind: TrafficRuleKind, ruleName: string, operation: RuleVersion['operation']) { + if (isDisabledName(ruleName) || isConflictName(ruleName) || isPendingName(ruleName)) return + const ledger = getOrSeed(kind, ruleName) + appendVersion(ledger, kind, ruleName, { + source: 'ADMIN', + operation, + specJson: operation === 'DELETE' ? '{}' : currentVersionOf(ledger)?.specJson ?? '{}' + }) + } +} diff --git a/ui-vue3/src/mocks/handlers/tagRule.ts b/ui-vue3/src/mocks/handlers/tagRule.ts index 7c656d430..2ab40604f 100644 --- a/ui-vue3/src/mocks/handlers/tagRule.ts +++ b/ui-vue3/src/mocks/handlers/tagRule.ts @@ -17,6 +17,7 @@ import { http, type HttpHandler } from 'msw' import { success, base } from '../utils' +import { ruleVersionMock } from './ruleVersion' import type { TagRule, TagRuleDetail, PaginatedData } from '@/types/api' function randomInt(min: number, max: number): number { @@ -28,6 +29,15 @@ function randomString(min: number, max: number): string { return Array.from({ length: len }, () => String.fromCharCode(97 + randomInt(0, 25))).join('') } +const writeOrConflict = (ruleName: string, operation: 'CREATE' | 'UPDATE' | 'DELETE') => { + if (ruleVersionMock.shouldConflict(ruleName)) + return ruleVersionMock.conflictResponse('tag-rule', ruleName) + if (ruleVersionMock.shouldPend(ruleName)) + return ruleVersionMock.pendingResponse('tag-rule', ruleName) + ruleVersionMock.recordAdminWrite('tag-rule', ruleName, operation) + return success(null) +} + export const tagRuleHandlers: HttpHandler[] = [ http.get(`${base}/tag-rule/search`, () => { const total = randomInt(8, 1000) @@ -55,9 +65,15 @@ export const tagRuleHandlers: HttpHandler[] = [ return success(detail) }), - http.delete(`${base}/tag-rule/:ruleName`, () => success(null)), + http.delete(`${base}/tag-rule/:ruleName`, ({ params }) => + writeOrConflict(params.ruleName as string, 'DELETE') + ), - http.put(`${base}/tag-rule/:ruleName`, () => success(null)), + http.put(`${base}/tag-rule/:ruleName`, ({ params }) => + writeOrConflict(params.ruleName as string, 'UPDATE') + ), - http.post(`${base}/tag-rule/:ruleName`, () => success(null)) + http.post(`${base}/tag-rule/:ruleName`, ({ params }) => + writeOrConflict(params.ruleName as string, 'CREATE') + ) ] 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..4b1d6293d --- /dev/null +++ b/ui-vue3/src/views/traffic/_shared/RuleDiffEditor.vue @@ -0,0 +1,92 @@ + + + + + + + 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..fb88dacb4 --- /dev/null +++ b/ui-vue3/src/views/traffic/_shared/RuleHistoryDrawer.vue @@ -0,0 +1,138 @@ + + + + + + + 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..5e3404120 --- /dev/null +++ b/ui-vue3/src/views/traffic/_shared/RuleHistoryPanel.vue @@ -0,0 +1,202 @@ + + + + + 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..ce2accf02 --- /dev/null +++ b/ui-vue3/src/views/traffic/_shared/ruleVersion.ts @@ -0,0 +1,196 @@ +/* + * 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 { h } from 'vue' +import { Button, Modal, notification, Space } from 'ant-design-vue' +import { + abandonRuleVersionIntentAPI, + listRuleVersionsAPI, + repairRuleVersionIntentAPI, + type RuleVersion, + type TrafficRuleKind, + type VersionConflictError +} from '@/api/service/traffic' +import { HTTP_STATUS } from '@/base/http/constants' + +export interface CurrentVersionState { + id?: number + versionNo?: number +} + +export const currentVersionStateFromItems = (items: RuleVersion[]): CurrentVersionState => { + const current = items.find((item) => item.isCurrent) + return { + id: current?.id, + versionNo: current?.versionNo + } +} + +export const fetchCurrentVersionState = async ( + kind: TrafficRuleKind, + ruleName: string +): Promise => { + try { + const res = await listRuleVersionsAPI(kind, ruleName) + if (res.code === HTTP_STATUS.SUCCESS) { + return currentVersionStateFromItems(res.data?.items || []) + } + } catch (e: any) { + if (e?.code !== 'FEATURE_DISABLED') { + throw e + } + } + return {} +} + +export const isVersionConflict = (e: any): e is VersionConflictError => { + return e?.code === 'VERSION_CONFLICT' || e?.code === 'VERSION_LEDGER_PENDING' +} + +export const notifyVersionConflict = ( + e: any, + options?: { reload?: () => void | Promise } +): boolean => { + if (isVersionConflict(e)) { + notification.warning({ + key: 'rule-version-conflict', + duration: 0, + message: '版本冲突', + description: '规则已被其他操作更新,请重新加载当前版本后再提交。', + btn: options?.reload + ? () => + h( + Button, + { + type: 'link', + size: 'small', + onClick: () => { + notification.close('rule-version-conflict') + options.reload?.() + } + }, + { default: () => 'Reload' } + ) + : undefined + }) + return true + } + return false +} + +export const notifyVersionLedgerPending = ( + e: any, + options?: { reload?: () => void | Promise } +): boolean => { + if (e?.code !== 'VERSION_LEDGER_PENDING') { + return false + } + const intentId = e.intentId + notification.warning({ + key: 'rule-version-ledger-pending', + duration: 0, + message: '版本账本待恢复', + description: intentId + ? `当前规则存在未完成的版本 intent #${intentId},请先修复或放弃后再提交。` + : '当前规则存在未完成的版本 intent,请重新加载后再提交。', + btn: intentId + ? () => + h( + Space, + {}, + { + default: () => [ + h( + Button, + { + type: 'link', + size: 'small', + onClick: async () => { + await repairRuleVersionIntentAPI(intentId) + notification.close('rule-version-ledger-pending') + await options?.reload?.() + } + }, + { default: () => 'Repair' } + ), + h( + Button, + { + type: 'link', + size: 'small', + danger: true, + onClick: () => { + Modal.confirm({ + title: '放弃版本 intent', + content: `确认放弃未完成的版本 intent #${intentId}?`, + okText: 'Abandon', + okButtonProps: { danger: true }, + async onOk() { + await abandonRuleVersionIntentAPI( + intentId, + 'operator abandoned stale intent' + ) + notification.close('rule-version-ledger-pending') + await options?.reload?.() + } + }) + } + }, + { default: () => 'Abandon' } + ) + ] + } + ) + : options?.reload + ? () => + h( + Button, + { + type: 'link', + size: 'small', + onClick: () => { + notification.close('rule-version-ledger-pending') + options.reload?.() + } + }, + { default: () => 'Reload' } + ) + : undefined + }) + return true +} + +export const notifyRuleVersionError = ( + e: any, + options?: { reload?: () => void | Promise } +): boolean => { + if (notifyVersionLedgerPending(e, options)) { + return true + } + return notifyVersionConflict(e, options) +} + +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..ec96c0741 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 { fetchCurrentVersionState, notifyRuleVersionError } from '../_shared/ruleVersion' const router = useRouter() @@ -143,8 +144,13 @@ onMounted(async () => { }) const delDynamicConfig = async (record: any) => { - await delConfiguratorDetail({ name: record.ruleName }) - await searchDomain.onSearch() + try { + const expectedVersionId = (await fetchCurrentVersionState('configurator', record.ruleName)).id + await delConfiguratorDetail({ name: record.ruleName }, { expectedVersionId }) + await searchDomain.onSearch() + } catch (e: any) { + notifyRuleVersionError(e, { reload: () => searchDomain.onSearch() }) + } } provide(PROVIDE_INJECT_KEY.SEARCH_DOMAIN, searchDomain) diff --git a/ui-vue3/src/views/traffic/dynamicConfig/tabs/YAMLView.vue b/ui-vue3/src/views/traffic/dynamicConfig/tabs/YAMLView.vue index df8ea5257..1bf990331 100644 --- a/ui-vue3/src/views/traffic/dynamicConfig/tabs/YAMLView.vue +++ b/ui-vue3/src/views/traffic/dynamicConfig/tabs/YAMLView.vue @@ -21,19 +21,16 @@ - - - - - - - - - - - - - + + + + + current v{{ currentVersionNo }} + + + Version history + + @@ -71,11 +68,21 @@ 保存 重置 + +