Compare commits

..

16 Commits

Author SHA1 Message Date
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
33 changed files with 2213 additions and 369 deletions

4
.gitignore vendored
View File

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

View File

@@ -6,30 +6,53 @@ 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
list
allocation
close
create
delete
list
modify
show
node
list
show
repl
search
shard
list
show
snapshot
list
show
```
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
},
}
}

58
cmd/doc.go Normal file
View File

@@ -0,0 +1,58 @@
/*
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),
//Delete(conf),
},
}
}
func DocAdd(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "add",
Aliases: []string{"+"},
Usage: "add JSON document index",
UsageText: "add [options] <index> '<json-doc>'",
Action: func(ctx context.Context, cmd *cli.Command) error {
args := cmd.Args()
if args.Len() != 2 {
return errors.New("missing arguments: <index> <json-doc>")
}
return es.DocAdd(conf, cmd.Args().Get(0), cmd.Args().Get(1))
},
}
}

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)
},
}
}

84
cmd/index_alias.go Normal file
View File

@@ -0,0 +1,84 @@
/*
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),
//IndexAliasShow(conf),
//IndexAliasDelete(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 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

@@ -73,10 +73,14 @@ func Main() int {
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),
},
Before: func(ctx context.Context, cmd *cli.Command) (context.Context, error) {

View File

@@ -18,6 +18,7 @@ package cmd
import (
"context"
"errors"
"codeberg.org/scip/esctl/pkg/cfg"
"codeberg.org/scip/esctl/pkg/es"
@@ -30,7 +31,7 @@ func Search(conf *cfg.Config) *cli.Command {
Name: "search",
Aliases: []string{"/"},
Usage: "search within an index",
UsageText: "search [options] <query>",
UsageText: "search [options] <field=pattern> ...",
Flags: []cli.Flag{
&cli.StringFlag{
@@ -63,11 +64,12 @@ func Search(conf *cfg.Config) *cli.Command {
},
Action: func(ctx context.Context, cmd *cli.Command) error {
if err := es.Search(conf, cmd.Args().Get(0)); err != nil {
return err
args := cmd.Args()
if args.Len() == 0 {
return errors.New("at least one query must be specified (format: field=pattern)")
}
return nil
return es.Search(conf, args.Slice())
},
}
}

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"

28
go.mod
View File

@@ -17,30 +17,36 @@ 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
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
)

19
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,28 @@ 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/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 +74,7 @@ go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt
go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=

View File

@@ -31,7 +31,7 @@ import (
)
const (
Version string = `v0.0.6`
Version string = `v0.0.11`
)
type Cluster struct {
@@ -47,13 +47,16 @@ type Config struct {
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
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 +93,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 +106,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
}

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

@@ -0,0 +1,134 @@
/*
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"
"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 := NewTable(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.PrintMarkdown(); err != nil {
return err
}
return nil
}

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

@@ -0,0 +1,198 @@
/*
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"
)
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 := NewTable(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.PrintMarkdown(); err != nil {
return err
}
return nil
}

View File

@@ -18,28 +18,26 @@ package es
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"slices"
"strings"
"sync"
"codeberg.org/scip/esctl/pkg/cfg"
"github.com/dustin/go-humanize"
"github.com/elastic/go-elasticsearch/v9/typedapi/ccr/stats"
"github.com/elastic/go-elasticsearch/v9/typedapi/cluster/health"
clusterstats "github.com/elastic/go-elasticsearch/v9/typedapi/cluster/stats"
"github.com/elastic/go-elasticsearch/v9/typedapi/core/info"
"github.com/elastic/go-elasticsearch/v9/typedapi/types"
"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 +47,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.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").
@@ -111,10 +80,8 @@ func ClusterList(conf *cfg.Config) error {
}
table.entries[idx] = []string{name, cluster.Uri, fmt.Sprintf("%t", current)}
idx++
}
table.Sort()
if err := table.PrintMarkdown(); err != nil {
return err
}
@@ -126,6 +93,10 @@ func ClusterList(conf *cfg.Config) error {
// have to do 3 of'em for each cluster. This speeds things up.
func ClusterStatus(conf *cfg.Config) error {
clusters := []string{}
gocount := 3
if conf.Verbose {
gocount++
}
if conf.All {
for key := range conf.Clusters {
@@ -141,21 +112,26 @@ func ClusterStatus(conf *cfg.Config) error {
es = conf.Clusters[cluster].ES
}
responses := make(chan apiResponse, 3)
responses := make(chan apiResponse, gocount)
wg := &sync.WaitGroup{}
wg.Add(3)
wg.Add(gocount)
go getClusterData(es, wg, responses, "health")
go getClusterData(es, wg, responses, "info")
go getClusterData(es, wg, responses, "ccrstats")
if conf.Verbose {
go getClusterData(es, wg, responses, "stats")
}
wg.Wait()
var clusterhealth *health.Response
var info *info.Response
var ccrstats *stats.Response
var clusterstats *clusterstats.Response
for i := 0; i < 3; i++ {
for i := 0; i < gocount; i++ {
r := <-responses
if r.error != nil {
@@ -169,6 +145,8 @@ func ClusterStatus(conf *cfg.Config) error {
ccrstats = r.ccr
case ResponseInfo:
info = r.info
case ResponseStats:
clusterstats = r.stats
}
}
@@ -192,11 +170,14 @@ func ClusterStatus(conf *cfg.Config) error {
{"ES Version", info.Version.Int},
{"Active Shards", fmt.Sprintf("%d", clusterhealth.ActiveShards)},
{"Active Primary Shards", fmt.Sprintf("%d", clusterhealth.ActivePrimaryShards)},
{"Indicies", fmt.Sprintf("%d", len(clusterhealth.Indices))},
{"Nodes", fmt.Sprintf("%d", clusterhealth.NumberOfNodes)},
{"AutoFollow (success/failed indices)", ccrfollowing},
}
if conf.Verbose {
table = gatherClusterStats(conf, clusterstats, table)
}
if err := table.PrintMarkdown(); err != nil {
return err
}
@@ -205,90 +186,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 *Table) *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
}

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

@@ -0,0 +1,160 @@
/*
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"
"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 := 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
}
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.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
}
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

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

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

@@ -0,0 +1,55 @@
/*
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"
"time"
"codeberg.org/scip/esctl/pkg/cfg"
)
// 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, index, 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())
_, err = conf.DefaultCluster.ES.Create(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", index, err)
}
return nil
}

View File

@@ -20,10 +20,13 @@ import (
"context"
"fmt"
"log/slog"
"regexp"
"strconv"
"strings"
"time"
"codeberg.org/scip/esctl/pkg/cfg"
"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 +50,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 +85,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 {
@@ -75,7 +98,7 @@ func IndexList(conf *cfg.Config) error {
table := NewTable(3, size)
table.Addheaders("name", "size", "docscount")
for idx, index := range res {
for idx, index := range list {
name := Colorize(*index.Health, *index.Index)
table.entries[idx] = []string{name, *index.DatasetSize, *index.DocsCount}
@@ -94,10 +117,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").
@@ -110,7 +129,7 @@ func IndexShow(conf *cfg.Config, index string) error {
slog.Debug("ES result", "index", res)
table := NewTable(2, 5)
table.Addheaders("field", "value")
table.Addheaders("index property", "value")
ts, err := strconv.ParseInt(res[index].Settings.Index.CreationDate.(string), 10, 64)
if err != nil {
@@ -134,11 +153,7 @@ func IndexShow(conf *cfg.Config, index string) error {
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 +171,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 +206,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 +216,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 := NewTable(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.PrintMarkdown(); 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
}

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

@@ -0,0 +1,101 @@
/*
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"
)
// FIXME: add filter support, see IndexCreate mapping
func IndexAliasCreate(conf *cfg.Config, index, alias string) error {
create := conf.DefaultCluster.ES.Indices.PutAlias(index, alias).
Header("content-type", "application/json").
Header("accept", "application/json")
res, err := create.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])
}
create := conf.DefaultCluster.ES.Indices.GetAlias().
Index("_all").
Header("content-type", "application/json").
Header("accept", "application/json")
res, err := create.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 := NewTable(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.PrintMarkdown(); err != nil {
return err
}
return nil
}

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

@@ -34,9 +34,17 @@ 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 {
query := esdsl.NewBoolQuery()
for _, q := range queries {
parts := strings.Split(q, "=")
if len(parts) != 2 {
return fmt.Errorf("search queries must be in the form field=pattern")
}
query.Must(esdsl.NewMatchQuery(parts[0], parts[1]))
}
filters := make([]types.QueryVariant, len(conf.Filter))

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

@@ -0,0 +1,150 @@
/*
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"
"github.com/elastic/go-elasticsearch/v9/typedapi/cat/shards"
)
func colorzizeShard(state, name string) string {
color := "red" // in case of RELOCATING and UNASSIGNED
switch state {
case "STARTED":
color = "green"
case "INITIALIZING":
color = "yellow"
}
return Colorize(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 := NewTable(len(headers), len(shardlist))
table.Addheaders(headers...)
for idx, shard := range shardlist {
name := colorzizeShard(*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.PrintMarkdown(); 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

@@ -117,7 +117,7 @@ func SnapshotShow(conf *cfg.Config, snapshot string) error {
}
table := NewTable(2, 17)
table.Addheaders("field", "value")
table.Addheaders("snapshot property", "value")
snap := res.Snapshots[0]