diff --git a/TODO.md b/TODO.md index c9c2364..7c3031e 100644 --- a/TODO.md +++ b/TODO.md @@ -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 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} diff --git a/cmd/ccr.go b/cmd/ccr.go index 053f030..4930587 100644 --- a/cmd/ccr.go +++ b/cmd/ccr.go @@ -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] []", + + Action: func(ctx context.Context, cmd *cli.Command) error { + return es.CcrRemoteInfo(conf, cmd.Args().Get(0)) + }, + } +} diff --git a/cmd/ccr_follower.go b/cmd/ccr_follower.go index 5cae8e0..1dec4d5 100644 --- a/cmd/ccr_follower.go +++ b/cmd/ccr_follower.go @@ -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] ", + + 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: ") + } + + 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] ", + + Action: func(ctx context.Context, cmd *cli.Command) error { + args := cmd.Args() + + if args.Len() != 1 { + return errors.New("missing arguments: ") + } + + 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] ", + + Action: func(ctx context.Context, cmd *cli.Command) error { + args := cmd.Args() + + if args.Len() != 1 { + return errors.New("missing arguments: ") + } + + 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] ", + + Action: func(ctx context.Context, cmd *cli.Command) error { + args := cmd.Args() + + if args.Len() != 1 { + return errors.New("missing arguments: ") + } + + 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 ", diff --git a/cmd/cluster.go b/cmd/cluster.go index fdb8264..d6b4252 100644 --- a/cmd/cluster.go +++ b/cmd/cluster.go @@ -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 { diff --git a/cmd/index.go b/cmd/index.go index 4bd7814..918df58 100644 --- a/cmd/index.go +++ b/cmd/index.go @@ -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)) + }, + } +} diff --git a/cmd/repl.go b/cmd/repl.go new file mode 100644 index 0000000..d4315d4 --- /dev/null +++ b/cmd/repl.go @@ -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 . +*/ +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) + }, + } +} diff --git a/cmd/root.go b/cmd/root.go index 34635fe..f6c65de 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -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) { diff --git a/go.mod b/go.mod index c1df583..ed4af76 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index a9b1be6..227f1c3 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/pkg/cfg/config.go b/pkg/cfg/config.go index cc67fa2..1ff2018 100644 --- a/pkg/cfg/config.go +++ b/pkg/cfg/config.go @@ -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 { diff --git a/pkg/es/ccr.go b/pkg/es/ccr.go index 308d4a3..8a14464 100644 --- a/pkg/es/ccr.go +++ b/pkg/es/ccr.go @@ -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 +} diff --git a/pkg/es/ccr_follower.go b/pkg/es/ccr_follower.go index 3ef2ebe..f4357fe 100644 --- a/pkg/es/ccr_follower.go +++ b/pkg/es/ccr_follower.go @@ -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). diff --git a/pkg/es/cluster.go b/pkg/es/cluster.go index 062944d..08ae3b5 100644 --- a/pkg/es/cluster.go +++ b/pkg/es/cluster.go @@ -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 +} diff --git a/pkg/es/cluster_util.go b/pkg/es/cluster_util.go index f1025d3..f0064ae 100644 --- a/pkg/es/cluster_util.go +++ b/pkg/es/cluster_util.go @@ -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 { diff --git a/pkg/es/index.go b/pkg/es/index.go index 4adc6dc..0b4f76a 100644 --- a/pkg/es/index.go +++ b/pkg/es/index.go @@ -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 +} diff --git a/pkg/es/repl.go b/pkg/es/repl.go new file mode 100644 index 0000000..73ed2f7 --- /dev/null +++ b/pkg/es/repl.go @@ -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 . +*/ +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 +}