Compare commits

...

46 Commits

Author SHA1 Message Date
20b79ce168 left align ints by default again, add -I to right align on request 2026-06-24 09:35:40 +02:00
T. von Dein
ffcb8adb70 enhanced default cluster selection, add 'cluster switch', overhaul json header setting (#47) 2026-06-24 09:25:50 +02:00
3c6819151f generate command tree from within (esctl --show-command-tree) 2026-06-23 14:02:58 +02:00
faa8c0fe26 fix typo 2026-06-23 14:02:40 +02:00
92ddf4f1f0 spelling fix 2026-06-23 13:04:25 +02:00
76e039d124 fix badges, add release badge 2026-06-23 13:02:43 +02:00
a5ae8f61a9 add anti ai paragraph and badge 2026-06-23 12:57:11 +02:00
9a19473ff1 upd features 2026-06-23 12:35:35 +02:00
T. von Dein
e16f2bfd0b add pager tui for 'api show' (#46) 2026-06-23 11:58:52 +02:00
T. von Dein
6d31d18e0c fix panic if using 'cluser ls -d' (#45) 2026-06-23 10:54:00 +02:00
355e3051b4 updtree 2026-06-22 14:13:15 +02:00
T. von Dein
a81d6b94e9 enhance/clusterstatus (#44)
fixes #35
2026-06-22 14:11:32 +02:00
T. von Dein
9b5ba0775b add ilm management support (#43) 2026-06-22 13:52:02 +02:00
T. von Dein
802217158c feature/add-profiler (#42) 2026-06-18 08:54:00 +02:00
5d345aaa9a fixed another panic 2026-06-17 14:26:28 +02:00
2f029304f3 fix panic and width determination 2026-06-17 14:16:26 +02:00
49bc2c7acb use real terminal width to determine markdown width in 'api show' 2026-06-17 13:33:03 +02:00
T. von Dein
f1ff361c12 add API viewer, mv repl subcommand to api (#41) 2026-06-17 13:11:09 +02:00
072e949147 upd command tree 2026-06-16 13:51:46 +02:00
T. von Dein
ce4a60f8e2 add alias+datastream rollover, and auto rollover on index tpl change (#40)
fixes #37
2026-06-16 13:48:41 +02:00
T. von Dein
97004e0957 add task support (#39)
fixes #34
2026-06-16 08:25:25 +02:00
T. von Dein
d1f3082e5a refactored IndexTemplateModify() (#38) 2026-06-16 08:22:02 +02:00
T. von Dein
d0d1d69791 add index template {ls,sh} (#33) 2026-06-15 15:18:21 +02:00
T. von Dein
ec1ce56b2d add datastream support, refactor custom completion (#32) 2026-06-10 15:43:14 +02:00
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
f1877c6903 fix doc add id creation, use rand.Int64() 2026-05-29 14:42:36 +02:00
T. von Dein
2b2094b155 allow multiline json data in repl (#24) 2026-05-29 12:42:02 +02:00
1c72b5ec9a Merge branch 'main' of ssh://codeberg.org/scip/esctl 2026-05-29 11:28:17 +02:00
aeded16b89 catch out-of-bounds error 2026-05-29 11:27:48 +02:00
57bbe680a7 fix http debugger 2026-05-29 11:27:40 +02:00
T. von Dein
130d0b879d add doc delete (#23) 2026-05-29 11:05:57 +02:00
T. von Dein
f7302ea506 more search enhancements (#22) 2026-05-29 10:19:18 +02:00
b2da6f0f29 also fix color status in ccr status 2026-05-27 09:59:56 +02:00
c92f10fb5d show cluster color status directly to make it clearer 2026-05-27 09:25:55 +02:00
501deec538 fix tab completion 2026-05-26 08:01:28 +02:00
63 changed files with 166961 additions and 851 deletions

2
.gitignore vendored
View File

@@ -30,3 +30,5 @@ esctl
*.sh
*.json
*.log
cpu.profile

View File

@@ -2,7 +2,7 @@ matrix:
platform:
- linux/amd64
goversion:
- 1.25
- 1.25.8
labels:
platform: ${platform}

View File

@@ -12,6 +12,13 @@ The programming language used for this project will always be
# Contributing
Everyone can contribute to this project except you are using "AI"
(LLM) generated content (code, issue text, etc), even if only a part
of it is. This is a human made project. It is being made to have fun
at work and learning coding and APIs. If you want fast results without
the hassle to invest time then please refrain from
contributing.
You can contribute to this project in various ways:
## Open an issue

View File

@@ -23,17 +23,27 @@ UID = root
GID = 0
HAVE_POD := $(shell pod2text -h 2>/dev/null)
BRANCH = $(shell git branch --show-current)
COMMIT = $(shell git rev-parse --short=8 HEAD)
BUILD = $(shell date +%Y.%m.%d.%H%M%S)
GOVERSION = $(shell grep -E "^go" go.mod | cut -d' ' -f2)
APIVERSION = $(shell grep github.com/elastic/go-elasticsearch go.mod | cut -d' ' -f2)
BRANCH = $(shell git branch --show-current)
COMMIT = $(shell git rev-parse --short=8 HEAD)
BUILD = $(shell date +%Y.%m.%d.%H%M%S)
GOVERSION = $(shell grep -E "^go" go.mod | cut -d' ' -f2)
APIVERSION = $(shell grep github.com/elastic/go-elasticsearch go.mod | cut -d' ' -f2)
APISPEC = "https://www.elastic.co/docs/api/doc/elasticsearch.json"
all: buildlocal
buildlocal:
# pkg/es/openspec.go:
# echo "package es" > pkg/es/openspec.go
# echo >> pkg/es/openspec.go
# echo "const Openspec = \`" >> pkg/es/openspec.go
# curl -o - $(APISPEC) | sed "s/\`/'/g" >> pkg/es/openspec.go
# echo "\`" >> pkg/es/openspec.go
assets/openapi.json:
curl -o assets/openapi.json $(APISPEC)
buildlocal: assets/openapi.json
CGO_LDFLAGS='-static' go build -tags osusergo,netgo -ldflags "-extldflags=-static" \
-ldflags "-X codeberg.org/scip/esctl/pkg/cfg.BRANCH=$(BRANCH) \
-X codeberg.org/scip/esctl/pkg/cfg.COMMIT=$(COMMIT) \
@@ -50,7 +60,7 @@ install: buildlocal
install -o $(UID) -g $(GID) -m 444 $(tool).1 $(PREFIX)/man/man1/
clean:
rm -rf $(tool) coverage.out testdata t/out
rm -rf $(tool) coverage.out testdata t/out pkg/es/openspec.go
test: clean
mkdir -p t/out
@@ -96,5 +106,8 @@ show-versions: buildlocal
@echo "### go version used for building:"
@grep -m 1 go go.mod
# lint:
# golangci-lint run -p bugs -p unused
.PHONY: profile
profile: buildlocal
./esctl api ls --profile-file cpu.profile
go tool pprof -text esctl cpu.profile
go tool pprof --http localhost:8888 ./esctl cpu.profile

240
README.md
View File

@@ -1,60 +1,152 @@
[![status-badge](https://ci.codeberg.org/api/badges/16999/status.svg)](https://ci.codeberg.org/repos/16999)
[![humanmande](https://img.shields.io/badge/human-made-green)](CONTRIBUTING.md)
[![License](https://img.shields.io/badge/license-GPL-blue.svg)](https://codeberg.org/scip/esctl/blob/master/LICENSE)
[![Go Report Card](https://goreportcard.com/badge/codeberg.org/scip/esctl)](https://goreportcard.com/report/codeberg.org/scip/esctl)
[![Latest Release](https://flat.badgen.net/codeberg/release/scip/esctl)](https://codeberg.org/scip/esctl/releases)
# esctl
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. Automatic rollover of aliases supported.
- Index template management: create, modify, delete etc
- Index alias management: create, modify, delete etc
- Node management: only list nodes yet.
- Shard management: only list shards yet.
- Snapshot management: only list snapshots yet.
- ILM management: list, create, delete etc.
- Task management: list and cancel tasks
- 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.
- API documentation (`api list` and `api show <path>`) with
interactive markdown pager for endpoint documentation.
- 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
Command tree:
```console
ccr
follower
add
delete
pause
renew
resume
show
unfollow
info
pause
resume
status
cluster
list
settings
list
set
status
doc
add
help
index
alias
create
delete
list
allocation
close
create
delete
list
modify
show
node
list
show
repl
search
shard
list
show
snapshot
list
show
version
api - api access and documentation
list - list index of API calls
show - show an API doc
repl - interactive API repl
ccr - manage cross cluster replication
status - cross cluster replication status (yaml config with 2 clusters required)
pause - pause shard allocation
resume - resume shard allocation
follower - manage ccr follower indices
show - show ccr follower index details
add - add ccr follower index
delete - delete ccr follower index
unfollow - unfollow ccr follower index
pause - pause ccr index to follow
resume - resume ccr index to follow
renew - renew ccr follower index
info - show ccr remote info
cluster - manage cluster[s]
status - show cluster status
switch - set current elasticsearch cluster
list - list configured clusters
settings - cluster settings management
list - show cluster settings
set - set|update cluster settings
datastream - manage data streams
list - list indicies
show - show details about an data stream
create - create a new data stream
delete - delete a data stream
rollover - roll over a data stream
doc - manage documents
add - add JSON document index
show - show a JSON document
delete - delete JSON document[s] from index[es]
ilm - manage index lifecycle
retry - retry applying an ILM profile to an index
status - get the current index lifecycle management status
list - list index lifecycle policies
show - show details about an index lifecycle policy
create - create a index lifecycle policy
index - manage indicies
list - list indicies
show - show details about an index
create - create a new index
delete - delete an index
close - close an index
allocation - explain index allocation
modify - modify an index
fields - show info about field capabilities
ilm - show ilm status
alias - manage index aliases
create - create an index alias
list - list index aliases
delete - delete an index alias
rollover - roll over an index alias
template - manage index templates
list - list index templates
show - show details about an index template
create - create a new index template
modify - modify a new index template
delete - delete an index template
node - manage nodes
list - list nodes
show - show details about a node
role - manage roles
list - list roles
show - show details about a role
diff - show differences between roles and CSV baseline
search - search within an index
shard - manage shards
list - list shards
show - show details about a shard
snapshot - manage snapshots
list - list snapshots
show - show details about a snapshot
task - manage tasks
list - list tasks
cancel - cancel running task
version - show esctl version information
debug - developer only
help-jsonpath - show jsonpath help
completion - Output shell completion script for bash, zsh, fish, or Powershell
bash - Output bash completion script
zsh - Output zsh completion script
fish - Output fish completion script
pwsh - Output pwsh completion script
```
Configure `esctl` with environment variables:
@@ -67,7 +159,7 @@ Or create a config file such as this:
```yaml
clusters:
default:
foobar:
uri: https://es.foo.bar:9200/
user: elastic
pass: 123456
@@ -81,12 +173,36 @@ and specify it with `-c configfile`. You may also put clusters into a
default config file in `~/.config/esctl/config.yaml`. In this case you
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, you need to make it the
current default one. You can either manually configure it in the
config:
```yaml
clusters:
foobar:
uri: https://es.foo.bar:9200/
user: elastic
pass: 123456
default: true
```
or - if the cluster already exists in the config - issue this command:
```console
esctl cluster switch foobar
```
You may also temporary set a cluster as the current default with the
global `-C` option.
## Introduction
The following rules apply for default cluster selection:
FIXME
1. If there's just one cluster configured (either via environment
variables or config), this one will be selected.
2. If there is one cluster configured with the `default` flag `true`,
use this one.
Use `esctl cluster ls` to check status and see which one would be used.
## Installation
@@ -138,6 +254,30 @@ make
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
[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)
- [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
- add shard explain, aka:
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

43
assets/assets.go Normal file
View File

@@ -0,0 +1,43 @@
/*
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 assets
import (
"embed"
"github.com/go-openapi/loads"
"github.com/go-openapi/swag/loading"
)
//go:embed *.json
var AssetFS embed.FS
var OpenAPI *loads.Document
func LoadAssetOpenApi() {
doc, err := loads.Spec(
"openapi.json",
loads.WithLoadingOptions(
loading.WithFS(AssetFS),
))
if err != nil {
panic(err)
}
OpenAPI = doc
}

160985
assets/openapi.json Normal file

File diff suppressed because one or more lines are too long

90
cmd/api.go Normal file
View File

@@ -0,0 +1,90 @@
/*
Copyright © 2026 Thomas von Dein
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package cmd
import (
"context"
"codeberg.org/scip/esctl/pkg/cfg"
"codeberg.org/scip/esctl/pkg/es"
"github.com/urfave/cli/v3"
)
func Api(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "api",
Usage: "api access and documentation",
Commands: []*cli.Command{
ApiList(conf),
ApiShow(conf),
ApiRepl(conf),
},
}
}
func ApiList(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "list",
Aliases: []string{"ls"},
Usage: "list index of API calls",
UsageText: "list [<pattern>]",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "tag",
Usage: "list only operations matching <tag>",
Destination: &conf.Tag,
Aliases: []string{"t"},
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
return es.ApiList(conf, cmd.Args().Get(0))
},
}
}
func ApiShow(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "show",
Aliases: []string{"sh"},
Usage: "show an API doc",
UsageText: "show <pattern> [<verb>]",
Action: func(ctx context.Context, cmd *cli.Command) error {
return es.ApiShow(conf, cmd.Args().Get(0), cmd.Args().Get(1))
},
ShellComplete: func(ctx context.Context, cmd *cli.Command) {
complete(cmd, Capi)
},
}
}
func ApiRepl(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "repl",
Aliases: []string{"shell"},
Usage: "interactive API repl",
Action: func(ctx context.Context, cmd *cli.Command) error {
return es.ApiRepl(conf)
},
}
}

View File

@@ -44,9 +44,10 @@ func Ccr(conf *cfg.Config) *cli.Command {
func CcrStatus(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "status",
Aliases: []string{"st"},
Usage: "cross cluster replication status (yaml config with 2 clusters required)",
Name: "status",
Aliases: []string{"st"},
Usage: "cross cluster replication status (yaml config with 2 clusters required)",
UsageText: "status <leader> <follower>",
Flags: []cli.Flag{
&cli.StringFlag{
@@ -57,6 +58,10 @@ func CcrStatus(conf *cfg.Config) *cli.Command {
},
},
ShellComplete: func(ctx context.Context, cmd *cli.Command) {
complete(cmd, Ccluster)
},
Action: func(ctx context.Context, cmd *cli.Command) error {
leader := cmd.Args().Get(0)
follower := cmd.Args().Get(1)
@@ -109,6 +114,10 @@ func CcrRemoteInfo(conf *cfg.Config) *cli.Command {
Usage: "show ccr remote info",
UsageText: "info [options] [<index>]",
ShellComplete: func(ctx context.Context, cmd *cli.Command) {
complete(cmd, Cindex)
},
Action: func(ctx context.Context, cmd *cli.Command) error {
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) {
complete(cmd, Cindex)
},
Action: func(ctx context.Context, cmd *cli.Command) error {
args := cmd.Args()
@@ -77,6 +81,10 @@ func CcrFollowerResume(conf *cfg.Config) *cli.Command {
Usage: "resume ccr index to follow",
UsageText: "resume [options] <index>",
ShellComplete: func(ctx context.Context, cmd *cli.Command) {
complete(cmd, Cindex)
},
Action: func(ctx context.Context, cmd *cli.Command) error {
args := cmd.Args()
@@ -95,6 +103,10 @@ func CcrFollowerPause(conf *cfg.Config) *cli.Command {
Usage: "pause ccr index to follow",
UsageText: "pause [options] <index>",
ShellComplete: func(ctx context.Context, cmd *cli.Command) {
complete(cmd, Cindex)
},
Action: func(ctx context.Context, cmd *cli.Command) error {
args := cmd.Args()
@@ -113,6 +125,10 @@ func CcrFollowerUnfollow(conf *cfg.Config) *cli.Command {
Usage: "unfollow ccr follower index",
UsageText: "unfollow [options] <index>",
ShellComplete: func(ctx context.Context, cmd *cli.Command) {
complete(cmd, Cindex)
},
Action: func(ctx context.Context, cmd *cli.Command) error {
args := cmd.Args()
@@ -141,6 +157,10 @@ func CcrFollowerAdd(conf *cfg.Config) *cli.Command {
},
},
ShellComplete: func(ctx context.Context, cmd *cli.Command) {
complete(cmd, Cindex)
},
Action: func(ctx context.Context, cmd *cli.Command) error {
args := cmd.Args()
@@ -160,6 +180,10 @@ func CcrFollowerDelete(conf *cfg.Config) *cli.Command {
Usage: "delete ccr follower index",
UsageText: "delete <index>",
ShellComplete: func(ctx context.Context, cmd *cli.Command) {
complete(cmd, Cindex)
},
Action: func(ctx context.Context, cmd *cli.Command) error {
args := cmd.Args()
@@ -180,6 +204,10 @@ func CcrFollowerShow(conf *cfg.Config) *cli.Command {
Usage: "show ccr follower index details",
UsageText: "show <index>",
ShellComplete: func(ctx context.Context, cmd *cli.Command) {
complete(cmd, Cindex)
},
Action: func(ctx context.Context, cmd *cli.Command) error {
args := cmd.Args()

View File

@@ -18,6 +18,7 @@ package cmd
import (
"context"
"errors"
"codeberg.org/scip/esctl/pkg/cfg"
"codeberg.org/scip/esctl/pkg/es"
@@ -33,6 +34,7 @@ func Cluster(conf *cfg.Config) *cli.Command {
Commands: []*cli.Command{
ClusterStatus(conf),
ClusterSwitch(conf),
ClusterList(conf),
ClusterSettings(conf),
},
@@ -77,3 +79,25 @@ func ClusterStatus(conf *cfg.Config) *cli.Command {
},
}
}
func ClusterSwitch(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "switch",
Usage: "set current elasticsearch cluster",
UsageText: "switch <cluster-name>",
Aliases: []string{"ctx"},
ShellComplete: func(ctx context.Context, cmd *cli.Command) {
complete(cmd, Ccluster)
},
Action: func(ctx context.Context, cmd *cli.Command) error {
name := cmd.Args().Get(0)
if name == "" {
return errors.New("no cluster name specified")
}
return conf.SwitchCluster(name)
},
}
}

View File

@@ -27,7 +27,7 @@ import (
)
const (
SETTINGS = `https://www.elastic.co/docs/reference/elasticsearch/configuration-reference`
SETTINGS = ``
)
func ClusterSettings(conf *cfg.Config) *cli.Command {
@@ -85,7 +85,9 @@ func ClusterSettingsSet(conf *cfg.Config) *cli.Command {
Name: "set",
Usage: "set|update cluster settings",
Aliases: []string{"set", "update"},
UsageText: "set [options] setting:value [setting:value ...]\n\nReference: " + SETTINGS,
UsageText: "set [options] setting:value [setting:value ...]",
CustomHelpTemplate: addReference(
"https://www.elastic.co/docs/reference/elasticsearch/configuration-reference"),
Flags: []cli.Flag{
&cli.BoolFlag{

76
cmd/completion.go Normal file
View File

@@ -0,0 +1,76 @@
/*
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"
)
const (
Cindex = iota
Cdatastream
Crole
Ccluster
Capi
Cilm
)
func complete(cmd *cli.Command, what int) {
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
}
var list []string
var err error
switch what {
case Cindex:
list, err = es.IndexNames(conf)
case Crole:
list, err = es.RoleNames(conf)
case Ccluster:
for cluster := range conf.Clusters {
list = append(list, cluster)
}
case Cdatastream:
list, err = es.DatastreamNames(conf)
case Capi:
list = es.ApiPathNames()
case Cilm:
list, err = es.IlmNames(conf)
}
if err != nil {
return
}
for _, item := range list {
fmt.Println(item)
}
}

220
cmd/datastream.go Normal file
View File

@@ -0,0 +1,220 @@
/*
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 Datastream(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "datastream",
Aliases: []string{"ds"},
Usage: "manage data streams",
Commands: []*cli.Command{
DatastreamList(conf),
DatastreamShow(conf),
DatastreamCreate(conf),
DatastreamDelete(conf),
DatastreamRollover(conf),
},
}
}
func DatastreamList(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "list",
Aliases: []string{"ls"},
Usage: "list indicies",
Flags: []cli.Flag{
&cli.IntFlag{
Name: "max",
Usage: "max number of items to process",
Destination: &conf.MaxItems,
Aliases: []string{"m"},
},
&cli.BoolFlag{
Name: "hidden",
Usage: "include hidden data streams",
Destination: &conf.Hidden,
Aliases: []string{"H"},
},
&cli.StringSliceFlag{
Name: "filter",
Usage: "show only data streams matching the filter",
Destination: &conf.Filter,
Aliases: []string{"F"},
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
return es.DatastreamList(conf)
},
}
}
func DatastreamShow(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "show",
Aliases: []string{"sh"},
Usage: "show details about an data stream",
Action: func(ctx context.Context, cmd *cli.Command) error {
index := cmd.Args().Get(0)
if index == "" {
return errors.New("no data stream specified")
}
return es.DatastreamShow(conf, cmd.Args().Get(0))
},
ShellComplete: func(ctx context.Context, cmd *cli.Command) {
complete(cmd, Cdatastream)
},
}
}
func DatastreamCreate(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "create",
Aliases: []string{"+"},
Usage: "create a new data stream",
UsageText: "create [options] <name>",
Action: func(ctx context.Context, cmd *cli.Command) error {
index := cmd.Args().Get(0)
if index == "" {
return errors.New("no data stream specified")
}
return es.DatastreamCreate(conf, cmd.Args().Get(0))
},
ShellComplete: func(ctx context.Context, cmd *cli.Command) {
complete(cmd, Cdatastream)
},
}
}
func DatastreamDelete(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "delete",
Aliases: []string{"rm"},
Usage: "delete a data stream",
UsageText: "delete [options] <name>",
Action: func(ctx context.Context, cmd *cli.Command) error {
index := cmd.Args().Get(0)
if index == "" {
return errors.New("no data stream specified")
}
return es.DatastreamDelete(conf, cmd.Args().Get(0))
},
ShellComplete: func(ctx context.Context, cmd *cli.Command) {
complete(cmd, Cdatastream)
},
}
}
func DatastreamRollover(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "rollover",
Aliases: []string{"roll"},
Usage: "roll over a data stream",
UsageText: "rollover <data stream>",
ShellComplete: func(ctx context.Context, cmd *cli.Command) {
complete(cmd, Cindex)
},
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "wait",
Usage: "wait for active shards",
Destination: &conf.Wait,
Aliases: []string{"w"},
},
&cli.BoolFlag{
Name: "dry-run",
Usage: "checks conditions but does not perform a rollover",
Destination: &conf.Wait,
Aliases: []string{"n"},
},
&cli.StringFlag{
Name: "max-age",
Usage: "roll over after max age (eg: 7d, 2m, 8h)",
Destination: &conf.MaxAge,
},
&cli.IntFlag{
Name: "max-docs",
Usage: "roll over after max docs",
Destination: &conf.MaxDocs,
},
&cli.IntFlag{
Name: "max-primary-shard-size",
Usage: "roll over when primary shard size reaches size",
Destination: &conf.MaxShardSize,
},
&cli.IntFlag{
Name: "max-primary-shard-docs",
Usage: "roll over after max docs in primary shard reached",
Destination: &conf.MaxShardDocs,
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
alias := cmd.Args().Get(0)
if alias == "" {
return errors.New("no data stream specified")
}
return es.DatastreamRollover(conf, alias)
},
}
}
func DatastreamIlm(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "ilm",
Usage: "show ilm status",
UsageText: "ds ilm <name>",
ShellComplete: func(ctx context.Context, cmd *cli.Command) {
complete(cmd, Cdatastream)
},
Action: func(ctx context.Context, cmd *cli.Command) error {
ds := cmd.Args().Get(0)
if ds == "" {
return errors.New("no ds name specified")
}
return es.IlmExplain(conf, ds)
},
}
}

View File

@@ -34,7 +34,7 @@ func Doc(conf *cfg.Config) *cli.Command {
Commands: []*cli.Command{
DocAdd(conf),
DocShow(conf),
//Delete(conf),
DocDelete(conf),
},
}
}
@@ -89,19 +89,9 @@ func DocShow(conf *cfg.Config) *cli.Command {
Destination: &conf.Path,
Aliases: []string{"p"},
},
&cli.BoolFlag{
Name: "help-jsonpath",
Usage: "show jsonPath help",
Destination: &conf.Subhelp,
Aliases: []string{"H"},
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
if conf.Subhelp {
return showJsonPathHelp()
}
args := cmd.Args()
if args.Len() != 1 {
@@ -112,3 +102,62 @@ func DocShow(conf *cfg.Config) *cli.Command {
},
}
}
func DocDelete(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "delete",
Aliases: []string{"rm"},
Usage: "delete JSON document[s] from index[es]",
UsageText: "delete [options] -i <index> [<[field<sep>]pattern> ...]\n" + SearchUsage,
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "all",
Usage: "delete all docs (dangerous!)",
Destination: &conf.All,
Aliases: []string{"a"},
},
&cli.StringFlag{
Name: "index",
Usage: "index to work on",
Sources: cli.EnvVars("ES_INDEX"),
Destination: &conf.Index,
Aliases: []string{"i"},
},
&cli.StringSliceFlag{
Name: "filter",
Usage: "additional boolean filters. format: key=value",
Destination: &conf.Filter,
Aliases: []string{"F"},
},
&cli.StringFlag{
Name: "timerange",
Usage: "field:<date> to <date> (e.g. @timestamp:2026-05-05 to 2026-05-15)",
Destination: &conf.Range,
Aliases: []string{"r"},
},
&cli.StringFlag{
Name: "timestamp-format",
Usage: "a valid ES builtin timestamp or custom format",
Destination: &conf.TimestampFormat,
Value: "strict_date_hour_minute",
},
&cli.BoolFlag{
Name: "or",
Usage: "logical operator (default: and)",
Destination: &conf.Or,
Aliases: []string{"O"},
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
args := cmd.Args()
if args.Len() == 0 && !conf.All {
return errors.New("missing arguments: <query>")
}
return es.DocDelete(conf, cmd.Args().Slice())
},
}
}

277
cmd/ilm.go Normal file
View File

@@ -0,0 +1,277 @@
/*
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 Ilm(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "ilm",
Usage: "manage index lifecycle",
Commands: []*cli.Command{
IlmRetry(conf),
IlmStatus(conf),
IlmList(conf),
IlmShow(conf),
IlmCreate(conf),
},
}
}
func IlmRetry(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "retry",
Usage: "retry applying an ILM profile to an index",
UsageText: "retry <index>",
Action: func(ctx context.Context, cmd *cli.Command) error {
return es.IlmRetry(conf, cmd.Args().Get(0))
},
}
}
func IlmStatus(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "status",
Usage: "get the current index lifecycle management status",
Action: func(ctx context.Context, cmd *cli.Command) error {
return es.IlmStatus(conf)
},
}
}
func IlmList(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "list",
Aliases: []string{"ls"},
Usage: "list index lifecycle policies",
UsageText: "list [<policy-pattern>]",
Action: func(ctx context.Context, cmd *cli.Command) error {
return es.IlmList(conf, cmd.Args().Get(0))
},
}
}
func IlmShow(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "show",
Aliases: []string{"sh"},
Usage: "show details about an index lifecycle policy",
UsageText: "show <policy>",
Action: func(ctx context.Context, cmd *cli.Command) error {
policy := cmd.Args().Get(0)
if policy == "" {
return errors.New("no policy specified")
}
return es.IlmShow(conf, cmd.Args().Get(0))
},
ShellComplete: func(ctx context.Context, cmd *cli.Command) {
complete(cmd, Cilm)
},
}
}
/*
*
{
"policy": {
"phases": {
"hot": {
"min_age": "0ms",
"actions": {
"rollover": {
"max_primary_shard_size": "50gb",
"max_age": "1d",
"max_docs": 100000000
},
"set_priority": {
"priority": 100
}
}
},
"warm": {
"min_age": "7d",
"actions": {
"migrate": {},
"shrink": {
"number_of_shards": 1
},
"forcemerge": {
"max_num_segments": 1
},
"set_priority": {
"priority": 50
}
}
},
"cold": {
"min_age": "30d",
"actions": {
"migrate": {},
"searchable_snapshot": {
"snapshot_repository": "s3-logs-archive",
"force_merge_index": true
},
"set_priority": {
"priority": 0
}
}
},
"delete": {
"min_age": "90d",
"actions": {
"delete": {
"delete_searchable_snapshot": true
}
}
}
}
}
}
*/
func IlmCreate(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "create",
Aliases: []string{"+"},
Usage: "create a index lifecycle policy",
UsageText: "create [options] <policy>",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "description",
Usage: "policy description",
Destination: &conf.Ilm.Description,
Aliases: []string{"D"},
},
// hot
&cli.StringFlag{
Name: "hot-min-age",
Usage: "hot phase: min age",
Destination: &conf.Ilm.HotMinAge,
},
&cli.StringFlag{
Name: "hot-rollover-max-age",
Usage: "hot phase: rollover after",
Destination: &conf.Ilm.HotRolloverMaxAge,
},
&cli.Int64Flag{
Name: "hot-rollover-max-primary-shard-size",
Usage: "hot phase: max primary shard size",
Destination: &conf.Ilm.HotRolloverMaxPrimaryShardSize,
},
&cli.Int64Flag{
Name: "hot-rollover-max-docs",
Usage: "hot phase: max docs",
Destination: &conf.Ilm.HotRolloverMaxDocs,
},
&cli.BoolFlag{
Name: "hot-readonly",
Usage: "hot phase: make index ro before rollover",
Destination: &conf.Ilm.HotReadonly,
},
// warm
&cli.StringFlag{
Name: "warm-min-age",
Usage: "warm phase: min age",
Destination: &conf.Ilm.WarmMinAge,
},
&cli.IntFlag{
Name: "warm-force-merge-segments",
Usage: "warm phase: force merge segments per shard",
Destination: &conf.Ilm.WarmForceMerge,
},
&cli.IntFlag{
Name: "warm-shrink-shards",
Usage: "warm phase: shrink on number of shards",
Destination: &conf.Ilm.WarmShrinkShards,
},
&cli.IntFlag{
Name: "warm-priority",
Usage: "warm phase: priority",
Destination: &conf.Ilm.WarmPriority,
},
// cold
&cli.StringFlag{
Name: "cold-min-age",
Usage: "cold phase: min age",
Destination: &conf.Ilm.ColdMinAge,
},
&cli.StringFlag{
Name: "cold-searchable-snapshot-repo",
Usage: "cold phase: searchable snapshot repo",
Destination: &conf.Ilm.ColdSearchableSnapshotRepo,
},
&cli.IntFlag{
Name: "cold-force-merge-segments",
Usage: "cold phase: force merge segments per shard",
Destination: &conf.Ilm.ColdForceMerge,
},
&cli.IntFlag{
Name: "cold-priority",
Usage: "cold phase: priority",
Destination: &conf.Ilm.ColdPriority,
},
// frozen
&cli.StringFlag{
Name: "frozen-min-age",
Usage: "frozen phase: min age",
Destination: &conf.Ilm.FrozenMinAge,
},
// delete
&cli.StringFlag{
Name: "delete-min-age",
Usage: "delete phase: min age",
Destination: &conf.Ilm.DeleteMinAge,
},
&cli.BoolFlag{
Name: "delete-searchable-snapshots",
Usage: "delete phase: delete searchable snapshots",
Destination: &conf.Ilm.DeleteSearchableSnapshots,
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
policy := cmd.Args().Get(0)
if policy == "" {
return errors.New("no policy name specified")
}
return es.IlmCreate(conf, cmd.Args().Get(0))
},
}
}

View File

@@ -41,7 +41,12 @@ func Index(conf *cfg.Config) *cli.Command {
IndexClose(conf),
IndexAllocation(conf),
IndexModify(conf),
IndexFields(conf),
IndexIlm(conf),
// sub commands
IndexAlias(conf),
IndexTemplate(conf),
},
}
}
@@ -132,22 +137,8 @@ func IndexShow(conf *cfg.Config) *cli.Command {
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) {
if cmd.NArg() > 0 {
return
}
indices, err := es.IndexNames(conf)
if err != nil {
return
}
for _, index := range indices {
fmt.Println(index)
}
complete(cmd, Cindex)
},
}
}
@@ -157,6 +148,8 @@ func IndexCreate(conf *cfg.Config) *cli.Command {
Name: "create",
Aliases: []string{"+"},
Usage: "create a new index",
UsageText: `create <name> <fieldmapping:type>...
Valid field mapping types: integer, text, date, keyword`,
Flags: []cli.Flag{
&cli.BoolFlag{
@@ -176,6 +169,7 @@ func IndexCreate(conf *cfg.Config) *cli.Command {
Usage: "number of replicas to create",
Destination: &conf.Replicas,
Aliases: []string{"r"},
Value: 1,
},
},
@@ -194,9 +188,14 @@ func IndexCreate(conf *cfg.Config) *cli.Command {
func IndexDelete(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "delete",
Aliases: []string{"rm"},
Usage: "delete an index",
Name: "delete",
Aliases: []string{"rm"},
Usage: "delete an index",
UsageText: "delete <index>",
ShellComplete: func(ctx context.Context, cmd *cli.Command) {
complete(cmd, Cindex)
},
Action: func(ctx context.Context, cmd *cli.Command) error {
index := cmd.Args().Get(0)
@@ -211,8 +210,13 @@ func IndexDelete(conf *cfg.Config) *cli.Command {
func IndexClose(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "close",
Usage: "close an index",
Name: "close",
Usage: "close an index",
UsageText: "close <index>",
ShellComplete: func(ctx context.Context, cmd *cli.Command) {
complete(cmd, Cindex)
},
Action: func(ctx context.Context, cmd *cli.Command) error {
index := cmd.Args().Get(0)
@@ -231,12 +235,17 @@ func IndexModify(conf *cfg.Config) *cli.Command {
Usage: "modify an index",
UsageText: "modify <index[,index,...]|_all>",
ShellComplete: func(ctx context.Context, cmd *cli.Command) {
complete(cmd, Cindex)
},
Flags: []cli.Flag{
&cli.IntFlag{
Name: "replicas",
Usage: "number of replicas",
Destination: &conf.Replicas,
Aliases: []string{"r"},
Value: 1,
},
},
@@ -250,3 +259,66 @@ 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) {
complete(cmd, Cindex)
},
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)
},
}
}
func IndexIlm(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "ilm",
Usage: "show ilm status",
UsageText: "index ilm <index>",
ShellComplete: func(ctx context.Context, cmd *cli.Command) {
complete(cmd, Cindex)
},
Action: func(ctx context.Context, cmd *cli.Command) error {
index := cmd.Args().Get(0)
if index == "" {
return errors.New("no index specified")
}
return es.IlmExplain(conf, index)
},
}
}

View File

@@ -36,7 +36,8 @@ func IndexAlias(conf *cfg.Config) *cli.Command {
IndexAliasCreate(conf),
IndexAliasList(conf),
IndexAliasDelete(conf),
//IndexAliasShow(conf),
IndexAliasRollover(conf),
// FIXME: implement IndexAliasAdd
//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",
UsageText: "create <index> <alias>",
ShellComplete: func(ctx context.Context, cmd *cli.Command) {
complete(cmd, Cindex)
},
Action: func(ctx context.Context, cmd *cli.Command) error {
index := cmd.Args().Get(0)
alias := cmd.Args().Get(1)
@@ -69,6 +74,10 @@ func IndexAliasDelete(conf *cfg.Config) *cli.Command {
Usage: "delete an index alias",
UsageText: "delete <index> <alias>",
ShellComplete: func(ctx context.Context, cmd *cli.Command) {
complete(cmd, Cindex)
},
Action: func(ctx context.Context, cmd *cli.Command) error {
index := cmd.Args().Get(0)
alias := cmd.Args().Get(1)
@@ -82,6 +91,64 @@ func IndexAliasDelete(conf *cfg.Config) *cli.Command {
}
}
func IndexAliasRollover(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "rollover",
Aliases: []string{"roll"},
Usage: "roll over an index alias",
UsageText: "rollover <alias>",
ShellComplete: func(ctx context.Context, cmd *cli.Command) {
complete(cmd, Cindex)
},
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "wait",
Usage: "wait for active shards",
Destination: &conf.Wait,
Aliases: []string{"w"},
},
&cli.BoolFlag{
Name: "dry-run",
Usage: "checks conditions but does not perform a rollover",
Destination: &conf.Wait,
Aliases: []string{"n"},
},
&cli.StringFlag{
Name: "max-age",
Usage: "roll over after max age (eg: 7d, 2m, 8h)",
Destination: &conf.MaxAge,
},
&cli.IntFlag{
Name: "max-docs",
Usage: "roll over after max docs",
Destination: &conf.MaxDocs,
},
&cli.IntFlag{
Name: "max-primary-shard-size",
Usage: "roll over when primary shard size reaches size",
Destination: &conf.MaxShardSize,
},
&cli.IntFlag{
Name: "max-primary-shard-docs",
Usage: "roll over after max docs in primary shard reached",
Destination: &conf.MaxShardDocs,
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
alias := cmd.Args().Get(0)
if alias == "" {
return errors.New("no alias specified")
}
return es.IndexAliasRollover(conf, alias)
},
}
}
func IndexAliasList(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "list",

216
cmd/index_template.go Normal file
View File

@@ -0,0 +1,216 @@
/*
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"
"fmt"
"slices"
"codeberg.org/scip/esctl/pkg/cfg"
"codeberg.org/scip/esctl/pkg/es"
"github.com/urfave/cli/v3"
)
func IndexTemplate(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "template",
Aliases: []string{"tpl"},
Usage: "manage index templates",
Commands: []*cli.Command{
IndexTemplateList(conf),
IndexTemplateShow(conf),
IndexTemplateCreate(conf, false),
IndexTemplateCreate(conf, true),
IndexTemplateDelete(conf),
},
}
}
func IndexTemplateList(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "list",
Aliases: []string{"ls"},
Usage: "list index templates",
Action: func(ctx context.Context, cmd *cli.Command) error {
return es.IndexTemplateList(conf)
},
}
}
func IndexTemplateShow(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "show",
Aliases: []string{"sh"},
Usage: "show details about an index template",
Action: func(ctx context.Context, cmd *cli.Command) error {
index := cmd.Args().Get(0)
if index == "" {
return errors.New("no index template specified")
}
return es.IndexTemplateShow(conf, cmd.Args().Get(0))
},
ShellComplete: func(ctx context.Context, cmd *cli.Command) {
complete(cmd, Cindex)
},
}
}
func IndexTemplateCreate(conf *cfg.Config, modify bool) *cli.Command {
name := "create"
alias := "+"
required := true
if modify {
name = "modify"
alias = "mod"
required = false
}
return &cli.Command{
Name: name,
Aliases: []string{alias},
Usage: name + " a new index template",
UsageText: name + ` <name> <fieldmapping:type>...`,
CustomHelpTemplate: addReference(
`Valid field mapping types: integer, text, date, keyword
Doc for index settings:
https://www.elastic.co/docs/reference/elasticsearch/index-settings
`),
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "data-stream",
Usage: "template creates a data stream",
Destination: &conf.Stream,
Aliases: []string{"S"},
},
&cli.BoolFlag{
Name: "allow-auto-create",
Usage: "automatically create indices",
Destination: &conf.AutoCreate,
Aliases: []string{"a"},
},
&cli.BoolFlag{
Name: "rollover",
Usage: "automatically rollover associated aliases",
Destination: &conf.Rollover,
Aliases: []string{"R"},
},
&cli.StringFlag{
Name: "mode",
Usage: "index mode, one of: standard, timeseries, logsdb or lookup",
Destination: &conf.Mode,
Aliases: []string{"m"},
},
&cli.StringFlag{
Name: "retention",
Usage: "data retention, e.g. -1 (infinitely), 30d, 3m or 1y (data stream only)",
Destination: &conf.Retention,
Aliases: []string{"r"},
Required: required,
},
&cli.IntFlag{
Name: "priority",
Usage: "index template priority",
Destination: &conf.Priority,
Aliases: []string{"p"},
},
&cli.StringSliceFlag{
Name: "index-pattern",
Usage: "index matching pattern",
Destination: &conf.Patterns,
Aliases: []string{"i"},
Required: required,
},
&cli.StringSliceFlag{
Name: "settings",
Usage: "individual template settings, format: name:value, multiple possible",
Destination: &conf.Settings,
Aliases: []string{"s"},
},
&cli.StringSliceFlag{
Name: "component",
Usage: "include component template, multiple possible",
Destination: &conf.Components,
Aliases: []string{"c"},
},
&cli.StringSliceFlag{
Name: "meta",
Usage: "custom meta field, format: name:value, multiple possible",
Destination: &conf.Meta,
Aliases: []string{"M"},
},
&cli.StringSliceFlag{
Name: "aliases",
Usage: "set up aliases to associate with indices",
Destination: &conf.Aliases,
Aliases: []string{"A"},
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
args := cmd.Args()
if args.Len() == 0 {
return fmt.Errorf("no name specified")
}
if conf.Mode != "" {
if !slices.Contains([]string{"standard", "timeseries", "logsdb", "lookup"}, conf.Mode) {
return errors.New("mode must be one of: standard, timeseries, logsdb or lookup")
}
}
mappings := args.Slice()[1:]
if modify {
return es.IndexTemplateModify(conf, args.Get(0), mappings)
}
return es.IndexTemplateCreate(conf, args.Get(0), mappings)
},
}
}
func IndexTemplateDelete(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "delete",
Aliases: []string{"rm"},
Usage: "delete an index template",
Action: func(ctx context.Context, cmd *cli.Command) error {
index := cmd.Args().Get(0)
if index == "" {
return errors.New("no index template specified")
}
return es.IndexTemplateDelete(conf, cmd.Args().Get(0))
},
ShellComplete: func(ctx context.Context, cmd *cli.Command) {
complete(cmd, Cindex)
},
}
}

View File

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

View File

@@ -15,24 +15,3 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package cmd
import (
"context"
"codeberg.org/scip/esctl/pkg/cfg"
"codeberg.org/scip/esctl/pkg/es"
"github.com/urfave/cli/v3"
)
func Repl(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "repl",
Aliases: []string{"shell"},
Usage: "interactive API repl",
Action: func(ctx context.Context, cmd *cli.Command) error {
return es.Repl(conf)
},
}
}

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) {
complete(cmd, Crole)
},
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

@@ -19,9 +19,13 @@ package cmd
import (
"context"
"fmt"
golog "log"
"os"
"runtime/pprof"
"strings"
"codeberg.org/scip/esctl/pkg/cfg"
"codeberg.org/scip/esctl/pkg/es"
"codeberg.org/scip/esctl/pkg/log"
"github.com/urfave/cli/v3"
@@ -38,11 +42,11 @@ func Finish(err error) int {
func Main() int {
conf := cfg.NewConfig()
tree := false
cmd := &cli.Command{
Name: "esctl",
Usage: "manage elasticsearch from cli",
//Version: cfg.Version,
Name: "esctl",
Usage: "manage elasticsearch from cli",
EnableShellCompletion: true,
Flags: []cli.Flag{
@@ -54,6 +58,26 @@ func Main() int {
Sources: cli.EnvVars("ES_DEBUG"),
Destination: &conf.Debug,
},
&cli.BoolFlag{
Name: "debug-http",
Value: false,
Usage: "enable HTTP debugging",
Destination: &conf.DebugHTTP,
},
&cli.BoolFlag{
Name: "show-command-tree",
Value: false,
Usage: "generate a command tree",
Destination: &tree,
Hidden: true,
},
&cli.BoolFlag{
Name: "align-ints",
Aliases: []string{"I"},
Value: false,
Usage: "right align integers in tabular output",
Destination: &conf.AlignInts,
},
&cli.StringFlag{
Name: "config",
Aliases: []string{"c"},
@@ -76,22 +100,42 @@ func Main() int {
Usage: "output mode (tsv, markdown, json, yaml) default: tsv",
Destination: &conf.Output,
},
&cli.StringFlag{
Name: "profile-file",
Usage: "golang profiler output file",
Destination: &conf.ProfileFile,
Hidden: true,
},
},
Commands: []*cli.Command{
Search(conf),
Cluster(conf),
Api(conf),
Ccr(conf),
Cluster(conf),
Datastream(conf),
Doc(conf),
Ilm(conf),
Index(conf),
Node(conf),
Roles(conf),
Search(conf),
Shard(conf),
Snapshot(conf),
Node(conf),
Doc(conf),
Repl(conf),
Task(conf),
Version(conf),
Debug(conf),
HelpJsonPath(conf),
},
Before: func(ctx context.Context, cmd *cli.Command) (context.Context, error) {
if tree {
if err := Tree(cmd); err != nil {
return nil, err
}
os.Exit(0)
}
if err := conf.Init(); err != nil {
if len(os.Args) > 1 {
return nil, err
@@ -103,6 +147,26 @@ func Main() int {
log.Init(conf)
if conf.ProfileFile != "" {
// enable cpu profiling. Do NOT use q to stop the game but
// close the window to get a profile
fd, err := os.Create(conf.ProfileFile)
if err != nil {
return nil, err
}
defer func() {
if err := fd.Close(); err != nil {
golog.Fatal(err)
}
}()
if err := pprof.StartCPUProfile(fd); err != nil {
golog.Fatal(err)
}
defer pprof.StopCPUProfile()
}
return nil, nil
},
}
@@ -110,6 +174,59 @@ func Main() int {
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"]
To extract more than one field, use:
{"ns":source.namespace_name,"time":source.@timestamp} >>
{
"ns": "3f80316965c64405-275a75782e984bcc8d82",
"time": "2026-06-10T04:23:38.914080361+00:00"
}
{
"ns": "3f80316965c64405-f0e1dcef668e45bb9afd",
"time": "2026-06-10T09:39:00.554892542+00:00"
}
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 {
return &cli.Command{
Name: "version",
@@ -122,3 +239,44 @@ func Version(conf *cfg.Config) *cli.Command {
},
}
}
func Debug(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "debug",
Usage: "developer only",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "index",
Usage: "index to search within",
Sources: cli.EnvVars("ES_INDEX"),
Destination: &conf.Index,
Aliases: []string{"i"},
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
return es.Debug(conf)
},
}
}
func Tree(cmd *cli.Command) error {
max := 20
return cmd.Walk(func(cmd *cli.Command) error {
path := cmd.Path()
command := path[len(path)-1]
if len(path) == 1 || command == "help" {
return nil
}
indent := strings.Repeat(" ", len(path[1:])-1)
space := strings.Repeat(" ", max-(len(command)+len(indent)))
fmt.Printf("%s%s %s - %s\n", indent, command, space, cmd.Usage)
return nil
})
}

View File

@@ -18,7 +18,6 @@ package cmd
import (
"context"
"fmt"
"codeberg.org/scip/esctl/pkg/cfg"
"codeberg.org/scip/esctl/pkg/es"
@@ -26,22 +25,33 @@ import (
"github.com/urfave/cli/v3"
)
func Search(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "search",
Aliases: []string{"/"},
Usage: "search within an index",
UsageText: `search [options] [<[field<sep>]pattern> ...]
<sep> might be one of:
const SearchUsage = `field <sep> might be one of:
=: Must match
!=: Must not match
?: Should match
You can omit a field spec and thereby search across all fields.
By default all queries contribute to matches (logical AND), use
-O to apply a logical OR operator.
You can also search multiple fields by separating them with comma, eg:
user,group=root`,
user,group=root
Use filters to further restrict results, they must match literally.
For datetime range format refer to:
https://www.elastic.co/docs/reference/elasticsearch/rest-apis/common-options#date-math
For timestamp formats refer to:
https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/mapping-date-format`
func Search(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "search",
Aliases: []string{"/"},
Usage: "search within an index",
UsageText: "search [options] [<[field<sep>]pattern> ...]\n",
CustomHelpTemplate: addReference(SearchUsage),
Flags: []cli.Flag{
&cli.StringFlag{
@@ -53,17 +63,17 @@ user,group=root`,
},
&cli.IntFlag{
Name: "from",
Usage: "show results FROM (default 0)",
Usage: "show results starting at <from>",
Destination: &conf.From,
Value: 0,
Aliases: []string{"f"},
},
&cli.IntFlag{
Name: "to",
Usage: "show results to (default 10)",
Name: "len",
Usage: "number of results to show (-1: all[max:10k], caution: might be slow)",
Destination: &conf.To,
Value: 10,
Aliases: []string{"t"},
Value: 20,
Aliases: []string{"l"},
},
&cli.StringSliceFlag{
Name: "filter",
@@ -77,53 +87,65 @@ user,group=root`,
Destination: &conf.Path,
Aliases: []string{"p"},
},
&cli.StringFlag{
Name: "timerange",
Usage: "field:<date> to <date> (e.g. @timestamp:2026-05-05 to 2026-05-15)",
Destination: &conf.Range,
Aliases: []string{"r"},
},
&cli.StringFlag{
Name: "timestamp-format",
Usage: "a valid ES builtin timestamp or custom format",
Destination: &conf.TimestampFormat,
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{
Name: "help-jsonpath",
Usage: "show jsonPath help",
Destination: &conf.Subhelp,
Aliases: []string{"H"},
Name: "ascending",
Usage: "sort in ascending order (default: descending)",
Destination: &conf.Ascending,
Aliases: []string{"a"},
},
&cli.BoolFlag{
Name: "tail",
Usage: "follow search live, like tail -f",
Destination: &conf.Tail,
Aliases: []string{"T"},
},
&cli.BoolFlag{
Name: "or",
Usage: "logical operator (default: and)",
Destination: &conf.Or,
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 {
if conf.Subhelp {
return showJsonPathHelp()
}
args := cmd.Args()
if conf.To == -1 {
conf.To = 10000
}
return es.Search(conf, args.Slice())
},
}
}
func showJsonPathHelp() error {
_, err := fmt.Println(`jsonPath usage:
name.last >> "Anderson"
age >> 37
children >> ["Sara","Alex","Jack"]
children.# >> 3
children.1 >> "Alex"
child*.2 >> "Jack"
c?ildren.0 >> "Sara"
fav\.movie >> "Deer Hunter"
friends.#.first >> ["Dale","Roger","Jane"]
friends.1.last >> "Craig"
You can also query an array for the first match by using #(...), or
find all matches with #(...)#. Queries support the ==, !=, <, <=, >,
>= comparison operators and the simple pattern matching % (like) and
!% (not like) operators. Eg:
friends.#(last=="Murphy").first >> "Dale"
friends.#(last=="Murphy")#.first >> ["Dale","Jane"]
friends.#(age>45)#.last >> ["Craig","Murphy"]
friends.#(first%"D*").last >> "Murphy"
friends.#(first!%"D*").last >> "Craig"
friends.#(nets.#(=="fb"))#.first >> ["Dale","Roger"]
Documentation: https://github.com/tidwall/gjson/blob/master/SYNTAX.md`)
return err
}

69
cmd/tasks.go Normal file
View File

@@ -0,0 +1,69 @@
/*
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 Task(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "task",
Usage: "manage tasks",
Commands: []*cli.Command{
TaskList(conf),
TaskCancel(conf),
},
}
}
func TaskList(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "list",
Aliases: []string{"ls"},
Usage: "list tasks",
Action: func(ctx context.Context, cmd *cli.Command) error {
return es.TaskList(conf)
},
}
}
func TaskCancel(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "cancel",
Aliases: []string{"rm"},
Usage: "cancel running task",
UsageText: "cancel <taskId>",
Action: func(ctx context.Context, cmd *cli.Command) error {
taskId := cmd.Args().Get(0)
if taskId == "" {
return errors.New("no taskId specified")
}
return es.TaskCancel(conf, cmd.Args().Get(0))
},
}
}

35
cmd/utilities.go Normal file
View File

@@ -0,0 +1,35 @@
/*
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"
"strings"
"github.com/urfave/cli/v3"
)
func addReference(ref string) string {
indentedRef := []string{}
for _, line := range strings.Split(ref, "\n") {
indentedRef = append(indentedRef, " "+line)
}
return fmt.Sprintf("%s\nREFERENCE:\n%s\n",
cli.SubcommandHelpTemplate,
strings.Join(indentedRef, "\n"))
}

View File

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

63
go.mod
View File

@@ -14,42 +14,89 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
module codeberg.org/scip/esctl
go 1.25.0
go 1.25.8
require (
github.com/MichaelMure/go-term-markdown v0.1.4
github.com/alecthomas/repr v0.5.2
github.com/charmbracelet/lipgloss v1.1.0
github.com/chzyer/readline v1.5.1
github.com/dustin/go-humanize v1.0.1
github.com/elastic/elastic-transport-go/v8 v8.11.0
github.com/elastic/go-elasticsearch/v9 v9.3.4
github.com/fatih/color v1.19.0
github.com/go-openapi/loads v0.24.0
github.com/go-openapi/spec v0.22.5
github.com/go-openapi/swag/loading v0.26.1
github.com/mattn/go-isatty v0.0.22
github.com/olekukonko/tablewriter v1.1.4
github.com/tidwall/gjson v1.19.0
github.com/tlinden/yadu v0.1.3
github.com/urfave/cli/v3 v3.9.0
github.com/urfave/cli/v3 v3.10.1-0.20260623012112-f980ca84bf65
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/MichaelMure/go-term-text v0.3.1 // indirect
github.com/alecthomas/chroma v0.7.1 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/clipperhouse/displaywidth v0.10.0 // indirect
github.com/clipperhouse/uax29/v2 v2.6.0 // indirect
github.com/charmbracelet/bubbles v1.0.0 // indirect
github.com/charmbracelet/bubbletea v1.3.10 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964 // indirect
github.com/disintegration/imaging v1.6.2 // indirect
github.com/dlclark/regexp2 v1.11.0 // indirect
github.com/eliukblau/pixterm/pkg/ansimage v0.0.0-20191210081756-9fb6cf8c2f75 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-openapi/analysis v0.25.2 // indirect
github.com/go-openapi/errors v0.22.8 // indirect
github.com/go-openapi/jsonpointer v0.23.1 // indirect
github.com/go-openapi/jsonreference v0.21.6 // indirect
github.com/go-openapi/strfmt v0.26.3 // indirect
github.com/go-openapi/swag/conv v0.26.1 // indirect
github.com/go-openapi/swag/jsonname v0.26.1 // indirect
github.com/go-openapi/swag/jsonutils v0.26.1 // indirect
github.com/go-openapi/swag/mangling v0.26.1 // indirect
github.com/go-openapi/swag/stringutils v0.26.1 // indirect
github.com/go-openapi/swag/typeutils v0.26.1 // indirect
github.com/go-openapi/swag/yamlutils v0.26.1 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/goccy/go-json v0.10.6 // indirect
github.com/lmittmann/tint v1.1.3 // indirect
github.com/gomarkdown/markdown v0.0.0-20191123064959-2c17d62f5098 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/kyokomi/emoji/v2 v2.2.8 // indirect
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.23 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/oklog/ulid/v2 v2.1.1 // indirect
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect
github.com/olekukonko/errors v1.2.0 // indirect
github.com/olekukonko/ll v0.1.8 // indirect
github.com/tidwall/gjson v1.19.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/otel v1.35.0 // indirect
go.opentelemetry.io/otel/metric v1.35.0 // indirect
go.opentelemetry.io/otel/trace v1.35.0 // indirect
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
golang.org/x/sys v0.42.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect
golang.org/x/image v0.0.0-20191206065243-da761ea9ff43 // indirect
golang.org/x/net v0.55.0 // indirect
golang.org/x/sys v0.46.0 // indirect
golang.org/x/term v0.44.0 // indirect
golang.org/x/text v0.37.0 // indirect
)

217
go.sum
View File

@@ -1,29 +1,74 @@
github.com/MichaelMure/go-term-markdown v0.1.4 h1:Ir3kBXDUtOX7dEv0EaQV8CNPpH+T7AfTh0eniMOtNcs=
github.com/MichaelMure/go-term-markdown v0.1.4/go.mod h1:EhcA3+pKYnlUsxYKBJ5Sn1cTQmmBMjeNlpV8nRb+JxA=
github.com/MichaelMure/go-term-text v0.3.1 h1:Kw9kZanyZWiCHOYu9v/8pWEgDQ6UVN9/ix2Vd2zzWf0=
github.com/MichaelMure/go-term-text v0.3.1/go.mod h1:QgVjAEDUnRMlzpS6ky5CGblux7ebeiLnuy9dAaFZu8o=
github.com/alecthomas/assert v0.0.0-20170929043011-405dbfeb8e38 h1:smF2tmSOzy2Mm+0dGI2AIUHY+w0BUc+4tn40djz7+6U=
github.com/alecthomas/assert v0.0.0-20170929043011-405dbfeb8e38/go.mod h1:r7bzyVFMNntcxPZXK3/+KdruV1H5KSlyVY0gc+NgInI=
github.com/alecthomas/chroma v0.7.1 h1:G1i02OhUbRi2nJxcNkwJaY/J1gHXj9tt72qN6ZouLFQ=
github.com/alecthomas/chroma v0.7.1/go.mod h1:gHw09mkX1Qp80JlYbmN9L3+4R5o6DJJ3GRShh+AICNc=
github.com/alecthomas/colour v0.0.0-20160524082231-60882d9e2721 h1:JHZL0hZKJ1VENNfmXvHbgYlbUOvpzYzvy2aZU5gXVeo=
github.com/alecthomas/colour v0.0.0-20160524082231-60882d9e2721/go.mod h1:QO9JBoKquHd+jz9nshCh40fOfO+JzsoXy8qTHF68zU0=
github.com/alecthomas/kong v0.2.1-0.20190708041108-0548c6b1afae/go.mod h1:+inYUSluD+p4L8KdviBSgzcqEjUQOfC5fQDRFuc36lI=
github.com/alecthomas/repr v0.0.0-20180818092828-117648cd9897/go.mod h1:xTS7Pm1pD1mvyM075QCDSRqH6qRLXylzS24ZTpRiSzQ=
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM=
github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ=
github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI=
github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk=
github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04=
github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8=
github.com/clipperhouse/displaywidth v0.10.0 h1:GhBG8WuerxjFQQYeuZAeVTuyxuX+UraiZGD4HJQ3Y8g=
github.com/clipperhouse/displaywidth v0.10.0/go.mod h1:XqJajYsaiEwkxOj4bowCTMcT1SgvHo9flfF3jQasdbs=
github.com/clipperhouse/uax29/v2 v2.6.0 h1:z0cDbUV+aPASdFb2/ndFnS9ts/WNXgTNNGFoKXuhpos=
github.com/clipperhouse/uax29/v2 v2.6.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964 h1:y5HC9v93H5EPKqaS1UYVg1uYah5Xf51mBfIoWehClUQ=
github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964/go.mod h1:Xd9hchkHSWYkEqJwUGisez3G1QY8Ryz0sdWrLPMGjLk=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/dlclark/regexp2 v1.1.6/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/elastic/elastic-transport-go/v8 v8.9.0 h1:KeT/2P54F0xS0S8Y3Pf+tFDg4HmBgReQMB+BMz8dDAs=
github.com/elastic/elastic-transport-go/v8 v8.9.0/go.mod h1:ssMTvNS2hwf7CaiGsRRsx4gQHFZ/jS/DkLcISxekWzc=
github.com/elastic/elastic-transport-go/v8 v8.11.0 h1:taYmqC2M6+fZt/+W+ENYh/W5L9+KrlJGOSbEJs8egWc=
github.com/elastic/elastic-transport-go/v8 v8.11.0/go.mod h1:DZQ0szCNywc9F+C9l/Kkd4n69SvJVj0I3yK1Of7s3l8=
github.com/elastic/go-elasticsearch/v9 v9.3.2 h1:nvtvfN/Gsp/rzUPz/9yILwDAsYJ3s5L0VmhR16zPKCA=
github.com/elastic/go-elasticsearch/v9 v9.3.2/go.mod h1:ubKUMJCJbX5V/gW5MIn2NQZyaEZ61ubXwJmD5UMNrM8=
github.com/elastic/go-elasticsearch/v9 v9.3.4 h1:vnqXl6jnlA+ZwlfRaKF9BR9woZC1KSB17gtyvgf8zVU=
github.com/elastic/go-elasticsearch/v9 v9.3.4/go.mod h1:ubKUMJCJbX5V/gW5MIn2NQZyaEZ61ubXwJmD5UMNrM8=
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/eliukblau/pixterm/pkg/ansimage v0.0.0-20191210081756-9fb6cf8c2f75 h1:vbix8DDQ/rfatfFr/8cf/sJfIL69i4BcZfjrVOxsMqk=
github.com/eliukblau/pixterm/pkg/ansimage v0.0.0-20191210081756-9fb6cf8c2f75/go.mod h1:0gZuvTO1ikSA5LtTI6E13LEOdWQNjIo5MTQOvrV0eFg=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU=
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
@@ -31,33 +76,109 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-openapi/analysis v0.25.2 h1:I0vy4n3alz+DHTiN1PRhCb7QZxkK6g5YmswZKv2TKuw=
github.com/go-openapi/analysis v0.25.2/go.mod h1:Uhs1t/2XR10EnwONYILGEzw8gcfGIG5Xk5K2AxnhqDo=
github.com/go-openapi/errors v0.22.8 h1:oP7sW7TWc3wFFjrzzj0nI83H2qMBkNjNfSd+XRejk/I=
github.com/go-openapi/errors v0.22.8/go.mod h1:BuUoHcYrU6E7V9gfj1I5wLQqgtIHnup/alXZ8KdgQ0w=
github.com/go-openapi/jsonpointer v0.23.1 h1:1HBACs7XIwR2RcmItfdSFlALhGbe6S92p0ry4d1GWg4=
github.com/go-openapi/jsonpointer v0.23.1/go.mod h1:iWRmZTrGn7XwYhtPt/fvdSFj1OfNBngqRT2UG3BxSqY=
github.com/go-openapi/jsonreference v0.21.6 h1:NZ5nGfnaM1n4I43Xjm1e5/M2GjOwQwndQz22uhxwD+Y=
github.com/go-openapi/jsonreference v0.21.6/go.mod h1:xzbgtQ3ZbWxvET3AxdzCJlJt6vkovbf+IfSPJjD0tUY=
github.com/go-openapi/loads v0.24.0 h1:4LLorXRPTzIN9V6ngMUZbAscsBOUBk3Oa8cClu/bFrQ=
github.com/go-openapi/loads v0.24.0/go.mod h1:xQMgX+hw5xRAhGrcDXxeMw78IFqUpIzhleu3HqPhyF4=
github.com/go-openapi/spec v0.22.5 h1:KhO7RBlKQfonUWX2WzQCoLIXVA6AcNqDGZ3a1Dutdlo=
github.com/go-openapi/spec v0.22.5/go.mod h1:vxpOtMya5TXtENXKE5bKqv5NjocVhyhxHrlZfvKnZ74=
github.com/go-openapi/strfmt v0.26.3 h1:rzmslHarJgBbf2qfGge+X3htclQfmXqBZMm0Too0HhU=
github.com/go-openapi/strfmt v0.26.3/go.mod h1:a5nsUw0oRpQzZeOwx8bi6cKbzFZslpbCKt1LEot+KnQ=
github.com/go-openapi/swag/conv v0.26.1 h1:slr5FVkg9Wc3Y5zcwenD8Sd/PQ94b2I/QJI7N7KTBpg=
github.com/go-openapi/swag/conv v0.26.1/go.mod h1:mvQXgPptZk9GTrFgGwWvT4q+dN+zQej9JfmGwnipz1A=
github.com/go-openapi/swag/jsonname v0.26.1 h1:VReupaV6WxlAsCn0e4DUfgV6bPmINnPpyJDLqSfNPcE=
github.com/go-openapi/swag/jsonname v0.26.1/go.mod h1:OvdW6BoWoj33pTfi7x9vFrgmT+fk7aw0BRwvCE0YOuc=
github.com/go-openapi/swag/jsonutils v0.26.1 h1:2hdBfFkHg+7Wrz2VsCbeyR6hzkRDs7AztnMR2u84yOY=
github.com/go-openapi/swag/jsonutils v0.26.1/go.mod h1:U+RMJH3wa+6BRiphuRtIyI8fW9HPFqFQ4sHk2oRx0UQ=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.1 h1:1CD7NiLLb/TXl3tOnFYU4b+mNfb5rtgHkaA+q7RMYYQ=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.1/go.mod h1:ZWafc8nMdYzTE3uYY6W86f0n46+IF0g4uUyRhJw/kXc=
github.com/go-openapi/swag/loading v0.26.1 h1:E9K4wqXeROlhjFQ13K9zMz6ojFGXIggGe+ad1odrK9w=
github.com/go-openapi/swag/loading v0.26.1/go.mod h1:3qvRIlWzWdq1HvmldwmuJ2ohpcAryN6xVt2OTKd0/7E=
github.com/go-openapi/swag/mangling v0.26.1 h1:gpYI4WuPKFJJVjV5cDLGlDVJhFIxYjQc7yN5eEb4CqM=
github.com/go-openapi/swag/mangling v0.26.1/go.mod h1:POETDH01hqAdASXfw7ISEd9bCOE6xBHOt8NHmGZRmYM=
github.com/go-openapi/swag/stringutils v0.26.1 h1:f88uYyTso7TnHrKM/bUBsQ5e2wKf37cpgo6pvbzd9yU=
github.com/go-openapi/swag/stringutils v0.26.1/go.mod h1:Sc6d3bU8fgk5AyZR8/8jEQ+Is/Ald+TD/IIggPN8UJk=
github.com/go-openapi/swag/typeutils v0.26.1 h1:yg42FgMzRR6PVQ3M3qHz1s+Y6/P4HoJ3cBarXa3OVnU=
github.com/go-openapi/swag/typeutils v0.26.1/go.mod h1:VfnV+oUtSP2vCSCn2aJgnr8OevUYemyIzzS1VOzS10o=
github.com/go-openapi/swag/yamlutils v0.26.1 h1:0TSLK+lXs9vfIhAWzBeI/lOzEnIoot6WTCO1aAeWFTk=
github.com/go-openapi/swag/yamlutils v0.26.1/go.mod h1:7W5b7PRX9MxwL7TjeG7H8HkyBGRsIDRObhyMWFgBI2M=
github.com/go-openapi/testify/enable/yaml/v2 v2.5.1 h1:q9NtHwK4qHF7yZziBPvZyv7zWAIk8ok88Gh2mR6Jpc8=
github.com/go-openapi/testify/enable/yaml/v2 v2.5.1/go.mod h1:JW0MXIotCYps/XsgJnG3a8Q7rE5xAiBwoOD5OfaIQBk=
github.com/go-openapi/testify/v2 v2.5.1 h1:TMdhCaw8fUNraVSf3Omoob1dO/AzBfhtFAPW0an6sBo=
github.com/go-openapi/testify/v2 v2.5.1/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/lmittmann/tint v1.1.3 h1:Hv4EaHWXQr+GTFnOU4VKf8UvAtZgn0VuKT+G0wFlO3I=
github.com/lmittmann/tint v1.1.3/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/gomarkdown/markdown v0.0.0-20191123064959-2c17d62f5098 h1:Qxs3bNRWe8GTcKMxYOSXm0jx6j0de8XUtb/fsP3GZ0I=
github.com/gomarkdown/markdown v0.0.0-20191123064959-2c17d62f5098/go.mod h1:aii0r/K0ZnHv7G0KF7xy1v0A7s2Ljrb5byB7MO5p6TU=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kyokomi/emoji/v2 v2.2.8 h1:jcofPxjHWEkJtkIbcLHvZhxKgCPl6C7MyjTrD4KDqUE=
github.com/kyokomi/emoji/v2 v2.2.8/go.mod h1:JUcn42DTdsXJo1SWanHh4HKDEyPaR5CqkmoirZZP9qE=
github.com/lucasb-eyer/go-colorful v1.0.3/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s=
github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 h1:zrbMGy9YXpIeTnGj4EljqMiZsIcE09mmF8XsD5AYOJc=
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6/go.mod h1:rEKTHC9roVVicUIfZK7DYrdIoM0EOr8mK1Hj5s3JjH0=
github.com/olekukonko/errors v1.2.0 h1:10Zcn4GeV59t/EGqJc8fUjtFT/FuUh5bTMzZ1XwmCRo=
github.com/olekukonko/errors v1.2.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y=
github.com/olekukonko/ll v0.1.6 h1:lGVTHO+Qc4Qm+fce/2h2m5y9LvqaW+DCN7xW9hsU3uA=
github.com/olekukonko/ll v0.1.6/go.mod h1:NVUmjBb/aCtUpjKk75BhWrOlARz3dqsM+OtszpY4o88=
github.com/olekukonko/ll v0.1.8 h1:ysHCJRGHYKzmBSdz9w5AySztx7lG8SQY+naTGYUbsz8=
github.com/olekukonko/ll v0.1.8/go.mod h1:RPRC6UcscfFZgjo1nulkfMH5IM0QAYim0LfnMvUuozw=
github.com/olekukonko/tablewriter v1.1.4 h1:ORUMI3dXbMnRlRggJX3+q7OzQFDdvgbN9nVWj1drm6I=
github.com/olekukonko/tablewriter v1.1.4/go.mod h1:+kedxuyTtgoZLwif3P1Em4hARJs+mVnzKxmsCL/C5RY=
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ=
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU=
github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
@@ -66,28 +187,52 @@ github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tlinden/yadu v0.1.3 h1:5cRCUmj+l5yvlM2irtpFBIJwVV2DPEgYSaWvF19FtcY=
github.com/tlinden/yadu v0.1.3/go.mod h1:l3bRmHKL9zGAR6pnBHY2HRPxBecf7L74BoBgOOpTcUA=
github.com/urfave/cli/v3 v3.8.0 h1:XqKPrm0q4P0q5JpoclYoCAv0/MIvH/jZ2umzuf8pNTI=
github.com/urfave/cli/v3 v3.8.0/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso=
github.com/urfave/cli/v3 v3.9.0 h1:AV9lIiPv3ukYnxunaCUsHnEozptYmDN2F0+yWqLMn/c=
github.com/urfave/cli/v3 v3.9.0/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso=
github.com/urfave/cli/v3 v3.9.1-0.20260524212652-be8b79d0c8de h1:ESKPiS7inVoBnv4FmgGNZdjWBI/wmvaragoyD3D9nM4=
github.com/urfave/cli/v3 v3.9.1-0.20260524212652-be8b79d0c8de/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso=
github.com/urfave/cli/v3 v3.10.1-0.20260623012112-f980ca84bf65 h1:NXitXXO9DupDLEFj8hIyYtsubRxSFTp5JXqohElT0nU=
github.com/urfave/cli/v3 v3.10.1-0.20260623012112-f980ca84bf65/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ=
go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y=
go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M=
go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE=
go.opentelemetry.io/otel/sdk v1.29.0 h1:vkqKjk7gwhS8VaWb0POZKmIEDimRCMsopNYnriHyryo=
go.opentelemetry.io/otel/sdk v1.29.0/go.mod h1:pM8Dx5WKnvxLCb+8lG1PRNIDxu9g9b9g59Qr7hfAAok=
go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs=
go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/dl v0.0.0-20190829154251-82a15e2f2ead/go.mod h1:IUMfjQLJQd4UTqG1Z90tenwKoCX93Gn3MAQJMOSBsDQ=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.0.0-20191206065243-da761ea9ff43 h1:gQ6GUSD102fPgli+Yb4cR/cGaHF7tNBt+GYoRCpGC7s=
golang.org/x/image v0.0.0-20191206065243-da761ea9ff43/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/sys v0.0.0-20181128092732-4ed8d59d0b35/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

126
pkg/cfg/cluster.go Normal file
View File

@@ -0,0 +1,126 @@
/*
Copyright © 2026 Thomas von Dein
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package cfg
import (
"errors"
"fmt"
"net/http"
"os"
"github.com/elastic/elastic-transport-go/v8/elastictransport"
"github.com/elastic/go-elasticsearch/v9"
"gopkg.in/yaml.v3"
)
// used in general config struct
type Cluster struct {
Uri, User, Pass string
client *elasticsearch.TypedClient
Default bool
}
// used just for writing back to the config file
type ClusterConfig struct {
Uri, User, Pass string
Default bool
}
// to write the config, we avoid all other config settings
type WriteConfig struct {
Clusters map[string]*ClusterConfig
}
func (cluster *Cluster) ES() *elasticsearch.TypedClient {
if cluster.client == nil {
fmt.Println("no current cluster, use 'esctl cluster switch <name>' to set one")
os.Exit(1)
}
return cluster.client
}
func (cluster *Cluster) SetClient(client *elasticsearch.TypedClient) {
cluster.client = client
}
// set Default=true for the given cluster in the config (if exists)
func (conf *Config) SwitchCluster(name string) error {
_, exists := conf.Clusters[name]
if !exists {
return errors.New("no cluster with that name configured")
}
cfg := WriteConfig{Clusters: map[string]*ClusterConfig{}}
for clustername, cluster := range conf.Clusters {
cfg.Clusters[clustername] = &ClusterConfig{
Uri: cluster.Uri,
User: cluster.User,
Pass: cluster.Pass,
Default: false,
}
if clustername == name {
cfg.Clusters[clustername].Default = true
}
}
raw, err := yaml.Marshal(cfg)
if err != nil {
return fmt.Errorf("failed to marshal cluster config: %w", err)
}
outfile := getDefaultPath()
if conf.ConfigFile != "" {
outfile = conf.ConfigFile
}
if err := os.WriteFile(outfile, raw, 0600); err != nil {
return err
}
return nil
}
func (conf *Config) SetupES() error {
// These headers are not needed with ES 9, but with ES 8, we set
// them here so every API call uses it. The only exception being
// the api repl, which does it on its own.
headers := http.Header{}
headers.Add("content-type", "application/json")
headers.Add("Accept", "application/json")
for _, cluster := range conf.Clusters {
es, err := elasticsearch.NewTyped(
elasticsearch.WithAddresses(cluster.Uri),
elasticsearch.WithBasicAuth(cluster.User, cluster.Pass),
elasticsearch.WithTransportOptions(
conf.getTransport(),
elastictransport.WithHeader(headers),
),
)
if err != nil {
return fmt.Errorf("failed to setup elasticsearch connection: %w", err)
}
cluster.SetClient(es)
}
return nil
}

View File

@@ -17,21 +17,18 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
package cfg
import (
"context"
"crypto/tls"
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"reflect"
"github.com/alecthomas/repr"
"github.com/elastic/elastic-transport-go/v8/elastictransport"
"github.com/elastic/go-elasticsearch/v9"
"gopkg.in/yaml.v3"
)
const (
Version string = `v0.0.13`
Version string = `v0.0.21`
)
var (
@@ -39,40 +36,84 @@ var (
APIVERSION, GOVERSION, BUILD, COMMIT, BRANCH string
)
type Cluster struct {
Uri, User, Pass string
ES *elasticsearch.TypedClient
}
type Config struct {
ConfigFile string // -c
CurrentCluster string // -C
Debug bool // -d
Output string // -o <mode>
Clusters map[string]*Cluster
DefaultCluster *Cluster
Index string // index: -i
Failed, Partials bool // index: flags
Shards, Replicas int // index create+allocation: -s -r
Wait bool // index create: -w
Primary bool // index allocation: -p
From, To, MaxItems int // search: flags
Filter []string // search: -F
Path string // search+doc sh: -p
Subhelp bool // search+doc sh: -H
Exclude string // cluster compare: -e (regexp)
All, Verbose bool // cluster status: -a -v
Persistent, Transient, Default bool // -p -t -D cluster settings set
Force bool // ccr follower renew: -f
HaveJQ bool // determined at runtime by ourselfes
ConfigFile string // -c
CurrentCluster string // -C
Debug bool // -d
Output string // -o <mode>
Clusters map[string]*Cluster
DefaultCluster *Cluster
HaveJQ bool // determined at runtime by ourselfes
ProfileFile string // for internal use (golang profiling)
AlignInts bool // -I
Index string // index: -i
Failed, Partials bool // index: flags
Shards, Replicas int // index create+allocation: -s -r
Wait bool // index create: -w
Primary bool // index allocation: -p
Searchable bool // index fields: -s
Aggretable bool // index fields: -a
Priority int // index template create: -p
Settings []string // index template create: -s
Patterns []string // index template create: -i
Components []string // index template create: -c
Meta []string // index template create: -M
Aliases []string // index template create: -A
Stream bool // index template create: -S
AutoCreate bool // index template create: -a
Mode string // index template create: -a
Retention string // index template create: -r
Rollover bool // index template create: -R
From, To, MaxItems int // search: flags
Filter []string // search: -F
Path string // search+doc sh: -p
Subhelp bool // search+doc sh: -H
Tail bool // search: -f [tail]
Or bool // search: -O
Range string // search: -r
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)
All, Verbose bool // cluster status: -a -v
Persistent, Transient, Default bool // cluster settings set: -p -t -D
Force bool // ccr follower renew: -f
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
Hidden bool // ds ls: -H
// rollover
MaxAge string
MaxDocs, MaxShardSize, MaxShardDocs int // roll over
DryRun bool // rollover: -n
Tag string // api ls: -t
Ilm Ilm // ilm create
}
func NewConfig() *Config {
return &Config{Clusters: map[string]*Cluster{}}
}
func getDefaultPath() string {
return filepath.Join([]string{os.Getenv("HOME"), ".config", "esctl", "config.yaml"}...)
}
func (conf *Config) Init() error {
DefaultConfig := os.Getenv("HOME") + "/.config/esctl/config.yaml"
DefaultConfig := getDefaultPath()
switch {
case fileExists(DefaultConfig):
@@ -93,26 +134,33 @@ func (conf *Config) Init() error {
}
if conf.CurrentCluster != "" {
// -C specified, set current cluster explicitly, no matter what the config says
current, exists := conf.Clusters[conf.CurrentCluster]
if !exists {
return fmt.Errorf("no cluster with alias %s configured", conf.CurrentCluster)
} else {
conf.DefaultCluster = current
// disable all others
for _, cluster := range conf.Clusters {
cluster.Default = false
}
conf.DefaultCluster.Default = true
}
} else {
// we need to determine ourselfes
if len(conf.Clusters) == 1 {
// ok, just one cluster configured, use this, of course
for name, cluster := range conf.Clusters {
conf.DefaultCluster = cluster
conf.CurrentCluster = name
conf.DefaultCluster.Default = true
}
} else {
// multiple ones exists, look if one is set as default
for name, cluster := range conf.Clusters {
_, err := cluster.ES.Cluster.Health().
Header("content-type", "application/json").
Header("accept", "application/json").
Do(context.Background())
if err == nil {
if cluster.Default {
conf.DefaultCluster = cluster
conf.CurrentCluster = name
}
@@ -127,17 +175,33 @@ func (conf *Config) Init() error {
return nil
}
// we are using reflect to clone a config obj w/o the ES stuff for
// shorter repr.Println() output (the ES structure is just too large)
func (conf *Config) Clone() Config {
clone := Config{}
ref := reflect.ValueOf(*conf)
typeOfS := ref.Type()
for i := 0; i < ref.NumField(); i++ {
field := typeOfS.Field(i).Name
if field == "Clusters" || field == "DefaultCluster" || !ref.Field(i).CanInterface() {
continue
}
reflect.ValueOf(&clone).Elem().FieldByName(field).Set(reflect.ValueOf(ref.Field(i).Interface()))
}
return clone
}
func (conf *Config) PrintDebug() {
if !conf.Debug {
return
}
clone := *conf
for name := range clone.Clusters {
clone.Clusters[name] = nil
}
clone.DefaultCluster = nil
clone := conf.Clone()
fmt.Println("config:")
repr.Println(clone)
@@ -185,40 +249,16 @@ func (conf *Config) LoadConfig() error {
if len(newconf.Clusters) > 0 {
conf.Clusters = newconf.Clusters
_, exists := conf.Clusters["default"]
if !exists {
// no "default", just use the first we stumble upon
for _, cluster := range conf.Clusters {
for _, cluster := range conf.Clusters {
if cluster.Default {
conf.DefaultCluster = cluster
break
}
} else {
conf.DefaultCluster = newconf.Clusters["default"]
}
}
return nil
}
func (conf *Config) SetupES() error {
for _, cluster := range conf.Clusters {
es, err := elasticsearch.NewTyped(
elasticsearch.WithAddresses(cluster.Uri),
elasticsearch.WithBasicAuth(cluster.User, cluster.Pass),
elasticsearch.WithTransportOptions(
elastictransport.WithTransport(
&http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
},
),
),
)
if err != nil {
return fmt.Errorf("failed to setup elasticsearch connection: %w", err)
}
cluster.ES = es
if conf.DefaultCluster == nil {
conf.DefaultCluster = &Cluster{}
}
}
return nil

72
pkg/cfg/ilm.go Normal file
View File

@@ -0,0 +1,72 @@
/*
Copyright © 2026 Thomas von Dein
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package cfg
type Ilm struct {
Description string
HotMinAge string
HotRolloverMaxAge string
HotRolloverMaxPrimaryShardSize int64
HotRolloverMaxDocs int64
HotReadonly bool
WarmMinAge string
WarmForceMerge int
WarmShrinkShards int
WarmPriority int
ColdMinAge string
ColdSearchableSnapshotRepo string
ColdForceMerge int
ColdPriority int
FrozenMinAge string
DeleteMinAge string
DeleteSearchableSnapshots bool
}
func (cfg *Ilm) HaveHot() bool {
return cfg.HotMinAge != "" ||
cfg.HotRolloverMaxAge != "" ||
cfg.HotRolloverMaxDocs > 0 ||
cfg.HotRolloverMaxPrimaryShardSize > 0 ||
cfg.HotReadonly
}
func (cfg *Ilm) HaveWarm() bool {
return cfg.WarmMinAge != "" ||
cfg.WarmForceMerge > 0 ||
cfg.WarmShrinkShards > 0 ||
cfg.WarmPriority > 0
}
func (cfg *Ilm) HaveCold() bool {
return cfg.ColdMinAge != "" ||
cfg.ColdSearchableSnapshotRepo != "" ||
cfg.ColdForceMerge > 0 ||
cfg.ColdPriority > 0
}
func (cfg *Ilm) HaveFrozen() bool {
return cfg.FrozenMinAge != ""
}
func (cfg *Ilm) HaveDelete() bool {
return cfg.DeleteSearchableSnapshots || cfg.DeleteMinAge != ""
}

78
pkg/cfg/transport.go Normal file
View File

@@ -0,0 +1,78 @@
/*
Copyright © 2026 Thomas von Dein
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package cfg
import (
"bytes"
"crypto/tls"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"github.com/elastic/elastic-transport-go/v8/elastictransport"
)
// used to print uri, path and body of a request made by the go-client
type DebugTransport struct {
Transport http.RoundTripper
}
func (t *DebugTransport) RoundTrip(req *http.Request) (*http.Response, error) {
content := ""
contentline := ""
if req.ContentLength > 0 {
buf := new(bytes.Buffer)
body, _ := req.GetBody()
_, err := buf.ReadFrom(body)
if err != nil {
return nil, err
}
var pretty bytes.Buffer
err = json.Indent(&pretty, buf.Bytes(), "", "\t")
if err != nil {
return nil, fmt.Errorf("json parse error: %s", err)
}
content = pretty.String()
contentline = buf.String()
}
slog.Info("req", "host", req.URL.Host, "uri", req.URL.Path,
"body", content, "bodyline", contentline,
"headers", req.Header,
)
return t.Transport.RoundTrip(req)
}
func (conf *Config) getTransport() elastictransport.Option {
transport := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
if conf.DebugHTTP {
return elastictransport.WithTransport(
&DebugTransport{Transport: transport},
)
}
return elastictransport.WithTransport(transport)
}

530
pkg/es/api.go Normal file
View File

@@ -0,0 +1,530 @@
/*
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"
"crypto/tls"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"os/exec"
"regexp"
"slices"
"strings"
"codeberg.org/scip/esctl/assets"
"codeberg.org/scip/esctl/pkg/cfg"
"codeberg.org/scip/esctl/pkg/printer"
markdown "github.com/MichaelMure/go-term-markdown"
"github.com/charmbracelet/lipgloss"
"github.com/chzyer/readline"
"github.com/go-openapi/spec"
"golang.org/x/term"
)
const (
DefaultMargin = 4
intro = `Input format: verb path [data]"
Example:
post /yourindex/_ccr/pause_follow
put /yourindex/_settings {"number_of_replicas": 1}
You can also put multiline JSON after the path like:
put /yourindex/_settings
{
"number_of_replicas": 1
}
If you do NOT supply a JSON in the first line, you need to hit ENTER
twice to complete.`
)
// holds an API operation via go-openapi/spec
type Op struct {
Verb string
Op *spec.Operation
}
type Param struct {
Description, Param string
}
// API operation parameter type
type Params struct {
Path []Param
Query []Param
}
func ApiRepl(conf *cfg.Config) error {
verbs := []string{"post", "get", "put", "delete"}
fmt.Println(intro)
fmt.Println()
reader, err := readline.NewEx(&readline.Config{
Prompt: "> ",
HistoryFile: os.Getenv("HOME") + "/.config/esctl/history",
HistoryLimit: 500,
InterruptPrompt: "^C",
EOFPrompt: "exit",
HistorySearchFold: true,
})
if err != nil {
return fmt.Errorf("failed to initialize readline lib: %s", err)
}
for {
text, err := reader.Readline()
if err != nil {
break
}
text = strings.TrimSpace(text)
if text == "" {
continue
}
parts := strings.SplitN(strings.TrimSpace(text), " ", 3)
if len(parts) < 2 {
fmt.Println("error: you need to input a verb, uri [and post data]")
continue
}
if !slices.Contains(verbs, strings.ToLower(parts[0])) {
fmt.Println("error: verb must be one of " + strings.Join(verbs, ","))
continue
}
if !strings.HasPrefix(parts[1], "/") {
parts[1] = "/" + parts[1]
}
json := ""
if len(parts) == 3 {
// put /uri {json}
json = parts[2]
}
// put /uri/<Ret> [json]
data, err := readJSON(json)
if err != nil {
fmt.Println(err)
}
raw, err := CallAPI(conf, parts[0], parts[1], data)
if err != nil {
fmt.Printf("failed to call API: %s\n", esErrorString(err))
}
if err := prettyfiJson(conf, raw); err != nil {
fmt.Println(err)
}
}
return nil
}
func encodeAuth(username, password string) string {
return base64.StdEncoding.EncodeToString([]byte(username + ":" + password))
}
func CallAPI(conf *cfg.Config, verb, path, data string) ([]byte, error) {
verb = strings.ToUpper(verb)
// we're using port-forwards anyway
tr := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
client := &http.Client{Transport: tr}
req, err := http.NewRequest(verb, conf.DefaultCluster.Uri+path, bytes.NewBuffer([]byte(data)))
if err != nil {
return nil, err
}
req.Header.Add("Content-Type", "application/json")
req.Header.Add("accept", "application/json")
req.Header.Add("Authorization", "Basic "+encodeAuth(conf.DefaultCluster.User, conf.DefaultCluster.Pass))
// actually execute the request
resp, err := client.Do(req)
if err != nil {
return nil, err
}
// Read and print response
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %s", err)
}
return body, nil
}
func prettyfiJson(conf *cfg.Config, raw []byte) error {
if conf.HaveJQ {
cmd := exec.CommandContext(context.Background(), "jq", "-C")
cmd.Stdin = bytes.NewReader(raw)
var out bytes.Buffer
cmd.Stdout = &out
err := cmd.Run()
if err != nil {
return err
}
fmt.Println(out.String())
return nil
}
var pretty bytes.Buffer
err := json.Indent(&pretty, raw, "", "\t")
if err != nil {
return fmt.Errorf("json parse error: %s", err)
}
fmt.Println(pretty.String())
return nil
}
// interactively read arbitrary JSON data from STDIN, which is
// virtually a repl inside the primary repl
func readJSON(input string) (string, error) {
data := ""
if input != "" {
data = input
} else {
scanner := bufio.NewScanner(os.Stdin)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
break
}
data += line
}
}
if data == "" {
return data, nil
}
// validate
check := map[string]any{}
err := json.Unmarshal([]byte(data), &check)
if err != nil {
return "", fmt.Errorf("error: input data is not proper JSON: %s", err)
}
return data, nil
}
func ApiList(conf *cfg.Config, pattern string) error {
assets.LoadAssetOpenApi()
filter := regexp.MustCompile(pattern)
table := printer.NewTable(conf, 4, 0)
table.Addheaders("path", "http verb", "tag", "description")
for path, item := range assets.OpenAPI.Spec().Paths.Paths {
if pattern != "" {
if !filter.MatchString(path) {
continue
}
}
ops := findOperation(item.PathItemProps)
for _, op := range ops {
tags := findTags(op)
if conf.Tag != "" {
if !slices.Contains(tags, conf.Tag) {
continue
}
}
table.AddRow(
path,
op.Verb,
strings.Join(tags, ","),
strings.TrimSpace(op.Op.Summary),
)
}
}
table.Sort()
return table.Print()
}
// for completion
func ApiPathNames() []string {
assets.LoadAssetOpenApi()
paths := []string{}
for path := range assets.OpenAPI.Spec().Paths.Paths {
paths = append(paths, path)
}
return paths
}
func ApiShow(conf *cfg.Config, showpath, verb string) error {
assets.LoadAssetOpenApi()
out := printer.Builder{}
op, err := matchOperation(showpath, verb)
if err != nil {
return err
}
slog.Debug("api operation", "op", op)
cleanMarkup := regexp.MustCompile(`<[^<>]+>`)
width := getTermWidth()
params := getApiParameters(op, showpath, width)
sample := getApiExample(op)
description := markdown.Render(cleanMarkup.ReplaceAllString(op.Op.Description, ""), width, DefaultMargin)
var bold = lipgloss.NewStyle().
Bold(true)
var paragraph = lipgloss.NewStyle().
MarginBottom(1).
MarginLeft(DefaultMargin)
var boldparagraph = lipgloss.NewStyle().
MarginBottom(1).
MarginLeft(DefaultMargin).
Bold(true)
var indentparagraph = lipgloss.NewStyle().
MarginBottom(1).
MarginLeft(2)
out.WriteStringLine(bold.Render("ID: " + op.Op.ID))
out.WriteStringLine(paragraph.Render(fmt.Sprintf("%s %s", op.Verb, showpath)))
out.WriteStringLine(bold.Render("Tags"))
out.WriteStringLine(paragraph.Render(strings.Join(op.Op.Tags, ", ")))
out.WriteStringLine(bold.Render("Summary"))
out.WriteStringLine(paragraph.Render(strings.TrimSpace(op.Op.Summary)))
out.WriteStringLine(bold.Render("Description"))
out.WriteStringLine(string(description)) // already indented
if len(params.Path) > 0 {
out.WriteStringLine(bold.Render("Path Parameters"))
for _, param := range params.Path {
out.WriteStringLine(boldparagraph.Render(param.Param))
if param.Description != "" {
//out.WriteStringLine(indentparagraph.Render(param.Description))
out.WriteStringLine(param.Description)
}
}
}
if len(params.Query) > 0 {
out.WriteStringLine(bold.Render("Query Parameters"))
for _, param := range params.Query {
out.WriteStringLine(boldparagraph.Render(param.Param))
if param.Description != "" {
out.WriteStringLine(indentparagraph.Render(param.Description))
}
}
}
if sample != "" {
out.WriteStringLine(bold.Render("Example"))
out.WriteStringLine(paragraph.Render(sample))
}
printer.Pager(showpath, out.String())
return nil
}
// find all tags associated with op
func findTags(op *Op) []string {
return op.Op.Tags
}
// Get API parameters
func getApiParameters(op *Op, path string, width int) Params {
params := Params{}
pathParams := []string{}
pathParamsReg := regexp.MustCompile(`{([a-z_]+)}`)
// find params in url path
for _, match := range pathParamsReg.FindAllStringSubmatch(path, -1) {
if len(match) == 2 {
pathParams = append(pathParams, match[1])
}
}
empty := spec.ParamProps{}
for _, param := range op.Op.Parameters {
if param.ParamProps == empty {
for _, token := range param.Refable.Ref.Ref.GetPointer().DecodedTokens() {
parts := strings.Split(token, "-")
if len(parts) == 2 {
param := parts[1]
if slices.Contains(pathParams, param) {
params.Path = append(params.Path, Param{Param: param})
} else {
params.Query = append(params.Query, Param{Param: param})
}
}
}
} else {
par := Param{
Description: string(markdown.Render(param.Description, width, DefaultMargin)),
Param: param.Name,
}
if param.In == "query" {
params.Query = append(params.Query, par)
} else {
params.Path = append(params.Path, par)
}
}
}
return params
}
// Get console example
func getApiExample(op *Op) string {
sample := ""
samples, sampleexist := op.Op.Extensions["x-codeSamples"]
if sampleexist {
for _, itemany := range samples.([]any) {
item := itemany.(map[string]any)
lang, haslang := item["lang"]
if haslang {
if lang == "Console" {
source, hassource := item["source"]
if hassource {
sample = strings.TrimSpace(source.(string))
}
}
}
}
}
return sample
}
// Find an API operation which matches given path and verb.
// If no verb is given and only 1 op exists, return this one,
// otherwise showpath+verb have to match precisely.
func matchOperation(showpath, verb string) (*Op, error) {
ops := []*Op{}
op := &Op{}
var found bool
for path, item := range assets.OpenAPI.Spec().Paths.Paths {
if path == showpath {
ops = findOperation(item.PathItemProps)
break
}
}
if len(ops) == 0 {
return nil, errors.New("no matching API call found")
}
if len(ops) == 1 && verb == "" {
return ops[0], nil
}
for _, item := range ops {
if strings.ToLower(item.Verb) == verb {
op = item
found = true
break
}
}
if !found {
return nil, errors.New("no matching API call found for given path+verb")
}
return op, nil
}
func findOperation(item spec.PathItemProps) []*Op {
ops := []*Op{}
if item.Post != nil {
ops = append(ops, &Op{"POST", item.Post})
}
if item.Get != nil {
ops = append(ops, &Op{"GET", item.Get})
}
if item.Delete != nil {
ops = append(ops, &Op{"DELETE", item.Delete})
}
if item.Put != nil {
ops = append(ops, &Op{"PUT", item.Put})
}
if item.Patch != nil {
ops = append(ops, &Op{"PATCH", item.Patch})
}
return ops
}
func getTermWidth() int {
if term.IsTerminal(int(os.Stdout.Fd())) {
width, _, err := term.GetSize(int(os.Stdout.Fd()))
if err == nil {
return width - DefaultMargin
}
}
return 80
}

View File

@@ -23,7 +23,7 @@ import (
"log/slog"
"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"
)
@@ -55,14 +55,10 @@ func CcrStatus(conf *cfg.Config, leader, follower string) error {
indices := ClusterIndices{}
for _, alias := range []string{leader, follower} {
cat := conf.Clusters[alias].ES.Cat.Indices().
// we need to add custom request headers, required for older ES instances
Header("content-type", "application/json").
Header("accept", "application/json")
res, err := cat.Do(context.Background())
res, err := conf.Clusters[alias].ES().Cat.Indices().
Do(context.Background())
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))
@@ -88,12 +84,10 @@ func CcrStatus(conf *cfg.Config, leader, follower string) error {
}
func CcrRemoteInfo(conf *cfg.Config, index string) error {
res, err := conf.DefaultCluster.ES.Cluster.RemoteInfo().
Header("content-type", "application/json").
Header("accept", "application/json").
res, err := conf.DefaultCluster.ES().Cluster.RemoteInfo().
Do(context.Background())
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)

View File

@@ -22,16 +22,14 @@ import (
"log/slog"
"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) {
res, err := conf.DefaultCluster.ES.Cluster.RemoteInfo().
Header("content-type", "application/json").
Header("accept", "application/json").
res, err := conf.DefaultCluster.ES().Cluster.RemoteInfo().
Do(context.Background())
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 := ""
@@ -41,7 +39,7 @@ func getRemoteName(conf *cfg.Config) (string, error) {
}
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
@@ -89,42 +87,33 @@ func CcrFollowerRenew(conf *cfg.Config, index string) error {
}
func CcrFollowerResume(conf *cfg.Config, index string) error {
create := conf.DefaultCluster.ES.Ccr.ResumeFollow(index).
Header("content-type", "application/json").
Header("accept", "application/json")
_, err := create.Do(context.Background())
_, err := conf.DefaultCluster.ES().Ccr.ResumeFollow(index).
Do(context.Background())
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
}
func CcrFollowerPause(conf *cfg.Config, index string) error {
create := conf.DefaultCluster.ES.Ccr.PauseFollow(index).
Header("content-type", "application/json").
Header("accept", "application/json")
_, err := create.Do(context.Background())
_, err := conf.DefaultCluster.ES().Ccr.PauseFollow(index).
Do(context.Background())
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
}
func CcrFollowerUnfollow(conf *cfg.Config, index string) error {
create := conf.DefaultCluster.ES.Ccr.ForgetFollower(index).
Header("content-type", "application/json").
Header("accept", "application/json")
_, err := create.Do(context.Background())
_, err := conf.DefaultCluster.ES().Ccr.ForgetFollower(index).
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to unfollow index: %s", err)
return fmt.Errorf("failed to unfollow index: %s", esErrorString(err))
}
return nil
@@ -136,11 +125,9 @@ func CcrFollowerAdd(conf *cfg.Config, index string) error {
return err
}
create := conf.DefaultCluster.ES.Ccr.Follow(index).
create := conf.DefaultCluster.ES().Ccr.Follow(index).
LeaderIndex(index).
RemoteCluster(remote).
Header("content-type", "application/json").
Header("accept", "application/json")
RemoteCluster(remote)
if conf.Wait {
create.WaitForActiveShards("all")
@@ -149,19 +136,17 @@ func CcrFollowerAdd(conf *cfg.Config, index string) error {
_, err = create.Do(context.Background())
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
}
func CcrFollowerShow(conf *cfg.Config, index string) error {
res, err := conf.DefaultCluster.ES.Ccr.FollowStats(index).
Header("content-type", "application/json").
Header("accept", "application/json").
res, err := conf.DefaultCluster.ES().Ccr.FollowStats(index).
Do(context.Background())
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)

View File

@@ -20,13 +20,14 @@ import (
"context"
"fmt"
"log/slog"
"slices"
"strings"
"sync"
"codeberg.org/scip/esctl/pkg/cfg"
"codeberg.org/scip/esctl/pkg/printer"
"github.com/dustin/go-humanize"
"github.com/elastic/go-elasticsearch/v9/typedapi/cat/indices"
"github.com/elastic/go-elasticsearch/v9/typedapi/cat/tasks"
"github.com/elastic/go-elasticsearch/v9/typedapi/ccr/stats"
"github.com/elastic/go-elasticsearch/v9/typedapi/cluster/health"
clusterstats "github.com/elastic/go-elasticsearch/v9/typedapi/cluster/stats"
@@ -39,50 +40,55 @@ const (
ResponseInfo
ResponseCcr
ResponseStats
ResponseIndices
ResponseTasks
)
type ClusterIndices map[string]map[string]*types.IndicesRecord
type apiResponse struct {
error error
info *info.Response
health *health.Response
ccr *stats.Response
stats *clusterstats.Response
which int
error error
info *info.Response
health *health.Response
ccr *stats.Response
stats *clusterstats.Response
indices *indices.Response
tasks *tasks.Response
which int
}
func ClusterList(conf *cfg.Config) error {
table := printer.NewTable(conf, 3, len(conf.Clusters))
table := printer.NewTable(conf, 4, len(conf.Clusters))
table.Addheaders("cluster", "uri", "default")
table.Addheaders("cluster", "uri", "reachable", "current")
idx := 0
names := make([]string, len(conf.Clusters))
for name := range conf.Clusters {
names[idx] = name
idx++
}
for name, cluster := range conf.Clusters {
reachable := "no"
current := "no"
slices.Sort(names)
for idx, name := range names {
current := name == "default" || name == conf.CurrentCluster
cluster := conf.Clusters[name]
_, err := cluster.ES.Cluster.Health().
Header("content-type", "application/json").
Header("accept", "application/json").
_, err := cluster.ES().Cluster.Health().
Do(context.Background())
if err == nil {
name = printer.Colorize(conf, "green", name)
reachable = printer.Colorize(conf, "green", "reachable")
}
table.Entries[idx] = []string{name, cluster.Uri, fmt.Sprintf("%t", current)}
if cluster.Default {
current = printer.Colorize(conf, "green", "yes")
if err != nil {
reachable = printer.Colorize(conf, "red", "no")
}
}
table.Entries[idx] = []string{name, cluster.Uri, reachable, current}
idx++
}
table.Sort()
if err := table.Print(); err != nil {
return err
}
@@ -94,7 +100,7 @@ func ClusterList(conf *cfg.Config) error {
// have to do 3 of'em for each cluster. This speeds things up.
func ClusterStatus(conf *cfg.Config) error {
clusters := []string{}
gocount := 3
gocount := 5
if conf.Verbose {
gocount++
}
@@ -108,9 +114,9 @@ func ClusterStatus(conf *cfg.Config) error {
}
for _, cluster := range clusters {
es := conf.DefaultCluster.ES
es := conf.DefaultCluster.ES()
if cluster != "default" {
es = conf.Clusters[cluster].ES
es = conf.Clusters[cluster].ES()
}
responses := make(chan apiResponse, gocount)
@@ -120,6 +126,8 @@ func ClusterStatus(conf *cfg.Config) error {
go getClusterData(es, wg, responses, "health")
go getClusterData(es, wg, responses, "info")
go getClusterData(es, wg, responses, "ccrstats")
go getClusterData(es, wg, responses, "indices")
go getClusterData(es, wg, responses, "tasks")
if conf.Verbose {
go getClusterData(es, wg, responses, "stats")
@@ -131,6 +139,8 @@ func ClusterStatus(conf *cfg.Config) error {
var info *info.Response
var ccrstats *stats.Response
var clusterstats *clusterstats.Response
var indexstats *indices.Response
var taskstatus *tasks.Response
for i := 0; i < gocount; i++ {
r := <-responses
@@ -148,11 +158,17 @@ func ClusterStatus(conf *cfg.Config) error {
info = r.info
case ResponseStats:
clusterstats = r.stats
case ResponseIndices:
indexstats = r.indices
case ResponseTasks:
taskstatus = r.tasks
}
}
slog.Debug("ES result", "cluster health", clusterhealth)
isleader := len(ccrstats.AutoFollowStats.AutoFollowedClusters) == 0
ccrfollowing := ""
if len(ccrstats.AutoFollowStats.AutoFollowedClusters) > 0 {
// is following another cluster
@@ -163,16 +179,45 @@ func ClusterStatus(conf *cfg.Config) error {
)
}
table := printer.NewTable(conf, 2, 5)
// look for red indices, if any
redindices := 0
for _, index := range *indexstats {
if *index.Health == "red" {
redindices++
}
}
// look for long running tasks
longtasks := 0
for _, task := range *taskstatus {
if strings.Contains(*task.RunningTime, "d") {
longtasks++
}
}
table := printer.NewTable(conf, 2, 7)
table.Addheaders(cluster, "status")
table.Entries = [][]string{
{"Cluster Name", printer.Colorize(conf, clusterhealth.Status.Name, clusterhealth.ClusterName)},
{"Cluster Name", clusterhealth.ClusterName},
{"ES Status", printer.Colorize(conf, clusterhealth.Status.Name, clusterhealth.Status.Name)},
{"ES Version", info.Version.Int},
{"Is Leader", fmt.Sprintf("%t", isleader)},
{"Active Shards", fmt.Sprintf("%d", clusterhealth.ActiveShards)},
{"Active Primary Shards", fmt.Sprintf("%d", clusterhealth.ActivePrimaryShards)},
{"Unassigned Shards", fmt.Sprintf("%d", clusterhealth.UnassignedShards)},
{"Unassigned Primary Shards", fmt.Sprintf("%d", clusterhealth.UnassignedPrimaryShards)},
{"Pending Tasks", fmt.Sprintf("%d", clusterhealth.NumberOfPendingTasks)},
{"Nodes", fmt.Sprintf("%d", clusterhealth.NumberOfNodes)},
{"AutoFollow (success/failed indices)", ccrfollowing},
{"Red Indices", fmt.Sprintf("%d", redindices)},
{"Long Running Tasks", fmt.Sprintf("%d", longtasks)},
}
if !isleader {
table.Entries = append(table.Entries, [][]string{
{"AutoFollow (success/failed indices)", ccrfollowing},
{"Followed Indices", fmt.Sprintf("%d", len(ccrstats.FollowStats.Indices))},
}...)
}
if conf.Verbose {
@@ -201,11 +246,8 @@ func gatherClusterStats(conf *cfg.Config, clusterstats *clusterstats.Response, t
clusterstats.Nodes.Jvm.Versions[0].VmVersion}, " ")
}
isleader := checkClusterIsLeader(conf, conf.CurrentCluster)
table.Entries = append(table.Entries, [][]string{
{"Indicies", fmt.Sprintf("%d", clusterstats.Indices.Count)},
{"Is Leader", fmt.Sprintf("%t", isleader)},
{"Docs", fmt.Sprintf("%d", clusterstats.Indices.Docs.Count)},
{"Total Size", humanize.Bytes(uint64(clusterstats.Indices.Docs.TotalSizeInBytes))},
{"Total Queries", fmt.Sprintf("%d", querycount)},
@@ -216,7 +258,7 @@ func gatherClusterStats(conf *cfg.Config, clusterstats *clusterstats.Response, t
humanize.Bytes(uint64(clusterstats.Indices.Store.SizeInBytes)),
humanize.Bytes(uint64(*clusterstats.Indices.Store.TotalDataSetSizeInBytes)),
)},
{"JVM Heap", fmt.Sprintf(
{"JVM Heap Memory", fmt.Sprintf(
"%s/%s",
humanize.Bytes(uint64(clusterstats.Nodes.Jvm.Mem.HeapUsedInBytes)),
humanize.Bytes(uint64(clusterstats.Nodes.Jvm.Mem.HeapMaxInBytes)),

View File

@@ -23,47 +23,15 @@ import (
"log/slog"
"codeberg.org/scip/esctl/pkg/cfg"
"codeberg.org/scip/esctl/pkg/printer"
"codeberg.org/scip/esctl/pkg/printer"
"github.com/urfave/cli/v3"
)
// recursively traverse the raw settings hash and build a flat map
// consisting of the translated path and its value.
//
// e.g.
// logger:
//
// org:
// elasticsearch:
// transport:
// OutboundHandler: "ERROR"
//
// gets:
//
// logger.org.elasticsearch.transport.OutboundHandler: "ERROR"
func getJsonPath(raw map[string]any, topic string) map[string]string {
paths := map[string]string{}
for name, data := range raw {
path := topic + "." + name
switch value := data.(type) {
case string:
paths[path] = value
case map[string]any:
paths = getJsonPath(value, path)
}
}
return paths
}
func ClusterSettingsList(conf *cfg.Config) error {
res, err := conf.DefaultCluster.ES.Cluster.GetSettings().
Header("content-type", "application/json").
Header("accept", "application/json").
res, err := conf.DefaultCluster.ES().Cluster.GetSettings().
Do(context.Background())
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)
@@ -87,7 +55,7 @@ func ClusterSettingsList(conf *cfg.Config) error {
return fmt.Errorf("failed to unmarshall setting for topic %s: %s", topic, err)
}
paths := getJsonPath(data, topic)
paths := getJsonPath(map[string]string{}, data, topic)
slog.Debug("settings", topic, paths)
for setting, value := range paths {
@@ -106,7 +74,7 @@ func ClusterSettingsList(conf *cfg.Config) error {
}
func ClusterSettingsSet(conf *cfg.Config, args cli.Args) error {
put := conf.Clusters[conf.CurrentCluster].ES.Cluster.PutSettings()
put := conf.DefaultCluster.ES().Cluster.PutSettings()
for _, arg := range args.Slice() {
setting, value := splitArg(arg)
@@ -129,13 +97,9 @@ func ClusterSettingsSet(conf *cfg.Config, args cli.Args) error {
}
}
_, err := put.
Header("content-type", "application/json").
Header("accept", "application/json").
Do(context.Background())
_, err := put.Do(context.Background())
if err != nil {
return fmt.Errorf("failed to set settings: %s", err)
return fmt.Errorf("failed to set settings: %s", esErrorString(err))
}
return nil
@@ -147,14 +111,12 @@ func ClusterSettingsSetSingle(conf *cfg.Config, setting, value string) error {
return fmt.Errorf("failed to marshall persistent value <%v> to valid JSON: %s", value, err)
}
_, err = conf.Clusters[conf.CurrentCluster].ES.Cluster.PutSettings().
_, err = conf.DefaultCluster.ES().Cluster.PutSettings().
AddPersistent(setting, message).
Header("content-type", "application/json").
Header("accept", "application/json").
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to set %s: %s", setting, err)
return fmt.Errorf("failed to set %s: %s", setting, esErrorString(err))
}
return nil

View File

@@ -21,11 +21,12 @@ import (
"errors"
"fmt"
"regexp"
"strconv"
"strings"
"sync"
"codeberg.org/scip/esctl/pkg/cfg"
"codeberg.org/scip/esctl/pkg/printer"
"codeberg.org/scip/esctl/pkg/printer"
"github.com/elastic/go-elasticsearch/v9"
"github.com/elastic/go-elasticsearch/v9/typedapi/cluster/health"
"github.com/elastic/go-elasticsearch/v9/typedapi/types"
@@ -36,12 +37,10 @@ const (
)
func checkClusterIsLeader(conf *cfg.Config, leader string) bool {
stats, err := conf.Clusters[leader].ES.Ccr.Stats().
Header("content-type", "application/json").
Header("accept", "application/json").
stats, err := conf.Clusters[leader].ES().Ccr.Stats().
Do(context.Background())
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
}
@@ -63,26 +62,25 @@ func checkClusterStatus(conf *cfg.Config, leader, follower string) bool {
status := map[string]*health.Response{}
for _, cluster := range []string{leader, follower} {
st, err := conf.Clusters[leader].ES.Cluster.Health().
Header("content-type", "application/json").
Header("accept", "application/json").
st, err := conf.Clusters[leader].ES().Cluster.Health().
Do(context.Background())
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
}
status[cluster] = st
}
table := printer.NewTable(conf, 3, 5)
table := printer.NewTable(conf, 3, 6)
table.Addheaders("setting", "leader:"+leader, "follower:"+follower)
table.Entries = [][]string{
{"Cluster Name",
printer.Colorize(conf, status[leader].Status.Name, status[leader].ClusterName),
printer.Colorize(conf, status[follower].Status.Name, status[follower].ClusterName),
{"Cluster Name", status[leader].ClusterName, status[follower].ClusterName},
{"Cluster Status",
printer.Colorize(conf, status[leader].Status.Name, status[leader].Status.Name),
printer.Colorize(conf, status[follower].Status.Name, status[follower].Status.Name),
},
{"Active Shards",
fmt.Sprintf("%d", status[leader].ActiveShards),
@@ -107,6 +105,8 @@ func checkClusterStatus(conf *cfg.Config, leader, follower string) bool {
return false
}
fmt.Println()
if status[leader].Status.Name == "green" && status[follower].Status.Name == "green" {
return true
}
@@ -119,13 +119,11 @@ func findIlmErrors(conf *cfg.Config, leader, follower string) bool {
failed := map[string]map[string]string{}
for _, cluster := range []string{leader, follower} {
ilm, err := conf.Clusters[cluster].ES.Ilm.ExplainLifecycle("_all").
ilm, err := conf.Clusters[cluster].ES().Ilm.ExplainLifecycle("_all").
OnlyManaged(true).
Header("content-type", "application/json").
Header("accept", "application/json").
Do(context.Background())
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
}
@@ -188,9 +186,7 @@ func findIndicesOnlyOnLeader(conf *cfg.Config, indices ClusterIndices, leader, f
_, followerHasIt := indices[follower][name]
if !followerHasIt {
// fetch index details
res, err := conf.Clusters[leader].ES.Indices.Get(name).
Header("content-type", "application/json").
Header("accept", "application/json").
res, err := conf.Clusters[leader].ES().Indices.Get(name).
Do(context.Background())
if err != nil {
continue // ignore it then
@@ -251,9 +247,7 @@ func findOrphanedIndices(conf *cfg.Config, indices ClusterIndices, leader, follo
_, leaderHasIt := indices[leader][name]
if !leaderHasIt {
// fetch index details
res, err := conf.Clusters[follower].ES.Indices.Get(name).
Header("content-type", "application/json").
Header("accept", "application/json").
res, err := conf.Clusters[follower].ES().Indices.Get(name).
Do(context.Background())
if err != nil {
continue // ignore it then
@@ -349,8 +343,6 @@ func getClusterData(es *elasticsearch.TypedClient, wg *sync.WaitGroup, reschan c
switch which {
case "health":
res, err := es.Cluster.Health().
Header("content-type", "application/json").
Header("accept", "application/json").
Do(context.Background())
ar.health = res
@@ -359,8 +351,6 @@ func getClusterData(es *elasticsearch.TypedClient, wg *sync.WaitGroup, reschan c
case "info":
res, err := es.Info().
Header("content-type", "application/json").
Header("accept", "application/json").
Do(context.Background())
ar.info = res
@@ -369,8 +359,6 @@ func getClusterData(es *elasticsearch.TypedClient, wg *sync.WaitGroup, reschan c
case "ccrstats":
res, err := es.Ccr.Stats().
Header("content-type", "application/json").
Header("accept", "application/json").
Do(context.Background())
ar.ccr = res
@@ -379,13 +367,27 @@ func getClusterData(es *elasticsearch.TypedClient, wg *sync.WaitGroup, reschan c
case "stats":
res, err := es.Cluster.Stats().
Header("content-type", "application/json").
Header("accept", "application/json").
Do(context.Background())
ar.stats = res
ar.which = ResponseStats
arerr = err
case "indices":
res, err := es.Cat.Indices().
Do(context.Background())
ar.indices = &res
ar.which = ResponseIndices
arerr = err
case "tasks":
res, err := es.Cat.Tasks().
Do(context.Background())
ar.tasks = &res
ar.which = ResponseTasks
arerr = err
}
if arerr != nil {
@@ -394,3 +396,44 @@ func getClusterData(es *elasticsearch.TypedClient, wg *sync.WaitGroup, reschan c
reschan <- ar
}
// recursively traverse the raw settings hash and build a flat map
// consisting of the translated path and its value.
//
// e.g.
// logger:
//
// org:
// elasticsearch:
// transport:
// OutboundHandler: "ERROR"
//
// gets:
//
// logger.org.elasticsearch.transport.OutboundHandler: "ERROR"
func getJsonPath(paths map[string]string, raw map[string]any, topic string) map[string]string {
for name, data := range raw {
path := topic + "." + name
switch value := data.(type) {
case string:
paths[path] = value
case *string:
paths[path] = *value
case int:
paths[path] = strconv.Itoa(value)
case *int:
paths[path] = strconv.Itoa(*value)
case map[string]any:
paths = getJsonPath(paths, value, path)
case []any:
val := []string{}
for _, item := range value {
val = append(val, fmt.Sprintf("%v", item))
}
paths[path] = strings.Join(val, ",")
}
}
return paths
}

247
pkg/es/datastream.go Normal file
View File

@@ -0,0 +1,247 @@
/*
Copyright © 2026 Thomas von Dein
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package es
import (
"context"
"errors"
"fmt"
"log/slog"
"regexp"
"codeberg.org/scip/esctl/pkg/cfg"
"codeberg.org/scip/esctl/pkg/printer"
"github.com/dustin/go-humanize"
"github.com/elastic/go-elasticsearch/v9/typedapi/types"
)
func DatastreamNames(conf *cfg.Config) ([]string, error) {
res, err := conf.DefaultCluster.ES().Indices.GetDataStream().
Do(context.Background())
if err != nil {
return nil, fmt.Errorf("failed to get data streams: %s", esErrorString(err))
}
dss := make([]string, len(res.DataStreams))
for idx, ds := range res.DataStreams {
dss[idx] = ds.Name
}
return dss, nil
}
func DatastreamList(conf *cfg.Config) error {
res, err := conf.DefaultCluster.ES().Indices.GetDataStream().
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to get data streams: %s", esErrorString(err))
}
slog.Debug("ES result", "data streams", res)
list := filterDatastreams(conf, res.DataStreams)
size := len(list)
if conf.MaxItems > 0 {
if size > conf.MaxItems {
size = conf.MaxItems
}
}
table := printer.NewTable(conf, 7, size)
table.Addheaders("name", "ilm policy", "hidden", "system", "replicated", "generation", "timestamp field")
for idx, ds := range list {
name := printer.Colorize(conf, ds.Status.String(), ds.Name)
policy := ""
if ds.IlmPolicy != nil {
policy = *ds.IlmPolicy
}
table.Entries[idx] = []string{
name,
policy,
fmt.Sprintf("%t", ds.Hidden),
fmt.Sprintf("%t", *ds.System),
fmt.Sprintf("%t", *ds.Replicated),
fmt.Sprintf("%d", ds.Generation),
ds.TimestampField.Name,
}
if idx == size-1 {
break
}
}
table.Sort()
if err := table.Print(); err != nil {
return err
}
return nil
}
func filterDatastreams(conf *cfg.Config, list []types.DataStream) []types.DataStream {
selected := []types.DataStream{}
for _, ds := range list {
if !conf.Hidden && ds.Hidden {
continue
}
selected = append(selected, ds)
}
if len(conf.Filter) == 0 {
return selected
}
// we support just one filter here, for now
filter := *regexp.MustCompile(conf.Filter[0])
newlist := []types.DataStream{}
for _, ds := range selected {
if filter.MatchString(ds.Name) {
newlist = append(newlist, ds)
}
}
return newlist
}
func DatastreamShow(conf *cfg.Config, dsname string) error {
res, err := conf.DefaultCluster.ES().Indices.GetDataStream().
Name(dsname).
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to get data stream: %s", esErrorString(err))
}
slog.Debug("ES result", "data stream", res)
if len(res.DataStreams) == 0 {
return errors.New("no data stream found with that name")
}
stats, err := conf.DefaultCluster.ES().Indices.DataStreamsStats().
Name(dsname).
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to get data stream stats: %s", esErrorString(err))
}
table := printer.NewTable(conf, 11, 2)
table.Addheaders("data stream property", "value")
ds := res.DataStreams[0]
name := printer.Colorize(conf, ds.Status.String(), ds.Name)
policy := ""
if ds.IlmPolicy != nil {
policy = *ds.IlmPolicy
}
table.Entries = [][]string{
{"name", name},
{"ilm policy", policy},
{"hidden", fmt.Sprintf("%t", ds.Hidden)},
{"system", fmt.Sprintf("%t", *ds.System)},
{"replicated", fmt.Sprintf("%t", *ds.Replicated)},
{"generation", fmt.Sprintf("%d", ds.Generation)},
{"timestamp field", ds.TimestampField.Name},
{"backing indices", fmt.Sprintf("%d", stats.BackingIndices)},
{"size", humanize.Bytes(uint64(stats.TotalStoreSizeBytes))},
{"shards-failed", fmt.Sprintf("%d", stats.Shards_.Failed)},
{"shards-successful", fmt.Sprintf("%d", stats.Shards_.Successful)},
}
if err := table.Print(); err != nil {
return err
}
table = printer.NewTable(conf, 5, len(ds.Indices))
table.Addheaders("backing index name", "uuid", "prefer ilm", "ilm policy", "managed by")
for idx, index := range ds.Indices {
policy := ""
if ds.IlmPolicy != nil {
policy = *ds.IlmPolicy
}
table.Entries[idx] = []string{
index.IndexName,
index.IndexUuid,
fmt.Sprintf("%t", *index.PreferIlm),
policy,
index.ManagedBy.Name,
}
if idx < len(ds.Indices) {
fmt.Println()
}
}
table.Sort()
if err := table.Print(); err != nil {
return err
}
return nil
}
func DatastreamCreate(conf *cfg.Config, dsname string) error {
_, err := conf.DefaultCluster.ES().Indices.CreateDataStream(dsname).
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to create datastream: %s", esErrorString(err))
}
return nil
}
func DatastreamDelete(conf *cfg.Config, dsname string) error {
_, err := conf.DefaultCluster.ES().Indices.DeleteDataStream(dsname).
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to delete datastream: %s", esErrorString(err))
}
return nil
}
func DatastreamRollover(conf *cfg.Config, ds string) error {
res, err := RolloverAlias(conf, ds)
if err != nil {
return fmt.Errorf("failed to rollover data stream: %s", esErrorString(err))
}
table := printer.NewTable(conf, 2, 5)
table.Addheaders("rollover response", "value")
table.Entries = [][]string{
{"acknowledged", fmt.Sprintf("%t", res.Acknowledged)},
{"rolled over", fmt.Sprintf("%t", res.RolledOver)},
{"shards acknowledged", fmt.Sprintf("%t", res.ShardsAcknowledged)},
{"old index", res.OldIndex},
{"new index", res.NewIndex},
}
return table.Print()
}

View File

@@ -21,9 +21,12 @@ import (
"encoding/json"
"errors"
"fmt"
"time"
"math/rand/v2"
"strings"
"codeberg.org/scip/esctl/pkg/cfg"
"github.com/elastic/go-elasticsearch/v9/typedapi/core/deletebyquery"
"github.com/elastic/go-elasticsearch/v9/typedapi/esdsl"
"github.com/tidwall/gjson"
)
@@ -42,15 +45,13 @@ func DocAdd(conf *cfg.Config, jsondoc string) error {
return fmt.Errorf("supplied document was not valid JSON: %s", err)
}
now := fmt.Sprintf("%d", time.Now().Unix())
now := fmt.Sprintf("%d", rand.Int64())
res, err := conf.DefaultCluster.ES.Create(conf.Index, now).
res, err := conf.DefaultCluster.ES().Create(conf.Index, now).
Document(data).
Header("content-type", "application/json").
Header("accept", "application/json").
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to create new doc in index %s: %s", conf.Index, err)
return fmt.Errorf("failed to create new doc in index %s: %s", conf.Index, esErrorString(err))
}
fmt.Println(res.Id_)
@@ -59,12 +60,10 @@ func DocAdd(conf *cfg.Config, jsondoc string) error {
}
func DocShow(conf *cfg.Config, id string) error {
res, err := conf.DefaultCluster.ES.Get(conf.Index, id).
Header("content-type", "application/json").
Header("accept", "application/json").
res, err := conf.DefaultCluster.ES().Get(conf.Index, id).
Do(context.Background())
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 {
@@ -87,3 +86,39 @@ func DocShow(conf *cfg.Config, id string) error {
return nil
}
func DocDelete(conf *cfg.Config, queries []string) error {
if len(queries) == 1 && strings.Contains(queries[0], "id=") {
id, _ := strings.CutPrefix(queries[0], "id=")
_, err := conf.DefaultCluster.ES().Delete(conf.Index, id).
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to delete doc in index %s: %s", conf.Index, esErrorString(err))
}
return nil
}
req := &deletebyquery.Request{}
if len(queries) == 0 && conf.All {
req.Query = esdsl.NewMatchAllQuery().QueryCaster()
} else {
queryCaster, err := prepareQuery(conf, queries)
if err != nil {
return err
}
req.Query = queryCaster
}
_, err := conf.DefaultCluster.ES().DeleteByQuery(conf.Index).
Request(req).
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to delete docs in index %s: %s", conf.Index, esErrorString(err))
}
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
}

301
pkg/es/ilm.go Normal file
View File

@@ -0,0 +1,301 @@
/*
Copyright © 2026 Thomas von Dein
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package es
import (
"context"
"errors"
"fmt"
"log/slog"
"strings"
"codeberg.org/scip/esctl/pkg/cfg"
"codeberg.org/scip/esctl/pkg/printer"
"github.com/alecthomas/repr"
"github.com/elastic/go-elasticsearch/v9/typedapi/esdsl"
"github.com/elastic/go-elasticsearch/v9/typedapi/ilm/putlifecycle"
"github.com/elastic/go-elasticsearch/v9/typedapi/types"
)
func IlmRetry(conf *cfg.Config, index string) error {
_, err := conf.DefaultCluster.ES().Ilm.Retry(index).
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to retry ilm: %s", esErrorString(err))
}
return nil
}
func IlmStatus(conf *cfg.Config) error {
res, err := conf.DefaultCluster.ES().Ilm.GetStatus().
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to get ilm status: %s", esErrorString(err))
}
fmt.Println(res.OperationMode.Name)
return nil
}
func IlmNames(conf *cfg.Config) ([]string, error) {
res, err := conf.DefaultCluster.ES().Ilm.GetLifecycle().
Do(context.Background())
if err != nil {
return nil, fmt.Errorf("failed to get ilm policies: %s", esErrorString(err))
}
names := make([]string, len(res))
idx := 0
for name := range res {
names[idx] = name
}
return names, nil
}
func IlmList(conf *cfg.Config, pattern string) error {
ilm := conf.DefaultCluster.ES().Ilm.GetLifecycle()
if pattern != "" {
ilm.FilterPath(pattern)
}
res, err := ilm.Do(context.Background())
if err != nil {
return fmt.Errorf("failed to get ilm policies: %s", esErrorString(err))
}
if conf.Debug {
repr.Println(res)
}
table := printer.NewTable(conf, 5, 0)
table.Addheaders("ilm policy", "hot", "warm", "frozen", "delete")
for name, ilm := range res {
table.AddRow(name,
ilmPhaseString(ilm.Policy.Phases.Hot, true),
ilmPhaseString(ilm.Policy.Phases.Warm, true),
ilmPhaseString(ilm.Policy.Phases.Frozen, true),
ilmPhaseString(ilm.Policy.Phases.Delete, true),
)
}
table.Sort()
return table.Print()
}
func IlmShow(conf *cfg.Config, policy string) error {
res, err := conf.DefaultCluster.ES().Ilm.GetLifecycle().
Policy(policy).
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to get ilm status: %s", esErrorString(err))
}
if conf.Debug {
repr.Println(res)
}
ilm, exists := res[policy]
if !exists {
return errors.New("no ilm policy retrieved")
}
table := printer.NewTable(conf, 2, 5)
table.Addheaders("ilm policy setting", "value")
table.Entries = [][]string{
{"policy", policy},
{"hot phase", ilmPhaseString(ilm.Policy.Phases.Hot, false)},
{"warm phase", ilmPhaseString(ilm.Policy.Phases.Warm, false)},
{"frozen phase", ilmPhaseString(ilm.Policy.Phases.Frozen, false)},
{"delete phase", ilmPhaseString(ilm.Policy.Phases.Delete, false)},
}
return table.Print()
}
func ilmPhaseString(phase *types.Phase, short bool) string {
if phase == nil {
return ""
}
out := []string{fmt.Sprintf("move-after:%s", phase.MinAge)}
if short {
return strings.Join(out, ",")
}
if phase.Actions.Rollover != nil {
if phase.Actions.Rollover.MaxAge != "" {
out = append(out, fmt.Sprintf("rollover:%s", phase.Actions.Rollover.MaxAge))
if phase.Actions.Rollover.MaxPrimaryShardSize != nil {
out = append(out, fmt.Sprintf("shardsize:%s", phase.Actions.Rollover.MaxPrimaryShardSize))
}
}
}
if phase.Actions.Delete != nil {
out = append(out, fmt.Sprintf("delete-searchable-snapshot:%t", *phase.Actions.Delete.DeleteSearchableSnapshot))
}
if phase.Actions.SearchableSnapshot != nil {
out = append(out, fmt.Sprintf("searchable-snapshot:%s", phase.Actions.SearchableSnapshot.SnapshotRepository))
}
return strings.Join(out, ",")
}
func IlmExplain(conf *cfg.Config, index string) error {
res, err := conf.DefaultCluster.ES().Ilm.ExplainLifecycle(index).
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to get ilm state: %s", esErrorString(err))
}
slog.Debug("ilm status", "ilm", res)
explain, exists := res.Indices[index]
if !exists {
return errors.New("no ilm status retrieved")
}
ilm := explain.(*types.LifecycleExplainManaged)
table := printer.NewTable(conf, 2, 0)
table.Addheaders("ilm status field", "value")
table.Entries = [][]string{
{"index", ilm.Index},
{"ilm policy", *ilm.Policy},
{"action", *ilm.Action},
{"step", *ilm.Step},
{"age", fmt.Sprintf("%s", ilm.Age)},
{"managed", fmt.Sprintf("%t", ilm.Managed)},
{"phase", *ilm.Phase},
{"phase execution", ilmPhaseString(ilm.PhaseExecution.PhaseDefinition, false)},
}
return table.Print()
}
func IlmCreate(conf *cfg.Config, policyname string) error {
ilm := conf.DefaultCluster.ES().Ilm.PutLifecycle(policyname)
cfg := conf.Ilm
var phases types.PhasesVariant = esdsl.NewPhases()
if cfg.HaveHot() {
hot := types.Phase{}
var actions types.IlmActionsVariant = esdsl.NewIlmActions()
rollover := &types.RolloverAction{}
haveroll := false
if cfg.HotMinAge != "" {
hot.MinAge = cfg.HotMinAge
}
if cfg.HotRolloverMaxAge != "" {
rollover.MaxAge = cfg.HotRolloverMaxAge
haveroll = true
}
if cfg.HotRolloverMaxDocs != 0 {
rollover.MaxDocs = &cfg.HotRolloverMaxDocs
haveroll = true
}
if cfg.HotRolloverMaxPrimaryShardSize != 0 {
rollover.MaxPrimaryShardSize = &cfg.HotRolloverMaxPrimaryShardSize
}
if haveroll {
actions.IlmActionsCaster().Rollover = rollover
}
hot.Actions = actions.IlmActionsCaster()
phases.PhasesCaster().Hot = &hot
}
if cfg.HaveWarm() {
warm := types.Phase{}
var actions types.IlmActionsVariant = esdsl.NewIlmActions()
if cfg.WarmForceMerge != 0 {
actions.IlmActionsCaster().Forcemerge = &types.ForceMergeAction{MaxNumSegments: cfg.WarmForceMerge}
}
if cfg.WarmMinAge != "" {
warm.MinAge = cfg.WarmMinAge
}
if cfg.WarmPriority != 0 {
actions.IlmActionsCaster().SetPriority = &types.SetPriorityAction{Priority: &cfg.WarmPriority}
}
if cfg.WarmShrinkShards != 0 {
actions.IlmActionsCaster().Shrink = &types.ShrinkAction{NumberOfShards: &cfg.WarmShrinkShards}
}
warm.Actions = actions.IlmActionsCaster()
phases.PhasesCaster().Warm = &warm
}
if cfg.HaveFrozen() {
froze := phases.PhasesCaster().Frozen
if cfg.FrozenMinAge != "" {
froze.MinAge = cfg.FrozenMinAge
}
}
if cfg.HaveDelete() {
del := phases.PhasesCaster().Delete
delete := types.DeleteAction{}
var actions types.IlmActionsVariant = esdsl.NewIlmActions()
if cfg.DeleteMinAge != "" {
del.MinAge = cfg.DeleteMinAge
}
if cfg.DeleteSearchableSnapshots {
delete.DeleteSearchableSnapshot = &cfg.DeleteSearchableSnapshots
}
actions.IlmActionsCaster().Delete = &delete
del.Actions = actions.IlmActionsCaster()
phases.PhasesCaster().Delete = del
}
put := &putlifecycle.Request{}
policy := &types.IlmPolicy{}
policy.IlmPolicyCaster().Phases = *phases.PhasesCaster()
put.Policy = policy
ilm.Request(put)
_, err := ilm.Do(context.Background())
if err != nil {
return fmt.Errorf("failed create ilm policy: %s", esErrorString(err))
}
return nil
}

View File

@@ -21,6 +21,7 @@ import (
"fmt"
"log/slog"
"regexp"
"slices"
"strconv"
"strings"
"time"
@@ -34,13 +35,11 @@ import (
// used for completion
func IndexNames(conf *cfg.Config) ([]string, error) {
res, err := conf.DefaultCluster.ES.Cat.Indices().
Header("content-type", "application/json").
Header("accept", "application/json").
res, err := conf.DefaultCluster.ES().Cat.Indices().
Do(context.Background())
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))
@@ -52,15 +51,30 @@ func IndexNames(conf *cfg.Config) ([]string, error) {
}
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
}
if strings.HasPrefix(*index.Index, ".ds-") {
// ignore data stream backing indicies
continue
}
selectedlist = append(selectedlist, index)
}
if len(conf.Filter) == 0 {
return list
return selectedlist
}
// we support just one filter here, for now
filter := *regexp.MustCompile(conf.Filter[0])
newlist := indices.Response{}
for _, index := range list {
for _, index := range selectedlist {
if filter.MatchString(*index.Index) {
newlist = append(newlist, index)
}
@@ -70,10 +84,7 @@ func filterIndices(conf *cfg.Config, list indices.Response) indices.Response {
}
func IndexList(conf *cfg.Config) error {
cat := conf.DefaultCluster.ES.Cat.Indices().
// we need to add custom request headers, required for older ES instances
Header("content-type", "application/json").
Header("accept", "application/json")
cat := conf.DefaultCluster.ES().Cat.Indices()
if conf.Failed {
cat = cat.Health(healthstatus.Red)
@@ -81,7 +92,7 @@ func IndexList(conf *cfg.Config) error {
res, err := cat.Do(context.Background())
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)
@@ -110,53 +121,48 @@ func IndexList(conf *cfg.Config) error {
}
table.Sort()
if err := table.Print(); err != nil {
return err
}
return nil
return table.Print()
}
func IndexShow(conf *cfg.Config, index string) error {
res, err := conf.DefaultCluster.ES.Indices.Get(index).
// we need to add custom request headers, required for older ES instances
Header("content-type", "application/json").
Header("accept", "application/json").
func IndexShow(conf *cfg.Config, indexpattern string) error {
res, err := conf.DefaultCluster.ES().Indices.Get(indexpattern).
Do(context.Background())
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))
idx := 0
for field := range res[index].Mappings.Properties {
fields[idx] = field
idx++
}
table := printer.NewTable(conf, 2, 5)
table.Addheaders("index property", "value")
table := printer.NewTable(conf, 2, 5)
table.Addheaders("index property", "value")
ts, err := strconv.ParseInt(index.Settings.Index.CreationDate.(string), 10, 64)
if err != nil {
ts = 0
}
ts, err := strconv.ParseInt(res[index].Settings.Index.CreationDate.(string), 10, 64)
if err != nil {
ts = 0
}
created := time.Unix(ts/1000, 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{
{"name", index},
{"replicas", *res[index].Settings.Index.NumberOfReplicas},
{"shards", *res[index].Settings.Index.NumberOfShards},
{"created", created.Format("2006-01-02 15:04:05")},
{"uuid", *res[index].Settings.Index.Uuid},
{"fields", strings.Join(fields, ",")},
}
if err := table.Print(); err != nil {
return err
}
if err := table.Print(); err != nil {
return err
fmt.Println()
}
return nil
@@ -165,9 +171,7 @@ func IndexShow(conf *cfg.Config, index string) error {
func IndexCreate(conf *cfg.Config, index string, mappings []string) error {
settings := esdsl.NewIndexSettings()
create := conf.DefaultCluster.ES.Indices.Create(index).
Header("content-type", "application/json").
Header("accept", "application/json")
create := conf.DefaultCluster.ES().Indices.Create(index)
if conf.Wait {
create.WaitForActiveShards("all")
@@ -208,48 +212,41 @@ func IndexCreate(conf *cfg.Config, index string, mappings []string) error {
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to create index: %s", err)
return fmt.Errorf("failed to create index: %s", esErrorString(err))
}
return nil
}
func IndexDelete(conf *cfg.Config, index string) error {
_, err := conf.DefaultCluster.ES.Indices.Delete(index).
Header("content-type", "application/json").
Header("accept", "application/json").
_, err := conf.DefaultCluster.ES().Indices.Delete(index).
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to delete index: %s", err)
return fmt.Errorf("failed to delete index: %s", esErrorString(err))
}
return nil
}
func IndexClose(conf *cfg.Config, index string) error {
create := conf.DefaultCluster.ES.Indices.Close(index).
Header("content-type", "application/json").
Header("accept", "application/json")
_, err := create.Do(context.Background())
_, err := conf.DefaultCluster.ES().Indices.Close(index).
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to close index: %s", err)
return fmt.Errorf("failed to close index: %s", esErrorString(err))
}
return nil
}
func IndexAllocation(conf *cfg.Config, index string) error {
res, err := conf.DefaultCluster.ES.Cluster.AllocationExplain().
res, err := conf.DefaultCluster.ES().Cluster.AllocationExplain().
Index(index).
Primary(conf.Primary).
Shard(conf.Shards).
Header("content-type", "application/json").
Header("accept", "application/json").
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to get index allocation explain: %s", err)
return fmt.Errorf("failed to get index allocation explain: %s", esErrorString(err))
}
slog.Debug("ES result", "index", res)
@@ -287,14 +284,56 @@ func IndexAllocation(conf *cfg.Config, index string) error {
func IndexModify(conf *cfg.Config, index string) error {
settings := esdsl.NewIndexSettings().NumberOfReplicas(strconv.Itoa(conf.Replicas))
_, err := conf.DefaultCluster.ES.Indices.PutSettings().
_, err := conf.DefaultCluster.ES().Indices.PutSettings().
Indices(index).
Index(settings).
Header("content-type", "application/json").
Header("accept", "application/json").
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to modify index settings: %s", err)
return 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("*").
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

View File

@@ -29,15 +29,13 @@ import (
// FIXME: add filter support, see IndexCreate mapping
func IndexAliasCreate(conf *cfg.Config, index, alias string) error {
res, err := conf.DefaultCluster.ES.Indices.PutAlias(index, alias).
Header("content-type", "application/json").
Header("accept", "application/json").
res, err := conf.DefaultCluster.ES().Indices.PutAlias(index, alias).
Do(context.Background())
slog.Debug("create alias", "result", res)
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
@@ -51,14 +49,12 @@ func IndexAliasList(conf *cfg.Config) error {
filter = *regexp.MustCompile(conf.Filter[0])
}
res, err := conf.DefaultCluster.ES.Indices.GetAlias().
res, err := conf.DefaultCluster.ES().Indices.GetAlias().
Index("_all").
Header("content-type", "application/json").
Header("accept", "application/json").
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to list index aliases: %s", err)
return fmt.Errorf("failed to list index aliases: %s", esErrorString(err))
}
slog.Debug("aliases list", "result", res)
@@ -100,16 +96,34 @@ func IndexAliasList(conf *cfg.Config) error {
}
func IndexAliasDelete(conf *cfg.Config, index, alias string) error {
res, err := conf.DefaultCluster.ES.Indices.DeleteAlias(index, alias).
Header("content-type", "application/json").
Header("accept", "application/json").
res, err := conf.DefaultCluster.ES().Indices.DeleteAlias(index, alias).
Do(context.Background())
slog.Debug("delete alias", "result", res)
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
}
func IndexAliasRollover(conf *cfg.Config, alias string) error {
res, err := RolloverAlias(conf, alias)
if err != nil {
return fmt.Errorf("failed to rollover index alias: %s", esErrorString(err))
}
table := printer.NewTable(conf, 2, 5)
table.Addheaders("rollover response", "value")
table.Entries = [][]string{
{"acknowledged", fmt.Sprintf("%t", res.Acknowledged)},
{"rolled over", fmt.Sprintf("%t", res.RolledOver)},
{"shards acknowledged", fmt.Sprintf("%t", res.ShardsAcknowledged)},
{"old index", res.OldIndex},
{"new index", res.NewIndex},
}
return table.Print()
}

491
pkg/es/index_template.go Normal file
View File

@@ -0,0 +1,491 @@
/*
Copyright © 2026 Thomas von Dein
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package es
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"strconv"
"strings"
"codeberg.org/scip/esctl/pkg/cfg"
"codeberg.org/scip/esctl/pkg/printer"
"github.com/elastic/go-elasticsearch/v9/typedapi/esdsl"
"github.com/elastic/go-elasticsearch/v9/typedapi/types"
)
// used for completion
func IndexTemplateList(conf *cfg.Config) error {
res, err := conf.DefaultCluster.ES().Indices.GetIndexTemplate().
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to get index templates: %s", esErrorString(err))
}
slog.Debug("res", "index templates", res)
table := printer.NewTable(conf, 5, len(res.IndexTemplates))
table.Addheaders("name", "description", "priority")
for idx, tpl := range res.IndexTemplates {
desc, err := json.Marshal(tpl.IndexTemplate.Meta_["description"])
if err != nil {
return fmt.Errorf("failed to unmarshal meta json data: %w", err)
}
table.Entries[idx] = []string{
tpl.Name,
string(desc),
fmt.Sprintf("%d", tpl.IndexTemplate.Priority),
}
}
table.Sort()
return table.Print()
}
func IndexTemplateShow(conf *cfg.Config, tplname string) error {
res, err := conf.DefaultCluster.ES().Indices.GetIndexTemplate().
Name(tplname).
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to get index template: %s", esErrorString(err))
}
slog.Debug("res", "index template", res)
if len(res.IndexTemplates) != 1 {
return errors.New("multiple or no index template matched the pattern")
}
tpl := res.IndexTemplates[0]
table := printer.NewTable(conf, 2, 6)
table.Addheaders("index template property", "value")
desc, err := json.Marshal(tpl.IndexTemplate.Meta_["description"])
if err != nil {
return fmt.Errorf("failed to unmarshal meta json data: %w", err)
}
hasds := tpl.IndexTemplate.DataStream != nil
aliases := []string{}
for alias := range tpl.IndexTemplate.Template.Aliases {
aliases = append(aliases, alias)
}
table.Entries = [][]string{
{"name", tpl.Name},
{"description", string(desc)},
{"index patterns", strings.Join(tpl.IndexTemplate.IndexPatterns, ",")},
{"composed of", strings.Join(tpl.IndexTemplate.ComposedOf, ",")},
{"data stream enabled", fmt.Sprintf("%t", hasds)},
{"aliases", strings.Join(aliases, ",")},
}
if err := table.Print(); err != nil {
return err
}
table = printer.NewTable(conf, 2, 0)
table.Addheaders("index setting property", "value")
err = getIndexTemplateSettings(conf, tplname, table)
if err != nil {
return nil
}
fmt.Println()
if err := table.Print(); err != nil {
return err
}
table = printer.NewTable(conf, 2, 0)
table.Addheaders("index field mapping", "type")
if tpl.IndexTemplate.Template.Mappings != nil {
for name, field := range tpl.IndexTemplate.Template.Mappings.Properties {
typeval := ""
switch val := field.(type) {
case *types.IntegerNumberProperty:
typeval = val.Type
case *types.KeywordProperty:
typeval = val.Type
case *types.DateProperty:
typeval = val.Type
}
table.Entries = append(table.Entries, []string{
name, typeval,
})
}
fmt.Println()
if err := table.Print(); err != nil {
return err
}
}
return nil
}
func IndexTemplateCreate(conf *cfg.Config, name string, mappings []string) error {
settings := esdsl.NewIndexSettings()
maps := esdsl.NewIndexTemplateMapping()
create := conf.DefaultCluster.ES().Indices.PutIndexTemplate(name)
if conf.Shards > 0 {
settings = settings.NumberOfShards(strconv.Itoa(conf.Shards))
}
if conf.Replicas > 0 {
settings = settings.NumberOfReplicas(strconv.Itoa(conf.Replicas))
}
if conf.Stream {
create.DataStream(esdsl.NewDataStreamVisibility())
if conf.Retention != "" {
maps.Lifecycle(
esdsl.NewDataStreamLifecycle().
DataRetention(
esdsl.NewDuration().String(conf.Retention)))
}
}
if conf.AutoCreate {
create.AllowAutoCreate(true)
}
if conf.Mode != "" {
settings = settings.Mode(conf.Mode)
}
if len(mappings) > 0 {
typemaps, err := modMappings(mappings)
if err != nil {
return err
}
maps.Mappings(typemaps)
}
if len(conf.Components) > 0 {
create.ComposedOf(conf.Components...)
}
for _, alias := range conf.Aliases {
maps.AddAlias(alias, esdsl.NewAlias())
}
if len(conf.Meta) > 0 {
metadata, err := modMeta(conf, nil)
if err != nil {
return err
}
create.Meta_(esdsl.NewMetadata(metadata))
}
if len(conf.Settings) > 0 {
usersettings, err := modSettings(conf)
if err != nil {
return err
}
settings = settings.IndexSettings(usersettings)
}
maps.Settings(settings)
create.Template(maps)
create.IndexPatterns(conf.Patterns...)
_, err := create.Do(context.Background())
if err != nil {
return fmt.Errorf("failed to create index template: %s", esErrorString(err))
}
return nil
}
// FIXME: func's too long, refactor
// FIXME: it's not yet possible to remove items in lists (aliases, mappings, settings), just overwrite them
func IndexTemplateModify(conf *cfg.Config, name string, mappings []string) error {
maps := esdsl.NewIndexTemplateMapping()
// load existing index mapping
res, err := conf.DefaultCluster.ES().Indices.GetIndexTemplate().
Name(name).
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to get index template: %s", esErrorString(err))
}
slog.Debug("res", "index template", res)
if len(res.IndexTemplates) != 1 {
return errors.New("multiple or no index template matched the pattern")
}
tpl := res.IndexTemplates[0]
// our modify PUT request
modify := conf.DefaultCluster.ES().Indices.PutIndexTemplate(name)
// load existing settings, if any
settings := tpl.IndexTemplate.Template.Settings
// pre fill mappings and aliases
maps.Mappings(tpl.IndexTemplate.Template.Mappings)
maps.Aliases(tpl.IndexTemplate.Template.Aliases)
// pre fill components
modify.ComposedOf(tpl.IndexTemplate.ComposedOf...)
// pre fill data stream config
if conf.Stream {
modify.DataStream(esdsl.NewDataStreamVisibility())
if conf.Retention != "" {
maps.Lifecycle(
esdsl.NewDataStreamLifecycle().
DataRetention(
esdsl.NewDuration().String(conf.Retention)))
}
}
modify.AllowAutoCreate(conf.AutoCreate)
if conf.Mode != "" {
*settings.Mode = conf.Mode
}
if len(mappings) > 0 {
typemaps, err := modMappings(mappings)
if err != nil {
return err
}
maps.Mappings(typemaps)
}
if len(conf.Components) > 0 {
modify.ComposedOf(conf.Components...)
}
for _, alias := range conf.Aliases {
maps.AddAlias(alias, esdsl.NewAlias())
}
if len(conf.Meta) > 0 {
// pre fill meta, if any
metadata, err := modMeta(conf, tpl.IndexTemplate.Meta_)
if err != nil {
return err
}
modify.Meta_(esdsl.NewMetadata(metadata))
}
if len(conf.Settings) > 0 {
usersettings, err := modSettings(conf)
if err != nil {
return err
}
settings.IndexSettings = usersettings
}
patterns := tpl.IndexTemplate.IndexPatterns
if len(conf.Patterns) > 0 {
patterns = conf.Patterns
}
maps.Settings(settings)
modify.Template(maps)
modify.IndexPatterns(patterns...)
_, err = modify.Do(context.Background())
if err != nil {
return fmt.Errorf("failed to modify index template: %s", esErrorString(err))
}
if conf.Rollover {
if err := rolloverAliasIndexTemplate(conf, name); err != nil {
return err
}
}
return nil
}
func IndexTemplateDelete(conf *cfg.Config, name string) error {
_, err := conf.DefaultCluster.ES().Indices.DeleteIndexTemplate(name).
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to delete index template: %s", esErrorString(err))
}
return nil
}
// Based on given index template find the associated index patterns,
// find indices matching those, find their associated aliases and
// rollover all we find. Only run when conf.Rollover==true
func rolloverAliasIndexTemplate(conf *cfg.Config, name string) error {
res, err := conf.DefaultCluster.ES().Indices.GetIndexTemplate().
Name(name).
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to get index template: %s", esErrorString(err))
}
slog.Debug("res", "index template", res)
if len(res.IndexTemplates) != 1 {
return errors.New("multiple or no index template matched the pattern")
}
patterns := res.IndexTemplates[0].IndexTemplate.IndexPatterns
aliases := map[string]int{}
// find all aliases matching the patterns
for _, pattern := range patterns {
res, err := conf.DefaultCluster.ES().Indices.ResolveIndex(pattern).
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to resolve index pattern: %s", esErrorString(err))
}
for _, index := range res.Indices {
for _, alias := range index.Aliases {
aliases[alias] = 1
}
}
}
if len(aliases) == 0 {
return nil
}
table := printer.NewTable(conf, 4, 0)
table.Addheaders("rollover alias", "status", "ack", "new index")
// apply rollover to all matching aliases, if any
for alias := range aliases {
res, err := RolloverAlias(conf, alias)
if err != nil {
return err
}
table.Entries = append(table.Entries, []string{
alias,
fmt.Sprintf("%t", res.RolledOver),
fmt.Sprintf("%t", res.Acknowledged),
res.NewIndex,
})
if err := table.Print(); err != nil {
return err
}
}
return nil
}
func modMappings(mappings []string) (types.TypeMappingVariant, error) {
typemaps := esdsl.NewTypeMapping()
for _, mapping := range mappings {
parts := strings.Split(mapping, ":")
if len(parts) != 2 {
return nil, fmt.Errorf(
"invalid mapping %s, expect <name:type> (type: integer, text, date, keyword)",
mapping)
}
switch parts[1] {
case "text":
typemaps.AddProperty(parts[0], esdsl.NewTextProperty())
case "integer":
typemaps.AddProperty(parts[0], esdsl.NewIntegerNumberProperty())
case "date":
typemaps.AddProperty(parts[0], esdsl.NewDateProperty())
case "keyword":
typemaps.AddProperty(parts[0], esdsl.NewKeywordProperty())
}
}
return typemaps, nil
}
func modMeta(conf *cfg.Config, meta types.Metadata) (map[string]json.RawMessage, error) {
metadata := map[string]json.RawMessage{}
for key, value := range meta {
metadata[key] = value
}
for _, meta := range conf.Meta {
parts := strings.Split(meta, ":")
if len(parts) != 2 {
return nil, errors.New("meta data must be in the form key:value")
}
msg, err := json.Marshal(parts[1])
if err != nil {
return nil, fmt.Errorf("failed to json marshal metadata %s: %w", meta, err)
}
metadata[parts[0]] = msg
}
return metadata, nil
}
func modSettings(conf *cfg.Config) (map[string]json.RawMessage, error) {
usersettings := map[string]json.RawMessage{}
for _, meta := range conf.Settings {
parts := strings.Split(meta, ":")
if len(parts) != 2 {
return nil, errors.New("settings data must be in the form key:value")
}
msg, err := json.Marshal(parts[1])
if err != nil {
return nil, fmt.Errorf("failed to json marshal metadata %s: %w", meta, err)
}
usersettings[parts[0]] = msg
}
return usersettings, nil
}

View File

@@ -0,0 +1,61 @@
package es
import (
"encoding/json"
"fmt"
"codeberg.org/scip/esctl/pkg/cfg"
"codeberg.org/scip/esctl/pkg/printer"
)
type TplTemplateData struct {
Settings map[string]any `json:"settings"`
Aliases map[string]any `json:"aliases"`
Lifecycle map[string]any `json:"lifecycle"`
Mappings map[string]any `json:"mappings"`
}
type TplTemplate struct {
Template TplTemplateData `json:"template"`
}
type TplTpl struct {
IndexTemplate TplTemplate `json:"index_template"`
}
type Tpl struct {
IndexTemplates []TplTpl `json:"index_templates"`
}
func getIndexTemplateSettings(conf *cfg.Config, tplname string, table *printer.Table) error {
raw, err := CallAPI(conf, "GET", "/_index_template/"+tplname, "")
if err != nil {
return err
}
data := Tpl{}
if err = json.Unmarshal(raw, &data); err != nil {
return err
}
if conf.Debug {
if err := prettyfiJson(conf, raw); err != nil {
return err
}
}
if len(data.IndexTemplates) == 0 {
return fmt.Errorf("index_template %s not found", tplname)
}
tpl := data.IndexTemplates[0].IndexTemplate.Template.Settings
for topic, val := range tpl {
paths := getJsonPath(map[string]string{}, val.(map[string]any), topic)
for setting, value := range paths {
table.Entries = append(table.Entries, []string{setting, fmt.Sprintf("%v", value)})
}
}
return nil
}

View File

@@ -22,14 +22,14 @@ import (
"log/slog"
"codeberg.org/scip/esctl/pkg/cfg"
"codeberg.org/scip/esctl/pkg/printer"
"codeberg.org/scip/esctl/pkg/printer"
)
func NodeList(conf *cfg.Config) error {
// 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 {
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)

View File

@@ -1,176 +0,0 @@
/*
Copyright © 2026 Thomas von Dein
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package es
import (
"bytes"
"context"
"crypto/tls"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"slices"
"strings"
"codeberg.org/scip/esctl/pkg/cfg"
"github.com/chzyer/readline"
)
func encodeAuth(username, password string) string {
return base64.StdEncoding.EncodeToString([]byte(username + ":" + password))
}
func CallAPI(conf *cfg.Config, input []string) error {
var data string
verb := strings.ToUpper(input[0])
path := input[1]
if len(input) == 3 {
data = input[2]
}
// we're using port-forwards anyway
tr := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
client := &http.Client{Transport: tr}
req, err := http.NewRequest(verb, conf.DefaultCluster.Uri+path, bytes.NewBuffer([]byte(data)))
if err != nil {
return err
}
req.Header.Add("Content-Type", "application/json")
req.Header.Add("accept", "application/json")
req.Header.Add("Authorization", "Basic "+encodeAuth(conf.DefaultCluster.User, conf.DefaultCluster.Pass))
// actually execute the request
resp, err := client.Do(req)
if err != nil {
return err
}
// Read and print response
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("failed to read response body: %s", err)
}
return prettyfiJson(conf, body)
}
func prettyfiJson(conf *cfg.Config, raw []byte) error {
if conf.HaveJQ {
cmd := exec.CommandContext(context.Background(), "jq", "-C")
cmd.Stdin = bytes.NewReader(raw)
var out bytes.Buffer
cmd.Stdout = &out
err := cmd.Run()
if err != nil {
return err
}
fmt.Println(out.String())
return nil
}
var pretty bytes.Buffer
err := json.Indent(&pretty, raw, "", "\t")
if err != nil {
return fmt.Errorf("json parse error: %s", err)
}
fmt.Println(pretty.String())
return nil
}
func Repl(conf *cfg.Config) error {
verbs := []string{"post", "get", "put", "delete"}
fmt.Println("Input format: verb path [data]")
fmt.Println("example: post /yourindex/_ccr/pause_follow")
reader, err := readline.NewEx(&readline.Config{
Prompt: "> ",
HistoryFile: os.Getenv("HOME") + "/.config/esctl/history",
HistoryLimit: 500,
InterruptPrompt: "^C",
EOFPrompt: "exit",
HistorySearchFold: true,
})
if err != nil {
return fmt.Errorf("failed to initialize readline lib: %s", err)
}
for {
text, err := reader.Readline()
if err != nil {
break
}
text = strings.TrimSpace(text)
if text == "" {
continue
}
parts := strings.SplitN(strings.TrimSpace(text), " ", 3)
if len(parts) < 2 {
fmt.Println("error: you need to input a verb, uri [and post data]")
continue
}
if !slices.Contains(verbs, strings.ToLower(parts[0])) {
fmt.Println("error: verb must be one of " + strings.Join(verbs, ","))
continue
}
if !strings.HasPrefix(parts[1], "/") {
fmt.Println("error: url path must start with /")
continue
}
if len(parts) == 3 {
data := map[string]any{}
err := json.Unmarshal([]byte(parts[2]), &data)
if err != nil {
fmt.Printf("error: input data is not proper JSON: %s", err)
continue
}
}
err = CallAPI(conf, parts)
if err != nil {
fmt.Printf("failed to call API: %s\n", err)
}
reader.SetPrompt("> ")
}
return nil
}

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
}

65
pkg/es/rollover.go Normal file
View File

@@ -0,0 +1,65 @@
/*
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"
"codeberg.org/scip/esctl/pkg/cfg"
"github.com/elastic/go-elasticsearch/v9/typedapi/esdsl"
"github.com/elastic/go-elasticsearch/v9/typedapi/indices/rollover"
"github.com/elastic/go-elasticsearch/v9/typedapi/types"
)
func RolloverConditions(conf *cfg.Config) types.RolloverConditionsVariant {
cond := esdsl.NewRolloverConditions()
if conf.MaxAge != "" {
cond.MaxAge(esdsl.NewDuration().String(conf.MaxAge))
}
if conf.MaxDocs > 0 {
cond.MaxDocs(int64(conf.MaxDocs))
}
if conf.MaxShardSize > 0 {
bs := esdsl.NewByteSize()
bs.Int64(int64(conf.MaxShardSize))
cond.MaxPrimaryShardSize(bs)
}
if conf.MaxShardDocs > 0 {
cond.MaxPrimaryShardDocs(int64(conf.MaxShardDocs))
}
return cond
}
func RolloverAlias(conf *cfg.Config, alias string) (*rollover.Response, error) {
roll := conf.DefaultCluster.ES().Indices.Rollover(alias)
if conf.Wait {
roll.WaitForActiveShards("all")
}
if conf.DryRun {
roll.DryRun(true)
}
cond := RolloverConditions(conf)
roll.Conditions(cond)
return roll.Do(context.Background())
}

View File

@@ -18,13 +18,27 @@ package es
import (
"context"
"encoding/json"
"fmt"
"log"
"log/slog"
"time"
"codeberg.org/scip/esctl/pkg/cfg"
"codeberg.org/scip/esctl/pkg/printer"
"github.com/alecthomas/repr"
"github.com/elastic/go-elasticsearch/v9/typedapi/core/search"
"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/enums/sortorder"
"github.com/tidwall/gjson"
)
const (
MAXPAGE = 5000
)
/*
Execute an ES search.
@@ -32,39 +46,219 @@ Execute an ES search.
additional filters can be given as -F key=value
*/
func Search(conf *cfg.Config, queries []string) error {
search := conf.DefaultCluster.ES.Search().
Index(conf.Index)
if conf.Validate {
return validateSearch(conf, queries)
}
if len(queries) > 0 {
req, err := prepareQuery(conf, queries)
searchEs := conf.DefaultCluster.ES().Search().Index(conf.Index)
queryCaster, err := prepareQuery(conf, queries)
if err != nil {
return err
}
req := &search.Request{Query: queryCaster}
searchEs.Request(req)
searchEs = addSort(conf, searchEs)
switch {
case conf.Tail:
return searchTail(conf, searchEs)
case conf.Explain:
return explainSearch(conf, searchEs)
default:
if conf.To > MAXPAGE {
return searchPit(conf, req)
} else {
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 err
return fmt.Errorf("failed to marshal explain result: %s", err)
}
search.Request(req)
value := gjson.Get(string(raw), "hits.hits.0._explanation")
fmt.Println(value.String())
}
res, err := search.Do(context.Background())
if err != nil {
return fmt.Errorf("failed to run search (esdsl): %s", err)
}
if len(res.Hits.Hits) > 0 {
ex := res.Hits.Hits[0].Explanation_
fmt.Println(ex.Description)
fmt.Println(ex.Value)
slog.Debug("ES result", "search", res)
for _, hit := range res.Hits.Hits {
docjson := fmt.Sprintf(`{"id":%s, "score":%0.4f, "index":"%s", "source":%s}`,
*hit.Id_,
*hit.Score_,
hit.Index_,
hit.Source_)
if conf.Path != "" {
value := gjson.Get(docjson, conf.Path)
fmt.Println(value.String())
} else {
fmt.Println(docjson)
// 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 {
res, err := conf.DefaultCluster.ES().Search().
Index(conf.Index).
Size(0).
Aggregations(map[string]types.Aggregations{
"min_ts": *esdsl.NewMinAggregation().Field("@timestamp").AggregationsCaster(),
"max_ts": *esdsl.NewMaxAggregation().Field("@timestamp").AggregationsCaster(),
}).
Do(context.Background())
if err != nil {
return err
}
repr.Println(res)
return nil
}
func searchOnce(conf *cfg.Config, search *search.Search) error {
res, err := search.
From(conf.From).
Size(conf.To).
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to run search (esdsl): %s", esErrorString(err))
}
slog.Debug("ES result", "search", res)
printer.PrintDocs(conf, res.Hits.Hits)
return nil
}
// https://www.elastic.co/docs/reference/elasticsearch/clients/go/using-the-api/searching#_pit_search_after
func searchPit(conf *cfg.Config, req *search.Request) error {
ctx := context.Background()
pit, err := conf.DefaultCluster.ES().OpenPointInTime(conf.Index).KeepAlive("1m").Do(ctx)
if err != nil {
return fmt.Errorf("failed to open point-in-time request for search: %s", err)
}
defer func() {
_, err := conf.DefaultCluster.ES().ClosePointInTime().Id(pit.Id).Do(ctx)
if err != nil {
log.Fatalf("failed to close PIT: %s", err)
}
}()
search := conf.DefaultCluster.ES().Search().
Request(req).
Pit(esdsl.NewPointInTimeReference().
Id(pit.Id).
KeepAlive(esdsl.NewDuration().String("1m"))).
Sort(esdsl.NewSortOptions().
AddSortOption("_shard_doc", esdsl.NewFieldSort(sortorder.Asc))).
Size(conf.To)
search = addSort(conf, search)
for {
res, err := search.Do(ctx)
if err != nil {
return fmt.Errorf("failed to run search (esdsl pit): %s", esErrorString(err))
}
if len(res.Hits.Hits) == 0 {
break
}
printer.PrintDocs(conf, res.Hits.Hits)
last := res.Hits.Hits[len(res.Hits.Hits)-1]
search = search.SearchAfterValues(last.Sort)
if res.PitId != nil {
search = search.Pit(esdsl.NewPointInTimeReference().
Id(*res.PitId).
KeepAlive(esdsl.NewDuration().String("1m")))
}
}
return nil
}
func searchTail(conf *cfg.Config, search *search.Search) error {
docs := map[string]int{}
fmt.Println("enter ctrl-c to abort...")
for {
res, err := search.Do(context.Background())
if err != nil {
return fmt.Errorf("failed to run search (esdsl): %s", esErrorString(err))
}
slog.Debug("ES result", "search", res)
for _, hit := range res.Hits.Hits {
_, exists := docs[*hit.Id_]
if exists {
continue
}
printer.PrintDoc(conf, hit)
fmt.Println()
docs[*hit.Id_] = 1
}
time.Sleep(100 * time.Millisecond)
}
}

View File

@@ -17,13 +17,18 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
package es
import (
"context"
"errors"
"fmt"
"log/slog"
"strings"
"codeberg.org/scip/esctl/pkg/cfg"
"github.com/elastic/go-elasticsearch/v9/typedapi/core/search"
"github.com/elastic/go-elasticsearch/v9/typedapi/esdsl"
"github.com/elastic/go-elasticsearch/v9/typedapi/types"
"github.com/elastic/go-elasticsearch/v9/typedapi/types/enums/operator"
"github.com/elastic/go-elasticsearch/v9/typedapi/types/enums/sortorder"
)
const (
@@ -40,7 +45,22 @@ type filter struct {
multi bool // message,title=foo => filter:foo, multi: []string{"message","title"}
}
// build a new filter object
// Build a new filter object. We use this to build our elastic query
// out of it. We support different types of queries:
//
// - nop filter: no query at all, just return the first N documents.
//
// - simple ones like: "authenticated", which match across all fields
//
// - simple ones combined like "authenticated monitoring", if -O was set,
// apply an OR logic
//
// - specific field queries: "message=authenticated" (can be combined too like above)
//
// - and specific field queries with negation: "message!=authenticated"
//
// All queries support additional filters using -F field=value, which
// must match literally, and range filters using -r "@timestamp:2026-05-28T10:00:00 to now"
func NewFilter(query string) (*filter, error) {
var separator string
var criteria int // we use the constants on top for this
@@ -49,9 +69,6 @@ func NewFilter(query string) (*filter, error) {
case strings.Contains(query, "!="):
criteria = Fmustnot
separator = "!="
case strings.Contains(query, "?"):
criteria = Fshould
separator = "?"
default:
criteria = Fmust
separator = "="
@@ -59,48 +76,30 @@ func NewFilter(query string) (*filter, error) {
part := strings.Split(query, separator)
if len(part) != 2 {
return nil, fmt.Errorf("search queries must be in the form field<sep>pattern where <sep> must be one of: =, !=, ?")
return nil, fmt.Errorf("search queries must be in the form field<sep>pattern where <sep> must be one of: = or !=")
}
f := &filter{term: part[0], filter: part[1], criteria: criteria}
flt := &filter{term: part[0], filter: part[1], criteria: criteria}
if strings.Contains(part[0], ",") {
// a MultiMatchQuery, match across multiple fields at once
multi := strings.Split(part[0], ",")
f.multi = true
f.mterm = multi
flt.multi = true
flt.mterm = multi
}
return f, nil
return flt, nil
}
// prepare q user search query and turn it into a proper search request
func prepareQuery(conf *cfg.Config, queries []string) (*search.Request, error) {
if len(queries) == 0 {
// nothing given, just return all docs, if any
return &search.Request{
Query: esdsl.NewMatchAllQuery().QueryCaster(),
From: &conf.From,
Size: &conf.To,
}, nil
}
if len(queries) == 1 && !strings.ContainsAny(queries[0], "!=?") {
// a general query w/o fields, search across all fields
return &search.Request{
Query: esdsl.NewSimpleQueryStringQuery(queries[0]).QueryCaster(),
From: &conf.From,
Size: &conf.To,
}, nil
}
// complex query, form a proper query struct
query := esdsl.NewBoolQuery()
// Build a complex query set for queries containing field[!]=pattern
func mkMatchQueries(queries []string, op operator.Operator) ([]types.QueryVariant, []types.QueryVariant, error) {
matchqueries := []types.QueryVariant{}
matchNotqueries := []types.QueryVariant{}
for _, q := range queries {
filter, err := NewFilter(q)
if err != nil {
return nil, err
return nil, nil, err
}
// by default we match on a single field
@@ -108,23 +107,89 @@ func prepareQuery(conf *cfg.Config, queries []string) (*search.Request, error) {
if filter.multi {
// ok, match across multiple given fields
match = esdsl.NewMultiMatchQuery(filter.filter).Fields(filter.mterm...)
match = esdsl.NewMultiMatchQuery(filter.filter).Fields(filter.mterm...).Operator(op)
}
// apply logic
switch filter.criteria {
case Fmustnot:
query.MustNot(match)
case Fmust:
query.Must(match)
case Fshould:
query.Should(match)
if filter.criteria == Fmustnot {
matchNotqueries = append(matchNotqueries, match)
} else {
matchqueries = append(matchqueries, match)
}
}
// there might be boolean filters as well
filters := make([]types.QueryVariant, len(conf.Filter))
return matchqueries, matchNotqueries, nil
}
// Prepare user search query and turn it into a proper search request
func prepareQuery(conf *cfg.Config, queries []string) (*types.Query, error) {
// logical operator for simple and multimatch queries
op := operator.Operator{Name: "AND"}
if conf.Or {
op = operator.Operator{Name: "OR"}
}
query := esdsl.NewBoolQuery()
wholeQuery := strings.Join(queries, " ")
switch {
case len(queries) == 0:
// nothing provided via ARGs, so match any docs
query.Must(esdsl.NewMatchAllQuery())
case !strings.ContainsAny(wholeQuery, "!="):
// simple query w/o any field[!]=pattern style
query.Must(esdsl.NewSimpleQueryStringQuery(wholeQuery).DefaultOperator(op))
default:
// a complex query
matchqueries, matchNotqueries, err := mkMatchQueries(queries, op)
if err != nil {
return nil, err
}
// apply boolean logic
if conf.Or {
query.Should(matchqueries...)
} else {
// by default we use AND
query.Must(matchqueries...)
}
if matchNotqueries != nil {
query.MustNot(matchNotqueries...)
}
}
slog.Debug("complex query", "query", query)
// there might be boolean or range filters like -Ffield=value as well
filters, err := addFilters(conf)
if err != nil {
return nil, err
}
if filters != nil {
query.Filter(filters...)
}
return query.QueryCaster(), nil
}
// Build a slice of filters, to be fed into query.Filter() including static ones
// like -Ffield=value and ranges like -r "@timestamp:2026-05-28T10:00:00 to now"
func addFilters(conf *cfg.Config) ([]types.QueryVariant, error) {
count := len(conf.Filter)
if conf.Range != "" {
count++
}
if count == 0 {
return nil, nil
}
filters := make([]types.QueryVariant, count)
// static filters
for idx, filter := range conf.Filter {
parts := strings.Split(filter, "=")
if len(parts) != 2 {
@@ -134,13 +199,82 @@ func prepareQuery(conf *cfg.Config, queries []string) (*search.Request, error) {
filters[idx] = esdsl.NewTermQuery(parts[0], esdsl.NewFieldValue().String(parts[1]))
}
if len(filters) > 0 {
query.Filter(filters...)
// range filters. format: @timestamp:2026-05-05 to 2026-05-15
// see: https://www.elastic.co/docs/reference/elasticsearch/rest-apis/common-options#date-math
if conf.Range != "" {
parts := strings.SplitN(conf.Range, ":", 2)
if len(parts) != 2 {
return nil, errors.New("invalid range format, expected field:range")
}
field := parts[0]
parts = strings.Split(parts[1], " to ")
if len(parts) != 2 {
return nil, errors.New("invalid date range format, expected '<start> to <end>'")
}
from := parts[0]
to := parts[1]
slog.Debug("time range filter",
"from", from,
"to", to,
"format", conf.TimestampFormat,
"count", count,
)
rng := esdsl.NewDateRangeQuery(field).
Gte(from).
Lte(to)
if strings.Contains(parts[1], ":") {
// specific format not needed as long as there are no times specified
rng.Format(conf.TimestampFormat)
}
filters[count-1] = rng
}
return &search.Request{
Query: query.QueryCaster(),
From: &conf.From,
Size: &conf.To,
}, 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

@@ -83,12 +83,10 @@ func filterShards(conf *cfg.Config, shardlist shards.Response) shards.Response {
}
func ShardList(conf *cfg.Config) error {
res, err := conf.DefaultCluster.ES.Cat.Shards().
Header("content-type", "application/json").
Header("accept", "application/json").
res, err := conf.DefaultCluster.ES().Cat.Shards().
Do(context.Background())
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)
@@ -135,12 +133,10 @@ func printShards(conf *cfg.Config, shardlist shards.Response) error {
}
func ShardShow(conf *cfg.Config, index string) error {
res, err := conf.DefaultCluster.ES.Cat.Shards().Index(index).
Header("content-type", "application/json").
Header("accept", "application/json").
res, err := conf.DefaultCluster.ES().Cat.Shards().Index(index).
Do(context.Background())
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)

View File

@@ -25,7 +25,7 @@ import (
"strings"
"codeberg.org/scip/esctl/pkg/cfg"
"codeberg.org/scip/esctl/pkg/printer"
"codeberg.org/scip/esctl/pkg/printer"
)
var (
@@ -43,9 +43,9 @@ type Snapshot struct {
func SnapshotList(conf *cfg.Config) error {
// 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 {
return fmt.Errorf("failed to get indicies: %s", err)
return fmt.Errorf("failed to get indicies: %s", esErrorString(err))
}
indicies := map[string]int{}
@@ -56,9 +56,9 @@ func SnapshotList(conf *cfg.Config) error {
}
// 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 {
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)
@@ -106,9 +106,9 @@ func SnapshotList(conf *cfg.Config) 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 {
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)

75
pkg/es/task.go Normal file
View File

@@ -0,0 +1,75 @@
/*
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"
"strconv"
"time"
"codeberg.org/scip/esctl/pkg/cfg"
"codeberg.org/scip/esctl/pkg/printer"
)
func TaskList(conf *cfg.Config) error {
res, err := conf.DefaultCluster.ES().Cat.Tasks().
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to get index templates: %s", esErrorString(err))
}
slog.Debug("res", "tasks", res)
table := printer.NewTable(conf, 8, len(res))
table.Addheaders("task id", "action", "start time", "run time", "node", "type")
for idx, task := range res {
var ts time.Time
epc, err := strconv.Atoi(*task.StartTime)
if err != nil {
ts = time.Now()
} else {
ts = time.Unix(int64(epc/1000), 0)
}
table.Entries[idx] = []string{
*task.TaskId,
*task.Action,
ts.Format("2006-01-02T15:04:05"),
*task.RunningTime,
*task.Node,
*task.Type,
}
}
return table.Print()
}
func TaskCancel(conf *cfg.Config, taskid string) error {
_, err := conf.DefaultCluster.ES().Tasks.Cancel().
TaskId(taskid).
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to cancel task: %s", esErrorString(err))
}
return nil
}

28
pkg/printer/builder.go Normal file
View File

@@ -0,0 +1,28 @@
/*
Copyright © 2026 Thomas von Dein
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package printer
import "strings"
// a wrapper around strings.Builder for convenience
type Builder struct {
strings.Builder
}
func (builder *Builder) WriteStringLine(in string) {
builder.WriteString(in + "\n")
}

View File

@@ -24,6 +24,7 @@ import (
var (
green = color.New(color.BgGreen, color.FgHiWhite).SprintFunc()
yellow = color.New(color.BgYellow, color.FgHiWhite).SprintFunc()
orange = color.BgRGB(255, 128, 0).AddRGB(0, 0, 0).SprintFunc()
red = color.New(color.BgRed, color.FgHiWhite).SprintFunc()
bold = color.New(color.Bold).SprintFunc()
@@ -42,6 +43,8 @@ func Colorize(conf *cfg.Config, col, what string) string {
what = orange(what)
case "red":
what = red(what)
case "yellow":
what = yellow(what)
}
return what

57
pkg/printer/doc.go Normal file
View File

@@ -0,0 +1,57 @@
/*
Copyright © 2026 Thomas von Dein
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package printer
import (
"fmt"
"slices"
"codeberg.org/scip/esctl/pkg/cfg"
"github.com/elastic/go-elasticsearch/v9/typedapi/types"
"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) {
var score types.Float64
if hit.Score_ != nil {
score = *hit.Score_
}
docjson := fmt.Sprintf(`{"id":"%s", "score":%0.4f, "index":"%s", "source":%s}`,
*hit.Id_,
score,
hit.Index_,
hit.Source_)
if conf.Path != "" {
value := gjson.Get(docjson, conf.Path)
fmt.Println(value.String())
} else {
fmt.Print(docjson)
}
}

136
pkg/printer/pager.go Normal file
View File

@@ -0,0 +1,136 @@
/*
Copyright © 2026 Thomas von Dein
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package printer
// pager setup using bubbletea
// file shamlelessly copied from:
// https://github.com/charmbracelet/bubbletea/tree/main/examples/pager
import (
"fmt"
"os"
"strings"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
var (
titleStyle = func() lipgloss.Style {
b := lipgloss.RoundedBorder()
b.Right = "├"
return lipgloss.NewStyle().BorderStyle(b).Padding(0, 1)
}()
infoStyle = func() lipgloss.Style {
b := lipgloss.RoundedBorder()
b.Left = "┤"
return titleStyle.BorderStyle(b)
}()
)
type model struct {
content string
title string
ready bool
viewport viewport.Model
}
func (m model) Init() tea.Cmd {
return nil
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var (
cmd tea.Cmd
cmds []tea.Cmd
)
switch msg := msg.(type) {
case tea.KeyMsg:
if k := msg.String(); k == "ctrl+c" || k == "q" || k == "esc" {
return m, tea.Quit
}
case tea.WindowSizeMsg:
headerHeight := lipgloss.Height(m.headerView())
footerHeight := lipgloss.Height(m.footerView())
verticalMarginHeight := headerHeight + footerHeight
if !m.ready {
// Since this program is using the full size of the viewport we
// need to wait until we've received the window dimensions before
// we can initialize the viewport. The initial dimensions come in
// quickly, though asynchronously, which is why we wait for them
// here.
m.viewport = viewport.New(msg.Width, msg.Height-verticalMarginHeight)
m.viewport.YPosition = headerHeight
m.viewport.SetContent(m.content)
m.ready = true
} else {
m.viewport.Width = msg.Width
m.viewport.Height = msg.Height - verticalMarginHeight
}
}
// Handle keyboard and mouse events in the viewport
m.viewport, cmd = m.viewport.Update(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
}
func (m model) View() string {
if !m.ready {
return "\n Initializing..."
}
return fmt.Sprintf("%s\n%s\n%s", m.headerView(), m.viewport.View(), m.footerView())
}
func (m model) headerView() string {
// title := titleStyle.Render("RPN Help Overview")
title := titleStyle.Render(m.title)
line := strings.Repeat("─", max(0, m.viewport.Width-lipgloss.Width(title)))
return lipgloss.JoinHorizontal(lipgloss.Center, title, line)
}
func (m model) footerView() string {
info := infoStyle.Render(fmt.Sprintf("%3.f%%", m.viewport.ScrollPercent()*100))
line := strings.Repeat("─", max(0, m.viewport.Width-lipgloss.Width(info)))
return lipgloss.JoinHorizontal(lipgloss.Center, line, info)
}
func max(a, b int) int {
if a > b {
return a
}
return b
}
func Pager(title, message string) {
p := tea.NewProgram(
model{content: message, title: title},
tea.WithAltScreen(), // use the full size of the terminal in its "alternate screen buffer"
tea.WithMouseCellMotion(), // turn on mouse support so we can track the mouse wheel
)
if _, err := p.Run(); err != nil {
fmt.Println("could not run pager:", err)
os.Exit(1)
}
}

View File

@@ -37,6 +37,7 @@ type Table struct {
Entries [][]string
lenHeaders []int
alignInts bool
}
func NewTable(conf *cfg.Config, columns, rows int) *Table {
@@ -45,6 +46,7 @@ func NewTable(conf *cfg.Config, columns, rows int) *Table {
table.Headers = make([]string, columns)
table.Entries = make([][]string, rows)
table.lenHeaders = make([]int, columns)
table.alignInts = conf.AlignInts
return &table
}
@@ -137,7 +139,7 @@ func (data *Table) PrintTSV() error {
for idx, entry := range entries {
length := visibleLen(entry)
if isInt(entry) {
if isInt(entry) && data.alignInts {
// align right
fmt.Print(strings.Repeat(" ", data.lenHeaders[idx]-length), entry)
} else {
@@ -292,6 +294,10 @@ func (data *Table) Addheaders(headers ...string) {
}
}
func (data *Table) AddRow(fields ...string) {
data.Entries = append(data.Entries, fields)
}
// return the length of a string but only visible chars, w/o ansi color escapes
func visibleLen(word string) int {
return len(ansiCtrlSeq.ReplaceAllLiteralString(word, ""))