Compare commits

...

10 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
19 changed files with 909 additions and 156 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:
@@ -34,6 +76,7 @@ Command tree:
delete delete
show show
help help
help-jsonpath
index index
alias alias
create create
@@ -52,6 +95,7 @@ Command tree:
show show
repl repl
role role
diff
list list
show show
search search
@@ -91,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
@@ -145,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!

26
TODO.md
View File

@@ -1,33 +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 validate: - add datastream support:
https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-indices-get-data-stream
> GET /mock/_validate/query?rewrite=true {"from":0,"query":{"bool":{"must":[{"match_all":{}}]}},"size":20,"sort":[{"name":{"order":"desc"}}]} also exclude data stream backing indices from index ls
{
"valid": false
}
- add explain to search (maybe option -e)
> GET /mock/_explain/1780043878 {"query":{"bool":{"must":[{"match_all":{}}]}}}
{
"_index": "mock",
"_id": "1780043878",
"matched": true,
"explanation": {
"value": 1.0,
"description": "*:*",
"details": []
}
}

View File

@@ -47,6 +47,7 @@ func CcrStatus(conf *cfg.Config) *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

@@ -133,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)
}
}, },
} }
} }
@@ -200,6 +186,11 @@ func IndexDelete(conf *cfg.Config) *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)
@@ -216,6 +207,11 @@ 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)
@@ -234,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",
@@ -260,6 +260,31 @@ func IndexFields(conf *cfg.Config) *cli.Command {
Usage: "show info about field capabilities", Usage: "show info about field capabilities",
UsageText: "index fields <index>", 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 { Action: func(ctx context.Context, cmd *cli.Command) error {
index := cmd.Args().Get(0) index := cmd.Args().Get(0)
if index == "" { if 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
}, },

View File

@@ -34,6 +34,7 @@ func Roles(conf *cfg.Config) *cli.Command {
Commands: []*cli.Command{ Commands: []*cli.Command{
RoleList(conf), RoleList(conf),
RoleShow(conf), RoleShow(conf),
RoleDiff(conf),
}, },
} }
} }
@@ -66,6 +67,10 @@ func RoleShow(conf *cfg.Config) *cli.Command {
Usage: "show details about a role", Usage: "show details about a role",
UsageText: "show [options] <role>", UsageText: "show [options] <role>",
ShellComplete: func(ctx context.Context, cmd *cli.Command) {
completeRole(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)
if index == "" { if index == "" {
@@ -76,3 +81,61 @@ func RoleShow(conf *cfg.Config) *cli.Command {
}, },
} }
} }
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

