Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
# specific language governing permissions and limitations
# under the License.
#
FROM golang:1.17 as build
FROM golang:1.20 as build

WORKDIR /kvctl

Expand All @@ -32,7 +32,7 @@ FROM ubuntu:focal
WORKDIR /kvctl

COPY --from=build /kvctl/_build/kvctl-server ./bin/
COPY --from=build /kvctl/_build/kvctl-client ./bin/
COPY --from=build /kvctl/_build/kvctl ./bin/

VOLUME /var/lib/kvctl

Expand Down
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,29 @@ $ ./_build/kvctl-server -c config/config.yaml
```
![image](docs/images/server.gif)

### 2. Use the terminal client to interact with the controller server

```shell
# Show help
$ ./_build/kvctl --help

# Create namespace
$ ./_build/kvctl create namespace test-ns

# List namespaces
$ ./_build/kvctl list namespaces

# Create cluster in the namespace
$ ./_build/kvctl create cluster test-cluster --nodes 127.0.0.1:6666,127.0.0.1:6667 -n test-ns

# List clusters in the namespace
$ ./_build/kvctl list clusters -n test-ns

# Get cluster in the namespace
$ ./_build/kvctl get cluster test-cluster -n test-ns

# Migrate slot from source to target
$ ./_build/kvctl migrate slot 123 --target 1 -n test-ns -c test-cluster
```

For the HTTP API, you can find the [HTTP API(work in progress)](docs/API.md) for more details.
8 changes: 6 additions & 2 deletions build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -79,10 +79,14 @@ BUILD_DATE=`date -u +'%Y-%m-%dT%H:%M:%SZ'`
GIT_REVISION=`git rev-parse --short HEAD`

SERVER_TARGET_NAME=kvctl-server
CLIENT_TARGET_NAME=kvctl-client
CLIENT_TARGET_NAME=kvctl

for TARGET_NAME in "$SERVER_TARGET_NAME" "$CLIENT_TARGET_NAME"; do
CMD_PATH="${GO_PROJECT}/cmd/${TARGET_NAME##*-}" # Remove everything to the left of the last - in the TARGET_NAME variable
if [[ "$TARGET_NAME" == "$SERVER_TARGET_NAME" ]]; then
CMD_PATH="${GO_PROJECT}/cmd/server"
else
CMD_PATH="${GO_PROJECT}/cmd/client"
fi

if [[ "$BUILDER_IMAGE" == "none" ]]; then
GOOS="$TARGET_OS" GOARCH="$TARGET_ARCH" CGO_ENABLED=0 go build -v -ldflags \
Expand Down
86 changes: 86 additions & 0 deletions cmd/client/command/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*
*/

package command

import (
"encoding/json"
"errors"
"reflect"

"github.com/go-resty/resty/v2"
)

const (
apiVersionV1 = "/api/v1"

defaultHost = "http://127.0.0.1:9379"
)

type client struct {
restyCli *resty.Client
host string
}

type ErrorMessage struct {
Message string `json:"message"`
}

type response struct {
Error *ErrorMessage `json:"error"`
Data any `json:"data"`
}

func newClient(host string) *client {
if host == "" {
host = defaultHost
}
restyCli := resty.New().SetBaseURL(host + apiVersionV1)
return &client{
restyCli: restyCli,
host: host,
}
}

func unmarshalData(body []byte, v any) error {
if len(body) == 0 {
return errors.New("empty response body")
}

rv := reflect.ValueOf(v)
if rv.Kind() != reflect.Ptr || rv.IsNil() {
return errors.New("unmarshal receiver was non-pointer")
}

var rsp response
rsp.Data = v
return json.Unmarshal(body, &rsp)
}

func unmarshalError(body []byte) error {
var rsp response
if err := json.Unmarshal(body, &rsp); err != nil {
return err
}
if rsp.Error != nil {
return errors.New(rsp.Error.Message)
}
return nil
}
28 changes: 28 additions & 0 deletions cmd/client/command/consts.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/*
* 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 command

const (
ResourceNamespace = "namespace"
ResourceCluster = "cluster"
ResourceShard = "shard"
ResourceNode = "node"
)
207 changes: 207 additions & 0 deletions cmd/client/command/create.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
/*
* 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 command

import (
"errors"
"strconv"
"strings"

"github.com/spf13/cobra"
)

type CreateOptions struct {
namespace string
cluster string
shard int
replica int
nodes []string
password string
}

var createOptions CreateOptions

var CreateCommand = &cobra.Command{
Use: "create",
Short: "Create a resource",
Example: `
# Create a namespace
kvctl create namespace <namespace>

# Create a cluster in the namespace
kvctl create cluster <cluster> -n <namespace> --replica 1 --nodes 127.0.0.1:6379,127.0.0.1:6380,127.0.0.1:6381

