Compare commits

...

4 Commits

Author SHA1 Message Date
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
22 changed files with 1042 additions and 223 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,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:

View File

@@ -18,6 +18,7 @@ package cmd
import (
"context"
"errors"
"fmt"
"codeberg.org/scip/esctl/pkg/cfg"
@@ -39,6 +40,8 @@ func Index(conf *cfg.Config) *cli.Command {
IndexDelete(conf),
IndexClose(conf),
IndexAllocation(conf),
IndexModify(conf),
IndexAlias(conf),
},
}
}
@@ -68,6 +71,12 @@ 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 {
@@ -98,6 +107,11 @@ func IndexAllocation(conf *cfg.Config) *cli.Command {
},
Action: func(ctx context.Context, cmd *cli.Command) error {
index := cmd.Args().Get(0)
if index == "" {
return errors.New("no index specified")
}
return es.IndexAllocation(conf, cmd.Args().Get(0))
},
}
@@ -110,6 +124,11 @@ func IndexShow(conf *cfg.Config) *cli.Command {
Usage: "show details about 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.IndexShow(conf, cmd.Args().Get(0))
},
@@ -180,6 +199,11 @@ func IndexDelete(conf *cfg.Config) *cli.Command {
Usage: "delete 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.IndexDelete(conf, cmd.Args().Get(0))
},
}
@@ -191,7 +215,38 @@ func IndexClose(conf *cfg.Config) *cli.Command {
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

@@ -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,17 +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) {
@@ -100,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
},
}
}

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

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"

View File

