Compare commits

..

1 Commits
0.0.8 ... 0.0.9

Author SHA1 Message Date
T. von Dein
15e0f9dc90 Additions and enhancements (#14)
- add interactive API repl
- enhance cluster status output (use -v)
- add more ccr commands
- refactoring
2026-05-13 13:51:09 +02:00
16 changed files with 557 additions and 13 deletions

View File

@@ -2,6 +2,6 @@
- [ES API docs](https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-indices-get)
- Fix index names custom completion
- add cluster default <name> which would add a flag to the config, so that no -C is needed subsequently
- add `cluster stats` from `/_cluster/stats` like mem, procs, open files, num indices, shards etc
or add these to `cluster status`, maybe add a `--stats` to include stats there?
- index show: add more details, see screenshots
- add shard explain, aka:
get /_cluster/allocation/explain {"index":"yourindex", "primary": true, "shard":0}

View File

@@ -37,6 +37,7 @@ func Ccr(conf *cfg.Config) *cli.Command {
CcrShardPause(conf),
CcrShardResume(conf),
CcrFollower(conf),
CcrRemoteInfo(conf),
},
}
}
@@ -101,3 +102,15 @@ func CcrShardResume(conf *cfg.Config) *cli.Command {
},
}
}
func CcrRemoteInfo(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "info",
Usage: "show ccr remote info",
UsageText: "info [options] [<index>]",
Action: func(ctx context.Context, cmd *cli.Command) error {
return es.CcrRemoteInfo(conf, cmd.Args().Get(0))
},
}
}

View File

@@ -36,6 +36,91 @@ func CcrFollower(conf *cfg.Config) *cli.Command {
CcrFollowerShow(conf),
CcrFollowerAdd(conf),
CcrFollowerDelete(conf),
CcrFollowerUnfollow(conf),
CcrFollowerPause(conf),
CcrFollowerResume(conf),
CcrFollowerRenew(conf),
},
}
}
func CcrFollowerRenew(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "renew",
Usage: "renew ccr follower index",
UsageText: "renew [options] <index>",
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "force",
Usage: "force even if unfollow fails (e.g. because following is red anyway)",
Destination: &conf.Force,
Aliases: []string{"f"},
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
args := cmd.Args()
if args.Len() != 1 {
return errors.New("missing arguments: <index>")
}
return es.CcrFollowerRenew(conf, cmd.Args().Get(0))
},
}
}
func CcrFollowerResume(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "resume",
Usage: "resume ccr index to follow",
UsageText: "resume [options] <index>",
Action: func(ctx context.Context, cmd *cli.Command) error {
args := cmd.Args()
if args.Len() != 1 {
return errors.New("missing arguments: <index>")
}
return es.CcrFollowerResume(conf, cmd.Args().Get(0))
},
}
}
func CcrFollowerPause(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "pause",
Usage: "pause ccr index to follow",
UsageText: "pause [options] <index>",
Action: func(ctx context.Context, cmd *cli.Command) error {
args := cmd.Args()
if args.Len() != 1 {
return errors.New("missing arguments: <index>")
}
return es.CcrFollowerPause(conf, cmd.Args().Get(0))
},
}
}
func CcrFollowerUnfollow(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "unfollow",
Usage: "unfollow ccr follower index",
UsageText: "unfollow [options] <index>",
Action: func(ctx context.Context, cmd *cli.Command) error {
args := cmd.Args()
if args.Len() != 1 {
return errors.New("missing arguments: <index>")
}
return es.CcrFollowerUnfollow(conf, cmd.Args().Get(0))
},
}
}
@@ -71,7 +156,7 @@ func CcrFollowerAdd(conf *cfg.Config) *cli.Command {
func CcrFollowerDelete(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "delete",
Aliases: []string{"-"},
Aliases: []string{"rm"},
Usage: "delete ccr follower index",
UsageText: "delete <index>",

View File

@@ -64,6 +64,12 @@ func ClusterStatus(conf *cfg.Config) *cli.Command {
Destination: &conf.All,
Aliases: []string{"a"},
},
&cli.BoolFlag{
Name: "verbose",
Usage: "include verbose statistics",
Destination: &conf.Verbose,
Aliases: []string{"v"},
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {

View File

@@ -37,6 +37,7 @@ func Index(conf *cfg.Config) *cli.Command {
IndexShow(conf),
IndexCreate(conf),
IndexDelete(conf),
IndexClose(conf),
},
}
}
@@ -156,3 +157,14 @@ func IndexDelete(conf *cfg.Config) *cli.Command {
},
}
}
func IndexClose(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "close",
Usage: "close an index",
Action: func(ctx context.Context, cmd *cli.Command) error {
return es.IndexClose(conf, cmd.Args().Get(0))
},
}
}