@@ -98,6 +98,7 @@ func Main() int {
Version(conf), Version(conf),
Debug(conf), Debug(conf),
Roles(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) {
@@ -119,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"
@@ -113,12 +112,6 @@ func Search(conf *cfg.Config) *cli.Command {
Destination: &conf.Ascending, Destination: &conf.Ascending,
Aliases: []string{"a"}, Aliases: []string{"a"},
}, },
&cli.BoolFlag{
Name: "help-jsonpath",
Usage: "show jsonPath help",
Destination: &conf.Subhelp,
Aliases: []string{"H"},
},
&cli.BoolFlag{ &cli.BoolFlag{
Name: "tail", Name: "tail",
Usage: "follow search live, like tail -f", Usage: "follow search live, like tail -f",
@@ -131,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 {
@@ -148,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.15` 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
@@ -68,6 +70,7 @@ type Config struct {
Range string // search: -r Range string // search: -r
TimestampFormat string // search: --timestamp-format TimestampFormat string // search: --timestamp-format
Explain bool // search: -e Explain bool // search: -e
Validate bool // search: --validate
SortBy string // sort: -k SortBy string // sort: -k
Ascending bool // sort: -a Ascending bool // sort: -a
Exclude string // cluster compare: -e (regexp) Exclude string // cluster compare: -e (regexp)
@@ -76,6 +79,10 @@ type Config struct {
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

@@ -33,7 +33,12 @@ func esErrorString(err error) string {
causes += fmt.Sprintf("%s\n", *cause.Reason) causes += fmt.Sprintf("%s\n", *cause.Reason)
} }
if e.ErrorCause.Reason != nil {
msg = *e.ErrorCause.Reason + ": " + causes msg = *e.ErrorCause.Reason + ": " + causes
} else {
msg = fmt.Sprintf("http status %d: ", e.Status)
}
} }
return msg return msg

View File

@@ -21,6 +21,7 @@ import (
"fmt" "fmt"
"log/slog" "log/slog"
"regexp" "regexp"
"slices"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -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)
} }
@@ -117,8 +127,8 @@ 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").
@@ -127,11 +137,10 @@ func IndexShow(conf *cfg.Config, index string) error {
return fmt.Errorf("failed to get index: %s", esErrorString(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))
fields := make([]string, len(res[index].Mappings.Properties))
idx := 0 idx := 0
for field := range res[index].Mappings.Properties { for field := range index.Mappings.Properties {
fields[idx] = field fields[idx] = field
idx++ idx++
} }
@@ -139,7 +148,7 @@ func IndexShow(conf *cfg.Config, index string) error {
table := printer.NewTable(conf, 2, 5) table := printer.NewTable(conf, 2, 5)
table.Addheaders("index property", "value") table.Addheaders("index property", "value")
ts, err := strconv.ParseInt(res[index].Settings.Index.CreationDate.(string), 10, 64) ts, err := strconv.ParseInt(index.Settings.Index.CreationDate.(string), 10, 64)
if err != nil { if err != nil {
ts = 0 ts = 0
} }
@@ -147,11 +156,11 @@ func IndexShow(conf *cfg.Config, index string) error {
created := time.Unix(ts/1000, 0) created := time.Unix(ts/1000, 0)
table.Entries = [][]string{ table.Entries = [][]string{
{"name", index}, {"name", name},
{"replicas", *res[index].Settings.Index.NumberOfReplicas}, {"replicas", *index.Settings.Index.NumberOfReplicas},
{"shards", *res[index].Settings.Index.NumberOfShards}, {"shards", *index.Settings.Index.NumberOfShards},
{"created", created.Format("2006-01-02 15:04:05")}, {"created", created.Format("2006-01-02 15:04:05")},
{"uuid", *res[index].Settings.Index.Uuid}, {"uuid", *index.Settings.Index.Uuid},
{"fields", strings.Join(fields, ",")}, {"fields", strings.Join(fields, ",")},
} }
@@ -159,6 +168,9 @@ func IndexShow(conf *cfg.Config, index string) error {
return err return err
} }
fmt.Println()
}
return nil return nil
} }
@@ -311,17 +323,26 @@ func IndexFields(conf *cfg.Config, index string) error {
return fmt.Errorf("failed to retrieve field capabilties: %s", esErrorString(err)) return fmt.Errorf("failed to retrieve field capabilties: %s", esErrorString(err))
} }
table := printer.NewTable(conf, 5, len(res.Fields)) table := printer.NewTable(conf, 5, 0)
table.Addheaders("field", "type", "searchable", "aggretable", "metadata") table.Addheaders("field", "type", "searchable", "aggretable", "metadata")
idx := 0 idx := 0
for name, field := range res.Fields { for name, field := range res.Fields {
for fieldtype, caps := range field { for fieldtype, caps := range field {
// fields only have 1 type, so this one is it // fields only have 1 type, so this one is it
table.Entries[idx] = []string{name, fieldtype, 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.Searchable),
fmt.Sprintf("%t", caps.Aggregatable), fmt.Sprintf("%t", caps.Aggregatable),
fmt.Sprintf("%t", *caps.MetadataField)} fmt.Sprintf("%t", *caps.MetadataField)})
break break
} }

View File

@@ -27,6 +27,23 @@ import (
"github.com/elastic/go-elasticsearch/v9/typedapi/types" "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 { func RoleList(conf *cfg.Config) error {
res, err := conf.DefaultCluster.ES.Security.GetRole(). res, err := conf.DefaultCluster.ES.Security.GetRole().
Do(context.Background()) Do(context.Background())
@@ -50,11 +67,7 @@ func RoleList(conf *cfg.Config) error {
} }
table.Sort() table.Sort()
if err := table.Print(); err != nil { return table.Print()
return err
}
return nil
} }
func RoleShow(conf *cfg.Config, rolename string) error { func RoleShow(conf *cfg.Config, rolename string) error {

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,6 +18,7 @@ package es
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"log" "log"
"log/slog" "log/slog"
@@ -28,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 (
@@ -43,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 {
@@ -57,9 +63,11 @@ func Search(conf *cfg.Config, queries []string) error {
searchEs = addSort(conf, searchEs) searchEs = addSort(conf, searchEs)
switch conf.Tail { switch {
case true: case conf.Tail:
return searchTail(conf, searchEs) return searchTail(conf, searchEs)
case conf.Explain:
return explainSearch(conf, searchEs)
default: default:
if conf.To > MAXPAGE { if conf.To > MAXPAGE {
return searchPit(conf, req) return searchPit(conf, req)
@@ -69,6 +77,78 @@ func Search(conf *cfg.Config, queries []string) error {
} }
} }
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
}
func Debug(conf *cfg.Config) error { func Debug(conf *cfg.Config) error {
res, err := conf.DefaultCluster.ES.Search(). res, err := conf.DefaultCluster.ES.Search().
Index(conf.Index). Index(conf.Index).
@@ -99,9 +179,7 @@ func searchOnce(conf *cfg.Config, search *search.Search) error {
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
} }
@@ -141,9 +219,7 @@ func searchPit(conf *cfg.Config, req *search.Request) error {
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)
@@ -178,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

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