# Create a shard in the cluster
kvctl create shard -n <namespace> -c <cluster> --nodes 127.0.0.1:6379,127.0.0.1:6380

# Create nodes in the cluster
kvctl create node 127.0.0.1:6379 -n <namespace> -c <cluster> --shard <shard>
`,
PreRunE: createPreRun,
RunE: func(cmd *cobra.Command, args []string) error {
host, _ := cmd.Flags().GetString("host")
client := newClient(host)
switch strings.ToLower(args[0]) {
case ResourceNamespace:
if len(args) < 2 {
return errors.New("missing namespace name")
}
return createNamespace(client, args[1])
case ResourceCluster:
if len(args) < 2 {
return errors.New("missing cluster name")
}
createOptions.cluster = args[1]
return createCluster(client, &createOptions)
case ResourceShard:
return createShard(client, &createOptions)
case ResourceNode:
if len(args) < 2 {
return errors.New("missing node address")
}
createOptions.nodes = []string{args[1]}
return createNodes(client, &createOptions)
default:
return errors.New("unsupported resource type, please specify one of [namespace, cluster, shard, nodes]")
}
},
SilenceUsage: true,
SilenceErrors: true,
}

func createPreRun(_ *cobra.Command, args []string) error {
if len(args) == 0 {
return errors.New("missing resource type, please specify one of [namespace, cluster, shard, node]")
}
resource := strings.ToLower(args[0])
if resource == ResourceNamespace {
return nil
}
if createOptions.namespace == "" {
return errors.New("missing namespace, please specify the namespace via -n or --namespace option")
}
if resource != ResourceNode && createOptions.nodes == nil {
return errors.New("missing nodes, please specify the nodes via --nodes option")
}
if resource == ResourceCluster {
return nil
}
if createOptions.cluster == "" {
return errors.New("missing cluster, please specify the cluster via -c or --cluster option")
}
if resource == ResourceShard {
return nil
}
if createOptions.shard == -1 {
return errors.New("missing shard, please specify the shard via -s or --shard option")
}
if createOptions.shard < 0 {
return errors.New("shard must be a positive number")
}
return nil
}

func createNamespace(cli *client, name string) error {
rsp, err := cli.restyCli.R().
SetBody(map[string]string{"namespace": name}).
Post("/namespaces")
if err != nil {
return err
}

if rsp.IsError() {
return unmarshalError(rsp.Body())
}
printLine("create namespace: %s successfully.", name)
return nil
}

func createCluster(cli *client, options *CreateOptions) error {
rsp, err := cli.restyCli.R().
SetPathParam("namespace", options.namespace).
SetBody(map[string]interface{}{
"name": options.cluster,
"replica": options.replica,
"nodes": options.nodes,
"password": options.password,
}).
Post("/namespaces/{namespace}/clusters")
if err != nil {
return err
}

if rsp.IsError() {
return unmarshalError(rsp.Body())
}
printLine("create cluster: %s successfully.", options.cluster)
return nil
}

func createShard(cli *client, options *CreateOptions) error {
rsp, err := cli.restyCli.R().
SetPathParam("namespace", options.namespace).
SetPathParam("cluster", options.cluster).
SetBody(map[string]interface{}{
"name": options.cluster,
"nodes": options.nodes,
"password": options.password,
}).
Post("/namespaces/{namespace}/clusters/{cluster}/shards")
if err != nil {
return err
}

if rsp.IsError() {
return unmarshalError(rsp.Body())
}
printLine("create the new shard successfully.")
return nil
}

func createNodes(cli *client, options *CreateOptions) error {
rsp, err := cli.restyCli.R().
SetPathParam("namespace", options.namespace).
SetPathParam("cluster", options.cluster).
SetPathParam("shard", strconv.Itoa(options.shard)).
SetBody(map[string]interface{}{
"addr": options.nodes[0],
"password": options.password,
}).
Post("/namespaces/{namespace}/clusters/{cluster}/shards/{shard}/nodes")
if err != nil {
return err
}

if rsp.IsError() {
return unmarshalError(rsp.Body())
}
printLine("create node: %v successfully.", options.nodes[0])
return nil
}

func init() {
CreateCommand.Flags().StringVarP(&createOptions.namespace, "namespace", "n", "", "The namespace")
CreateCommand.Flags().StringVarP(&createOptions.cluster, "cluster", "c", "", "The cluster")
CreateCommand.Flags().IntVarP(&createOptions.shard, "shard", "s", -1, "The shard number")
CreateCommand.Flags().IntVarP(&createOptions.replica, "replica", "r", 1, "The replica number")
CreateCommand.Flags().StringSliceVarP(&createOptions.nodes, "nodes", "", nil, "The node list")
CreateCommand.Flags().StringVarP(&createOptions.password, "password", "", "", "The password")
}
Loading