38
cmd/repl.go Normal file
View File

@@ -0,0 +1,38 @@
/*
Copyright © 2026 Thomas von Dein
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package cmd
import (
"context"
"codeberg.org/scip/esctl/pkg/cfg"
"codeberg.org/scip/esctl/pkg/es"
"github.com/urfave/cli/v3"
)
func Repl(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "repl",
Aliases: []string{"shell"},
Usage: "interactive API repl",
Action: func(ctx context.Context, cmd *cli.Command) error {
return es.Repl(conf)
},
}
}

View File

@@ -79,6 +79,7 @@ func Main() int {
Ccr(conf),
Node(conf),
Doc(conf),
Repl(conf),
},
Before: func(ctx context.Context, cmd *cli.Command) (context.Context, error) {

2
go.mod
View File

@@ -19,8 +19,10 @@ go 1.25.0
require (
github.com/alecthomas/repr v0.5.2 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/chzyer/readline v1.5.1 // indirect
github.com/clipperhouse/displaywidth v0.10.0 // indirect
github.com/clipperhouse/uax29/v2 v2.6.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/elastic/elastic-transport-go/v8 v8.11.0 // indirect
github.com/elastic/go-elasticsearch/v9 v9.3.2 // indirect
github.com/fatih/color v1.19.0 // indirect

7
go.sum
View File

@@ -2,10 +2,16 @@ github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ=
github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI=
github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk=
github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8=
github.com/clipperhouse/displaywidth v0.10.0 h1:GhBG8WuerxjFQQYeuZAeVTuyxuX+UraiZGD4HJQ3Y8g=
github.com/clipperhouse/displaywidth v0.10.0/go.mod h1:XqJajYsaiEwkxOj4bowCTMcT1SgvHo9flfF3jQasdbs=
github.com/clipperhouse/uax29/v2 v2.6.0 h1:z0cDbUV+aPASdFb2/ndFnS9ts/WNXgTNNGFoKXuhpos=
github.com/clipperhouse/uax29/v2 v2.6.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/elastic/elastic-transport-go/v8 v8.9.0 h1:KeT/2P54F0xS0S8Y3Pf+tFDg4HmBgReQMB+BMz8dDAs=
github.com/elastic/elastic-transport-go/v8 v8.9.0/go.mod h1:ssMTvNS2hwf7CaiGsRRsx4gQHFZ/jS/DkLcISxekWzc=
github.com/elastic/elastic-transport-go/v8 v8.11.0 h1:taYmqC2M6+fZt/+W+ENYh/W5L9+KrlJGOSbEJs8egWc=
@@ -56,6 +62,7 @@ go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt
go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=

View File

@@ -31,7 +31,7 @@ import (
)
const (
Version string = `v0.0.8`
Version string = `v0.0.9`
)
type Cluster struct {
@@ -52,8 +52,9 @@ type Config struct {
From, To, MaxItems int // search: flags
Filter []string // search: -F
Exclude string // cluster compare: -e (regexp)
All bool // cluster status: -a
All, Verbose bool // cluster status: -a -v
Persistent, Transient, Default bool // -p -t -D cluster settings set
Force bool // ccr follower renew: -f
}
func NewConfig() *Config {

View File

@@ -85,3 +85,50 @@ func CcrStatus(conf *cfg.Config, leader, follower string) error {
return nil
}
func CcrRemoteInfo(conf *cfg.Config, index string) error {
res, err := conf.DefaultCluster.ES.Cluster.RemoteInfo().
Header("content-type", "application/json").
Header("accept", "application/json").
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to retrieve follower info: %s", err)
}
slog.Debug("ccr remote info", "info", res)
remote := ""
var info *types.ClusterRemoteProxyInfo
for name, data := range res {
remote = name
info = data.(*types.ClusterRemoteProxyInfo)
break
}
if remote == "" {
return fmt.Errorf("cluster doesn't follow any other: %s", err)
}
mode := "follower"
if checkClusterIsLeader(conf, conf.CurrentCluster) {
mode = "leader"
}
table := NewTable(2, 5)
table.Addheaders("field", "value")
table.entries = [][]string{
{"Remote Cluster", remote},
{"CCR Mode", mode},
{"Connected", fmt.Sprintf("%t", info.Connected)},
{"Num Proxy Sockets Connected", fmt.Sprintf("%d", info.NumProxySocketsConnected)},
{"Proxy Address", info.ProxyAddress},
}
if err := table.PrintMarkdown(); err != nil {
return err
}
return nil
}

View File

@@ -24,13 +24,13 @@ import (
"codeberg.org/scip/esctl/pkg/cfg"
)
func CcrFollowerAdd(conf *cfg.Config, index string) error {
func getRemoteName(conf *cfg.Config) (string, error) {
res, err := conf.DefaultCluster.ES.Cluster.RemoteInfo().
Header("content-type", "application/json").
Header("accept", "application/json").
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to retrieve follower info: %s", err)
return "", fmt.Errorf("failed to retrieve follower info: %s", err)
}
remote := ""
@@ -40,7 +40,99 @@ func CcrFollowerAdd(conf *cfg.Config, index string) error {
}
if remote == "" {
return fmt.Errorf("cluster doesn't have a follower: %s", err)
return "", fmt.Errorf("cluster doesn't have a follower: %s", err)
}
return remote, nil
}
func wrapError(call func(*cfg.Config, string) error, conf *cfg.Config, index string) error {
err := call(conf, index)
if conf.Force {
fmt.Printf("caught error: %s, continuing anyway", err)
} else {
return err
}
return nil
}
func CcrFollowerRenew(conf *cfg.Config, index string) error {
if err := wrapError(IndexClose, conf, index); err != nil {
return err
}
fmt.Printf("closed %s", index)
if err := wrapError(CcrFollowerPause, conf, index); err != nil {
return err
}
fmt.Printf("paused %s", index)
if err := wrapError(CcrFollowerUnfollow, conf, index); err != nil {
return err
}
fmt.Printf("unfollowed %s", index)
if err := wrapError(IndexDelete, conf, index); err != nil {
return err
}
fmt.Printf("deleted %s", index)
if err := CcrFollowerAdd(conf, index); err != nil {
return err
}
fmt.Printf("added follower %s", index)
return nil
}
func CcrFollowerResume(conf *cfg.Config, index string) error {
create := conf.DefaultCluster.ES.Ccr.ResumeFollow(index).
Header("content-type", "application/json").
Header("accept", "application/json")
_, err := create.Do(context.Background())
if err != nil {
return fmt.Errorf("failed to resume ccr following: %s", err)
}
return nil
}
func CcrFollowerPause(conf *cfg.Config, index string) error {
create := conf.DefaultCluster.ES.Ccr.PauseFollow(index).
Header("content-type", "application/json").
Header("accept", "application/json")
_, err := create.Do(context.Background())
if err != nil {
return fmt.Errorf("failed to pause ccr following: %s", err)
}
return nil
}
func CcrFollowerUnfollow(conf *cfg.Config, index string) error {
create := conf.DefaultCluster.ES.Ccr.ForgetFollower(index).
Header("content-type", "application/json").
Header("accept", "application/json")
_, err := create.Do(context.Background())
if err != nil {
return fmt.Errorf("failed to unfollow index: %s", err)
}
return nil
}
func CcrFollowerAdd(conf *cfg.Config, index string) error {
remote, err := getRemoteName(conf)
if err != nil {
return err
}
create := conf.DefaultCluster.ES.Ccr.Follow(index).

View File

@@ -21,11 +21,14 @@ import (
"fmt"
"log/slog"
"slices"
"strings"
"sync"
"codeberg.org/scip/esctl/pkg/cfg"
"github.com/dustin/go-humanize"
"github.com/elastic/go-elasticsearch/v9/typedapi/ccr/stats"
"github.com/elastic/go-elasticsearch/v9/typedapi/cluster/health"
clusterstats "github.com/elastic/go-elasticsearch/v9/typedapi/cluster/stats"
"github.com/elastic/go-elasticsearch/v9/typedapi/core/info"
"github.com/elastic/go-elasticsearch/v9/typedapi/types"
)
@@ -34,6 +37,7 @@ const (
ResponseHealth = iota
ResponseInfo
ResponseCcr
ResponseStats
)
type ClusterIndices map[string]map[string]*types.IndicesRecord
@@ -43,6 +47,7 @@ type apiResponse struct {
info *info.Response
health *health.Response
ccr *stats.Response
stats *clusterstats.Response
which int
}
@@ -88,6 +93,10 @@ func ClusterList(conf *cfg.Config) error {
// have to do 3 of'em for each cluster. This speeds things up.
func ClusterStatus(conf *cfg.Config) error {
clusters := []string{}
gocount := 3
if conf.Verbose {
gocount++
}
if conf.All {
for key := range conf.Clusters {
@@ -103,21 +112,26 @@ func ClusterStatus(conf *cfg.Config) error {
es = conf.Clusters[cluster].ES
}
responses := make(chan apiResponse, 3)
responses := make(chan apiResponse, gocount)
wg := &sync.WaitGroup{}
wg.Add(3)
wg.Add(gocount)
go getClusterData(es, wg, responses, "health")
go getClusterData(es, wg, responses, "info")
go getClusterData(es, wg, responses, "ccrstats")
if conf.Verbose {
go getClusterData(es, wg, responses, "stats")
}
wg.Wait()
var clusterhealth *health.Response
var info *info.Response
var ccrstats *stats.Response
var clusterstats *clusterstats.Response
for i := 0; i < 3; i++ {
for i := 0; i < gocount; i++ {
r := <-responses
if r.error != nil {
@@ -131,6 +145,8 @@ func ClusterStatus(conf *cfg.Config) error {
ccrstats = r.ccr
case ResponseInfo:
info = r.info
case ResponseStats:
clusterstats = r.stats
}
}
@@ -154,11 +170,14 @@ func ClusterStatus(conf *cfg.Config) error {
{"ES Version", info.Version.Int},
{"Active Shards", fmt.Sprintf("%d", clusterhealth.ActiveShards)},
{"Active Primary Shards", fmt.Sprintf("%d", clusterhealth.ActivePrimaryShards)},
{"Indicies", fmt.Sprintf("%d", len(clusterhealth.Indices))},
{"Nodes", fmt.Sprintf("%d", clusterhealth.NumberOfNodes)},
{"AutoFollow (success/failed indices)", ccrfollowing},
}
if conf.Verbose {
table = gatherClusterStats(conf, clusterstats, table)
}
if err := table.PrintMarkdown(); err != nil {
return err
}
@@ -166,3 +185,47 @@ func ClusterStatus(conf *cfg.Config) error {
return nil
}
func gatherClusterStats(conf *cfg.Config, clusterstats *clusterstats.Response, table *Table) *Table {
var querycount int64
var vmversion string
for _, count := range clusterstats.Indices.Search.Queries {
querycount += count
}
if len(clusterstats.Nodes.Jvm.Versions) > 0 {
vmversion = strings.Join([]string{
clusterstats.Nodes.Jvm.Versions[0].VmName,
clusterstats.Nodes.Jvm.Versions[0].VmVersion}, " ")
}
isleader := checkClusterIsLeader(conf, conf.CurrentCluster)
table.entries = append(table.entries, [][]string{
{"Indicies", fmt.Sprintf("%d", clusterstats.Indices.Count)},
{"Is Leader", fmt.Sprintf("%t", isleader)},
{"Docs", fmt.Sprintf("%d", clusterstats.Indices.Docs.Count)},
{"Total Size", humanize.Bytes(uint64(clusterstats.Indices.Docs.TotalSizeInBytes))},
{"Total Queries", fmt.Sprintf("%d", querycount)},
{"Shards Primaries", fmt.Sprintf("%d", clusterstats.Indices.Shards.Primaries)},
{"Shards Total", fmt.Sprintf("%d", clusterstats.Indices.Shards.Total)},
{"Storage", fmt.Sprintf(
"%s/%s",
humanize.Bytes(uint64(clusterstats.Indices.Store.SizeInBytes)),
humanize.Bytes(uint64(*clusterstats.Indices.Store.TotalDataSetSizeInBytes)),
)},
{"JVM Heap", fmt.Sprintf(
"%s/%s",
humanize.Bytes(uint64(clusterstats.Nodes.Jvm.Mem.HeapUsedInBytes)),
humanize.Bytes(uint64(clusterstats.Nodes.Jvm.Mem.HeapMaxInBytes)),
)},
{"JVM Threads", fmt.Sprintf("%d", clusterstats.Nodes.Jvm.Threads)},
{"JVM Version", vmversion},
{"CPUs", fmt.Sprintf("%d", clusterstats.Nodes.Os.AllocatedProcessors)},
{"CPU Usage", fmt.Sprintf("%d%%", clusterstats.Nodes.Process.Cpu.Percent)},
{"Open FDs", fmt.Sprintf("%d", clusterstats.Nodes.Process.OpenFileDescriptors.Avg)},
}...)
return table
}

View File

@@ -375,6 +375,16 @@ func getClusterData(es *elasticsearch.TypedClient, wg *sync.WaitGroup, reschan c
ar.ccr = res
ar.which = ResponseCcr
arerr = err
case "stats":
res, err := es.Cluster.Stats().
Header("content-type", "application/json").
Header("accept", "application/json").
Do(context.Background())
ar.stats = res
ar.which = ResponseStats
arerr = err
}
if arerr != nil {

View File

@@ -202,3 +202,17 @@ func IndexDelete(conf *cfg.Config, index string) error {
return nil
}
func IndexClose(conf *cfg.Config, index string) error {
create := conf.DefaultCluster.ES.Indices.Close(index).
Header("content-type", "application/json").
Header("accept", "application/json")
_, err := create.Do(context.Background())
if err != nil {
return fmt.Errorf("failed to close index: %s", err)
}
return nil
}

153
pkg/es/repl.go Normal file
View File

@@ -0,0 +1,153 @@
/*
Copyright © 2026 Thomas von Dein
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package es
import (
"bytes"
"crypto/tls"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"slices"
"strings"
"codeberg.org/scip/esctl/pkg/cfg"
"github.com/chzyer/readline"
)
func encodeAuth(username, password string) string {
return base64.StdEncoding.EncodeToString([]byte(username + ":" + password))
}
func CallAPI(conf *cfg.Config, input []string) error {
var data string
verb := strings.ToUpper(input[0])
path := input[1]
if len(input) == 3 {
data = input[2]
}
// we're using port-forwards anyway
tr := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
client := &http.Client{Transport: tr}
req, err := http.NewRequest(verb, conf.DefaultCluster.Uri+path, bytes.NewBuffer([]byte(data)))
if err != nil {
return err
}
req.Header.Add("Content-Type", "application/json")
req.Header.Add("accept", "application/json")
req.Header.Add("Authorization", "Basic "+encodeAuth(conf.DefaultCluster.User, conf.DefaultCluster.Pass))
// actually execute the request
resp, err := client.Do(req)
if err != nil {
return err
}
// Read and print response
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("failed to read response body: %s", err)
}
var pretty bytes.Buffer
error := json.Indent(&pretty, body, "", "\t")
if error != nil {
return fmt.Errorf("json parse error: %s", err)
}
fmt.Println(pretty.String())
return nil
}
func Repl(conf *cfg.Config) error {
verbs := []string{"post", "get", "put", "delete"}
fmt.Println("Input format: verb path [data]")
fmt.Println("example: post /yourindex/_ccr/pause_follow")
reader, err := readline.NewEx(&readline.Config{
Prompt: "> ",
HistoryFile: os.Getenv("HOME") + "/.config/esctl/history",
HistoryLimit: 500,
InterruptPrompt: "^C",
EOFPrompt: "exit",
HistorySearchFold: true,
})
if err != nil {
return fmt.Errorf("failed to initialize readline lib: %s", err)
}
for {
text, err := reader.Readline()
if err != nil {
break
}
text = strings.TrimSpace(text)
if text == "" {
continue
}
parts := strings.SplitN(strings.TrimSpace(text), " ", 3)
if len(parts) < 2 {
fmt.Println("error: you need to input a verb, uri [and post data]")
continue
}
if !slices.Contains(verbs, strings.ToLower(parts[0])) {
fmt.Println("error: verb must be one of " + strings.Join(verbs, ","))
continue
}
if !strings.HasPrefix(parts[1], "/") {
fmt.Println("error: url path must start with /")
continue
}
if len(parts) == 3 {
data := map[string]any{}
err := json.Unmarshal([]byte(parts[2]), &data)
if err != nil {
fmt.Printf("error: input data is not proper JSON: %s", err)
continue
}
}
err = CallAPI(conf, parts)
if err != nil {
fmt.Printf("failed to call API: %s\n", err)
}
reader.SetPrompt("> ")
}
return nil
}