Compare commits

...

12 Commits

Author SHA1 Message Date
925b21823a marshall explain output only in debug mode 2026-06-08 14:11:26 +02:00
9b9b394539 upd tree 2026-06-08 14:05:25 +02:00
T. von Dein
b9eb3e3e2f add search validate+explain (#31) 2026-06-08 14:00:22 +02:00
T. von Dein
f30c837d53 fix search output order (last on bottom) and index ls output partials (#30) 2026-06-08 12:09:01 +02:00
c4143093fd typo 2026-06-04 17:09:23 +02:00
7e9a9f82a7 add intro and feature list 2026-06-04 17:07:34 +02:00
0f48a17536 +todos 2026-06-03 13:13:44 +02:00
T. von Dein
01e0fd024b fix-index-show-completion, add more completions (#29) 2026-06-03 13:07:11 +02:00
T. von Dein
5ecba5abe6 add index fields filter flags (#27) 2026-06-03 10:14:41 +02:00
T. von Dein
18af1b6073 add role diff (#26) 2026-06-03 08:47:54 +02:00
0696095bb0 add roles ls+sh 2026-06-01 13:20:06 +02:00
T. von Dein
504db9835d add sort support to search, add index fields command, fix error messages (#25) 2026-06-01 12:30:41 +02:00
30 changed files with 1421 additions and 184 deletions

View File

@@ -4,6 +4,48 @@
Elasticsearch CLI Elasticsearch CLI
## Introduction
This is a handy cli tool which interfaces to an elasticsearch cluster
(or two of them if you're using cross cluster replication). It is a
work-in-progress project yet, things might change occasionally. Expect
a stable release once we reach major version 1.0.0.
Features:
- Configuration of cluster credentials using environment vars or
config file. Multiple clusters can be configured. `esctl cluster ls`
shows which one is reachable.
- Shell completion support (bash, zsh and fish). Put this into your
rc: `source <(esctl completion bash)`.
- Cluster settings can be viewed and modified.
- Search: you can search indices using full text or by fields, select
logical condition (OR, AND), use PIT, limit datetime (ES date math
can be used), etc. It is however not yet possible to create
recursive searches like: `(cond1 AND cond2) OR (cond3 OR cond4)`.
- Cross cluster replication (ccr): view, pause, resume, delete
replication. You can also manage follower configuration.
- Index management: manage aliases, create, modify, delete indices,
display field mappings etc.
- Node management: only list nodes yet.
- Shard management: only list shards yet.
- Snapshot management: only list snapshots yet.
- Role management: only list roles yet. There's also a `role diff`
subcommand, which is for internal use. It can be used to verify if
role defs in a CSV match the deployed roles.
- Repl: this is an interactive REPL (read eval print loop) towards the
elasticsearch API. You can run API calls on the current selected
cluster w/o the hassle to specify the whole url, credentials etc. It
has line editing and history support. If `jq` is installed output
JSON will be syntax highlighted.
- Doc support. You can put, delete and show docs for an index. Very
handy if you want to play with it. Just create a new index:
`esctl index create foo` and then insert docs into it for search
experiments:
```console
esctl doc add -i foo '{"title":"curry in a hurry", "message":"australian thai"}'
```
## Usage ## Usage
Command tree: Command tree:
@@ -28,9 +70,13 @@ Command tree:
list list
set set
status status
debug
doc doc
add add
delete
show
help help
help-jsonpath
index index
alias alias
create create
@@ -40,6 +86,7 @@ Command tree:
close close
create create
delete delete
fields
list list
modify modify
show show
@@ -47,6 +94,10 @@ Command tree:
list list
show show
repl repl
role
diff
list
show
search search
shard shard
list list
@@ -84,10 +135,6 @@ can omit `-c ...`.
If you want to work on a specific cluster, specify its name with the If you want to work on a specific cluster, specify its name with the
global `-C` option. global `-C` option.
## Introduction
FIXME
## Installation ## Installation
The tool does not have any dependencies. Just download the binary for The tool does not have any dependencies. Just download the binary for
@@ -138,6 +185,30 @@ make
sudo make install sudo make install
``` ```
# Development
## To test completion
Add the flag `--generate-shell-completion` to any command, e.g.:
```console
./esctl role show --generate-shell-completion
machine_learning_admin
rollup_admin
editor
reporting_user
snapshot_user
fcn_admin
machine_learning_user
kibana_system
beats_admin
kibana_user
fcns_space
transport_client
transform_user
[..]
```
# Report bugs # Report bugs
[Please open an issue](https://codeberg.org/scip/esctl/issues). Thanks! [Please open an issue](https://codeberg.org/scip/esctl/issues). Thanks!

10
TODO.md
View File

@@ -1,11 +1,13 @@
- [Go client docs](https://www.elastic.co/docs/reference/elasticsearch/clients/go/typed-api) - [Go client docs](https://www.elastic.co/docs/reference/elasticsearch/clients/go/typed-api)
- [ES API docs](https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-indices-get) - [ES API docs](https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-indices-get)
- Fix index names custom completion
https://github.com/urfave/cli/issues/2332
https://github.com/urfave/cli/issues/2333
- index show: add more details, see screenshots - index show: add more details, see screenshots
- add shard explain, aka: - add shard explain, aka:
get /_cluster/allocation/explain {"index":"yourindex", "primary": true, "shard":0} get /_cluster/allocation/explain {"index":"yourindex", "primary": true, "shard":0}
- add datastream support:
https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-indices-get-data-stream
also exclude data stream backing indices from index ls

View File

@@ -44,9 +44,10 @@ func Ccr(conf *cfg.Config) *cli.Command {
func CcrStatus(conf *cfg.Config) *cli.Command { func CcrStatus(conf *cfg.Config) *cli.Command {
return &cli.Command{ return &cli.Command{
Name: "status", Name: "status",
Aliases: []string{"st"}, Aliases: []string{"st"},
Usage: "cross cluster replication status (yaml config with 2 clusters required)", Usage: "cross cluster replication status (yaml config with 2 clusters required)",
UsageText: "status <leader> <follower>",
Flags: []cli.Flag{ Flags: []cli.Flag{
&cli.StringFlag{ &cli.StringFlag{
@@ -57,6 +58,10 @@ func CcrStatus(conf *cfg.Config) *cli.Command {
}, },
}, },
ShellComplete: func(ctx context.Context, cmd *cli.Command) {
completeCluster(cmd)
},
Action: func(ctx context.Context, cmd *cli.Command) error { Action: func(ctx context.Context, cmd *cli.Command) error {
leader := cmd.Args().Get(0) leader := cmd.Args().Get(0)
follower := cmd.Args().Get(1) follower := cmd.Args().Get(1)
@@ -109,6 +114,10 @@ func CcrRemoteInfo(conf *cfg.Config) *cli.Command {
Usage: "show ccr remote info", Usage: "show ccr remote info",
UsageText: "info [options] [<index>]", UsageText: "info [options] [<index>]",
ShellComplete: func(ctx context.Context, cmd *cli.Command) {
completeIndex(cmd)
},
Action: func(ctx context.Context, cmd *cli.Command) error { Action: func(ctx context.Context, cmd *cli.Command) error {
return es.CcrRemoteInfo(conf, cmd.Args().Get(0)) return es.CcrRemoteInfo(conf, cmd.Args().Get(0))
}, },

View File

@@ -59,6 +59,10 @@ func CcrFollowerRenew(conf *cfg.Config) *cli.Command {
}, },
}, },
ShellComplete: func(ctx context.Context, cmd *cli.Command) {
completeIndex(cmd)
},
Action: func(ctx context.Context, cmd *cli.Command) error { Action: func(ctx context.Context, cmd *cli.Command) error {
args := cmd.Args() args := cmd.Args()
@@ -77,6 +81,10 @@ func CcrFollowerResume(conf *cfg.Config) *cli.Command {
Usage: "resume ccr index to follow", Usage: "resume ccr index to follow",
UsageText: "resume [options] <index>", UsageText: "resume [options] <index>",
ShellComplete: func(ctx context.Context, cmd *cli.Command) {
completeIndex(cmd)
},
Action: func(ctx context.Context, cmd *cli.Command) error { Action: func(ctx context.Context, cmd *cli.Command) error {
args := cmd.Args() args := cmd.Args()
@@ -95,6 +103,10 @@ func CcrFollowerPause(conf *cfg.Config) *cli.Command {
Usage: "pause ccr index to follow", Usage: "pause ccr index to follow",
UsageText: "pause [options] <index>", UsageText: "pause [options] <index>",
ShellComplete: func(ctx context.Context, cmd *cli.Command) {
completeIndex(cmd)
},
Action: func(ctx context.Context, cmd *cli.Command) error { Action: func(ctx context.Context, cmd *cli.Command) error {
args := cmd.Args() args := cmd.Args()
@@ -113,6 +125,10 @@ func CcrFollowerUnfollow(conf *cfg.Config) *cli.Command {
Usage: "unfollow ccr follower index", Usage: "unfollow ccr follower index",
UsageText: "unfollow [options] <index>", UsageText: "unfollow [options] <index>",
ShellComplete: func(ctx context.Context, cmd *cli.Command) {
completeIndex(cmd)
},
Action: func(ctx context.Context, cmd *cli.Command) error { Action: func(ctx context.Context, cmd *cli.Command) error {
args := cmd.Args() args := cmd.Args()
@@ -141,6 +157,10 @@ func CcrFollowerAdd(conf *cfg.Config) *cli.Command {
}, },
}, },
ShellComplete: func(ctx context.Context, cmd *cli.Command) {
completeIndex(cmd)
},
Action: func(ctx context.Context, cmd *cli.Command) error { Action: func(ctx context.Context, cmd *cli.Command) error {
args := cmd.Args() args := cmd.Args()
@@ -160,6 +180,10 @@ func CcrFollowerDelete(conf *cfg.Config) *cli.Command {
Usage: "delete ccr follower index", Usage: "delete ccr follower index",
UsageText: "delete <index>", UsageText: "delete <index>",
ShellComplete: func(ctx context.Context, cmd *cli.Command) {
completeIndex(cmd)
},
Action: func(ctx context.Context, cmd *cli.Command) error { Action: func(ctx context.Context, cmd *cli.Command) error {
args := cmd.Args() args := cmd.Args()
@@ -180,6 +204,10 @@ func CcrFollowerShow(conf *cfg.Config) *cli.Command {
Usage: "show ccr follower index details", Usage: "show ccr follower index details",
UsageText: "show <index>", UsageText: "show <index>",
ShellComplete: func(ctx context.Context, cmd *cli.Command) {
completeIndex(cmd)
},
Action: func(ctx context.Context, cmd *cli.Command) error { Action: func(ctx context.Context, cmd *cli.Command) error {
args := cmd.Args() args := cmd.Args()

89
cmd/completion.go Normal file
View File

@@ -0,0 +1,89 @@
/*
Copyright © 2026 Thomas von Dein
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package cmd
import (
"fmt"
"codeberg.org/scip/esctl/pkg/cfg"
"codeberg.org/scip/esctl/pkg/es"
"github.com/urfave/cli/v3"
)
func completeIndex(cmd *cli.Command) {
if cmd.NArg() > 0 {
return
}
// FIXME: config should load from root.Before(), see https://github.com/urfave/cli/issues/2348
// workaround: load it directly here
conf := cfg.NewConfig()
if err := conf.Init(); err != nil {
return
}
indices, err := es.IndexNames(conf)
if err != nil {
return
}
for _, index := range indices {
fmt.Println(index)
}
}
func completeRole(cmd *cli.Command) {
if cmd.NArg() > 0 {
return
}
// FIXME: config should load from root.Before(), see https://github.com/urfave/cli/issues/2348
// workaround: load it directly here
conf := cfg.NewConfig()
if err := conf.Init(); err != nil {
return
}
roles, err := es.RoleNames(conf)
if err != nil {
return
}
for _, role := range roles {
fmt.Println(role)
}
}
func completeCluster(cmd *cli.Command) {
if cmd.NArg() > 0 {
return
}
// FIXME: config should load from root.Before(), see https://github.com/urfave/cli/issues/2348
// workaround: load it directly here
conf := cfg.NewConfig()
if err := conf.Init(); err != nil {
return
}
for cluster := range conf.Clusters {
fmt.Println(cluster)
}
}

View File

@@ -89,19 +89,9 @@ func DocShow(conf *cfg.Config) *cli.Command {
Destination: &conf.Path, Destination: &conf.Path,
Aliases: []string{"p"}, Aliases: []string{"p"},
}, },
&cli.BoolFlag{
Name: "help-jsonpath",
Usage: "show jsonPath help",
Destination: &conf.Subhelp,
Aliases: []string{"H"},
},
}, },
Action: func(ctx context.Context, cmd *cli.Command) error { Action: func(ctx context.Context, cmd *cli.Command) error {
if conf.Subhelp {
return showJsonPathHelp()
}
args := cmd.Args() args := cmd.Args()
if args.Len() != 1 { if args.Len() != 1 {
@@ -161,10 +151,6 @@ func DocDelete(conf *cfg.Config) *cli.Command {
}, },
Action: func(ctx context.Context, cmd *cli.Command) error { Action: func(ctx context.Context, cmd *cli.Command) error {
if conf.Subhelp {
return showJsonPathHelp()
}
args := cmd.Args() args := cmd.Args()
if args.Len() == 0 && !conf.All { if args.Len() == 0 && !conf.All {

View File

@@ -42,6 +42,7 @@ func Index(conf *cfg.Config) *cli.Command {
IndexAllocation(conf), IndexAllocation(conf),
IndexModify(conf), IndexModify(conf),
IndexAlias(conf), IndexAlias(conf),
IndexFields(conf),
}, },
} }
} }
@@ -132,22 +133,8 @@ func IndexShow(conf *cfg.Config) *cli.Command {
return es.IndexShow(conf, cmd.Args().Get(0)) return es.IndexShow(conf, cmd.Args().Get(0))
}, },
// FIXME: doesn't work at all
// FIXME: also it would ONLY work if the user uses env vars, -C would not be
// there when the completion output is being generated
ShellComplete: func(ctx context.Context, cmd *cli.Command) { ShellComplete: func(ctx context.Context, cmd *cli.Command) {
if cmd.NArg() > 0 { completeIndex(cmd)
return
}
indices, err := es.IndexNames(conf)
if err != nil {
return
}
for _, index := range indices {
fmt.Println(index)
}
}, },
} }
} }
@@ -157,6 +144,8 @@ func IndexCreate(conf *cfg.Config) *cli.Command {
Name: "create", Name: "create",
Aliases: []string{"+"}, Aliases: []string{"+"},
Usage: "create a new index", Usage: "create a new index",
UsageText: `create <name> <fieldmapping:type>...
Valid field mapping types: integer, text, date, keyword`,
Flags: []cli.Flag{ Flags: []cli.Flag{
&cli.BoolFlag{ &cli.BoolFlag{
@@ -194,9 +183,14 @@ func IndexCreate(conf *cfg.Config) *cli.Command {
func IndexDelete(conf *cfg.Config) *cli.Command { func IndexDelete(conf *cfg.Config) *cli.Command {
return &cli.Command{ return &cli.Command{
Name: "delete", Name: "delete",
Aliases: []string{"rm"}, Aliases: []string{"rm"},
Usage: "delete an index", Usage: "delete an index",
UsageText: "delete <index>",
ShellComplete: func(ctx context.Context, cmd *cli.Command) {
completeIndex(cmd)
},
Action: func(ctx context.Context, cmd *cli.Command) error { Action: func(ctx context.Context, cmd *cli.Command) error {
index := cmd.Args().Get(0) index := cmd.Args().Get(0)
@@ -211,8 +205,13 @@ func IndexDelete(conf *cfg.Config) *cli.Command {
func IndexClose(conf *cfg.Config) *cli.Command { func IndexClose(conf *cfg.Config) *cli.Command {
return &cli.Command{ return &cli.Command{
Name: "close", Name: "close",
Usage: "close an index", Usage: "close an index",
UsageText: "close <index>",
ShellComplete: func(ctx context.Context, cmd *cli.Command) {
completeIndex(cmd)
},
Action: func(ctx context.Context, cmd *cli.Command) error { Action: func(ctx context.Context, cmd *cli.Command) error {
index := cmd.Args().Get(0) index := cmd.Args().Get(0)
@@ -231,6 +230,10 @@ func IndexModify(conf *cfg.Config) *cli.Command {
Usage: "modify an index", Usage: "modify an index",
UsageText: "modify <index[,index,...]|_all>", UsageText: "modify <index[,index,...]|_all>",
ShellComplete: func(ctx context.Context, cmd *cli.Command) {
completeIndex(cmd)
},
Flags: []cli.Flag{ Flags: []cli.Flag{
&cli.IntFlag{ &cli.IntFlag{
Name: "replicas", Name: "replicas",
@@ -250,3 +253,45 @@ func IndexModify(conf *cfg.Config) *cli.Command {
}, },
} }
} }
func IndexFields(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "fields",
Usage: "show info about field capabilities",
UsageText: "index fields <index>",
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "aggretable",
Usage: "include only aggretable fields",
Destination: &conf.Aggretable,
Aliases: []string{"a"},
},
&cli.BoolFlag{
Name: "searchable",
Usage: "include only searchable fields",
Destination: &conf.Searchable,
Aliases: []string{"s"},
},
&cli.StringSliceFlag{
Name: "type",
Usage: "show only fields of this type",
Destination: &conf.Filter,
Aliases: []string{"t"},
},
},
ShellComplete: func(ctx context.Context, cmd *cli.Command) {
completeIndex(cmd)
},
Action: func(ctx context.Context, cmd *cli.Command) error {
index := cmd.Args().Get(0)
if index == "" {
return errors.New("no index specified")
}
return es.IndexFields(conf, index)
},
}
}

View File

@@ -36,6 +36,7 @@ func IndexAlias(conf *cfg.Config) *cli.Command {
IndexAliasCreate(conf), IndexAliasCreate(conf),
IndexAliasList(conf), IndexAliasList(conf),
IndexAliasDelete(conf), IndexAliasDelete(conf),
// FIXME: implement IndexAliasShow + IndexAliasAdd
//IndexAliasShow(conf), //IndexAliasShow(conf),
//IndexAliasAdd(conf), // see https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-indices-update-aliases //IndexAliasAdd(conf), // see https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-indices-update-aliases
}, },
@@ -49,6 +50,10 @@ func IndexAliasCreate(conf *cfg.Config) *cli.Command {
Usage: "create an index alias", Usage: "create an index alias",
UsageText: "create <index> <alias>", UsageText: "create <index> <alias>",
ShellComplete: func(ctx context.Context, cmd *cli.Command) {
completeIndex(cmd)
},
Action: func(ctx context.Context, cmd *cli.Command) error { Action: func(ctx context.Context, cmd *cli.Command) error {
index := cmd.Args().Get(0) index := cmd.Args().Get(0)
alias := cmd.Args().Get(1) alias := cmd.Args().Get(1)
@@ -69,6 +74,10 @@ func IndexAliasDelete(conf *cfg.Config) *cli.Command {
Usage: "delete an index alias", Usage: "delete an index alias",
UsageText: "delete <index> <alias>", UsageText: "delete <index> <alias>",
ShellComplete: func(ctx context.Context, cmd *cli.Command) {
completeIndex(cmd)
},
Action: func(ctx context.Context, cmd *cli.Command) error { Action: func(ctx context.Context, cmd *cli.Command) error {
index := cmd.Args().Get(0) index := cmd.Args().Get(0)
alias := cmd.Args().Get(1) alias := cmd.Args().Get(1)

View File

@@ -58,6 +58,7 @@ func NodeShow(conf *cfg.Config) *cli.Command {
UsageText: "show [options] <node>", UsageText: "show [options] <node>",
Action: func(ctx context.Context, cmd *cli.Command) error { Action: func(ctx context.Context, cmd *cli.Command) error {
// FIXME: implement es.NodeShow()
// return es.NodeShow(conf, cmd.Args().Get(0)) // return es.NodeShow(conf, cmd.Args().Get(0))
return nil return nil
}, },

141
cmd/roles.go Normal file
View File

@@ -0,0 +1,141 @@
/*
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 Roles(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "role",
Usage: "manage roles",
Commands: []*cli.Command{
RoleList(conf),
RoleShow(conf),
RoleDiff(conf),
},
}
}
func RoleList(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "list",
Aliases: []string{"ls"},
Usage: "list roles",
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "orphaned",
Usage: "include only orphaned roles",
Destination: &conf.Failed,
Aliases: []string{"o"},
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
return es.RoleList(conf)
},
}
}
func RoleShow(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "show",
Aliases: []string{"sh"},
Usage: "show details about a role",
UsageText: "show [options] <role>",
ShellComplete: func(ctx context.Context, cmd *cli.Command) {
completeRole(cmd)
},
Action: func(ctx context.Context, cmd *cli.Command) error {
index := cmd.Args().Get(0)
if index == "" {
return errors.New("no role specified")
}
return es.RoleShow(conf, cmd.Args().Get(0))
},
}
}
func RoleDiff(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "diff",
Usage: "show differences between roles and CSV baseline",
UsageText: `diff [options] <file.csv> [<role>]
CSV format:
index_name;role;index_privilege;cluster_privilege;ad_group;space;retention;kibana_privilege;field_privilege`,
MutuallyExclusiveFlags: []cli.MutuallyExclusiveFlags{{
Flags: [][]cli.Flag{
{
&cli.BoolFlag{
Name: "not-deployed",
Usage: "include only not deployed but defined roles",
Destination: &conf.NotDeployed,
Aliases: []string{"n"},
},
},
{
&cli.BoolFlag{
Name: "undefined",
Usage: "include only deployed but undefined roles",
Destination: &conf.Undefined,
Aliases: []string{"u"},
},
},
{
&cli.BoolFlag{
Name: "diff",
Usage: "include only differing roles",
Destination: &conf.Diff,
Aliases: []string{"D"},
},
},
}},
},
Flags: []cli.Flag{
&cli.StringFlag{
Name: "separator",
Usage: "CSV field separator",
Destination: &conf.Separator,
Aliases: []string{"s"},
Value: ",",
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
csvfile := cmd.Args().Get(0)
if csvfile == "" {
return errors.New("no CSV file specified")
}
return es.RoleDiff(conf, cmd.Args().Get(0), cmd.Args().Get(1))
},
}
}

View File

@@ -97,6 +97,8 @@ func Main() int {
Repl(conf), Repl(conf),
Version(conf), Version(conf),
Debug(conf), Debug(conf),
Roles(conf),
HelpJsonPath(conf),
}, },
Before: func(ctx context.Context, cmd *cli.Command) (context.Context, error) { Before: func(ctx context.Context, cmd *cli.Command) (context.Context, error) {
@@ -118,6 +120,45 @@ func Main() int {
return Finish(cmd.Run(context.Background(), os.Args)) return Finish(cmd.Run(context.Background(), os.Args))
} }
func HelpJsonPath(conf *cfg.Config) *cli.Command {
msg := `jsonPath usage:
name.last >> "Anderson"
age >> 37
children >> ["Sara","Alex","Jack"]
children.# >> 3
children.1 >> "Alex"
child*.2 >> "Jack"
c?ildren.0 >> "Sara"
fav\.movie >> "Deer Hunter"
friends.#.first >> ["Dale","Roger","Jane"]
friends.1.last >> "Craig"
You can also query an array for the first match by using #(...), or
find all matches with #(...)#. Queries support the ==, !=, <, <=, >,
>= comparison operators and the simple pattern matching % (like) and
!% (not like) operators. Eg:
friends.#(last=="Murphy").first >> "Dale"
friends.#(last=="Murphy")#.first >> ["Dale","Jane"]
friends.#(age>45)#.last >> ["Craig","Murphy"]
friends.#(first%"D*").last >> "Murphy"
friends.#(first!%"D*").last >> "Craig"
friends.#(nets.#(=="fb"))#.first >> ["Dale","Roger"]
Documentation: https://github.com/tidwall/gjson/blob/master/SYNTAX.md`
return &cli.Command{
Name: "help-jsonpath",
Usage: "show jsonpath help",
Action: func(ctx context.Context, cmd *cli.Command) error {
_, err := fmt.Println(msg)
return err
},
}
}
func Version(conf *cfg.Config) *cli.Command { func Version(conf *cfg.Config) *cli.Command {
return &cli.Command{ return &cli.Command{
Name: "version", Name: "version",

View File

@@ -18,7 +18,6 @@ package cmd
import ( import (
"context" "context"
"fmt"
"codeberg.org/scip/esctl/pkg/cfg" "codeberg.org/scip/esctl/pkg/cfg"
"codeberg.org/scip/esctl/pkg/es" "codeberg.org/scip/esctl/pkg/es"
@@ -100,11 +99,18 @@ func Search(conf *cfg.Config) *cli.Command {
Destination: &conf.TimestampFormat, Destination: &conf.TimestampFormat,
Value: "strict_date_hour_minute", Value: "strict_date_hour_minute",
}, },
&cli.StringFlag{
Name: "sort-by",
Usage: "sort by a field",
Destination: &conf.SortBy,
Value: "@timestamp",
Aliases: []string{"k"},
},
&cli.BoolFlag{ &cli.BoolFlag{
Name: "help-jsonpath", Name: "ascending",
Usage: "show jsonPath help", Usage: "sort in ascending order (default: descending)",
Destination: &conf.Subhelp, Destination: &conf.Ascending,
Aliases: []string{"H"}, Aliases: []string{"a"},
}, },
&cli.BoolFlag{ &cli.BoolFlag{
Name: "tail", Name: "tail",
@@ -118,13 +124,21 @@ func Search(conf *cfg.Config) *cli.Command {
Destination: &conf.Or, Destination: &conf.Or,
Aliases: []string{"O"}, Aliases: []string{"O"},
}, },
&cli.BoolFlag{
Name: "validate",
Usage: "validate search query",
Destination: &conf.Validate,
Aliases: []string{"v"},
},
&cli.BoolFlag{
Name: "explain",
Usage: "explain search query",
Destination: &conf.Explain,
Aliases: []string{"e"},
},
}, },
Action: func(ctx context.Context, cmd *cli.Command) error { Action: func(ctx context.Context, cmd *cli.Command) error {
if conf.Subhelp {
return showJsonPathHelp()
}
args := cmd.Args() args := cmd.Args()
if conf.To == -1 { if conf.To == -1 {
@@ -135,34 +149,3 @@ func Search(conf *cfg.Config) *cli.Command {
}, },
} }
} }
func showJsonPathHelp() error {
_, err := fmt.Println(`jsonPath usage:
name.last >> "Anderson"
age >> 37
children >> ["Sara","Alex","Jack"]
children.# >> 3
children.1 >> "Alex"
child*.2 >> "Jack"
c?ildren.0 >> "Sara"
fav\.movie >> "Deer Hunter"
friends.#.first >> ["Dale","Roger","Jane"]
friends.1.last >> "Craig"
You can also query an array for the first match by using #(...), or
find all matches with #(...)#. Queries support the ==, !=, <, <=, >,
>= comparison operators and the simple pattern matching % (like) and
!% (not like) operators. Eg:
friends.#(last=="Murphy").first >> "Dale"
friends.#(last=="Murphy")#.first >> ["Dale","Jane"]
friends.#(age>45)#.last >> ["Craig","Murphy"]
friends.#(first%"D*").last >> "Murphy"
friends.#(first!%"D*").last >> "Craig"
friends.#(nets.#(=="fb"))#.first >> ["Dale","Roger"]
Documentation: https://github.com/tidwall/gjson/blob/master/SYNTAX.md`)
return err
}

View File

@@ -34,7 +34,7 @@ import (
) )
const ( const (
Version string = `v0.0.14` Version string = `v0.0.18`
) )
var ( var (
@@ -59,6 +59,8 @@ type Config struct {
Shards, Replicas int // index create+allocation: -s -r Shards, Replicas int // index create+allocation: -s -r
Wait bool // index create: -w Wait bool // index create: -w
Primary bool // index allocation: -p Primary bool // index allocation: -p
Searchable bool // index fields: -s
Aggretable bool // index fields: -a
From, To, MaxItems int // search: flags From, To, MaxItems int // search: flags
Filter []string // search: -F Filter []string // search: -F
Path string // search+doc sh: -p Path string // search+doc sh: -p
@@ -67,12 +69,20 @@ type Config struct {
Or bool // search: -O Or bool // search: -O
Range string // search: -r Range string // search: -r
TimestampFormat string // search: --timestamp-format TimestampFormat string // search: --timestamp-format
Explain bool // search: -e
Validate bool // search: --validate
SortBy string // sort: -k
Ascending bool // sort: -a
Exclude string // cluster compare: -e (regexp) Exclude string // cluster compare: -e (regexp)
All, Verbose bool // cluster status: -a -v All, Verbose bool // cluster status: -a -v
Persistent, Transient, Default bool // -p -t -D cluster settings set Persistent, Transient, Default bool // -p -t -D cluster settings set
Force bool // ccr follower renew: -f Force bool // ccr follower renew: -f
HaveJQ bool // determined at runtime by ourselfes HaveJQ bool // determined at runtime by ourselfes
DebugHTTP bool // root: --debug-http DebugHTTP bool // root: --debug-http
Separator string // role diff: -s
NotDeployed bool // role diff: -n
Undefined bool // role diff: -u
Diff bool // role diff: -D
} }
func NewConfig() *Config { func NewConfig() *Config {

View File

@@ -23,7 +23,7 @@ import (
"log/slog" "log/slog"
"codeberg.org/scip/esctl/pkg/cfg" "codeberg.org/scip/esctl/pkg/cfg"
"codeberg.org/scip/esctl/pkg/printer" "codeberg.org/scip/esctl/pkg/printer"
"github.com/elastic/go-elasticsearch/v9/typedapi/types" "github.com/elastic/go-elasticsearch/v9/typedapi/types"
) )
@@ -62,7 +62,7 @@ func CcrStatus(conf *cfg.Config, leader, follower string) error {
res, err := cat.Do(context.Background()) res, err := cat.Do(context.Background())
if err != nil { if err != nil {
return fmt.Errorf("failed to get indicies on %s: %s", alias, err) return fmt.Errorf("failed to get indicies on %s: %s", alias, esErrorString(err))
} }
indices[alias] = make(map[string]*types.IndicesRecord, len(res)) indices[alias] = make(map[string]*types.IndicesRecord, len(res))
@@ -93,7 +93,7 @@ func CcrRemoteInfo(conf *cfg.Config, index string) error {
Header("accept", "application/json"). Header("accept", "application/json").
Do(context.Background()) Do(context.Background())
if err != nil { if err != nil {
return fmt.Errorf("failed to retrieve follower info: %s", err) return fmt.Errorf("failed to retrieve follower info: %s", esErrorString(err))
} }
slog.Debug("ccr remote info", "info", res) slog.Debug("ccr remote info", "info", res)

View File

@@ -22,7 +22,7 @@ import (
"log/slog" "log/slog"
"codeberg.org/scip/esctl/pkg/cfg" "codeberg.org/scip/esctl/pkg/cfg"
"codeberg.org/scip/esctl/pkg/printer" "codeberg.org/scip/esctl/pkg/printer"
) )
func getRemoteName(conf *cfg.Config) (string, error) { func getRemoteName(conf *cfg.Config) (string, error) {
@@ -31,7 +31,7 @@ func getRemoteName(conf *cfg.Config) (string, error) {
Header("accept", "application/json"). Header("accept", "application/json").
Do(context.Background()) Do(context.Background())
if err != nil { if err != nil {
return "", fmt.Errorf("failed to retrieve follower info: %s", err) return "", fmt.Errorf("failed to retrieve follower info: %s", esErrorString(err))
} }
remote := "" remote := ""
@@ -41,7 +41,7 @@ func getRemoteName(conf *cfg.Config) (string, error) {
} }
if remote == "" { if remote == "" {
return "", fmt.Errorf("cluster doesn't have a follower: %s", err) return "", fmt.Errorf("cluster doesn't have a follower")
} }
return remote, nil return remote, nil
@@ -96,7 +96,7 @@ func CcrFollowerResume(conf *cfg.Config, index string) error {
_, err := create.Do(context.Background()) _, err := create.Do(context.Background())
if err != nil { if err != nil {
return fmt.Errorf("failed to resume ccr following: %s", err) return fmt.Errorf("failed to resume ccr following: %s", esErrorString(err))
} }
return nil return nil
@@ -110,7 +110,7 @@ func CcrFollowerPause(conf *cfg.Config, index string) error {
_, err := create.Do(context.Background()) _, err := create.Do(context.Background())
if err != nil { if err != nil {
return fmt.Errorf("failed to pause ccr following: %s", err) return fmt.Errorf("failed to pause ccr following: %s", esErrorString(err))
} }
return nil return nil
@@ -124,7 +124,7 @@ func CcrFollowerUnfollow(conf *cfg.Config, index string) error {
_, err := create.Do(context.Background()) _, err := create.Do(context.Background())
if err != nil { if err != nil {
return fmt.Errorf("failed to unfollow index: %s", err) return fmt.Errorf("failed to unfollow index: %s", esErrorString(err))
} }
return nil return nil
@@ -149,7 +149,7 @@ func CcrFollowerAdd(conf *cfg.Config, index string) error {
_, err = create.Do(context.Background()) _, err = create.Do(context.Background())
if err != nil { if err != nil {
return fmt.Errorf("failed to create follower index: %s", err) return fmt.Errorf("failed to create follower index: %s", esErrorString(err))
} }
return nil return nil
@@ -161,7 +161,7 @@ func CcrFollowerShow(conf *cfg.Config, index string) error {
Header("accept", "application/json"). Header("accept", "application/json").
Do(context.Background()) Do(context.Background())
if err != nil { if err != nil {
return fmt.Errorf("failed to retrieve follower index info: %s", err) return fmt.Errorf("failed to retrieve follower index info: %s", esErrorString(err))
} }
slog.Debug("ES result", "follower stats", res.Indices) slog.Debug("ES result", "follower stats", res.Indices)

View File

@@ -23,7 +23,7 @@ import (
"log/slog" "log/slog"
"codeberg.org/scip/esctl/pkg/cfg" "codeberg.org/scip/esctl/pkg/cfg"
"codeberg.org/scip/esctl/pkg/printer" "codeberg.org/scip/esctl/pkg/printer"
"github.com/urfave/cli/v3" "github.com/urfave/cli/v3"
) )
@@ -63,7 +63,7 @@ func ClusterSettingsList(conf *cfg.Config) error {
Header("accept", "application/json"). Header("accept", "application/json").
Do(context.Background()) Do(context.Background())
if err != nil { if err != nil {
return fmt.Errorf("failed to get cluster settings: %s", err) return fmt.Errorf("failed to get cluster settings: %s", esErrorString(err))
} }
table := printer.NewTable(conf, 2, 0) table := printer.NewTable(conf, 2, 0)
@@ -135,7 +135,7 @@ func ClusterSettingsSet(conf *cfg.Config, args cli.Args) error {
Do(context.Background()) Do(context.Background())
if err != nil { if err != nil {
return fmt.Errorf("failed to set settings: %s", err) return fmt.Errorf("failed to set settings: %s", esErrorString(err))
} }
return nil return nil
@@ -154,7 +154,7 @@ func ClusterSettingsSetSingle(conf *cfg.Config, setting, value string) error {
Do(context.Background()) Do(context.Background())
if err != nil { if err != nil {
return fmt.Errorf("failed to set %s: %s", setting, err) return fmt.Errorf("failed to set %s: %s", setting, esErrorString(err))
} }
return nil return nil

View File

@@ -41,7 +41,7 @@ func checkClusterIsLeader(conf *cfg.Config, leader string) bool {
Header("accept", "application/json"). Header("accept", "application/json").
Do(context.Background()) Do(context.Background())
if err != nil { if err != nil {
fmt.Printf("failed to get ccr stats from %s: %s", leader, err) fmt.Printf("failed to get ccr stats from %s: %s", leader, esErrorString(err))
return false return false
} }
@@ -68,7 +68,7 @@ func checkClusterStatus(conf *cfg.Config, leader, follower string) bool {
Header("accept", "application/json"). Header("accept", "application/json").
Do(context.Background()) Do(context.Background())
if err != nil { if err != nil {
fmt.Printf("failed to get health from %s: %s", cluster, err) fmt.Printf("failed to get health from %s: %s", cluster, esErrorString(err))
return false return false
} }
@@ -128,7 +128,7 @@ func findIlmErrors(conf *cfg.Config, leader, follower string) bool {
Header("accept", "application/json"). Header("accept", "application/json").
Do(context.Background()) Do(context.Background())
if err != nil { if err != nil {
fmt.Printf("failed to get ilm status from %s: %s", cluster, err) fmt.Printf("failed to get ilm status from %s: %s", cluster, esErrorString(err))
return false return false
} }

View File

@@ -53,7 +53,7 @@ func DocAdd(conf *cfg.Config, jsondoc string) error {
Header("accept", "application/json"). Header("accept", "application/json").
Do(context.Background()) Do(context.Background())
if err != nil { if err != nil {
return fmt.Errorf("failed to create new doc in index %s: %s", conf.Index, err) return fmt.Errorf("failed to create new doc in index %s: %s", conf.Index, esErrorString(err))
} }
fmt.Println(res.Id_) fmt.Println(res.Id_)
@@ -67,7 +67,7 @@ func DocShow(conf *cfg.Config, id string) error {
Header("accept", "application/json"). Header("accept", "application/json").
Do(context.Background()) Do(context.Background())
if err != nil { if err != nil {
return fmt.Errorf("failed to retrieve doc in index %s: %s", conf.Index, err) return fmt.Errorf("failed to retrieve doc in index %s: %s", conf.Index, esErrorString(err))
} }
if !res.Found { if !res.Found {
@@ -100,7 +100,7 @@ func DocDelete(conf *cfg.Config, queries []string) error {
Header("accept", "application/json"). Header("accept", "application/json").
Do(context.Background()) Do(context.Background())
if err != nil { if err != nil {
return fmt.Errorf("failed to delete doc in index %s: %s", conf.Index, err) return fmt.Errorf("failed to delete doc in index %s: %s", conf.Index, esErrorString(err))
} }
return nil return nil
@@ -125,7 +125,7 @@ func DocDelete(conf *cfg.Config, queries []string) error {
Header("accept", "application/json"). Header("accept", "application/json").
Do(context.Background()) Do(context.Background())
if err != nil { if err != nil {
return fmt.Errorf("failed to delete docs in index %s: %s", conf.Index, err) return fmt.Errorf("failed to delete docs in index %s: %s", conf.Index, esErrorString(err))
} }
return nil return nil

45
pkg/es/errors.go Normal file
View File

@@ -0,0 +1,45 @@
/*
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"
"github.com/elastic/go-elasticsearch/v9/typedapi/types"
)
func esErrorString(err error) string {
msg := err.Error()
switch e := err.(type) {
case *types.ElasticsearchError:
causes := ""
for _, cause := range e.ErrorCause.RootCause {
causes += fmt.Sprintf("%s\n", *cause.Reason)
}
if e.ErrorCause.Reason != nil {
msg = *e.ErrorCause.Reason + ": " + causes
} else {
msg = fmt.Sprintf("http status %d: ", e.Status)
}
}
return msg
}

View File

@@ -21,6 +21,7 @@ import (
"fmt" "fmt"
"log/slog" "log/slog"
"regexp" "regexp"
"slices"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -40,7 +41,7 @@ func IndexNames(conf *cfg.Config) ([]string, error) {
Do(context.Background()) Do(context.Background())
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get indicies: %s", err) return nil, fmt.Errorf("failed to get indicies: %s", esErrorString(err))
} }
indices := make([]string, len(res)) indices := make([]string, len(res))
@@ -52,15 +53,24 @@ func IndexNames(conf *cfg.Config) ([]string, error) {
} }
func filterIndices(conf *cfg.Config, list indices.Response) indices.Response { func filterIndices(conf *cfg.Config, list indices.Response) indices.Response {
// apply partials filter first
selectedlist := indices.Response{}
for _, index := range list {
if !conf.Partials && strings.HasPrefix(*index.Index, "partial-") {
continue
}
selectedlist = append(selectedlist, index)
}
if len(conf.Filter) == 0 { if len(conf.Filter) == 0 {
return list return selectedlist
} }
// we support just one filter here, for now // we support just one filter here, for now
filter := *regexp.MustCompile(conf.Filter[0]) filter := *regexp.MustCompile(conf.Filter[0])
newlist := indices.Response{} newlist := indices.Response{}
for _, index := range list { for _, index := range selectedlist {
if filter.MatchString(*index.Index) { if filter.MatchString(*index.Index) {
newlist = append(newlist, index) newlist = append(newlist, index)
} }
@@ -81,7 +91,7 @@ func IndexList(conf *cfg.Config) error {
res, err := cat.Do(context.Background()) res, err := cat.Do(context.Background())
if err != nil { if err != nil {
return fmt.Errorf("failed to get indicies: %s", err) return fmt.Errorf("failed to get indicies: %s", esErrorString(err))
} }
slog.Debug("ES result", "indicies", res) slog.Debug("ES result", "indicies", res)
@@ -117,46 +127,48 @@ func IndexList(conf *cfg.Config) error {
return nil return nil
} }
func IndexShow(conf *cfg.Config, index string) error { func IndexShow(conf *cfg.Config, indexpattern string) error {
res, err := conf.DefaultCluster.ES.Indices.Get(index). res, err := conf.DefaultCluster.ES.Indices.Get(indexpattern).
// we need to add custom request headers, required for older ES instances // we need to add custom request headers, required for older ES instances
Header("content-type", "application/json"). Header("content-type", "application/json").
Header("accept", "application/json"). Header("accept", "application/json").
Do(context.Background()) Do(context.Background())
if err != nil { if err != nil {
return fmt.Errorf("failed to get index: %s", err) return fmt.Errorf("failed to get index: %s", esErrorString(err))
} }
slog.Debug("ES result", "index", res) for name, index := range res {
fields := make([]string, len(index.Mappings.Properties))
idx := 0
for field := range index.Mappings.Properties {
fields[idx] = field
idx++
}
fields := make([]string, len(res[index].Mappings.Properties)) table := printer.NewTable(conf, 2, 5)
idx := 0 table.Addheaders("index property", "value")
for field := range res[index].Mappings.Properties {
fields[idx] = field
idx++
}
table := printer.NewTable(conf, 2, 5) ts, err := strconv.ParseInt(index.Settings.Index.CreationDate.(string), 10, 64)
table.Addheaders("index property", "value") if err != nil {
ts = 0
}
ts, err := strconv.ParseInt(res[index].Settings.Index.CreationDate.(string), 10, 64) created := time.Unix(ts/1000, 0)
if err != nil {
ts = 0
}
created := time.Unix(ts/1000, 0) table.Entries = [][]string{
{"name", name},
{"replicas", *index.Settings.Index.NumberOfReplicas},
{"shards", *index.Settings.Index.NumberOfShards},
{"created", created.Format("2006-01-02 15:04:05")},
{"uuid", *index.Settings.Index.Uuid},
{"fields", strings.Join(fields, ",")},
}
table.Entries = [][]string{ if err := table.Print(); err != nil {
{"name", index}, return err
{"replicas", *res[index].Settings.Index.NumberOfReplicas}, }
{"shards", *res[index].Settings.Index.NumberOfShards},
{"created", created.Format("2006-01-02 15:04:05")},
{"uuid", *res[index].Settings.Index.Uuid},
{"fields", strings.Join(fields, ",")},
}
if err := table.Print(); err != nil { fmt.Println()
return err
} }
return nil return nil
@@ -208,7 +220,7 @@ func IndexCreate(conf *cfg.Config, index string, mappings []string) error {
Do(context.Background()) Do(context.Background())
if err != nil { if err != nil {
return fmt.Errorf("failed to create index: %s", err) return fmt.Errorf("failed to create index: %s", esErrorString(err))
} }
return nil return nil
@@ -220,7 +232,7 @@ func IndexDelete(conf *cfg.Config, index string) error {
Header("accept", "application/json"). Header("accept", "application/json").
Do(context.Background()) Do(context.Background())
if err != nil { if err != nil {
return fmt.Errorf("failed to delete index: %s", err) return fmt.Errorf("failed to delete index: %s", esErrorString(err))
} }
return nil return nil
@@ -234,7 +246,7 @@ func IndexClose(conf *cfg.Config, index string) error {
_, err := create.Do(context.Background()) _, err := create.Do(context.Background())
if err != nil { if err != nil {
return fmt.Errorf("failed to close index: %s", err) return fmt.Errorf("failed to close index: %s", esErrorString(err))
} }
return nil return nil
@@ -249,7 +261,7 @@ func IndexAllocation(conf *cfg.Config, index string) error {
Header("accept", "application/json"). Header("accept", "application/json").
Do(context.Background()) Do(context.Background())
if err != nil { if err != nil {
return fmt.Errorf("failed to get index allocation explain: %s", err) return fmt.Errorf("failed to get index allocation explain: %s", esErrorString(err))
} }
slog.Debug("ES result", "index", res) slog.Debug("ES result", "index", res)
@@ -294,7 +306,53 @@ func IndexModify(conf *cfg.Config, index string) error {
Header("accept", "application/json"). Header("accept", "application/json").
Do(context.Background()) Do(context.Background())
if err != nil { if err != nil {
return fmt.Errorf("failed to modify index settings: %s", err) return fmt.Errorf("failed to modify index settings: %s", esErrorString(err))
}
return nil
}
func IndexFields(conf *cfg.Config, index string) error {
res, err := conf.DefaultCluster.ES.FieldCaps().
Index(index).
Fields("*").
Header("content-type", "application/json").
Header("accept", "application/json").
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to retrieve field capabilties: %s", esErrorString(err))
}
table := printer.NewTable(conf, 5, 0)
table.Addheaders("field", "type", "searchable", "aggretable", "metadata")
idx := 0
for name, field := range res.Fields {
for fieldtype, caps := range field {
// fields only have 1 type, so this one is it
switch {
case conf.Searchable && !caps.Searchable:
continue
case conf.Aggretable && !caps.Aggregatable:
continue
case len(conf.Filter) > 0 && !slices.Contains(conf.Filter, fieldtype):
continue
}
table.Entries = append(table.Entries, []string{name, fieldtype,
fmt.Sprintf("%t", caps.Searchable),
fmt.Sprintf("%t", caps.Aggregatable),
fmt.Sprintf("%t", *caps.MetadataField)})
break
}
idx++
}
table.Sort()
if err := table.Print(); err != nil {
return err
} }
return nil return nil

View File

@@ -37,7 +37,7 @@ func IndexAliasCreate(conf *cfg.Config, index, alias string) error {
slog.Debug("create alias", "result", res) slog.Debug("create alias", "result", res)
if err != nil { if err != nil {
return fmt.Errorf("failed to create index alias: %s", err) return fmt.Errorf("failed to create index alias: %s", esErrorString(err))
} }
return nil return nil
@@ -58,7 +58,7 @@ func IndexAliasList(conf *cfg.Config) error {
Do(context.Background()) Do(context.Background())
if err != nil { if err != nil {
return fmt.Errorf("failed to list index aliases: %s", err) return fmt.Errorf("failed to list index aliases: %s", esErrorString(err))
} }
slog.Debug("aliases list", "result", res) slog.Debug("aliases list", "result", res)
@@ -108,7 +108,7 @@ func IndexAliasDelete(conf *cfg.Config, index, alias string) error {
slog.Debug("delete alias", "result", res) slog.Debug("delete alias", "result", res)
if err != nil { if err != nil {
return fmt.Errorf("failed to delete index alias: %s", err) return fmt.Errorf("failed to delete index alias: %s", esErrorString(err))
} }
return nil return nil

View File

@@ -22,14 +22,14 @@ import (
"log/slog" "log/slog"
"codeberg.org/scip/esctl/pkg/cfg" "codeberg.org/scip/esctl/pkg/cfg"
"codeberg.org/scip/esctl/pkg/printer" "codeberg.org/scip/esctl/pkg/printer"
) )
func NodeList(conf *cfg.Config) error { func NodeList(conf *cfg.Config) error {
// get nodes // get nodes
nodes, err := conf.DefaultCluster.ES.Cat.Nodes().Do(context.Background()) nodes, err := conf.DefaultCluster.ES.Cat.Nodes().Do(context.Background())
if err != nil { if err != nil {
return fmt.Errorf("failed to get nodes: %s", err) return fmt.Errorf("failed to get nodes: %s", esErrorString(err))
} }
slog.Debug("ES result", "nodes", nodes) slog.Debug("ES result", "nodes", nodes)

View File

@@ -112,7 +112,7 @@ func Repl(conf *cfg.Config) error {
err = CallAPI(conf, parts[0], parts[1], data) err = CallAPI(conf, parts[0], parts[1], data)
if err != nil { if err != nil {
fmt.Printf("failed to call API: %s\n", err) fmt.Printf("failed to call API: %s\n", esErrorString(err))
} }
reader.SetPrompt("> ") reader.SetPrompt("> ")

245
pkg/es/role.go Normal file
View File

@@ -0,0 +1,245 @@
/*
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"
"strings"
"codeberg.org/scip/esctl/pkg/cfg"
"codeberg.org/scip/esctl/pkg/printer"
"github.com/elastic/go-elasticsearch/v9/typedapi/types"
)
func RoleNames(conf *cfg.Config) ([]string, error) {
res, err := conf.DefaultCluster.ES.Security.GetRole().
Do(context.Background())
if err != nil {
return nil, fmt.Errorf("failed to get roles: %s", esErrorString(err))
}
roles := make([]string, len(res))
idx := 0
for name := range res {
roles[idx] = name
idx++
}
return roles, nil
}
func RoleList(conf *cfg.Config) error {
res, err := conf.DefaultCluster.ES.Security.GetRole().
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to get roles: %s", esErrorString(err))
}
slog.Debug("ES result", "roles", res)
table := printer.NewTable(conf, 3, len(res))
table.Addheaders("role", "index roles", "cluster roles")
idx := 0
for name, role := range res {
table.Entries[idx] = []string{
name,
fmt.Sprintf("%d", len(role.Cluster)),
fmt.Sprintf("%d", len(role.Indices)),
}
idx++
}
table.Sort()
return table.Print()
}
func RoleShow(conf *cfg.Config, rolename string) error {
res, err := conf.DefaultCluster.ES.Security.GetRole().
Name(rolename).
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to get role: %s", esErrorString(err))
}
slog.Debug("ES result", "role", res)
role, exists := res[rolename]
if !exists {
return fmt.Errorf("role %s does not exist", rolename)
}
if len(role.Indices) > 0 {
if err := roleIndices(conf, role); err != nil {
return err
}
fmt.Println()
}
if len(role.RemoteIndices) > 0 {
if err := roleRemoteIndices(conf, role); err != nil {
return err
}
fmt.Println()
}
if len(role.Cluster) > 0 {
if err := roleClusters(conf, role); err != nil {
return err
}
fmt.Println()
}
if len(role.RemoteCluster) > 0 {
if err := roleRemoteClusters(conf, role); err != nil {
return err
}
fmt.Println()
}
if len(role.Applications) > 0 {
if err := roleApplications(conf, role); err != nil {
return err
}
}
return nil
}
func roleRemoteClusters(conf *cfg.Config, role types.Role) error {
if len(role.RemoteCluster) == 0 {
return nil
}
table := printer.NewTable(conf, 2, len(role.RemoteCluster))
table.Addheaders("remote cluster", "privilege")
idx := 0
for _, priv := range role.RemoteCluster {
perms := []string{}
for _, perm := range priv.Privileges {
perms = append(perms, perm.Name)
}
table.Entries[idx] = []string{
strings.Join(priv.Clusters, ","),
strings.Join(perms, ","),
}
idx++
}
table.Sort()
return table.Print()
}
func roleClusters(conf *cfg.Config, role types.Role) error {
if len(role.Cluster) == 0 {
return nil
}
table := printer.NewTable(conf, 1, len(role.Cluster))
table.Addheaders("cluster rights")
idx := 0
for _, cluster := range role.Cluster {
table.Entries[idx] = []string{cluster.Name}
idx++
}
table.Sort()
return table.Print()
}
func roleRemoteIndices(conf *cfg.Config, role types.Role) error {
if len(role.RemoteIndices) == 0 {
return nil
}
table := printer.NewTable(conf, 3, len(role.RemoteIndices))
table.Addheaders("remote index names", "index permissions", "allow restricted")
idx := 0
for _, priv := range role.RemoteIndices {
perms := []string{}
for _, perm := range priv.Privileges {
perms = append(perms, perm.Name)
}
table.Entries[idx] = []string{
strings.Join(priv.Names, ", "),
strings.Join(perms, ", "),
fmt.Sprintf("%t", *priv.AllowRestrictedIndices),
}
idx++
}
table.Sort()
return table.Print()
}
func roleIndices(conf *cfg.Config, role types.Role) error {
if len(role.Indices) == 0 {
return nil
}
table := printer.NewTable(conf, 3, len(role.Indices))
table.Addheaders("index names", "index permissions", "allow restricted")
idx := 0
for _, priv := range role.Indices {
perms := []string{}
for _, perm := range priv.Privileges {
perms = append(perms, perm.Name)
}
table.Entries[idx] = []string{
strings.Join(priv.Names, ", "),
strings.Join(perms, ", "),
fmt.Sprintf("%t", *priv.AllowRestrictedIndices),
}
idx++
}
table.Sort()
return table.Print()
}
func roleApplications(conf *cfg.Config, role types.Role) error {
if len(role.Applications) == 0 {
return nil
}
table := printer.NewTable(conf, 3, len(role.Applications))
table.Addheaders("application", "privileges", "resources")
idx := 0
for _, priv := range role.Applications {
table.Entries[idx] = []string{
priv.Application,
strings.Join(priv.Privileges, ", "),
strings.Join(priv.Resources, ", "),
}
idx++
}
table.Sort()
return table.Print()
}

354
pkg/es/role_diff.go Normal file
View File

@@ -0,0 +1,354 @@
/*
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 (
"bufio"
"bytes"
"context"
"encoding/csv"
"fmt"
"log"
"log/slog"
"os"
"slices"
"strings"
"codeberg.org/scip/esctl/pkg/cfg"
"codeberg.org/scip/esctl/pkg/printer"
"github.com/alecthomas/repr"
"github.com/elastic/go-elasticsearch/v9/typedapi/security/getrole"
)
// use static csv record positions as const vars so we can modify it
// if the csv format ever changes
const (
Rindexname = iota
Rrole
Rindexprivilege
Rclusterprivilege
Radgroup
Rspace
Rretention
Rkibanaprivilege
Rfieldprivilege
)
type Record struct {
// filled from CSV input
index_name string
role string
index_privilege string
cluster_privilege []string
ad_group []string
space string
retention string
kibana_privilege string
field_privilege string
// set by ourselfes
defined bool
}
type Register struct {
name string
deployed, defined bool
}
// generic variant, we do not account for multiple rows of the same
// role, in such cases an entry will simply overwritten. Use
// getCsvRecord() for a single role.
func getCsvRecords(conf *cfg.Config, csvfile string) (map[string]Record, error) {
data, err := os.ReadFile(csvfile)
if err != nil {
return nil, fmt.Errorf("failed to read CSV file: %s", err)
}
csvreader := csv.NewReader(bytes.NewReader(data))
csvreader.Comma = rune(conf.Separator[0])
csvreader.Comment = '#'
csvreader.TrimLeadingSpace = true
rows, err := csvreader.ReadAll()
if err != nil {
return nil, fmt.Errorf("failed to parse CSV: %s", err)
}
records := make(map[string]Record, len(rows)-1)
for idx, row := range rows {
if idx == 0 {
continue // header
}
records[row[1]] = Record{
index_name: row[Rindexname],
role: row[Rrole],
index_privilege: row[Rindexprivilege],
cluster_privilege: []string{row[Rindexprivilege]},
ad_group: []string{row[Rclusterprivilege]},
space: row[Rspace],
retention: row[Rretention],
kibana_privilege: row[Rkibanaprivilege],
field_privilege: row[Rfieldprivilege],
defined: true,
}
}
return records, nil
}
// same thing as above but for one specific role. supports multiple
// rows of the same record with different values which will be
// combined.
func getCsvRecord(conf *cfg.Config, csvfile, rolename string) (*Record, error) {
fd, err := os.Open(csvfile)
if err != nil {
return nil, fmt.Errorf("failed to open CSV file: %s", err)
}
defer func() {
if err := fd.Close(); err != nil {
log.Fatalf("failed to close file: %s", err)
}
}()
scanner := bufio.NewScanner(fd)
record := Record{role: rolename}
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if strings.HasPrefix(line, "#") || line == "" {
continue
}
row := strings.Split(line, conf.Separator)
if row[Rrole] == rolename {
record.index_name = row[Rindexname]
record.index_privilege = row[Rindexprivilege]
record.cluster_privilege = strings.Split(row[Rclusterprivilege], ",")
record.ad_group = append(record.ad_group, row[Radgroup])
record.space = row[Rspace]
record.retention = row[Rretention]
record.kibana_privilege = row[Rkibanaprivilege]
record.field_privilege = row[Rfieldprivilege]
record.defined = true
}
}
return &record, nil
}
func diffRoles(conf *cfg.Config, records map[string]Record, res getrole.Response) []Register {
rows := []Register{}
filtered := []Register{}
deployed := map[string]int{}
// iterate over deployed roles
for name := range res {
reg := Register{name: name}
_, defined := records[name]
if defined {
reg.deployed = true
reg.defined = true
} else {
reg.deployed = true
reg.defined = false
}
deployed[name] = 1
rows = append(rows, reg)
}
// iterate over records from CSV and register only those which are not deployed
for name := range records {
reg := Register{name: name, defined: true}
_, deployed := deployed[name]
if !deployed {
rows = append(rows, reg)
}
}
for _, reg := range rows {
if (conf.NotDeployed && !reg.deployed) ||
(conf.Undefined && !reg.defined) ||
(conf.Diff && reg.deployed != reg.defined) ||
(!conf.Undefined && !conf.NotDeployed && !conf.Diff) {
filtered = append(filtered, reg)
}
}
return filtered
}
func RoleDiff(conf *cfg.Config, csvfile, role string) error {
records, err := getCsvRecords(conf, csvfile)
if err != nil {
return err
}
if role != "" {
return RoleDiffSingle(conf, csvfile, role)
}
res, err := conf.DefaultCluster.ES.Security.GetRole().
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to get roles: %s", esErrorString(err))
}
rows := diffRoles(conf, records, res)
table := printer.NewTable(conf, 3, len(rows))
table.Addheaders("role", "is deployed", "is defined")
for idx, row := range rows {
deployed := printer.Colorize(conf, "green", "deployed")
if !row.deployed {
deployed = printer.Colorize(conf, "red", "not deployed")
}
defined := printer.Colorize(conf, "green", "defined")
if !row.defined {
defined = printer.Colorize(conf, "red", "undefined")
}
table.Entries[idx] = []string{
row.name,
deployed,
defined,
}
}
table.Sort()
return table.Print()
}
func getRoleMappingGroups(conf *cfg.Config, rolename string) ([]string, error) {
mappings, err := conf.DefaultCluster.ES.Security.GetRoleMapping().
Do(context.Background())
if err != nil {
return nil, fmt.Errorf("failed to get role mappings: %s", esErrorString(err))
}
groups := []string{}
for _, mapping := range mappings {
if slices.Contains(mapping.Roles, rolename) {
for _, rule := range mapping.Rules.Any {
for _, group := range rule.Field["groups"] {
groups = append(groups, group.(string))
}
}
}
}
return groups, nil
}
func compareSlices(name string, a, b []string) {
slices.Sort(a)
slices.Sort(b)
if slices.Compare(a, b) != 0 {
fmt.Printf("%s differs:\ndeployed: %s\n csv: %s\n",
name, strings.Join(a, ","), strings.Join(b, ","))
} else {
fmt.Printf("deployed %s matches csv definition\n", name)
}
}
func RoleDiffSingle(conf *cfg.Config, csvfile, rolename string) error {
res, err := conf.DefaultCluster.ES.Security.GetRole().
Name(rolename).
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to get role: %s", esErrorString(err))
}
record, err := getCsvRecord(conf, csvfile, rolename)
if err != nil {
return err
}
if !record.defined {
fmt.Printf("role %s is not defined\n", rolename)
return nil
}
role, exists := res[rolename]
if !exists {
fmt.Printf("role %s is not deployed\n", rolename)
return nil
} else {
fmt.Printf("role %s is deployed\n", rolename)
}
slog.Debug("found role", "role", role)
if conf.Debug {
// slog.Debug doesn't print it, for whatever reason
repr.Println(record)
}
groups, err := getRoleMappingGroups(conf, rolename)
if err != nil {
return err
}
slog.Debug("group mappings", "groups", groups)
// check cluster setting
clusters := []string{}
for _, cluster := range role.Cluster {
clusters = append(clusters, cluster.Name)
}
// check index names+privs
indices := []string{}
privs := []string{}
for _, index := range role.Indices {
for _, name := range index.Names {
indices = append(indices, strings.ReplaceAll(name, "**", "*"))
}
for _, priv := range index.Privileges {
privs = append(privs, priv.Name)
}
}
// check kibana application space
spaces := []string{}
for _, app := range role.Applications {
for _, resource := range app.Resources {
if strings.Contains(resource, "space:") {
parts := strings.Split(resource, ":")
if len(parts) == 2 {
spaces = append(spaces, parts[1])
}
}
}
}
compareSlices("cluster_privilege", clusters, record.cluster_privilege)
compareSlices("ad_group", groups, record.ad_group)
compareSlices("index_name", indices, []string{record.index_name})
compareSlices("index_privilege", privs, []string{record.index_privilege})
compareSlices("space", spaces, []string{record.space})
return nil
}

View File

@@ -18,10 +18,10 @@ package es
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"log" "log"
"log/slog" "log/slog"
"strings"
"time" "time"
"codeberg.org/scip/esctl/pkg/cfg" "codeberg.org/scip/esctl/pkg/cfg"
@@ -29,8 +29,10 @@ import (
"github.com/alecthomas/repr" "github.com/alecthomas/repr"
"github.com/elastic/go-elasticsearch/v9/typedapi/core/search" "github.com/elastic/go-elasticsearch/v9/typedapi/core/search"
"github.com/elastic/go-elasticsearch/v9/typedapi/esdsl" "github.com/elastic/go-elasticsearch/v9/typedapi/esdsl"
"github.com/elastic/go-elasticsearch/v9/typedapi/indices/validatequery"
"github.com/elastic/go-elasticsearch/v9/typedapi/types" "github.com/elastic/go-elasticsearch/v9/typedapi/types"
"github.com/elastic/go-elasticsearch/v9/typedapi/types/enums/sortorder" "github.com/elastic/go-elasticsearch/v9/typedapi/types/enums/sortorder"
"github.com/tidwall/gjson"
) )
const ( const (
@@ -44,8 +46,11 @@ Execute an ES search.
additional filters can be given as -F key=value additional filters can be given as -F key=value
*/ */
func Search(conf *cfg.Config, queries []string) error { func Search(conf *cfg.Config, queries []string) error {
searchEs := conf.DefaultCluster.ES.Search(). if conf.Validate {
Index(conf.Index) return validateSearch(conf, queries)
}
searchEs := conf.DefaultCluster.ES.Search().Index(conf.Index)
queryCaster, err := prepareQuery(conf, queries) queryCaster, err := prepareQuery(conf, queries)
if err != nil { if err != nil {
@@ -56,16 +61,90 @@ func Search(conf *cfg.Config, queries []string) error {
searchEs.Request(req) searchEs.Request(req)
switch conf.Tail { searchEs = addSort(conf, searchEs)
case true:
switch {
case conf.Tail:
return searchTail(conf, searchEs) return searchTail(conf, searchEs)
case false: case conf.Explain:
return explainSearch(conf, searchEs)
default:
if conf.To > MAXPAGE { if conf.To > MAXPAGE {
return searchPit(conf, req) return searchPit(conf, req)
} else { } else {
return searchOnce(conf, searchEs) return searchOnce(conf, searchEs)
} }
} }
}
func explainSearch(conf *cfg.Config, search *search.Search) error {
res, err := search.
Explain(true).
Size(1). // one's enough for explain
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to call explain search (esdsl): %s", esErrorString(err))
}
if conf.Debug {
raw, err := json.Marshal(res)
if err != nil {
return fmt.Errorf("failed to marshal explain result: %s", err)
}
value := gjson.Get(string(raw), "hits.hits.0._explanation")
fmt.Println(value.String())
}
if len(res.Hits.Hits) > 0 {
ex := res.Hits.Hits[0].Explanation_
fmt.Println(ex.Description)
fmt.Println(ex.Value)
// recurse into explanation details (it's a tree)
for _, ex := range ex.Details {
explain(&ex, " ")
}
}
return nil
}
func explain(res *types.ExplanationDetail, indent string) {
fmt.Println(indent + "- " + res.Description)
for _, ex := range res.Details {
fmt.Println(indent + " - " + ex.Description)
fmt.Println(indent + fmt.Sprintf(" score: %f", res.Value))
explain(&ex, indent+" ")
}
}
func validateSearch(conf *cfg.Config, queries []string) error {
validate := conf.DefaultCluster.ES.Indices.ValidateQuery()
queryCaster, err := prepareQuery(conf, queries)
if err != nil {
return err
}
req := &validatequery.Request{Query: queryCaster}
validate.Request(req)
res, err := validate.
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to validate search (esdsl): %s", esErrorString(err))
}
slog.Debug("ES result", "search", res)
if res.Valid {
fmt.Println(printer.Colorize(conf, "green", "valid"))
} else {
fmt.Println(printer.Colorize(conf, "red", "invalid"))
}
return nil return nil
} }
@@ -95,18 +174,12 @@ func searchOnce(conf *cfg.Config, search *search.Search) error {
Size(conf.To). Size(conf.To).
Do(context.Background()) Do(context.Background())
if err != nil { if err != nil {
if strings.Contains(err.Error(), "reason: all shards failed") { return fmt.Errorf("failed to run search (esdsl): %s", esErrorString(err))
return nil
}
return fmt.Errorf("failed to run search (esdsl): %s", err)
} }
slog.Debug("ES result", "search", res) slog.Debug("ES result", "search", res)
for _, hit := range res.Hits.Hits { printer.PrintDocs(conf, res.Hits.Hits)
printer.PrintDoc(conf, hit)
}
return nil return nil
} }
@@ -134,23 +207,19 @@ func searchPit(conf *cfg.Config, req *search.Request) error {
AddSortOption("_shard_doc", esdsl.NewFieldSort(sortorder.Asc))). AddSortOption("_shard_doc", esdsl.NewFieldSort(sortorder.Asc))).
Size(conf.To) Size(conf.To)
search = addSort(conf, search)
for { for {
res, err := search.Do(ctx) res, err := search.Do(ctx)
if err != nil { if err != nil {
if strings.Contains(err.Error(), "reason: all shards failed") { return fmt.Errorf("failed to run search (esdsl pit): %s", esErrorString(err))
return nil
}
return fmt.Errorf("failed to run search (esdsl pit): %s", err)
} }
if len(res.Hits.Hits) == 0 { if len(res.Hits.Hits) == 0 {
break break
} }
for _, hit := range res.Hits.Hits { printer.PrintDocs(conf, res.Hits.Hits)
printer.PrintDoc(conf, hit)
}
last := res.Hits.Hits[len(res.Hits.Hits)-1] last := res.Hits.Hits[len(res.Hits.Hits)-1]
search = search.SearchAfterValues(last.Sort) search = search.SearchAfterValues(last.Sort)
@@ -173,11 +242,7 @@ func searchTail(conf *cfg.Config, search *search.Search) error {
for { for {
res, err := search.Do(context.Background()) res, err := search.Do(context.Background())
if err != nil { if err != nil {
if strings.Contains(err.Error(), "reason: all shards failed") { return fmt.Errorf("failed to run search (esdsl): %s", esErrorString(err))
return nil
}
return fmt.Errorf("failed to run search (esdsl): %s", err)
} }
slog.Debug("ES result", "search", res) slog.Debug("ES result", "search", res)
@@ -189,6 +254,7 @@ func searchTail(conf *cfg.Config, search *search.Search) error {
} }
printer.PrintDoc(conf, hit) printer.PrintDoc(conf, hit)
fmt.Println()
docs[*hit.Id_] = 1 docs[*hit.Id_] = 1
} }

View File

@@ -17,15 +17,18 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
package es package es
import ( import (
"context"
"errors" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
"strings" "strings"
"codeberg.org/scip/esctl/pkg/cfg" "codeberg.org/scip/esctl/pkg/cfg"
"github.com/elastic/go-elasticsearch/v9/typedapi/core/search"
"github.com/elastic/go-elasticsearch/v9/typedapi/esdsl" "github.com/elastic/go-elasticsearch/v9/typedapi/esdsl"
"github.com/elastic/go-elasticsearch/v9/typedapi/types" "github.com/elastic/go-elasticsearch/v9/typedapi/types"
"github.com/elastic/go-elasticsearch/v9/typedapi/types/enums/operator" "github.com/elastic/go-elasticsearch/v9/typedapi/types/enums/operator"
"github.com/elastic/go-elasticsearch/v9/typedapi/types/enums/sortorder"
) )
const ( const (
@@ -236,3 +239,42 @@ func addFilters(conf *cfg.Config) ([]types.QueryVariant, error) {
return filters, nil return filters, nil
} }
// check if the conf.SortBy field (default: @timestamp) is searchable
// by using the field capabilities API.
func addSort(conf *cfg.Config, search *search.Search) *search.Search {
res, err := conf.DefaultCluster.ES.FieldCaps().
Index(conf.Index).
Fields(conf.SortBy).
Do(context.Background())
if err != nil {
// whatever it was, do not add Sort()
slog.Debug("get field capabilities", "field", conf.SortBy, "error", esErrorString(err))
return search
}
field, exists := res.Fields[conf.SortBy]
if exists {
// good, the field exists
for _, cap := range field {
if cap.Searchable {
// ok, ES would be willing to sort by this field
order := esdsl.NewFieldSort(sortorder.Desc)
if conf.Ascending {
order = esdsl.NewFieldSort(sortorder.Asc)
}
search = search.Sort(
esdsl.NewSortOptions().AddSortOption(conf.SortBy, order),
)
break
}
}
}
return search
}

View File

@@ -88,7 +88,7 @@ func ShardList(conf *cfg.Config) error {
Header("accept", "application/json"). Header("accept", "application/json").
Do(context.Background()) Do(context.Background())
if err != nil { if err != nil {
return fmt.Errorf("failed to get shards: %s", err) return fmt.Errorf("failed to get shards: %s", esErrorString(err))
} }
shardlist := filterShards(conf, res) shardlist := filterShards(conf, res)
@@ -140,7 +140,7 @@ func ShardShow(conf *cfg.Config, index string) error {
Header("accept", "application/json"). Header("accept", "application/json").
Do(context.Background()) Do(context.Background())
if err != nil { if err != nil {
return fmt.Errorf("failed to get shards: %s", err) return fmt.Errorf("failed to get shards: %s", esErrorString(err))
} }
slog.Debug("ES result", "shards", res) slog.Debug("ES result", "shards", res)

View File

@@ -25,7 +25,7 @@ import (
"strings" "strings"
"codeberg.org/scip/esctl/pkg/cfg" "codeberg.org/scip/esctl/pkg/cfg"
"codeberg.org/scip/esctl/pkg/printer" "codeberg.org/scip/esctl/pkg/printer"
) )
var ( var (
@@ -45,7 +45,7 @@ func SnapshotList(conf *cfg.Config) error {
// get partial indicies // get partial indicies
ires, err := conf.DefaultCluster.ES.Cat.Indices().Do(context.Background()) ires, err := conf.DefaultCluster.ES.Cat.Indices().Do(context.Background())
if err != nil { if err != nil {
return fmt.Errorf("failed to get indicies: %s", err) return fmt.Errorf("failed to get indicies: %s", esErrorString(err))
} }
indicies := map[string]int{} indicies := map[string]int{}
@@ -58,7 +58,7 @@ func SnapshotList(conf *cfg.Config) error {
// get snapshots // get snapshots
sres, err := conf.DefaultCluster.ES.Cat.Snapshots().Do(context.Background()) sres, err := conf.DefaultCluster.ES.Cat.Snapshots().Do(context.Background())
if err != nil { if err != nil {
return fmt.Errorf("failed to get snapshots: %s", err) return fmt.Errorf("failed to get snapshots: %s", esErrorString(err))
} }
slog.Debug("ES result", "indicies", sres) slog.Debug("ES result", "indicies", sres)
@@ -108,7 +108,7 @@ func SnapshotList(conf *cfg.Config) error {
func SnapshotShow(conf *cfg.Config, snapshot string) error { func SnapshotShow(conf *cfg.Config, snapshot string) error {
res, err := conf.DefaultCluster.ES.Snapshot.Get("*", snapshot).Do(context.Background()) res, err := conf.DefaultCluster.ES.Snapshot.Get("*", snapshot).Do(context.Background())
if err != nil { if err != nil {
return fmt.Errorf("failed to get snapshot: %s", err) return fmt.Errorf("failed to get snapshot: %s", esErrorString(err))
} }
slog.Debug("ES result", "snapshot", res) slog.Debug("ES result", "snapshot", res)

View File

@@ -18,12 +18,24 @@ package printer
import ( import (
"fmt" "fmt"
"slices"
"codeberg.org/scip/esctl/pkg/cfg" "codeberg.org/scip/esctl/pkg/cfg"
"github.com/elastic/go-elasticsearch/v9/typedapi/types" "github.com/elastic/go-elasticsearch/v9/typedapi/types"
"github.com/tidwall/gjson" "github.com/tidwall/gjson"
) )
func PrintDocs(conf *cfg.Config, hits []types.Hit) {
if !conf.Ascending {
slices.Reverse(hits)
}
for _, hit := range hits {
PrintDoc(conf, hit)
}
}
func PrintDoc(conf *cfg.Config, hit types.Hit) { func PrintDoc(conf *cfg.Config, hit types.Hit) {
var score types.Float64 var score types.Float64
if hit.Score_ != nil { if hit.Score_ != nil {
@@ -40,6 +52,6 @@ func PrintDoc(conf *cfg.Config, hit types.Hit) {
value := gjson.Get(docjson, conf.Path) value := gjson.Get(docjson, conf.Path)
fmt.Println(value.String()) fmt.Println(value.String())
} else { } else {
fmt.Println(docjson) fmt.Print(docjson)
} }
} }