| /* |
| * 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 api |
| |
| import ( |
| "bytes" |
| "context" |
| "encoding/json" |
| "io" |
| "net/http" |
| "net/http/httptest" |
| "testing" |
| "time" |
| |
| "github.com/gin-gonic/gin" |
| "github.com/go-redis/redis/v8" |
| "github.com/stretchr/testify/require" |
| |
| "github.com/apache/kvrocks-controller/config" |
| "github.com/apache/kvrocks-controller/consts" |
| "github.com/apache/kvrocks-controller/controller" |
| "github.com/apache/kvrocks-controller/server/middleware" |
| "github.com/apache/kvrocks-controller/store" |
| "github.com/apache/kvrocks-controller/store/engine" |
| "github.com/apache/kvrocks-controller/util" |
| ) |
| |
| func TestClusterBasics(t *testing.T) { |
| ns := "test-ns" |
| handler := &ClusterHandler{s: store.NewClusterStore(engine.NewMock())} |
| |
| runCreate := func(t *testing.T, name string, expectedStatusCode int) { |
| testCreateRequest := &CreateClusterRequest{ |
| Name: name, |
| Nodes: []string{"127.0.0.1:1234", "127.0.0.1:1235", "127.0.0.1:1236", "127.0.0.1:1237"}, |
| Replicas: 2, |
| } |
| recorder := httptest.NewRecorder() |
| ctx := GetTestContext(recorder) |
| body, err := json.Marshal(testCreateRequest) |
| require.NoError(t, err) |
| |
| ctx.Header(consts.HeaderDontCheckClusterMode, "yes") |
| ctx.Request.Body = io.NopCloser(bytes.NewBuffer(body)) |
| ctx.Params = []gin.Param{{Key: "namespace", Value: ns}} |
| |
| handler.Create(ctx) |
| require.Equal(t, expectedStatusCode, recorder.Code) |
| } |
| |
| runGet := func(t *testing.T, name string, expectedStatusCode int) { |
| recorder := httptest.NewRecorder() |
| ctx := GetTestContext(recorder) |
| ctx.Set(consts.ContextKeyStore, handler.s) |
| ctx.Params = []gin.Param{{Key: "namespace", Value: ns}, {Key: "cluster", Value: name}} |
| |
| middleware.RequiredCluster(ctx) |
| if recorder.Code != http.StatusOK { |
| return |
| } |
| handler.Get(ctx) |
| require.Equal(t, expectedStatusCode, recorder.Code) |
| } |
| |
| runRemove := func(t *testing.T, name string, expectedStatusCode int) { |
| recorder := httptest.NewRecorder() |
| ctx := GetTestContext(recorder) |
| ctx.Set(consts.ContextKeyStore, handler.s) |
| ctx.Params = []gin.Param{{Key: "namespace", Value: ns}, {Key: "cluster", Value: name}} |
| |
| middleware.RequiredCluster(ctx) |
| if recorder.Code != http.StatusOK { |
| return |
| } |
| handler.Remove(ctx) |
| require.Equal(t, expectedStatusCode, recorder.Code) |
| } |
| |
| t.Run("create cluster", func(t *testing.T) { |
| runCreate(t, "test-cluster", http.StatusCreated) |
| runCreate(t, "test-cluster", http.StatusConflict) |
| }) |
| |
| t.Run("get cluster", func(t *testing.T) { |
| runGet(t, "test-cluster", http.StatusOK) |
| runGet(t, "not-exist", http.StatusNotFound) |
| }) |
| |
| t.Run("list cluster", func(t *testing.T) { |
| recorder := httptest.NewRecorder() |
| ctx := GetTestContext(recorder) |
| ctx.Set(consts.ContextKeyStore, handler.s) |
| ctx.Params = []gin.Param{{Key: "namespace", Value: ns}} |
| |
| handler.List(ctx) |
| require.Equal(t, http.StatusOK, recorder.Code) |
| |
| var rsp struct { |
| Data struct { |
| Clusters []string `json:"clusters"` |
| } `json:"data"` |
| } |
| require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &rsp)) |
| require.ElementsMatch(t, []string{"test-cluster"}, rsp.Data.Clusters) |
| }) |
| |
| t.Run("migrate slot only", func(t *testing.T) { |
| handler := &ClusterHandler{s: store.NewClusterStore(engine.NewMock())} |
| clusterName := "test-migrate-slot-only-cluster" |
| recorder := httptest.NewRecorder() |
| ctx := GetTestContext(recorder) |
| ctx.Set(consts.ContextKeyStore, handler.s) |
| ctx.Params = []gin.Param{{Key: "namespace", Value: ns}, {Key: "cluster", Value: clusterName}} |
| slotRange, err := store.NewSlotRange(3, 3) |
| require.NoError(t, err) |
| testMigrateReq := &MigrateSlotRequest{ |
| Slot: slotRange, |
| SlotOnly: true, |
| Target: 1, |
| } |
| body, err := json.Marshal(testMigrateReq) |
| require.NoError(t, err) |
| ctx.Request.Body = io.NopCloser(bytes.NewBuffer(body)) |
| |
| cluster, err := store.NewCluster(clusterName, []string{"127.0.0.1:1111", "127.0.0.1:2222"}, 1) |
| require.NoError(t, err) |
| require.NoError(t, handler.s.CreateCluster(ctx, ns, cluster)) |
| |
| before, err := handler.s.GetCluster(ctx, ns, clusterName) |
| require.NoError(t, err) |
| require.EqualValues(t, store.SlotRange{Start: 0, Stop: 8191}, before.Shards[0].SlotRanges[0]) |
| require.EqualValues(t, store.SlotRange{Start: 8192, Stop: store.MaxSlotID}, before.Shards[1].SlotRanges[0]) |
| |
| middleware.RequiredCluster(ctx) |
| handler.MigrateSlot(ctx) |
| require.Equal(t, http.StatusOK, recorder.Code) |
| after, err := handler.s.GetCluster(ctx, ns, clusterName) |
| require.NoError(t, err) |
| |
| require.EqualValues(t, before.Version.Add(1), after.Version.Load()) |
| require.Len(t, after.Shards[0].SlotRanges, 2) |
| require.EqualValues(t, store.SlotRange{Start: 0, Stop: 2}, after.Shards[0].SlotRanges[0]) |
| require.EqualValues(t, store.SlotRange{Start: 4, Stop: 8191}, after.Shards[0].SlotRanges[1]) |
| require.Len(t, after.Shards[1].SlotRanges, 2) |
| require.EqualValues(t, store.SlotRange{Start: 3, Stop: 3}, after.Shards[1].SlotRanges[0]) |
| require.EqualValues(t, store.SlotRange{Start: 8192, Stop: store.MaxSlotID}, after.Shards[1].SlotRanges[1]) |
| }) |
| |
| t.Run("remove cluster", func(t *testing.T) { |
| runRemove(t, "test-cluster", http.StatusNoContent) |
| runRemove(t, "not-exist", http.StatusNotFound) |
| }) |
| } |
| |
| func TestClusterImport(t *testing.T) { |
| ns := "test-ns" |
| clusterName := "test-cluster-import" |
| handler := &ClusterHandler{s: store.NewClusterStore(engine.NewMock())} |
| // cluster import must be done on a real cluster |
| testNodeAddr := "127.0.0.1:7770" |
| clusterNode := store.NewClusterNode(testNodeAddr, "") |
| cluster, err := store.NewCluster(clusterName, []string{testNodeAddr}, 1) |
| require.NoError(t, err) |
| ctx := context.Background() |
| require.NoError(t, cluster.Reset(ctx)) |
| require.NoError(t, clusterNode.SyncClusterInfo(ctx, cluster)) |
| defer func() { |
| // clean up the cluster information to avoid affecting other tests |
| require.NoError(t, cluster.Reset(ctx)) |
| }() |
| |
| var req struct { |
| Nodes []string `json:"nodes"` |
| } |
| req.Nodes = []string{testNodeAddr} |
| |
| recorder := httptest.NewRecorder() |
| testCtx := GetTestContext(recorder) |
| body, err := json.Marshal(req) |
| require.NoError(t, err) |
| |
| testCtx.Request.Body = io.NopCloser(bytes.NewBuffer(body)) |
| testCtx.Params = []gin.Param{{Key: "namespace", Value: ns}, {Key: "cluster", Value: "test-cluster-import"}} |
| handler.Import(testCtx) |
| require.Equal(t, http.StatusOK, recorder.Code) |
| |
| var rsp struct { |
| Data struct { |
| Cluster *store.Cluster `json:"cluster"` |
| } `json:"data"` |
| } |
| require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &rsp)) |
| require.Len(t, rsp.Data.Cluster.Shards, 1) |
| require.Len(t, rsp.Data.Cluster.Shards[0].Nodes, 1) |
| require.Equal(t, testNodeAddr, rsp.Data.Cluster.Shards[0].Nodes[0].Addr()) |
| } |
| |
| func TestClusterMigrateData(t *testing.T) { |
| ns := "test-ns" |
| clusterName := "test-cluster" |
| clusterStore := store.NewClusterStore(engine.NewMock()) |
| handler := &ClusterHandler{s: clusterStore} |
| |
| ctx := context.Background() |
| nodeAddrs := []string{"127.0.0.1:7770", "127.0.0.1:7771"} |
| sourceRedisClient := redis.NewClient(&redis.Options{Addr: nodeAddrs[0]}) |
| targetRedisClient := redis.NewClient(&redis.Options{Addr: nodeAddrs[1]}) |
| |
| cluster, err := store.NewCluster(clusterName, nodeAddrs, 1) |
| require.NoError(t, err) |
| require.NoError(t, cluster.Reset(ctx)) |
| defer func() { |
| require.NoError(t, cluster.Reset(ctx)) |
| }() |
| require.NoError(t, cluster.SyncToNodes(ctx)) |
| clusterStore.CreateCluster(ctx, ns, cluster) |
| |
| sendRequest := func(t *testing.T, ns, cluster string, slotRange store.SlotRange) { |
| recorder := httptest.NewRecorder() |
| reqCtx := GetTestContext(recorder) |
| reqCtx.Set(consts.ContextKeyStore, handler.s) |
| reqCtx.Params = []gin.Param{{Key: "namespace", Value: ns}, {Key: "cluster", Value: cluster}} |
| body, err := json.Marshal(&MigrateSlotRequest{Target: 1, Slot: slotRange}) |
| require.NoError(t, err) |
| reqCtx.Request.Body = io.NopCloser(bytes.NewBuffer(body)) |
| |
| middleware.RequiredCluster(reqCtx) |
| handler.MigrateSlot(reqCtx) |
| require.Equal(t, http.StatusOK, recorder.Code) |
| } |
| |
| runController := func(t *testing.T) *controller.Controller { |
| ctrl, err := controller.New(clusterStore, &config.ControllerConfig{ |
| FailOver: &config.FailOverConfig{ |
| PingIntervalSeconds: 1, |
| MaxPingCount: 3, |
| }, |
| }) |
| require.NoError(t, err) |
| require.NoError(t, ctrl.Start(ctx)) |
| ctrl.WaitForReady() |
| return ctrl |
| } |
| |
| t.Run("migrate slot(s) from one shard to another", func(t *testing.T) { |
| for _, slotRange := range []store.SlotRange{ |
| {Start: 10, Stop: 10}, |
| {Start: 11, Stop: 14}, |
| } { |
| for i := slotRange.Start; i <= slotRange.Stop; i++ { |
| require.NoError(t, sourceRedisClient.Set(ctx, util.SlotTable[i], "test-value", 0).Err()) |
| } |
| sendRequest(t, ns, "test-cluster", slotRange) |
| gotCluster, err := clusterStore.GetCluster(ctx, ns, clusterName) |
| require.NoError(t, err) |
| |
| currentVersion := gotCluster.Version.Load() |
| sourceSlotRanges := gotCluster.Shards[0].SlotRanges |
| targetSlotRanges := gotCluster.Shards[1].SlotRanges |
| require.EqualValues(t, slotRange, gotCluster.Shards[0].MigratingSlot.SlotRange) |
| require.EqualValues(t, 1, gotCluster.Shards[0].TargetShardIndex) |
| |
| // Run the controller to check and update the migration status |
| controller := runController(t) |
| require.Eventually(t, func() bool { |
| gotCluster, err := handler.s.GetCluster(ctx, ns, "test-cluster") |
| require.NoError(t, err) |
| return gotCluster.Shards[0].MigratingSlot == nil |
| }, 10*time.Second, 100*time.Millisecond) |
| controller.Close() |
| |
| // Check if the slot range has been removed from the source shard and added to the target shard |
| gotCluster, err = clusterStore.GetCluster(ctx, ns, clusterName) |
| require.NoError(t, err) |
| require.EqualValues(t, currentVersion+1, gotCluster.Version.Load()) |
| require.Nil(t, gotCluster.Shards[0].MigratingSlot) |
| require.EqualValues(t, -1, gotCluster.Shards[0].TargetShardIndex) |
| require.EqualValues(t, store.RemoveSlotFromSlotRanges(sourceSlotRanges, slotRange), gotCluster.Shards[0].SlotRanges) |
| require.EqualValues(t, store.AddSlotToSlotRanges(targetSlotRanges, slotRange), gotCluster.Shards[1].SlotRanges) |
| |
| for i := slotRange.Start; i <= slotRange.Stop; i++ { |
| val, err := targetRedisClient.Get(ctx, util.SlotTable[i]).Result() |
| require.NoError(t, err) |
| require.EqualValues(t, "test-value", val) |
| } |
| } |
| }) |
| } |