Compare commits

...

19 Commits

Author SHA1 Message Date
T. von Dein
98a577f68a add doc show, enhance search, add jsonpath to search and doc show 2026-05-22 13:50:17 +02:00
T. von Dein
89e2498aad add index alias delete (#20) 2026-05-21 14:00:35 +02:00
T. von Dein
0870ceb3df Refactor output code, add JSON, TSV and YAML output modes, add -o (#19), add version subcommand 2026-05-20 13:06:39 +02:00
32de4863af upd command tree 2026-05-19 13:59:19 +02:00
T. von Dein
58d94a4f56 shards, aliases and filtering (#18) 2026-05-19 13:58:08 +02:00
565d92b491 add command tree 2026-05-18 14:29:46 +02:00
T. von Dein
40e0612bef add explain index allocation 2026-05-18 13:58:08 +02:00
T. von Dein
db50072a7b add support for json color output in repl using jq, if installed (#16) 2026-05-18 13:56:07 +02:00
T. von Dein
b71819f9e8 update deps: cli v3.9.0 and some minor updates (#15) 2026-05-18 12:19:45 +02:00
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
T. von Dein
dd619ab815 Add CCR stuff, fix search, add docs support, support index maps, refactor (#13) 2026-05-12 18:28:57 +02:00
327bc84bd8 fix cluster list sorting 2026-05-11 14:03:30 +02:00
a785eb642f add settings reference to usage 2026-05-11 14:03:11 +02:00
a209debe09 fix linting issues 2026-05-11 10:46:17 +02:00
d1d97fed1b enhanced config debug print (dump w/o ES structs) 2026-05-11 10:42:58 +02:00
4194a004c0 separate cluster settings code 2026-05-11 10:28:25 +02:00
ceab7698e2 dont debug the whole confi, bump version 2026-05-11 10:23:44 +02:00
d22352da12 fix settings output, recursively determine the json path of settings 2026-05-11 10:22:17 +02:00
3bd26fde7a fix coredump, register default cluster name 2026-05-11 10:21:43 +02:00
39 changed files with 2987 additions and 562 deletions

4
.gitignore vendored
View File

@@ -26,3 +26,7 @@ go.work.sum
.env
esctl
*.sh
*.json
*.log

View File

@@ -23,11 +23,24 @@ UID = root
GID = 0
HAVE_POD := $(shell pod2text -h 2>/dev/null)
BRANCH = $(shell git branch --show-current)
COMMIT = $(shell git rev-parse --short=8 HEAD)
BUILD = $(shell date +%Y.%m.%d.%H%M%S)
GOVERSION = $(shell grep -E "^go" go.mod | cut -d' ' -f2)
APIVERSION = $(shell grep github.com/elastic/go-elasticsearch go.mod | cut -d' ' -f2)
all: buildlocal
buildlocal:
CGO_LDFLAGS='-static' go build -tags osusergo,netgo -ldflags "-extldflags=-static" -o $(tool)
CGO_LDFLAGS='-static' go build -tags osusergo,netgo -ldflags "-extldflags=-static" \
-ldflags "-X codeberg.org/scip/esctl/pkg/cfg.BRANCH=$(BRANCH) \
-X codeberg.org/scip/esctl/pkg/cfg.COMMIT=$(COMMIT) \
-X codeberg.org/scip/esctl/pkg/cfg.GOVERSION=$(GOVERSION) \
-X codeberg.org/scip/esctl/pkg/cfg.APIVERSION=$(APIVERSION) \
-X codeberg.org/scip/esctl/pkg/cfg.BUILD=$(BUILD)" \
-o $(tool)
strip $(tool)
install: buildlocal

View File

@@ -6,30 +6,55 @@ Elasticsearch CLI
## Usage
Command tree:
```console
NAME:
esctl - manage elasticsearch from cli
USAGE:
esctl [global options] [command [command options]]
VERSION:
v0.0.4
COMMANDS:
search, / search within an index
index, i manage indicies
snapshot, snap manage snapshots
cluster, c manage cluster[s]
node, snap manage nodes
help, h Shows a list of commands or help for one command
GLOBAL OPTIONS:
--debug, -d enable debugging [$ES_DEBUG]
--config string, -c string config file [$ES_CONFIG]
--cluster string, -C string cluster alias to work with
--help, -h show help
--version, -v print the version
ccr
follower
add
delete
pause
renew
resume
show
unfollow
info
pause
resume
status
cluster
list
settings
list
set
status
doc
add
help
index
alias
create
delete
list
allocation
close
create
delete
list
modify
show
node
list
show
repl
search
shard
list
show
snapshot
list
show
version
```
Configure `esctl` with environment variables:

10
TODO.md
View File

@@ -2,6 +2,10 @@
- [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?
https://github.com/urfave/cli/issues/2332
https://github.com/urfave/cli/issues/2333
- index show: add more details, see screenshots
- add shard explain, aka:
get /_cluster/allocation/explain {"index":"yourindex", "primary": true, "shard":0}

116
cmd/ccr.go Normal file
View File

@@ -0,0 +1,116 @@
/*
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"
"errors"
"codeberg.org/scip/esctl/pkg/cfg"
"codeberg.org/scip/esctl/pkg/es"
"github.com/urfave/cli/v3"
)
func Ccr(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "ccr",
Aliases: []string{"replication", "rep"},
Usage: "manage cross cluster replication",
Commands: []*cli.Command{
CcrStatus(conf),
CcrShardPause(conf),
CcrShardResume(conf),
CcrFollower(conf),
CcrRemoteInfo(conf),
},
}
}
func CcrStatus(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "status",
Aliases: []string{"st"},
Usage: "cross cluster replication status (yaml config with 2 clusters required)",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "exclude",
Usage: "regexp of indicies to exclude",
Destination: &conf.Exclude,
Aliases: []string{"e"},
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
leader := cmd.Args().Get(0)
follower := cmd.Args().Get(1)
if leader == "" || follower == "" {
return errors.New("no leader and follower aliases specified")
}
_, hasLeader := conf.Clusters[leader]
_, hasFollower := conf.Clusters[follower]
if !hasLeader || !hasFollower {
return errors.New("either leader or follower alias not configured")
}
if err := es.CcrStatus(conf, leader, follower); err != nil {
return err
}
return nil
},
}
}
func CcrShardPause(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "pause",
Usage: "pause shard allocation",
Action: func(ctx context.Context, cmd *cli.Command) error {
return es.CcrShardPause(conf)
},
}
}
func CcrShardResume(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "resume",
Usage: "resume shard allocation",
Action: func(ctx context.Context, cmd *cli.Command) error {
return es.CcrShardResume(conf)
},
}
}
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))
},
}
}

193
cmd/ccr_follower.go Normal file
View File

@@ -0,0 +1,193 @@
/*
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"
"errors"
"codeberg.org/scip/esctl/pkg/cfg"
"codeberg.org/scip/esctl/pkg/es"
"github.com/urfave/cli/v3"
)
func CcrFollower(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "follower",
Aliases: []string{"f"},
Usage: "manage ccr follower indices",
Commands: []*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))
},
}
}
func CcrFollowerAdd(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "add",
Aliases: []string{"+"},
Usage: "add ccr follower index",
UsageText: "add [options] <index>",
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "wait",
Usage: "wait for active shards",
Destination: &conf.Wait,
Aliases: []string{"w"},
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
args := cmd.Args()
if args.Len() != 1 {
return errors.New("missing arguments: <index>")
}
return es.CcrFollowerAdd(conf, cmd.Args().Get(0))
},
}
}
func CcrFollowerDelete(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "delete",
Aliases: []string{"rm"},
Usage: "delete ccr follower index",
UsageText: "delete <index>",
Action: func(ctx context.Context, cmd *cli.Command) error {
args := cmd.Args()
if args.Len() != 1 {
return errors.New("missing arguments: <index>")
}
// just an alias to index delete
return es.IndexDelete(conf, cmd.Args().Get(0))
},
}
}
func CcrFollowerShow(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "show",
Aliases: []string{"sh"},
Usage: "show ccr follower index details",
UsageText: "show <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.CcrFollowerShow(conf, cmd.Args().Get(0))
},
}
}

View File

@@ -18,7 +18,6 @@ package cmd
import (
"context"
"errors"
"codeberg.org/scip/esctl/pkg/cfg"
"codeberg.org/scip/esctl/pkg/es"
@@ -33,7 +32,6 @@ func Cluster(conf *cfg.Config) *cli.Command {
Usage: "manage cluster[s]",
Commands: []*cli.Command{
ClusterCompare(conf),
ClusterStatus(conf),
ClusterList(conf),
ClusterSettings(conf),
@@ -48,11 +46,7 @@ func ClusterList(conf *cfg.Config) *cli.Command {
Aliases: []string{"ls"},
Action: func(ctx context.Context, cmd *cli.Command) error {
if err := es.ClusterList(conf); err != nil {
return err
}
return nil
return es.ClusterList(conf)
},
}
}
@@ -70,135 +64,16 @@ func ClusterStatus(conf *cfg.Config) *cli.Command {
Destination: &conf.All,
Aliases: []string{"a"},
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
if err := es.ClusterStatus(conf); err != nil {
return err
}
return nil
},
}
}
func ClusterCompare(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "compare",
Aliases: []string{"c"},
Usage: "compare cluster[s] (yaml config with 2 clusters required)",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "exclude",
Usage: "regexp of indicies to exclude",
Destination: &conf.Exclude,
Aliases: []string{"e"},
&cli.BoolFlag{
Name: "verbose",
Usage: "include verbose statistics",
Destination: &conf.Verbose,
Aliases: []string{"v"},
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
leader := cmd.Args().Get(0)
follower := cmd.Args().Get(1)
if leader == "" || follower == "" {
return errors.New("no leader and follower aliases specified")
}
_, hasLeader := conf.Clusters[leader]
_, hasFollower := conf.Clusters[follower]
if !hasLeader || !hasFollower {
return errors.New("either leader or follower alias not configured")
}
if err := es.ClusterCompare(conf, leader, follower); err != nil {
return err
}
return nil
},
}
}
func ClusterSettings(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "settings",
Usage: "cluster settings management",
Aliases: []string{"config"},
Commands: []*cli.Command{
ClusterSettingsList(conf),
ClusterSettingsSet(conf),
},
}
}
func ClusterSettingsList(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "list",
Usage: "show cluster settings",
Aliases: []string{"ls", "get"},
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "persistent",
Usage: "only show persistent setting[s] (default)",
Destination: &conf.Persistent,
Aliases: []string{"p"},
},
&cli.BoolFlag{
Name: "transient",
Usage: "only show transient setting[s]",
Destination: &conf.Transient,
Aliases: []string{"t"},
},
&cli.BoolFlag{
Name: "default",
Usage: "only show default setting[s]",
Destination: &conf.Default,
Aliases: []string{"D"},
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
if err := es.ClusterSettingsList(conf); err != nil {
return err
}
return nil
},
}
}
func ClusterSettingsSet(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "set",
Usage: "set|update cluster settings",
Aliases: []string{"set", "update"},
UsageText: "set [options] setting:value [setting:value ...]",
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "persistent",
Usage: "add persistent setting[s] (default)",
Destination: &conf.Persistent,
Aliases: []string{"p"},
},
&cli.BoolFlag{
Name: "transient",
Usage: "add transient setting[s]",
Destination: &conf.Transient,
Aliases: []string{"t"},
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
if err := es.ClusterSettingsSet(conf, cmd.Args()); err != nil {
return err
}
return nil
return es.ClusterStatus(conf)
},
}
}

119
cmd/cluster_settings.go Normal file
View File

@@ -0,0 +1,119 @@
/*
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"
"errors"
"codeberg.org/scip/esctl/pkg/cfg"
"codeberg.org/scip/esctl/pkg/es"
"github.com/urfave/cli/v3"
)
const (
SETTINGS = `https://www.elastic.co/docs/reference/elasticsearch/configuration-reference`
)
func ClusterSettings(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "settings",
Usage: "cluster settings management",
Aliases: []string{"config"},
Commands: []*cli.Command{
ClusterSettingsList(conf),
ClusterSettingsSet(conf),
},
}
}
func ClusterSettingsList(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "list",
Usage: "show cluster settings",
Aliases: []string{"ls", "get"},
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "persistent",
Usage: "only show persistent setting[s] (default)",
Destination: &conf.Persistent,
Aliases: []string{"p"},
},
&cli.BoolFlag{
Name: "transient",
Usage: "only show transient setting[s]",
Destination: &conf.Transient,
Aliases: []string{"t"},
},
&cli.BoolFlag{
Name: "default",
Usage: "only show default setting[s]",
Destination: &conf.Default,
Aliases: []string{"D"},
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
if err := es.ClusterSettingsList(conf); err != nil {
return err
}
return nil
},
}
}
func ClusterSettingsSet(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "set",
Usage: "set|update cluster settings",
Aliases: []string{"set", "update"},
UsageText: "set [options] setting:value [setting:value ...]\n\nReference: " + SETTINGS,
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "persistent",
Usage: "add persistent setting[s] (default)",
Destination: &conf.Persistent,
Aliases: []string{"p"},
},
&cli.BoolFlag{
Name: "transient",
Usage: "add transient setting[s]",
Destination: &conf.Transient,
Aliases: []string{"t"},
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
args := cmd.Args()
if args.Len() == 0 {
return errors.New("at least one setting must be specified (format: setting:value)")
}
if err := es.ClusterSettingsSet(conf, cmd.Args()); err != nil {
return err
}
return nil
},
}
}

114
cmd/doc.go Normal file
View File

@@ -0,0 +1,114 @@
/*
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"
"errors"
"codeberg.org/scip/esctl/pkg/cfg"
"codeberg.org/scip/esctl/pkg/es"
"github.com/urfave/cli/v3"
)
func Doc(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "doc",
Usage: "manage documents",
Commands: []*cli.Command{
DocAdd(conf),
DocShow(conf),
//Delete(conf),
},
}
}
func DocAdd(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "add",
Aliases: []string{"+"},
Usage: "add JSON document index",
UsageText: "add [options] -i <index> '<json-doc>'",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "index",
Usage: "index to work on",
Sources: cli.EnvVars("ES_INDEX"),
Destination: &conf.Index,
Aliases: []string{"i"},
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
args := cmd.Args()
if args.Len() != 1 {
return errors.New("missing arguments: <json-doc>")
}
return es.DocAdd(conf, cmd.Args().Get(0))
},
}
}
func DocShow(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "show",
Aliases: []string{"sh"},
Usage: "show a JSON document",
UsageText: "show [options] -i <index> <id>",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "index",
Usage: "index to work on",
Sources: cli.EnvVars("ES_INDEX"),
Destination: &conf.Index,
Aliases: []string{"i"},
},
&cli.StringFlag{
Name: "path",
Usage: "jsonPath filter (e.g. source.message)",
Destination: &conf.Path,
Aliases: []string{"p"},
},
&cli.BoolFlag{
Name: "help-jsonpath",
Usage: "show jsonPath help",
Destination: &conf.Subhelp,
Aliases: []string{"H"},
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
if conf.Subhelp {
return showJsonPathHelp()
}
args := cmd.Args()
if args.Len() != 1 {
return errors.New("missing arguments: <id>")
}
return es.DocShow(conf, cmd.Args().Get(0))
},
}
}

View File

@@ -18,6 +18,7 @@ package cmd
import (
"context"
"errors"
"fmt"
"codeberg.org/scip/esctl/pkg/cfg"
@@ -37,6 +38,10 @@ func Index(conf *cfg.Config) *cli.Command {
IndexShow(conf),
IndexCreate(conf),
IndexDelete(conf),
IndexClose(conf),
IndexAllocation(conf),
IndexModify(conf),
IndexAlias(conf),
},
}
}
@@ -66,14 +71,48 @@ func IndexList(conf *cfg.Config) *cli.Command {
Destination: &conf.Failed,
Aliases: []string{"r"},
},
&cli.StringSliceFlag{
Name: "filter",
Usage: "show only indicies matching the filter",
Destination: &conf.Filter,
Aliases: []string{"F"},
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
if err := es.IndexList(conf); err != nil {
return err
return es.IndexList(conf)
},
}
}
func IndexAllocation(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "allocation",
Aliases: []string{"a"},
Usage: "explain index allocation",
Flags: []cli.Flag{
&cli.IntFlag{
Name: "shard",
Usage: "shard number to explain for",
Destination: &conf.Shards,
Aliases: []string{"s"},
},
&cli.BoolFlag{
Name: "primary",
Usage: "explain primary allocation (default true)",
Destination: &conf.Primary,
Aliases: []string{"p"},
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
index := cmd.Args().Get(0)
if index == "" {
return errors.New("no index specified")
}
return nil
return es.IndexAllocation(conf, cmd.Args().Get(0))
},
}
}
@@ -85,11 +124,12 @@ func IndexShow(conf *cfg.Config) *cli.Command {
Usage: "show details about an index",
Action: func(ctx context.Context, cmd *cli.Command) error {
if err := es.IndexShow(conf, cmd.Args().Get(0)); err != nil {
return err
index := cmd.Args().Get(0)
if index == "" {
return errors.New("no index specified")
}
return nil
return es.IndexShow(conf, cmd.Args().Get(0))
},
// FIXME: doesn't work at all
@@ -140,11 +180,14 @@ func IndexCreate(conf *cfg.Config) *cli.Command {
},
Action: func(ctx context.Context, cmd *cli.Command) error {
if err := es.IndexCreate(conf, cmd.Args().Get(0)); err != nil {
return err
args := cmd.Args()
if args.Len() == 0 {
return fmt.Errorf("no index specified")
}
return nil
mappings := args.Slice()[1:]
return es.IndexCreate(conf, args.Get(0), mappings)
},
}
}
@@ -156,11 +199,54 @@ func IndexDelete(conf *cfg.Config) *cli.Command {
Usage: "delete an index",
Action: func(ctx context.Context, cmd *cli.Command) error {
if err := es.IndexDelete(conf, cmd.Args().Get(0)); err != nil {
return err
index := cmd.Args().Get(0)
if index == "" {
return errors.New("no index specified")
}
return nil
return es.IndexDelete(conf, cmd.Args().Get(0))
},
}
}
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 {
index := cmd.Args().Get(0)
if index == "" {
return errors.New("no index specified")
}
return es.IndexClose(conf, cmd.Args().Get(0))
},
}
}
func IndexModify(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "modify",
Usage: "modify an index",
UsageText: "modify <index[,index,...]|_all>",
Flags: []cli.Flag{
&cli.IntFlag{
Name: "replicas",
Usage: "number of replicas",
Destination: &conf.Replicas,
Aliases: []string{"r"},
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
index := cmd.Args().Get(0)
if index == "" {
return errors.New("no index specified")
}
return es.IndexModify(conf, index)
},
}
}

104
cmd/index_alias.go Normal file
View File

@@ -0,0 +1,104 @@
/*
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"
"errors"
"codeberg.org/scip/esctl/pkg/cfg"
"codeberg.org/scip/esctl/pkg/es"
"github.com/urfave/cli/v3"
)
func IndexAlias(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "alias",
Aliases: []string{"a"},
Usage: "manage index aliases",
Commands: []*cli.Command{
IndexAliasCreate(conf),
IndexAliasList(conf),
IndexAliasDelete(conf),
//IndexAliasShow(conf),
//IndexAliasAdd(conf), // see https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-indices-update-aliases
},
}
}
func IndexAliasCreate(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "create",
Aliases: []string{"+"},
Usage: "create an index alias",
UsageText: "create <index> <alias>",
Action: func(ctx context.Context, cmd *cli.Command) error {
index := cmd.Args().Get(0)
alias := cmd.Args().Get(1)
if index == "" || alias == "" {
return errors.New("no index or alias specified")
}
return es.IndexAliasCreate(conf, index, alias)
},
}
}
func IndexAliasDelete(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "delete",
Aliases: []string{"rm"},
Usage: "delete an index alias",
UsageText: "delete <index> <alias>",
Action: func(ctx context.Context, cmd *cli.Command) error {
index := cmd.Args().Get(0)
alias := cmd.Args().Get(1)
if index == "" || alias == "" {
return errors.New("no index or alias specified")
}
return es.IndexAliasDelete(conf, index, alias)
},
}
}
func IndexAliasList(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "list",
Aliases: []string{"ls"},
Usage: "list index aliases",
Flags: []cli.Flag{
&cli.StringSliceFlag{
Name: "filter",
Usage: "show only aliases for indicies matching the filter",
Destination: &conf.Filter,
Aliases: []string{"F"},
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
return es.IndexAliasList(conf)
},
}
}

View File

@@ -45,11 +45,7 @@ func NodeList(conf *cfg.Config) *cli.Command {
Usage: "list nodes",
Action: func(ctx context.Context, cmd *cli.Command) error {
if err := es.NodeList(conf); err != nil {
return err
}
return nil
return es.NodeList(conf)
},
}
}
@@ -62,10 +58,7 @@ func NodeShow(conf *cfg.Config) *cli.Command {
UsageText: "show [options] <node>",
Action: func(ctx context.Context, cmd *cli.Command) error {
// if err := es.NodeShow(conf, cmd.Args().Get(0)); err != nil {
// return err
// }
// return es.NodeShow(conf, cmd.Args().Get(0))
return nil
},
}

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

@@ -40,9 +40,9 @@ func Main() int {
conf := cfg.NewConfig()
cmd := &cli.Command{
Name: "esctl",
Usage: "manage elasticsearch from cli",
Version: cfg.Version,
Name: "esctl",
Usage: "manage elasticsearch from cli",
//Version: cfg.Version,
EnableShellCompletion: true,
Flags: []cli.Flag{
@@ -69,14 +69,26 @@ func Main() int {
Usage: "cluster alias to work with",
Destination: &conf.CurrentCluster,
},
&cli.StringFlag{
Name: "output",
Aliases: []string{"o"},
Value: "",
Usage: "output mode (tsv, markdown, json, yaml) default: tsv",
Destination: &conf.Output,
},
},
Commands: []*cli.Command{
Search(conf),
Index(conf),
Snapshot(conf),
Cluster(conf),
Ccr(conf),
Index(conf),
Shard(conf),
Snapshot(conf),
Node(conf),
Doc(conf),
Repl(conf),
Version(conf),
},
Before: func(ctx context.Context, cmd *cli.Command) (context.Context, error) {
@@ -97,3 +109,16 @@ func Main() int {
return Finish(cmd.Run(context.Background(), os.Args))
}
func Version(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "version",
Usage: "show esctl version information",
Action: func(ctx context.Context, cmd *cli.Command) error {
_, err := fmt.Printf("esctl version: %s\n build: %s\n branch: %s\n commit: %s\n go version: %s\n API Version: %s\n",
cfg.Version, cfg.BUILD, cfg.BRANCH, cfg.COMMIT, cfg.GOVERSION, cfg.APIVERSION)
return err
},
}
}

View File

@@ -18,6 +18,7 @@ package cmd
import (
"context"
"fmt"
"codeberg.org/scip/esctl/pkg/cfg"
"codeberg.org/scip/esctl/pkg/es"
@@ -27,10 +28,20 @@ import (
func Search(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "search",
Aliases: []string{"/"},
Usage: "search within an index",
UsageText: "search [options] <query>",
Name: "search",
Aliases: []string{"/"},
Usage: "search within an index",
UsageText: `search [options] [<[field<sep>]pattern> ...]
<sep> might be one of:
=: Must match
!=: Must not match
?: Should match
You can omit a field spec and thereby search across all fields.
You can also search multiple fields by separating them with comma, eg:
user,group=root`,
Flags: []cli.Flag{
&cli.StringFlag{
@@ -56,18 +67,63 @@ func Search(conf *cfg.Config) *cli.Command {
},
&cli.StringSliceFlag{
Name: "filter",
Usage: "additional filters. format: key=value",
Usage: "additional boolean filters. format: key=value",
Destination: &conf.Filter,
Aliases: []string{"F"},
},
&cli.StringFlag{
Name: "jsonpath",
Usage: "jsonPath filter (e.g. source.message)",
Destination: &conf.Path,
Aliases: []string{"p"},
},
&cli.BoolFlag{
Name: "help-jsonpath",
Usage: "show jsonPath help",
Destination: &conf.Subhelp,
Aliases: []string{"H"},
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
if err := es.Search(conf, cmd.Args().Get(0)); err != nil {
return err
if conf.Subhelp {
return showJsonPathHelp()
}
return nil
args := cmd.Args()
return es.Search(conf, args.Slice())
},
}
}
func showJsonPathHelp() error {
_, err := fmt.Println(`jsonPath usage:
name.last >> "Anderson"
age >> 37
children >> ["Sara","Alex","Jack"]
children.# >> 3
children.1 >> "Alex"
child*.2 >> "Jack"
c?ildren.0 >> "Sara"
fav\.movie >> "Deer Hunter"
friends.#.first >> ["Dale","Roger","Jane"]
friends.1.last >> "Craig"
You can also query an array for the first match by using #(...), or
find all matches with #(...)#. Queries support the ==, !=, <, <=, >,
>= comparison operators and the simple pattern matching % (like) and
!% (not like) operators. Eg:
friends.#(last=="Murphy").first >> "Dale"
friends.#(last=="Murphy")#.first >> ["Dale","Jane"]
friends.#(age>45)#.last >> ["Craig","Murphy"]
friends.#(first%"D*").last >> "Murphy"
friends.#(first!%"D*").last >> "Craig"
friends.#(nets.#(=="fb"))#.first >> ["Dale","Roger"]
Documentation: https://github.com/tidwall/gjson/blob/master/SYNTAX.md`)
return err
}

96
cmd/shards.go Normal file
View File

@@ -0,0 +1,96 @@
/*
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"
"errors"
"codeberg.org/scip/esctl/pkg/cfg"
"codeberg.org/scip/esctl/pkg/es"
"github.com/urfave/cli/v3"
)
func Shard(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "shard",
Aliases: []string{"s"},
Usage: "manage shards",
Commands: []*cli.Command{
ShardList(conf),
ShardShow(conf),
},
}
}
func ShardList(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "list",
Aliases: []string{"ls"},
Usage: "list shards",
Flags: []cli.Flag{
&cli.IntFlag{
Name: "max",
Usage: "max number of items to process",
Destination: &conf.MaxItems,
Aliases: []string{"m"},
},
&cli.BoolFlag{
Name: "reds",
Usage: "include only failed shards",
Destination: &conf.Failed,
Aliases: []string{"r"},
},
&cli.BoolFlag{
Name: "primaries",
Usage: "show only primary shards",
Destination: &conf.Primary,
Aliases: []string{"p"},
},
&cli.BoolFlag{
Name: "verbose",
Usage: "show node name and ip as well",
Destination: &conf.Verbose,
Aliases: []string{"v"},
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
return es.ShardList(conf)
},
}
}
func ShardShow(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "show",
Aliases: []string{"sh"},
Usage: "show details about a shard",
Action: func(ctx context.Context, cmd *cli.Command) error {
index := cmd.Args().Get(0)
if index == "" {
return errors.New("no index specified")
}
return es.ShardShow(conf, index)
},
}
}

View File

@@ -54,11 +54,7 @@ func SnapshotList(conf *cfg.Config) *cli.Command {
},
Action: func(ctx context.Context, cmd *cli.Command) error {
if err := es.SnapshotList(conf); err != nil {
return err
}
return nil
return es.SnapshotList(conf)
},
}
}
@@ -71,11 +67,7 @@ func SnapshotShow(conf *cfg.Config) *cli.Command {
UsageText: "show [options] <snapshot>",
Action: func(ctx context.Context, cmd *cli.Command) error {
if err := es.SnapshotShow(conf, cmd.Args().Get(0)); err != nil {
return err
}
return nil
return es.SnapshotShow(conf, cmd.Args().Get(0))
},
}
}

31
commandtree.sh Executable file
View File

@@ -0,0 +1,31 @@
#!/bin/bash
getcommands() {
local command
command="$*"
if ! test "$command" = "help"; then
./esctl $command -h | sed -e '/^COMMANDS:/,/^$/!d;//d' -e 's/^ //' -e 's/[, ].*//' | sort
fi
}
printcommands() {
local indent basecommand commands command
indent="$1"
basecommand="$2"
commands="$3"
for command in $commands; do
echo "$indent" "$command"
commands=$(getcommands $basecommand $command)
if test -n "$commands"; then
printcommands "${indent} " "$basecommand $command" "$commands"
fi
done
}
commands=$(getcommands "")
printcommands "" "" "$commands"

31
go.mod
View File

@@ -17,30 +17,39 @@ module codeberg.org/scip/esctl
go 1.25.0
require (
github.com/alecthomas/repr v0.5.2 // indirect
github.com/alecthomas/repr v0.5.2
github.com/chzyer/readline v1.5.1
github.com/dustin/go-humanize v1.0.1
github.com/elastic/elastic-transport-go/v8 v8.11.0
github.com/elastic/go-elasticsearch/v9 v9.3.4
github.com/fatih/color v1.19.0
github.com/mattn/go-isatty v0.0.22
github.com/olekukonko/tablewriter v1.1.4
github.com/tlinden/yadu v0.1.3
github.com/urfave/cli/v3 v3.9.0
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/clipperhouse/displaywidth v0.10.0 // indirect
github.com/clipperhouse/uax29/v2 v2.6.0 // 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
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/goccy/go-json v0.10.6 // indirect
github.com/lmittmann/tint v1.1.3 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.21 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/mattn/go-runewidth v0.0.23 // indirect
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect
github.com/olekukonko/errors v1.2.0 // indirect
github.com/olekukonko/ll v0.1.6 // indirect
github.com/olekukonko/tablewriter v1.1.4 // indirect
github.com/tlinden/yadu v0.1.3 // indirect
github.com/urfave/cli/v3 v3.8.0 // indirect
github.com/olekukonko/ll v0.1.8 // indirect
github.com/tidwall/gjson v1.19.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/otel v1.35.0 // indirect
go.opentelemetry.io/otel/metric v1.35.0 // indirect
go.opentelemetry.io/otel/trace v1.35.0 // indirect
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
golang.org/x/sys v0.42.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

25
go.sum
View File

@@ -2,16 +2,24 @@ 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=
github.com/elastic/elastic-transport-go/v8 v8.11.0/go.mod h1:DZQ0szCNywc9F+C9l/Kkd4n69SvJVj0I3yK1Of7s3l8=
github.com/elastic/go-elasticsearch/v9 v9.3.2 h1:nvtvfN/Gsp/rzUPz/9yILwDAsYJ3s5L0VmhR16zPKCA=
github.com/elastic/go-elasticsearch/v9 v9.3.2/go.mod h1:ubKUMJCJbX5V/gW5MIn2NQZyaEZ61ubXwJmD5UMNrM8=
github.com/elastic/go-elasticsearch/v9 v9.3.4 h1:vnqXl6jnlA+ZwlfRaKF9BR9woZC1KSB17gtyvgf8zVU=
github.com/elastic/go-elasticsearch/v9 v9.3.4/go.mod h1:ubKUMJCJbX5V/gW5MIn2NQZyaEZ61ubXwJmD5UMNrM8=
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
@@ -23,6 +31,8 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/lmittmann/tint v1.1.3 h1:Hv4EaHWXQr+GTFnOU4VKf8UvAtZgn0VuKT+G0wFlO3I=
github.com/lmittmann/tint v1.1.3/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
@@ -32,20 +42,34 @@ github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stg
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 h1:zrbMGy9YXpIeTnGj4EljqMiZsIcE09mmF8XsD5AYOJc=
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6/go.mod h1:rEKTHC9roVVicUIfZK7DYrdIoM0EOr8mK1Hj5s3JjH0=
github.com/olekukonko/errors v1.2.0 h1:10Zcn4GeV59t/EGqJc8fUjtFT/FuUh5bTMzZ1XwmCRo=
github.com/olekukonko/errors v1.2.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y=
github.com/olekukonko/ll v0.1.6 h1:lGVTHO+Qc4Qm+fce/2h2m5y9LvqaW+DCN7xW9hsU3uA=
github.com/olekukonko/ll v0.1.6/go.mod h1:NVUmjBb/aCtUpjKk75BhWrOlARz3dqsM+OtszpY4o88=
github.com/olekukonko/ll v0.1.8 h1:ysHCJRGHYKzmBSdz9w5AySztx7lG8SQY+naTGYUbsz8=
github.com/olekukonko/ll v0.1.8/go.mod h1:RPRC6UcscfFZgjo1nulkfMH5IM0QAYim0LfnMvUuozw=
github.com/olekukonko/tablewriter v1.1.4 h1:ORUMI3dXbMnRlRggJX3+q7OzQFDdvgbN9nVWj1drm6I=
github.com/olekukonko/tablewriter v1.1.4/go.mod h1:+kedxuyTtgoZLwif3P1Em4hARJs+mVnzKxmsCL/C5RY=
github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU=
github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tlinden/yadu v0.1.3 h1:5cRCUmj+l5yvlM2irtpFBIJwVV2DPEgYSaWvF19FtcY=
github.com/tlinden/yadu v0.1.3/go.mod h1:l3bRmHKL9zGAR6pnBHY2HRPxBecf7L74BoBgOOpTcUA=
github.com/urfave/cli/v3 v3.8.0 h1:XqKPrm0q4P0q5JpoclYoCAv0/MIvH/jZ2umzuf8pNTI=
github.com/urfave/cli/v3 v3.8.0/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso=
github.com/urfave/cli/v3 v3.9.0 h1:AV9lIiPv3ukYnxunaCUsHnEozptYmDN2F0+yWqLMn/c=
github.com/urfave/cli/v3 v3.9.0/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ=
@@ -56,6 +80,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,12 @@ import (
)
const (
Version string = `v0.0.6`
Version string = `v0.0.13`
)
var (
// initialized during build, see Makefile:buildlocal
APIVERSION, GOVERSION, BUILD, COMMIT, BRANCH string
)
type Cluster struct {
@@ -43,17 +48,23 @@ type Config struct {
ConfigFile string // -c
CurrentCluster string // -C
Debug bool // -d
Output string // -o <mode>
Clusters map[string]*Cluster
DefaultCluster *Cluster
Index string // index: -i
Failed, Partials bool // index: flags
Shards, Replicas int // index create: -s -r
Shards, Replicas int // index create+allocation: -s -r
Wait bool // index create: -w
Primary bool // index allocation: -p
From, To, MaxItems int // search: flags
Filter []string // search: -F
Path string // search+doc sh: -p
Subhelp bool // search+doc sh: -H
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
HaveJQ bool // determined at runtime by ourselfes
}
func NewConfig() *Config {
@@ -90,11 +101,12 @@ func (conf *Config) Init() error {
}
} else {
if len(conf.Clusters) == 1 {
for _, cluster := range conf.Clusters {
for name, cluster := range conf.Clusters {
conf.DefaultCluster = cluster
conf.CurrentCluster = name
}
} else {
for _, cluster := range conf.Clusters {
for name, cluster := range conf.Clusters {
_, err := cluster.ES.Cluster.Health().
Header("content-type", "application/json").
Header("accept", "application/json").
@@ -102,18 +114,35 @@ func (conf *Config) Init() error {
if err == nil {
conf.DefaultCluster = cluster
conf.CurrentCluster = name
}
}
}
}
if conf.Debug {
repr.Println(conf)
}
conf.HaveJQ = isJQinstalled()
conf.PrintDebug()
return nil
}
func (conf *Config) PrintDebug() {
if !conf.Debug {
return
}
clone := *conf
for name := range clone.Clusters {
clone.Clusters[name] = nil
}
clone.DefaultCluster = nil
fmt.Println("config:")
repr.Println(clone)
}
func (conf *Config) LoadEnv() error {
cluster := Cluster{
Uri: os.Getenv("ES_URI"),

38
pkg/cfg/jq.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 cfg
import (
"context"
"os/exec"
"strings"
)
func isJQinstalled() bool {
cmd := exec.CommandContext(context.Background(), "jq", "-h")
out, err := cmd.Output()
if err != nil {
return false
}
if strings.Contains(string(out), "Usage") {
return true
}
return false
}

135
pkg/es/ccr.go Normal file
View File

@@ -0,0 +1,135 @@
/*
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 (
"context"
"errors"
"fmt"
"log/slog"
"codeberg.org/scip/esctl/pkg/cfg"
"codeberg.org/scip/esctl/pkg/printer"
"github.com/elastic/go-elasticsearch/v9/typedapi/types"
)
const (
shard_pause = `cluster.routing.allocation.enable`
)
func CcrShardPause(conf *cfg.Config) error {
return ClusterSettingsSetSingle(conf, shard_pause, "none")
}
func CcrShardResume(conf *cfg.Config) error {
return ClusterSettingsSetSingle(conf, shard_pause, "all")
}
func CcrStatus(conf *cfg.Config, leader, follower string) error {
if !checkClusterIsLeader(conf, leader) {
if !checkClusterIsLeader(conf, follower) {
return errors.New("leader/follower attribution is invalid, both clusters are followers")
}
// reverse attribution
f := follower
follower = leader
leader = f
slog.Debug("leader/follower attribution is invalid, reversing", "leader", leader, "follower", follower)
}
indices := ClusterIndices{}
for _, alias := range []string{leader, follower} {
cat := conf.Clusters[alias].ES.Cat.Indices().
// we need to add custom request headers, required for older ES instances
Header("content-type", "application/json").
Header("accept", "application/json")
res, err := cat.Do(context.Background())
if err != nil {
return fmt.Errorf("failed to get indicies on %s: %s", alias, err)
}
indices[alias] = make(map[string]*types.IndicesRecord, len(res))
for _, index := range res {
indices[alias][*index.Index] = &index
}
}
if !checkClusterStatus(conf, leader, follower) {
return errors.New("one of the two clusters is in a failed state")
}
findIlmErrors(conf, leader, follower)
if findIndicesOnlyOnLeader(conf, indices, leader, follower) &&
findOrphanedIndices(conf, indices, leader, follower) &&
findFailedFollowerIndices(conf, indices, follower) {
fmt.Println("everything's hunky-dory.")
}
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 := printer.NewTable(conf, 2, 5)
table.Addheaders("ccr remote property", "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.Print(); err != nil {
return err
}
return nil
}

199
pkg/es/ccr_follower.go Normal file
View File

@@ -0,0 +1,199 @@
/*
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 (
"context"
"fmt"
"log/slog"
"codeberg.org/scip/esctl/pkg/cfg"
"codeberg.org/scip/esctl/pkg/printer"
)
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)
}
remote := ""
for name := range res {
remote = name
break
}
if remote == "" {
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).
LeaderIndex(index).
RemoteCluster(remote).
Header("content-type", "application/json").
Header("accept", "application/json")
if conf.Wait {
create.WaitForActiveShards("all")
}
_, err = create.Do(context.Background())
if err != nil {
return fmt.Errorf("failed to create follower index: %s", err)
}
return nil
}
func CcrFollowerShow(conf *cfg.Config, index string) error {
res, err := conf.DefaultCluster.ES.Ccr.FollowStats(index).
Header("content-type", "application/json").
Header("accept", "application/json").
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to retrieve follower index info: %s", err)
}
slog.Debug("ES result", "follower stats", res.Indices)
if len(res.Indices) == 0 {
return fmt.Errorf("cluster did not return any follower stats for index %s", index)
}
if len(res.Indices[0].Shards) == 0 {
return fmt.Errorf("cluster did not return any shard stats on follower index %s", index)
}
follower := res.Indices[0].Shards[0]
table := printer.NewTable(conf, 2, 9)
table.Addheaders("ccr follower property", "value")
table.Entries = [][]string{
{"name", index},
{"remote_cluster", follower.RemoteCluster},
{"leader_checkpoint", fmt.Sprintf("%d", follower.LeaderGlobalCheckpoint)},
{"follower_checkpoint", fmt.Sprintf("%d", follower.FollowerGlobalCheckpoint)},
{"bytes_read", fmt.Sprintf("%d", follower.BytesRead)},
{"failed_read_requests", fmt.Sprintf("%d", follower.FailedReadRequests)},
{"failed_write_requests", fmt.Sprintf("%d", follower.FailedWriteRequests)},
{"successful_read_requests", fmt.Sprintf("%d", follower.SuccessfulReadRequests)},
{"successful_write_requests", fmt.Sprintf("%d", follower.SuccessfulWriteRequests)},
}
if err := table.Print(); err != nil {
return err
}
return nil
}

View File

@@ -18,28 +18,27 @@ package es
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"slices"
"strings"
"sync"
"codeberg.org/scip/esctl/pkg/cfg"
"codeberg.org/scip/esctl/pkg/printer"
"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"
"github.com/urfave/cli/v3"
)
const (
DefaultExclude = `(part|monitoring|.internal|metrics-endpoint)`
)
const (
ResponseHealth = iota
ResponseInfo
ResponseCcr
ResponseStats
)
type ClusterIndices map[string]map[string]*types.IndicesRecord
@@ -49,57 +48,28 @@ type apiResponse struct {
info *info.Response
health *health.Response
ccr *stats.Response
stats *clusterstats.Response
which int
}
func ClusterCompare(conf *cfg.Config, leader, follower string) error {
if !checkClusterFollower(conf, leader) {
return errors.New("leader/follower attribution is invalid, reverse cluster attribution and retry")
}
indices := ClusterIndices{}
for _, alias := range []string{leader, follower} {
cat := conf.Clusters[alias].ES.Cat.Indices().
// we need to add custom request headers, required for older ES instances
Header("content-type", "application/json").
Header("accept", "application/json")
res, err := cat.Do(context.Background())
if err != nil {
return fmt.Errorf("failed to get indicies on %s: %s", alias, err)
}
indices[alias] = make(map[string]*types.IndicesRecord, len(res))
for _, index := range res {
indices[alias][*index.Index] = &index
}
}
if !checkClusterStatus(conf, leader, follower) {
return errors.New("one of the two clusters is in a failed state")
}
findIlmErrors(conf, leader, follower)
if findIndicesOnlyOnLeader(conf, indices, leader, follower) &&
findOrphanedIndices(conf, indices, leader, follower) &&
findFailedFollowerIndices(conf, indices, follower) {
fmt.Println("everything's hunky-dory.")
}
return nil
}
func ClusterList(conf *cfg.Config) error {
table := NewTable(3, len(conf.Clusters))
table := printer.NewTable(conf, 3, len(conf.Clusters))
table.Addheaders("cluster", "uri", "default")
idx := 0
for name, cluster := range conf.Clusters {
names := make([]string, len(conf.Clusters))
for name := range conf.Clusters {
names[idx] = name
idx++
}
slices.Sort(names)
for idx, name := range names {
current := name == "default" || name == conf.CurrentCluster
cluster := conf.Clusters[name]
_, err := cluster.ES.Cluster.Health().
Header("content-type", "application/json").
@@ -107,15 +77,13 @@ func ClusterList(conf *cfg.Config) error {
Do(context.Background())
if err == nil {
name = Colorize("green", name)
name = printer.Colorize(conf, "green", name)
}
table.entries[idx] = []string{name, cluster.Uri, fmt.Sprintf("%t", current)}
idx++
table.Entries[idx] = []string{name, cluster.Uri, fmt.Sprintf("%t", current)}
}
table.Sort()
if err := table.PrintMarkdown(); err != nil {
if err := table.Print(); err != nil {
return err
}
@@ -126,6 +94,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 {
@@ -141,21 +113,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 {
@@ -169,6 +146,8 @@ func ClusterStatus(conf *cfg.Config) error {
ccrstats = r.ccr
case ResponseInfo:
info = r.info
case ResponseStats:
clusterstats = r.stats
}
}
@@ -184,20 +163,23 @@ func ClusterStatus(conf *cfg.Config) error {
)
}
table := NewTable(2, 5)
table := printer.NewTable(conf, 2, 5)
table.Addheaders(cluster, "status")
table.entries = [][]string{
{"Cluster Name", Colorize(clusterhealth.Status.Name, clusterhealth.ClusterName)},
table.Entries = [][]string{
{"Cluster Name", printer.Colorize(conf, clusterhealth.Status.Name, clusterhealth.ClusterName)},
{"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 err := table.PrintMarkdown(); err != nil {
if conf.Verbose {
table = gatherClusterStats(conf, clusterstats, table)
}
if err := table.Print(); err != nil {
return err
}
}
@@ -205,90 +187,46 @@ func ClusterStatus(conf *cfg.Config) error {
return nil
}
func ClusterSettingsList(conf *cfg.Config) error {
res, err := conf.DefaultCluster.ES.Cluster.GetSettings().
Header("content-type", "application/json").
Header("accept", "application/json").
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to get cluster settings: %s", err)
func gatherClusterStats(conf *cfg.Config, clusterstats *clusterstats.Response, table *printer.Table) *printer.Table {
var querycount int64
var vmversion string
for _, count := range clusterstats.Indices.Search.Queries {
querycount += count
}
table := NewTable(2, 0)
table.Addheaders("setting", "value")
entries := [][]string{}
settingshash := res.Persistent // == map[string]json.RawMessage
switch {
case conf.Transient:
settingshash = res.Transient
case conf.Default:
settingshash = res.Defaults
if len(clusterstats.Nodes.Jvm.Versions) > 0 {
vmversion = strings.Join([]string{
clusterstats.Nodes.Jvm.Versions[0].VmName,
clusterstats.Nodes.Jvm.Versions[0].VmVersion}, " ")
}
for topic, val := range settingshash {
data := map[string]map[string]any{}
isleader := checkClusterIsLeader(conf, conf.CurrentCluster)
err := json.Unmarshal(val, &data)
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)},
}...)
if err != nil {
return fmt.Errorf("failed to unmarshall setting for topic %s: %s", topic, err)
}
for key, settings := range data {
for setting, value := range settings {
entries = append(entries, []string{
fmt.Sprintf("%s.%s.%s", topic, key, setting),
fmt.Sprintf("%v", value),
})
}
}
}
table.entries = entries
table.Sort()
if err := table.PrintMarkdown(); err != nil {
return err
}
return nil
}
func ClusterSettingsSet(conf *cfg.Config, args cli.Args) error {
put := conf.Clusters[conf.CurrentCluster].ES.Cluster.PutSettings()
for _, arg := range args.Slice() {
setting, value := splitArg(arg)
switch {
case conf.Transient:
message, err := json.Marshal(value)
if err != nil {
return fmt.Errorf("failed to marshall transient value <%v> to valid JSON: %s", value, err)
}
put.AddTransient(setting, message)
case conf.Persistent:
fallthrough
default:
message, err := json.Marshal(value)
if err != nil {
return fmt.Errorf("failed to marshall persistent value <%v> to valid JSON: %s", value, err)
}
put.AddPersistent(setting, message)
}
}
_, err := put.
Header("content-type", "application/json").
Header("accept", "application/json").
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to set settings: %s", err)
}
return nil
return table
}

161
pkg/es/cluster_settings.go Normal file
View File

@@ -0,0 +1,161 @@
/*
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 (
"context"
"encoding/json"
"fmt"
"log/slog"
"codeberg.org/scip/esctl/pkg/cfg"
"codeberg.org/scip/esctl/pkg/printer"
"github.com/urfave/cli/v3"
)
// recursively traverse the raw settings hash and build a flat map
// consisting of the translated path and its value.
//
// e.g.
// logger:
//
// org:
// elasticsearch:
// transport:
// OutboundHandler: "ERROR"
//
// gets:
//
// logger.org.elasticsearch.transport.OutboundHandler: "ERROR"
func getJsonPath(raw map[string]any, topic string) map[string]string {
paths := map[string]string{}
for name, data := range raw {
path := topic + "." + name
switch value := data.(type) {
case string:
paths[path] = value
case map[string]any:
paths = getJsonPath(value, path)
}
}
return paths
}
func ClusterSettingsList(conf *cfg.Config) error {
res, err := conf.DefaultCluster.ES.Cluster.GetSettings().
Header("content-type", "application/json").
Header("accept", "application/json").
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to get cluster settings: %s", err)
}
table := printer.NewTable(conf, 2, 0)
table.Addheaders("setting", "value")
entries := [][]string{}
settingshash := res.Persistent // == map[string]json.RawMessage
switch {
case conf.Transient:
settingshash = res.Transient
case conf.Default:
settingshash = res.Defaults
}
for topic, val := range settingshash {
data := map[string]any{}
err := json.Unmarshal(val, &data)
if err != nil {
return fmt.Errorf("failed to unmarshall setting for topic %s: %s", topic, err)
}
paths := getJsonPath(data, topic)
slog.Debug("settings", topic, paths)
for setting, value := range paths {
entries = append(entries, []string{setting, fmt.Sprintf("%v", value)})
}
}
table.Entries = entries
table.Sort()
if err := table.Print(); err != nil {
return err
}
return nil
}
func ClusterSettingsSet(conf *cfg.Config, args cli.Args) error {
put := conf.Clusters[conf.CurrentCluster].ES.Cluster.PutSettings()
for _, arg := range args.Slice() {
setting, value := splitArg(arg)
switch {
case conf.Transient:
message, err := json.Marshal(value)
if err != nil {
return fmt.Errorf("failed to marshall transient value <%v> to valid JSON: %s", value, err)
}
put.AddTransient(setting, message)
case conf.Persistent:
fallthrough
default:
message, err := json.Marshal(value)
if err != nil {
return fmt.Errorf("failed to marshall persistent value <%v> to valid JSON: %s", value, err)
}
put.AddPersistent(setting, message)
}
}
_, err := put.
Header("content-type", "application/json").
Header("accept", "application/json").
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to set settings: %s", err)
}
return nil
}
func ClusterSettingsSetSingle(conf *cfg.Config, setting, value string) error {
message, err := json.Marshal(value)
if err != nil {
return fmt.Errorf("failed to marshall persistent value <%v> to valid JSON: %s", value, err)
}
_, err = conf.Clusters[conf.CurrentCluster].ES.Cluster.PutSettings().
AddPersistent(setting, message).
Header("content-type", "application/json").
Header("accept", "application/json").
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to set %s: %s", setting, err)
}
return nil
}

View File

@@ -25,12 +25,17 @@ import (
"sync"
"codeberg.org/scip/esctl/pkg/cfg"
"codeberg.org/scip/esctl/pkg/printer"
"github.com/elastic/go-elasticsearch/v9"
"github.com/elastic/go-elasticsearch/v9/typedapi/cluster/health"
"github.com/elastic/go-elasticsearch/v9/typedapi/types"
)
func checkClusterFollower(conf *cfg.Config, leader string) bool {
const (
DefaultExclude = `(part|monitoring|.internal|metrics-endpoint)`
)
func checkClusterIsLeader(conf *cfg.Config, leader string) bool {
stats, err := conf.Clusters[leader].ES.Ccr.Stats().
Header("content-type", "application/json").
Header("accept", "application/json").
@@ -70,14 +75,14 @@ func checkClusterStatus(conf *cfg.Config, leader, follower string) bool {
status[cluster] = st
}
table := NewTable(3, 5)
table := printer.NewTable(conf, 3, 5)
table.Addheaders("setting", "leader:"+leader, "follower:"+follower)
table.entries = [][]string{
table.Entries = [][]string{
{"Cluster Name",
Colorize(status[leader].Status.Name, status[leader].ClusterName),
Colorize(status[follower].Status.Name, status[follower].ClusterName),
printer.Colorize(conf, status[leader].Status.Name, status[leader].ClusterName),
printer.Colorize(conf, status[follower].Status.Name, status[follower].ClusterName),
},
{"Active Shards",
fmt.Sprintf("%d", status[leader].ActiveShards),
@@ -97,7 +102,7 @@ func checkClusterStatus(conf *cfg.Config, leader, follower string) bool {
},
}
if err := table.PrintMarkdown(); err != nil {
if err := table.Print(); err != nil {
fmt.Println(err)
return false
}
@@ -147,17 +152,17 @@ func findIlmErrors(conf *cfg.Config, leader, follower string) bool {
if len(failed[cluster]) > 0 {
idx := 0
table := NewTable(2, len(failed[cluster]))
table := printer.NewTable(conf, 2, len(failed[cluster]))
table.Addheaders("ilm errors on "+which, "errors")
for name, count := range failed[cluster] {
table.entries[idx] = []string{name, count}
table.Entries[idx] = []string{name, count}
idx++
}
table.Sort()
if err := table.PrintMarkdown(); err != nil {
if err := table.Print(); err != nil {
fmt.Println(err)
return false
}
@@ -219,18 +224,18 @@ func findIndicesOnlyOnLeader(conf *cfg.Config, indices ClusterIndices, leader, f
}
idx := 0
table := NewTable(3, len(indexOnlyOnLeader))
table := printer.NewTable(conf, 3, len(indexOnlyOnLeader))
table.Addheaders("index only on leader", "size", "docscount")
for name, index := range indexOnlyOnLeader {
name := Colorize("red", name)
name := printer.Colorize(conf, "red", name)
table.entries[idx] = []string{name, *index.DatasetSize, *index.DocsCount}
table.Entries[idx] = []string{name, *index.DatasetSize, *index.DocsCount}
idx++
}
table.Sort()
if err := table.PrintMarkdown(); err != nil {
if err := table.Print(); err != nil {
fmt.Println(err)
return false
}
@@ -269,18 +274,18 @@ func findOrphanedIndices(conf *cfg.Config, indices ClusterIndices, leader, follo
}
idx := 0
table := NewTable(3, len(orphaned))
table := printer.NewTable(conf, 3, len(orphaned))
table.Addheaders("orphaned index on follower", "size", "docscount")
for name, index := range orphaned {
name := Colorize("red", name)
name := printer.Colorize(conf, "red", name)
table.entries[idx] = []string{name, *index.DatasetSize, *index.DocsCount}
table.Entries[idx] = []string{name, *index.DatasetSize, *index.DocsCount}
idx++
}
table.Sort()
if err := table.PrintMarkdown(); err != nil {
if err := table.Print(); err != nil {
fmt.Println(err)
return false
}
@@ -303,18 +308,18 @@ func findFailedFollowerIndices(conf *cfg.Config, indices ClusterIndices, followe
}
idx := 0
table := NewTable(3, len(red))
table := printer.NewTable(conf, 3, len(red))
table.Addheaders("red index on follower", "size", "docscount")
for name, index := range red {
name := Colorize("red", name)
name := printer.Colorize(conf, "red", name)
table.entries[idx] = []string{name, *index.DatasetSize, *index.DocsCount}
table.Entries[idx] = []string{name, *index.DatasetSize, *index.DocsCount}
idx++
}
table.Sort()
if err := table.PrintMarkdown(); err != nil {
if err := table.Print(); err != nil {
fmt.Println(err)
return false
}
@@ -371,6 +376,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 {

89
pkg/es/doc.go Normal file
View File

@@ -0,0 +1,89 @@
/*
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 (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"codeberg.org/scip/esctl/pkg/cfg"
"github.com/tidwall/gjson"
)
// create index with:
//
// esctl index create foo [id:keyword user:text age:integer]
//
// then add a doc:
//
// esctl doc add foo2 '{"id":"d8d8d","user":"scip"}'
func DocAdd(conf *cfg.Config, jsondoc string) error {
data := map[string]any{}
err := json.Unmarshal([]byte(jsondoc), &data)
if err != nil {
return fmt.Errorf("supplied document was not valid JSON: %s", err)
}
now := fmt.Sprintf("%d", time.Now().Unix())
res, err := conf.DefaultCluster.ES.Create(conf.Index, now).
Document(data).
Header("content-type", "application/json").
Header("accept", "application/json").
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to create new doc in index %s: %s", conf.Index, err)
}
fmt.Println(res.Id_)
return nil
}
func DocShow(conf *cfg.Config, id string) error {
res, err := conf.DefaultCluster.ES.Get(conf.Index, id).
Header("content-type", "application/json").
Header("accept", "application/json").
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to retrieve doc in index %s: %s", conf.Index, err)
}
if !res.Found {
return errors.New("no document with that id found")
}
docjson := fmt.Sprintf(`{"id":%s, "index":"%s", "source":%s}`,
res.Id_,
res.Index_,
res.Source_)
if conf.Path != "" {
value := gjson.Get(docjson, conf.Path)
fmt.Println(value.String())
} else {
fmt.Println(docjson)
}
fmt.Println()
return nil
}

View File

@@ -20,10 +20,14 @@ import (
"context"
"fmt"
"log/slog"
"regexp"
"strconv"
"strings"
"time"
"codeberg.org/scip/esctl/pkg/cfg"
"codeberg.org/scip/esctl/pkg/printer"
"github.com/elastic/go-elasticsearch/v9/typedapi/cat/indices"
"github.com/elastic/go-elasticsearch/v9/typedapi/esdsl"
"github.com/elastic/go-elasticsearch/v9/typedapi/types/enums/healthstatus"
)
@@ -47,6 +51,24 @@ func IndexNames(conf *cfg.Config) ([]string, error) {
return indices, nil
}
func filterIndices(conf *cfg.Config, list indices.Response) indices.Response {
if len(conf.Filter) == 0 {
return list
}
// we support just one filter here, for now
filter := *regexp.MustCompile(conf.Filter[0])
newlist := indices.Response{}
for _, index := range list {
if filter.MatchString(*index.Index) {
newlist = append(newlist, index)
}
}
return newlist
}
func IndexList(conf *cfg.Config) error {
cat := conf.DefaultCluster.ES.Cat.Indices().
// we need to add custom request headers, required for older ES instances
@@ -64,7 +86,9 @@ func IndexList(conf *cfg.Config) error {
slog.Debug("ES result", "indicies", res)
size := len(res)
list := filterIndices(conf, res)
size := len(list)
if conf.MaxItems > 0 {
if size > conf.MaxItems {
@@ -72,13 +96,13 @@ func IndexList(conf *cfg.Config) error {
}
}
table := NewTable(3, size)
table := printer.NewTable(conf, 3, size)
table.Addheaders("name", "size", "docscount")
for idx, index := range res {
name := Colorize(*index.Health, *index.Index)
for idx, index := range list {
name := printer.Colorize(conf, *index.Health, *index.Index)
table.entries[idx] = []string{name, *index.DatasetSize, *index.DocsCount}
table.Entries[idx] = []string{name, *index.DatasetSize, *index.DocsCount}
if idx == size-1 {
break
@@ -86,7 +110,7 @@ func IndexList(conf *cfg.Config) error {
}
table.Sort()
if err := table.PrintMarkdown(); err != nil {
if err := table.Print(); err != nil {
return err
}
@@ -94,10 +118,6 @@ func IndexList(conf *cfg.Config) error {
}
func IndexShow(conf *cfg.Config, index string) error {
if index == "" {
return fmt.Errorf("no index specified")
}
res, err := conf.DefaultCluster.ES.Indices.Get(index).
// we need to add custom request headers, required for older ES instances
Header("content-type", "application/json").
@@ -109,8 +129,15 @@ func IndexShow(conf *cfg.Config, index string) error {
slog.Debug("ES result", "index", res)
table := NewTable(2, 5)
table.Addheaders("field", "value")
fields := make([]string, len(res[index].Mappings.Properties))
idx := 0
for field := range res[index].Mappings.Properties {
fields[idx] = field
idx++
}
table := printer.NewTable(conf, 2, 5)
table.Addheaders("index property", "value")
ts, err := strconv.ParseInt(res[index].Settings.Index.CreationDate.(string), 10, 64)
if err != nil {
@@ -119,26 +146,23 @@ func IndexShow(conf *cfg.Config, index string) error {
created := time.Unix(ts/1000, 0)
table.entries = [][]string{
table.Entries = [][]string{
{"name", index},
{"replicas", *res[index].Settings.Index.NumberOfReplicas},
{"shards", *res[index].Settings.Index.NumberOfShards},
{"created", created.Format("2006-01-02 15:04:05")},
{"uuid", *res[index].Settings.Index.Uuid},
{"fields", strings.Join(fields, ",")},
}
if err := table.PrintMarkdown(); err != nil {
if err := table.Print(); err != nil {
return err
}
return nil
}
func IndexCreate(conf *cfg.Config, index string) error {
if index == "" {
return fmt.Errorf("no index specified")
}
func IndexCreate(conf *cfg.Config, index string, mappings []string) error {
settings := esdsl.NewIndexSettings()
create := conf.DefaultCluster.ES.Indices.Create(index).
@@ -156,6 +180,30 @@ func IndexCreate(conf *cfg.Config, index string) error {
settings = settings.NumberOfReplicas(strconv.Itoa(conf.Replicas))
}
if len(mappings) > 0 {
maps := esdsl.NewTypeMapping()
for _, mapping := range mappings {
parts := strings.Split(mapping, ":")
if len(parts) != 2 {
return fmt.Errorf("invalid mapping %s, expect <name:type> (type: integer, text, date, keyword)", mapping)
}
switch parts[1] {
case "text":
maps.AddProperty(parts[0], esdsl.NewTextProperty())
case "integer":
maps.AddProperty(parts[0], esdsl.NewIntegerNumberProperty())
case "date":
maps.AddProperty(parts[0], esdsl.NewDateProperty())
case "keyword":
maps.AddProperty(parts[0], esdsl.NewKeywordProperty())
}
}
create.Mappings(maps)
}
_, err := create.Settings(settings).
Do(context.Background())
@@ -167,10 +215,6 @@ func IndexCreate(conf *cfg.Config, index string) error {
}
func IndexDelete(conf *cfg.Config, index string) error {
if index == "" {
return fmt.Errorf("no index specified")
}
_, err := conf.DefaultCluster.ES.Indices.Delete(index).
Header("content-type", "application/json").
Header("accept", "application/json").
@@ -181,3 +225,77 @@ 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
}
func IndexAllocation(conf *cfg.Config, index string) error {
res, err := conf.DefaultCluster.ES.Cluster.AllocationExplain().
Index(index).
Primary(conf.Primary).
Shard(conf.Shards).
Header("content-type", "application/json").
Header("accept", "application/json").
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to get index allocation explain: %s", err)
}
slog.Debug("ES result", "index", res)
currentNode := res.CurrentNode
table := printer.NewTable(conf, 2, 10)
table.Addheaders("index allocation setting", "value")
roles := make([]string, len(currentNode.Roles))
for idx, role := range currentNode.Roles {
roles[idx] = role.Name
}
table.Entries = [][]string{
{"Index", index},
{"Current node", currentNode.Name},
{"Current k8s node", currentNode.Attributes["k8s_node_name"]},
{"Current node address", currentNode.TransportAddress},
{"Current node id", currentNode.Id},
{"Current node weight", fmt.Sprintf("%d", currentNode.WeightRanking)},
{"Current node roles", strings.Join(roles, ",")},
{"Can rebalance cluster", res.CanRebalanceCluster.Name},
{"Can rebalance to another node", res.CanRebalanceToOtherNode.Name},
{"Can remain on current node", res.CanRemainOnCurrentNode.Name},
}
if err := table.Print(); err != nil {
return err
}
return nil
}
func IndexModify(conf *cfg.Config, index string) error {
settings := esdsl.NewIndexSettings().NumberOfReplicas(strconv.Itoa(conf.Replicas))
_, err := conf.DefaultCluster.ES.Indices.PutSettings().
Indices(index).
Index(settings).
Header("content-type", "application/json").
Header("accept", "application/json").
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to modify index settings: %s", err)
}
return nil
}

115
pkg/es/index_alias.go Normal file
View File

@@ -0,0 +1,115 @@
/*
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 (
"context"
"fmt"
"log/slog"
"regexp"
"strings"
"codeberg.org/scip/esctl/pkg/cfg"
"codeberg.org/scip/esctl/pkg/printer"
)
// FIXME: add filter support, see IndexCreate mapping
func IndexAliasCreate(conf *cfg.Config, index, alias string) error {
res, err := conf.DefaultCluster.ES.Indices.PutAlias(index, alias).
Header("content-type", "application/json").
Header("accept", "application/json").
Do(context.Background())
slog.Debug("create alias", "result", res)
if err != nil {
return fmt.Errorf("failed to create index alias: %s", err)
}
return nil
}
func IndexAliasList(conf *cfg.Config) error {
filter := regexp.Regexp{}
if len(conf.Filter) > 0 {
// we support just one filter here, for now
filter = *regexp.MustCompile(conf.Filter[0])
}
res, err := conf.DefaultCluster.ES.Indices.GetAlias().
Index("_all").
Header("content-type", "application/json").
Header("accept", "application/json").
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to list index aliases: %s", err)
}
slog.Debug("aliases list", "result", res)
aliaslist := map[string][]string{} // index => []aliases
for index, aliases := range res {
if len(conf.Filter) > 0 {
if !filter.MatchString(index) {
continue
}
}
for alias := range aliases.Aliases {
aliaslist[index] = append(aliaslist[index], alias)
}
}
table := printer.NewTable(conf, 2, len(aliaslist))
table.Addheaders("index", "alias")
idx := 0
for index, aliases := range aliaslist {
table.Entries[idx] = []string{
index,
strings.Join(aliases, ","),
}
idx++
}
table.Sort()
if err := table.Print(); err != nil {
return err
}
return nil
}
func IndexAliasDelete(conf *cfg.Config, index, alias string) error {
res, err := conf.DefaultCluster.ES.Indices.DeleteAlias(index, alias).
Header("content-type", "application/json").
Header("accept", "application/json").
Do(context.Background())
slog.Debug("delete alias", "result", res)
if err != nil {
return fmt.Errorf("failed to delete index alias: %s", err)
}
return nil
}

View File

@@ -22,6 +22,7 @@ import (
"log/slog"
"codeberg.org/scip/esctl/pkg/cfg"
"codeberg.org/scip/esctl/pkg/printer"
)
func NodeList(conf *cfg.Config) error {
@@ -33,11 +34,11 @@ func NodeList(conf *cfg.Config) error {
slog.Debug("ES result", "nodes", nodes)
table := NewTable(7, len(nodes))
table := printer.NewTable(conf, 7, len(nodes))
table.Addheaders("name", "ip", "load1m", "load5m", "load15m", "ram %", "heap %")
for idx, node := range nodes {
table.entries[idx] = []string{
table.Entries[idx] = []string{
*node.Name,
*node.Ip,
*node.Load1M,
@@ -49,7 +50,7 @@ func NodeList(conf *cfg.Config) error {
}
table.Sort()
if err := table.PrintMarkdown(); err != nil {
if err := table.Print(); err != nil {
return err
}

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

@@ -0,0 +1,176 @@
/*
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"
"context"
"crypto/tls"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"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)
}
return prettyfiJson(conf, body)
}
func prettyfiJson(conf *cfg.Config, raw []byte) error {
if conf.HaveJQ {
cmd := exec.CommandContext(context.Background(), "jq", "-C")
cmd.Stdin = bytes.NewReader(raw)
var out bytes.Buffer
cmd.Stdout = &out
err := cmd.Run()
if err != nil {
return err
}
fmt.Println(out.String())
return nil
}
var pretty bytes.Buffer
err := json.Indent(&pretty, raw, "", "\t")
if err != 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
}

View File

@@ -20,12 +20,9 @@ import (
"context"
"fmt"
"log/slog"
"strings"
"codeberg.org/scip/esctl/pkg/cfg"
"github.com/elastic/go-elasticsearch/v9/typedapi/core/search"
"github.com/elastic/go-elasticsearch/v9/typedapi/esdsl"
"github.com/elastic/go-elasticsearch/v9/typedapi/types"
"github.com/tidwall/gjson"
)
/*
@@ -34,33 +31,20 @@ Execute an ES search.
q is the actual search query given as arg to the 'search' cmd
additional filters can be given as -F key=value
*/
func Search(conf *cfg.Config, q string) error {
query := esdsl.NewBoolQuery().
Must(esdsl.NewMatchQuery("message", q))
func Search(conf *cfg.Config, queries []string) error {
search := conf.DefaultCluster.ES.Search().
Index(conf.Index)
filters := make([]types.QueryVariant, len(conf.Filter))
for idx, filter := range conf.Filter {
parts := strings.Split(filter, "=")
if len(parts) != 2 {
return fmt.Errorf("invalid filter spec: %s, expecting key=value", filter)
if len(queries) > 0 {
req, err := prepareQuery(conf, queries)
if err != nil {
return err
}
filters[idx] = esdsl.NewTermQuery(parts[0], esdsl.NewFieldValue().String(parts[1]))
search.Request(req)
}
if len(filters) > 0 {
query.Filter(filters...)
}
res, err := conf.DefaultCluster.ES.Search().
Index(conf.Index).
Request(&search.Request{
Query: query.QueryCaster(),
From: &conf.From,
Size: &conf.To,
}).
Do(context.Background())
res, err := search.Do(context.Background())
if err != nil {
return fmt.Errorf("failed to run search (esdsl): %s", err)
}
@@ -68,7 +52,18 @@ func Search(conf *cfg.Config, q string) error {
slog.Debug("ES result", "search", res)
for _, hit := range res.Hits.Hits {
fmt.Printf("%s\n", hit.Source_)
docjson := fmt.Sprintf(`{"id":%s, "score":%0.4f, "index":"%s", "source":%s}`,
*hit.Id_,
*hit.Score_,
hit.Index_,
hit.Source_)
if conf.Path != "" {
value := gjson.Get(docjson, conf.Path)
fmt.Println(value.String())
} else {
fmt.Println(docjson)
}
}
return nil

146
pkg/es/search_filter.go Normal file
View File

@@ -0,0 +1,146 @@
/*
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 (
"fmt"
"strings"
"codeberg.org/scip/esctl/pkg/cfg"
"github.com/elastic/go-elasticsearch/v9/typedapi/core/search"
"github.com/elastic/go-elasticsearch/v9/typedapi/esdsl"
"github.com/elastic/go-elasticsearch/v9/typedapi/types"
)
const (
Fmust = iota
Fmustnot
Fshould
)
// hold filter configuration, can be used multiple times through a search
type filter struct {
term, filter string // message=foo
mterm []string // multi_match
criteria int // must(=), mustnot(!=), should(?)
multi bool // message,title=foo => filter:foo, multi: []string{"message","title"}
}
// build a new filter object
func NewFilter(query string) (*filter, error) {
var separator string
var criteria int // we use the constants on top for this
switch {
case strings.Contains(query, "!="):
criteria = Fmustnot
separator = "!="
case strings.Contains(query, "?"):
criteria = Fshould
separator = "?"
default:
criteria = Fmust
separator = "="
}
part := strings.Split(query, separator)
if len(part) != 2 {
return nil, fmt.Errorf("search queries must be in the form field<sep>pattern where <sep> must be one of: =, !=, ?")
}
f := &filter{term: part[0], filter: part[1], criteria: criteria}
if strings.Contains(part[0], ",") {
// a MultiMatchQuery, match across multiple fields at once
multi := strings.Split(part[0], ",")
f.multi = true
f.mterm = multi
}
return f, nil
}
// prepare q user search query and turn it into a proper search request
func prepareQuery(conf *cfg.Config, queries []string) (*search.Request, error) {
if len(queries) == 0 {
// nothing given, just return all docs, if any
return &search.Request{
Query: esdsl.NewMatchAllQuery().QueryCaster(),
From: &conf.From,
Size: &conf.To,
}, nil
}
if len(queries) == 1 && !strings.ContainsAny(queries[0], "!=?") {
// a general query w/o fields, search across all fields
return &search.Request{
Query: esdsl.NewSimpleQueryStringQuery(queries[0]).QueryCaster(),
From: &conf.From,
Size: &conf.To,
}, nil
}
// complex query, form a proper query struct
query := esdsl.NewBoolQuery()
for _, q := range queries {
filter, err := NewFilter(q)
if err != nil {
return nil, err
}
// by default we match on a single field
var match types.QueryVariant = esdsl.NewMatchQuery(filter.term, filter.filter)
if filter.multi {
// ok, match across multiple given fields
match = esdsl.NewMultiMatchQuery(filter.filter).Fields(filter.mterm...)
}
// apply logic
switch filter.criteria {
case Fmustnot:
query.MustNot(match)
case Fmust:
query.Must(match)
case Fshould:
query.Should(match)
}
}
// there might be boolean filters as well
filters := make([]types.QueryVariant, len(conf.Filter))
for idx, filter := range conf.Filter {
parts := strings.Split(filter, "=")
if len(parts) != 2 {
return nil, fmt.Errorf("invalid filter spec: %s, expecting key=value", filter)
}
filters[idx] = esdsl.NewTermQuery(parts[0], esdsl.NewFieldValue().String(parts[1]))
}
if len(filters) > 0 {
query.Filter(filters...)
}
return &search.Request{
Query: query.QueryCaster(),
From: &conf.From,
Size: &conf.To,
}, nil
}

151
pkg/es/shard.go Normal file
View File

@@ -0,0 +1,151 @@
/*
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 (
"context"
"fmt"
"log/slog"
"codeberg.org/scip/esctl/pkg/cfg"
"codeberg.org/scip/esctl/pkg/printer"
"github.com/elastic/go-elasticsearch/v9/typedapi/cat/shards"
)
func colorzizeShard(conf *cfg.Config, state, name string) string {
color := "red" // in case of RELOCATING and UNASSIGNED
switch state {
case "STARTED":
color = "green"
case "INITIALIZING":
color = "yellow"
}
return printer.Colorize(conf, color, name)
}
func resolvePrirep(state string) string {
switch state {
case "p":
return "primary"
}
return "replica"
}
func filterShards(conf *cfg.Config, shardlist shards.Response) shards.Response {
filtered := shards.Response{}
size := len(shardlist)
if conf.MaxItems > 0 {
if size > conf.MaxItems {
size = conf.MaxItems
}
}
for idx, shard := range shardlist {
if conf.Failed {
if *shard.State == "STARTED" {
continue
}
}
if conf.Primary {
if *shard.Prirep != "p" {
continue
}
}
if idx == size-1 {
break
}
filtered = append(filtered, shard)
}
return filtered
}
func ShardList(conf *cfg.Config) error {
res, err := conf.DefaultCluster.ES.Cat.Shards().
Header("content-type", "application/json").
Header("accept", "application/json").
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to get shards: %s", err)
}
shardlist := filterShards(conf, res)
slog.Debug("ES result", "shards", res)
return printShards(conf, shardlist)
}
func printShards(conf *cfg.Config, shardlist shards.Response) error {
headers := []string{"index", "shard", "is primary", "store", "dataset", "docs"}
if conf.Verbose {
headers = append(headers, "node", "ip")
}
table := printer.NewTable(conf, len(headers), len(shardlist))
table.Addheaders(headers...)
for idx, shard := range shardlist {
name := colorzizeShard(conf, *shard.State, *shard.Index)
table.Entries[idx] = []string{
name,
*shard.Shard,
resolvePrirep(*shard.Prirep),
*shard.Store,
*shard.Dataset,
*shard.Docs,
}
if conf.Verbose {
table.Entries[idx] = append(table.Entries[idx],
*shard.Node,
*shard.Ip,
)
}
}
table.Sort()
if err := table.Print(); err != nil {
return err
}
return nil
}
func ShardShow(conf *cfg.Config, index string) error {
res, err := conf.DefaultCluster.ES.Cat.Shards().Index(index).
Header("content-type", "application/json").
Header("accept", "application/json").
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to get shards: %s", err)
}
slog.Debug("ES result", "shards", res)
conf.Verbose = true
return printShards(conf, res)
}

View File

@@ -25,6 +25,7 @@ import (
"strings"
"codeberg.org/scip/esctl/pkg/cfg"
"codeberg.org/scip/esctl/pkg/printer"
)
var (
@@ -83,11 +84,11 @@ func SnapshotList(conf *cfg.Config) error {
}
}
table := NewTable(5, len(snapshots))
table := printer.NewTable(conf, 5, len(snapshots))
table.Addheaders("name", "index", "start", "orphaned", "status")
for idx, snap := range snapshots {
table.entries[idx] = []string{
table.Entries[idx] = []string{
snap.Name,
snap.Forindex,
snap.Start,
@@ -97,7 +98,7 @@ func SnapshotList(conf *cfg.Config) error {
}
table.Sort()
if err := table.PrintMarkdown(); err != nil {
if err := table.Print(); err != nil {
return err
}
@@ -116,12 +117,12 @@ func SnapshotShow(conf *cfg.Config, snapshot string) error {
return errors.New("no snapshot retrieved")
}
table := NewTable(2, 17)
table.Addheaders("field", "value")
table := printer.NewTable(conf, 2, 17)
table.Addheaders("snapshot property", "value")
snap := res.Snapshots[0]
table.entries = [][]string{
table.Entries = [][]string{
{"snapshot", snapshot},
{"uuid", snap.Uuid},
{"repository", *snap.Repository},
@@ -137,7 +138,7 @@ func SnapshotShow(conf *cfg.Config, snapshot string) error {
{"shards-successful", fmt.Sprintf("%d", snap.Shards.Successful)},
}
if err := table.PrintMarkdown(); err != nil {
if err := table.Print(); err != nil {
return err
}

View File

@@ -1,114 +0,0 @@
/*
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 (
"fmt"
"sort"
"strings"
"github.com/olekukonko/tablewriter"
"github.com/olekukonko/tablewriter/renderer"
"github.com/olekukonko/tablewriter/tw"
)
type Table struct {
headers []string
entries [][]string
}
func NewTable(columns, rows int) *Table {
table := Table{}
table.headers = make([]string, columns)
table.entries = make([][]string, rows)
return &table
}
func (data *Table) PrintMarkdown() error {
tableString := &strings.Builder{}
table := tablewriter.NewTable(tableString,
tablewriter.WithRenderer(
renderer.NewBlueprint(
tw.Rendition{
Borders: tw.Border{
Left: tw.On,
Right: tw.On,
Top: tw.Off,
Bottom: tw.Off,
},
Settings: tw.Settings{
Separators: tw.Separators{
ShowHeader: tw.On,
ShowFooter: tw.Off,
BetweenRows: tw.Off,
BetweenColumns: 0,
},
},
Symbols: tw.NewSymbols(tw.StyleMarkdown),
})),
tablewriter.WithConfig(
tablewriter.Config{
Header: tw.CellConfig{
Formatting: tw.CellFormatting{
Alignment: tw.AlignLeft,
AutoFormat: tw.Off,
},
},
Row: tw.CellConfig{
Formatting: tw.CellFormatting{
Alignment: tw.AlignLeft,
},
},
},
),
)
table.Header(data.headers)
if err := table.Bulk(data.entries); err != nil {
return fmt.Errorf("failed to add data to table renderer: %s", err)
}
if err := table.Render(); err != nil {
return fmt.Errorf("failed to render table: %s", err)
}
fmt.Println(tableString.String())
return nil
}
func (data *Table) Sort() {
// sanity checks
if len(data.entries) == 0 {
return
}
sort.Slice(data.entries, func(i, j int) bool {
return data.entries[i][0] < data.entries[j][0]
})
}
func (data *Table) Addheaders(headers ...string) {
for idx, header := range headers {
data.headers[idx] = bold(strings.ToUpper(header))
}
}

View File

@@ -15,9 +15,12 @@ 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
package printer
import "github.com/fatih/color"
import (
"codeberg.org/scip/esctl/pkg/cfg"
"github.com/fatih/color"
)
var (
green = color.New(color.BgGreen, color.FgHiWhite).SprintFunc()
@@ -26,7 +29,12 @@ var (
bold = color.New(color.Bold).SprintFunc()
)
func Colorize(col, what string) string {
func Colorize(conf *cfg.Config, col, what string) string {
switch conf.Output {
case "json", "yaml":
return what
}
switch col {
case "green":
what = green(what)

306
pkg/printer/table.go Normal file
View File

@@ -0,0 +1,306 @@
/*
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 printer
import (
"encoding/json"
"fmt"
"regexp"
"sort"
"strconv"
"strings"
"codeberg.org/scip/esctl/pkg/cfg"
"github.com/olekukonko/tablewriter"
"github.com/olekukonko/tablewriter/renderer"
"github.com/olekukonko/tablewriter/tw"
"gopkg.in/yaml.v3"
)
type Table struct {
Mode string // table, markdown, json
Headers []string
Entries [][]string
lenHeaders []int
}
func NewTable(conf *cfg.Config, columns, rows int) *Table {
table := Table{Mode: conf.Output}
table.Headers = make([]string, columns)
table.Entries = make([][]string, rows)
table.lenHeaders = make([]int, columns)
return &table
}
func (data *Table) Print() error {
switch data.Mode {
case "markdown", "md":
return data.PrintMarkdown()
case "json":
return data.PrintJSON()
case "yaml":
return data.PrintYAML()
default:
return data.PrintTSV()
}
}
var (
ansiCtrlSeq = regexp.MustCompile(`\033.[0-9;]+m`)
)
// needed for json and yaml output
func (data *Table) toMap() []map[string]string {
raw := make([]map[string]string, len(data.Entries))
for idx, entries := range data.Entries {
raw[idx] = make(map[string]string, len(data.Headers))
for eidx, entry := range entries {
raw[idx][data.Headers[eidx]] = entry
}
}
return raw
}
func (data *Table) PrintYAML() error {
raw := data.toMap()
body, err := yaml.Marshal(raw)
if err != nil {
return fmt.Errorf("failed to produce YAML output: %s", err)
}
fmt.Println(string(body))
return nil
}
func (data *Table) PrintJSON() error {
raw := data.toMap()
body, err := json.MarshalIndent(raw, "", " ")
if err != nil {
return fmt.Errorf("failed to produce JSON output: %s", err)
}
fmt.Println(string(body))
return nil
}
// Well. tablewriter should be able to print in TSV mode, but it
// doesn't work because of the various ansic color escapes we're
// using. Therefore we do it the hard way and use our own TSV printer.
func (data *Table) PrintTSV() error {
// determine lenght's
for idx, head := range data.Headers {
data.lenHeaders[idx] = visibleLen(head)
}
for _, entries := range data.Entries {
for idx, entry := range entries {
length := visibleLen(entry)
if data.lenHeaders[idx] < length {
data.lenHeaders[idx] = length
}
}
}
// output
for idx, header := range data.Headers {
fmt.Print(header, strings.Repeat(" ", data.lenHeaders[idx]-visibleLen(header)))
if idx < len(data.Headers)-1 {
fmt.Print(" ")
}
}
fmt.Println()
for _, entries := range data.Entries {
for idx, entry := range entries {
length := visibleLen(entry)
if isInt(entry) {
// align right
fmt.Print(strings.Repeat(" ", data.lenHeaders[idx]-length), entry)
} else {
fmt.Print(entry, strings.Repeat(" ", data.lenHeaders[idx]-length))
}
if idx < len(data.Headers)-1 {
fmt.Print(" ")
}
}
fmt.Println()
}
return nil
}
func (data *Table) PrintTabularFail() error {
OFS := " "
tableString := &strings.Builder{}
styleTSV := tw.NewSymbolCustom("space").WithColumn("\t")
table := tablewriter.NewTable(tableString,
tablewriter.WithRenderer(
renderer.NewBlueprint(tw.Rendition{
Borders: tw.BorderNone,
Symbols: styleTSV,
Settings: tw.Settings{
Separators: tw.SeparatorsNone,
Lines: tw.LinesNone,
},
})),
tablewriter.WithConfig(tablewriter.Config{
Header: tw.CellConfig{
Formatting: tw.CellFormatting{
AutoFormat: tw.Off,
},
Padding: tw.CellPadding{Global: tw.Padding{Left: "", Right: OFS}},
},
Row: tw.CellConfig{
Formatting: tw.CellFormatting{
AutoWrap: tw.WrapNone,
Alignment: tw.AlignLeft,
},
Padding: tw.CellPadding{Global: tw.Padding{Right: OFS}},
},
Debug: true,
}),
)
table.Header(data.Headers)
if err := table.Bulk(data.Entries); err != nil {
return fmt.Errorf("failed to add data to table renderer: %s", err)
}
if err := table.Render(); err != nil {
return fmt.Errorf("failed to render table: %s", err)
}
// we need to trim our output here, because tablewriter appends
// excess whitespace to our rows.
cleanedString := &strings.Builder{}
for _, row := range strings.Split(strings.TrimSpace(tableString.String()), "\n") {
cleanedString.WriteString(strings.TrimSpace(row))
cleanedString.WriteString("\n")
}
fmt.Println(cleanedString.String())
return nil
}
func (data *Table) PrintMarkdown() error {
tableString := &strings.Builder{}
table := tablewriter.NewTable(tableString,
tablewriter.WithRenderer(
renderer.NewBlueprint(
tw.Rendition{
Borders: tw.Border{
Left: tw.On,
Right: tw.On,
Top: tw.Off,
Bottom: tw.Off,
},
Settings: tw.Settings{
Separators: tw.Separators{
ShowHeader: tw.On,
ShowFooter: tw.Off,
BetweenRows: tw.Off,
BetweenColumns: 0,
},
},
Symbols: tw.NewSymbols(tw.StyleMarkdown),
})),
tablewriter.WithConfig(
tablewriter.Config{
Header: tw.CellConfig{
Formatting: tw.CellFormatting{
Alignment: tw.AlignLeft,
AutoFormat: tw.Off,
},
},
Row: tw.CellConfig{
Formatting: tw.CellFormatting{
Alignment: tw.AlignLeft,
},
},
},
),
)
table.Header(data.Headers)
if err := table.Bulk(data.Entries); err != nil {
return fmt.Errorf("failed to add data to table renderer: %s", err)
}
if err := table.Render(); err != nil {
return fmt.Errorf("failed to render table: %s", err)
}
fmt.Println(tableString.String())
return nil
}
func (data *Table) Sort() {
// sanity checks
if len(data.Entries) == 0 {
return
}
sort.Slice(data.Entries, func(i, j int) bool {
return data.Entries[i][0] < data.Entries[j][0]
})
}
func (data *Table) Addheaders(headers ...string) {
for idx, header := range headers {
switch data.Mode {
case "json", "yaml":
data.Headers[idx] = strings.ReplaceAll(strings.ToLower(header), " ", "_")
default:
data.Headers[idx] = bold(strings.ReplaceAll(strings.ToUpper(header), " ", "-"))
}
}
}
// return the length of a string but only visible chars, w/o ansi color escapes
func visibleLen(word string) int {
return len(ansiCtrlSeq.ReplaceAllLiteralString(word, ""))
}
func isInt(num string) bool {
if _, err := strconv.Atoi(num); err == nil {
return true
}
return false
}