@@ -31,7 +31,11 @@ import (
)
const (
Version string = `v0.0.10`
Version string = `v0.0.12`
)
var (
APIVERSION, GOVERSION, BUILD, COMMIT, BRANCH string
)
type Cluster struct {
@@ -43,6 +47,7 @@ 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

View File

@@ -23,6 +23,7 @@ import (
"log/slog"
"codeberg.org/scip/esctl/pkg/cfg"
"codeberg.org/scip/esctl/pkg/printer"
"github.com/elastic/go-elasticsearch/v9/typedapi/types"
)
@@ -115,10 +116,10 @@ func CcrRemoteInfo(conf *cfg.Config, index string) error {
mode = "leader"
}
table := NewTable(2, 5)
table := printer.NewTable(conf, 2, 5)
table.Addheaders("ccr remote property", "value")
table.entries = [][]string{
table.Entries = [][]string{
{"Remote Cluster", remote},
{"CCR Mode", mode},
{"Connected", fmt.Sprintf("%t", info.Connected)},
@@ -126,7 +127,7 @@ func CcrRemoteInfo(conf *cfg.Config, index string) error {
{"Proxy Address", info.ProxyAddress},
}
if err := table.PrintMarkdown(); err != nil {
if err := table.Print(); err != nil {
return err
}

View File

@@ -22,6 +22,7 @@ import (
"log/slog"
"codeberg.org/scip/esctl/pkg/cfg"
"codeberg.org/scip/esctl/pkg/printer"
)
func getRemoteName(conf *cfg.Config) (string, error) {
@@ -175,10 +176,10 @@ func CcrFollowerShow(conf *cfg.Config, index string) error {
follower := res.Indices[0].Shards[0]
table := NewTable(2, 9)
table := printer.NewTable(conf, 2, 9)
table.Addheaders("ccr follower property", "value")
table.entries = [][]string{
table.Entries = [][]string{
{"name", index},
{"remote_cluster", follower.RemoteCluster},
{"leader_checkpoint", fmt.Sprintf("%d", follower.LeaderGlobalCheckpoint)},
@@ -190,7 +191,7 @@ func CcrFollowerShow(conf *cfg.Config, index string) error {
{"successful_write_requests", fmt.Sprintf("%d", follower.SuccessfulWriteRequests)},
}
if err := table.PrintMarkdown(); err != nil {
if err := table.Print(); err != nil {
return err
}

View File

@@ -25,6 +25,7 @@ import (
"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"
@@ -52,7 +53,7 @@ type apiResponse struct {
}
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")
@@ -76,13 +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)}
table.Entries[idx] = []string{name, cluster.Uri, fmt.Sprintf("%t", current)}
}
if err := table.PrintMarkdown(); err != nil {
if err := table.Print(); err != nil {
return err
}
@@ -162,11 +163,11 @@ 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)},
@@ -178,7 +179,7 @@ func ClusterStatus(conf *cfg.Config) error {
table = gatherClusterStats(conf, clusterstats, table)
}
if err := table.PrintMarkdown(); err != nil {
if err := table.Print(); err != nil {
return err
}
}
@@ -186,7 +187,7 @@ func ClusterStatus(conf *cfg.Config) error {
return nil
}
func gatherClusterStats(conf *cfg.Config, clusterstats *clusterstats.Response, table *Table) *Table {
func gatherClusterStats(conf *cfg.Config, clusterstats *clusterstats.Response, table *printer.Table) *printer.Table {
var querycount int64
var vmversion string
@@ -202,7 +203,7 @@ func gatherClusterStats(conf *cfg.Config, clusterstats *clusterstats.Response, t
isleader := checkClusterIsLeader(conf, conf.CurrentCluster)
table.entries = append(table.entries, [][]string{
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)},

View File

@@ -23,6 +23,7 @@ import (
"log/slog"
"codeberg.org/scip/esctl/pkg/cfg"
"codeberg.org/scip/esctl/pkg/printer"
"github.com/urfave/cli/v3"
)
@@ -65,7 +66,7 @@ func ClusterSettingsList(conf *cfg.Config) error {
return fmt.Errorf("failed to get cluster settings: %s", err)
}
table := NewTable(2, 0)
table := printer.NewTable(conf, 2, 0)
table.Addheaders("setting", "value")
entries := [][]string{}
@@ -94,10 +95,10 @@ func ClusterSettingsList(conf *cfg.Config) error {
}
}
table.entries = entries
table.Entries = entries
table.Sort()
if err := table.PrintMarkdown(); err != nil {
if err := table.Print(); err != nil {
return err
}

View File

@@ -25,6 +25,7 @@ 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"
@@ -74,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),
@@ -101,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
}
@@ -151,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
}
@@ -223,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
}
@@ -273,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
}
@@ -307,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
}

View File

@@ -20,11 +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"
)
@@ -48,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
@@ -65,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 {
@@ -73,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
@@ -87,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
}
@@ -95,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").
@@ -110,7 +129,7 @@ func IndexShow(conf *cfg.Config, index string) error {
slog.Debug("ES result", "index", res)
table := NewTable(2, 5)
table := printer.NewTable(conf, 2, 5)
table.Addheaders("index property", "value")
ts, err := strconv.ParseInt(res[index].Settings.Index.CreationDate.(string), 10, 64)
@@ -120,7 +139,7 @@ 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},
@@ -128,7 +147,7 @@ func IndexShow(conf *cfg.Config, index string) error {
{"uuid", *res[index].Settings.Index.Uuid},
}
if err := table.PrintMarkdown(); err != nil {
if err := table.Print(); err != nil {
return err
}
@@ -188,10 +207,6 @@ func IndexCreate(conf *cfg.Config, index string, mappings []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").
@@ -218,16 +233,11 @@ func IndexClose(conf *cfg.Config, index string) error {
}
func IndexAllocation(conf *cfg.Config, index string) error {
if index == "" {
return fmt.Errorf("no index specified")
}
alloc := conf.DefaultCluster.ES.Cluster.AllocationExplain().
res, err := conf.DefaultCluster.ES.Cluster.AllocationExplain().
Index(index).
Primary(conf.Primary).
Shard(conf.Shards)
res, err := alloc.Header("content-type", "application/json").
Shard(conf.Shards).
Header("content-type", "application/json").
Header("accept", "application/json").
Do(context.Background())
if err != nil {
@@ -238,7 +248,7 @@ func IndexAllocation(conf *cfg.Config, index string) error {
currentNode := res.CurrentNode
table := NewTable(2, 10)
table := printer.NewTable(conf, 2, 10)
table.Addheaders("index allocation setting", "value")
roles := make([]string, len(currentNode.Roles))
@@ -246,7 +256,7 @@ func IndexAllocation(conf *cfg.Config, index string) error {
roles[idx] = role.Name
}
table.entries = [][]string{
table.Entries = [][]string{
{"Index", index},
{"Current node", currentNode.Name},
{"Current k8s node", currentNode.Attributes["k8s_node_name"]},
@@ -259,9 +269,25 @@ func IndexAllocation(conf *cfg.Config, index string) error {
{"Can remain on current node", res.CanRemainOnCurrentNode.Name},
}
if err := table.PrintMarkdown(); err != nil {
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
}

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

@@ -0,0 +1,102 @@
/*
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 {
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 := 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
}

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
}

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 := 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.ToLower(header)
default:
data.Headers[idx] = bold(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
}