Compare commits

...

49 Commits

Author SHA1 Message Date
b0d671b06f bump version 2026-07-06 14:20:38 +02:00
01247ae744 add more tests 2026-07-06 14:16:12 +02:00
2fed42fbf4 print ask passwd prompt to stderr 2026-07-06 14:15:46 +02:00
c855a7ebbe fix license show panics 2026-07-06 14:15:27 +02:00
a366cb5010 fix ilm show completion 2026-07-06 14:15:14 +02:00
67faee8f1c add automated tests 2026-07-06 11:21:06 +02:00
4ddddb4cb0 add newline after each doc in search output 2026-07-06 09:35:03 +02:00
035194b6f0 load -c <file> if exists 2026-07-06 09:09:23 +02:00
392249f483 fix ccr info error msg 2026-07-05 22:13:34 +02:00
90c7da98ea dont show ccr status if license insufficient 2026-07-05 22:11:01 +02:00
T. von Dein
a07f38e945 add 'license show', add printer.NewTableEmpty() along .WithHeaders() (#69) 2026-07-03 13:52:36 +02:00
T. von Dein
c197e138ef fix repl output: didn't print json smaller than term height, add debug-http to api repl as well (#68) 2026-07-03 13:30:37 +02:00
T. von Dein
fb6f2389fe add 'cluster settings set' settings completion (#67) 2026-07-03 12:52:13 +02:00
T. von Dein
c9fb572935 get rid of getJsonPath, use flat_settings query flag instead (#66) 2026-07-03 10:25:57 +02:00
T. von Dein
1fc2004474 add external pager support via ES_JSON_PAGER (#65) 2026-07-03 10:07:11 +02:00
T. von Dein
7b576c976c add option -f to usage and filter support (#64) 2026-07-03 08:54:19 +02:00
T. von Dein
b51ee109f2 tune cluster ls: parallized and use tcp connect instead of https (#63) 2026-07-03 08:30:35 +02:00
T. von Dein
b573d63c54 enhance cluster status, add word wrap to wide table rows (#62) 2026-07-02 12:16:59 +02:00
T. von Dein
e3b96b3e3b add node show + node clients (#61) 2026-07-01 13:13:16 +02:00
T. von Dein
6436bcfb22 ask for pass if missing or use env pass, add token support (#60) 2026-07-01 10:58:39 +02:00
T. von Dein
f5c8a23589 add cluster reroute commands, fix usage width bug (#59)
fixes #36
2026-06-29 13:08:05 +02:00
T. von Dein
f9eded5f92 enhance ilm show, add ilm update, rename modify to update everywhere (#58) 2026-06-29 11:48:29 +02:00
T. von Dein
b7a87051c0 add 'ilm show -t' to show a better visualization of a ilm policy (#57) 2026-06-29 10:11:50 +02:00
T. von Dein
81987b75f2 turn --show-command-tree into sub command, hide hidden commands (#56) 2026-06-29 08:38:03 +02:00
34bf4fbed9 add ilm forecast example 2026-06-26 11:41:55 +02:00
T. von Dein
a87f6d0c4f add --hidden flag (default off) to 'index ls' and 'ilm forecast ls' (#55) 2026-06-26 11:32:53 +02:00
T. von Dein
bbe72a474b fix #53: shardsize to string, fix nil pointer, add cold phase (#54) 2026-06-26 11:25:30 +02:00
e3aa50af37 remove debug print 2026-06-26 10:07:42 +02:00
T. von Dein
d2a0812579 feature add ilm-explain (#52) 2026-06-26 10:02:41 +02:00
T. von Dein
6200b5e7b0 enhance/ilm-forecast (#51) 2026-06-26 09:08:14 +02:00
b1b701303c optimzie virtualAge() a little 2026-06-25 20:30:42 +02:00
8f2c68b5b6 fix index response, fix linter issues 2026-06-25 14:19:43 +02:00
d60983411e refactored large func 2026-06-25 14:16:35 +02:00
e1291d1d92 add ilm forecast show 2026-06-25 13:29:24 +02:00
5f60696b01 add ilm forecast data collection, generalized parallel api calls 2026-06-25 11:54:06 +02:00
T. von Dein
825b3ce5d1 fix #49: add ilm policy to index+index-template create+modify (#50) 2026-06-25 07:52:54 +02:00
75a94fade3 fix doc 2026-06-24 10:30:53 +02:00
3e36f97e4b upd documentation 2026-06-24 10:29:17 +02:00
1b9309bca0 upd readme 2026-06-24 09:44:21 +02:00
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
69 changed files with 5156 additions and 996 deletions

3
.gitignore vendored
View File

@@ -32,3 +32,6 @@ esctl
*.log
cpu.profile
t
single

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

@@ -62,9 +62,8 @@ install: buildlocal
clean:
rm -rf $(tool) coverage.out testdata t/out pkg/es/openspec.go
test: clean
mkdir -p t/out
go test ./... $(ARGS)
test: clean buildlocal
make -C t test
testlint: test lint

599
README.md
View File

@@ -1,4 +1,8 @@
[![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
@@ -19,6 +23,8 @@ Features:
- Shell completion support (bash, zsh and fish). Put this into your
rc: `source <(esctl completion bash)`.
- Cluster settings can be viewed and modified.
- Comprehensive cluster status.
- Cluster reroute support.
- 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
@@ -26,18 +32,26 @@ Features:
- 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.
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.
JSON will be syntax highlighted. A simple internal pager will be
used if output exceeds the terminal height. You can tweak this using
the `$ES_JSON_PAGER` environment variable (I'd recommend [fx](https://fx.wtf/)).
- 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
@@ -46,121 +60,6 @@ Features:
esctl doc add -i foo '{"title":"curry in a hurry", "message":"australian thai"}'
```
## Usage
Command tree:
```console
api
list
repl
show
ccr
follower
add
delete
pause
renew
resume
show
unfollow
info
pause
resume
status
cluster
list
settings
list
set
status
datastream
create
delete
list
rollover
show
debug
doc
add
delete
show
help
help-jsonpath
ilm
create
list
retry
show
status
index
alias
create
delete
list
rollover
allocation
close
create
delete
fields
ilm
list
modify
show
template
create
delete
list
modify
show
node
list
show
role
diff
list
show
search
shard
list
show
snapshot
list
show
task
cancel
list
version
```
Configure `esctl` with environment variables:
- `ES_URI`: elasticsearch uri
- `ES_USER`: username
- `ES_PASS`: password
Or create a config file such as this:
```yaml
clusters:
default:
uri: https://es.foo.bar:9200/
user: elastic
pass: 123456
other:
uri: https://myes.foo:9200/
user: elastic
pass: asdasdasd
```
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
global `-C` option.
## Installation
The tool does not have any dependencies. Just download the binary for
@@ -211,6 +110,472 @@ make
sudo make install
```
## Configure
Configure `esctl` with environment variables:
- `ES_URI`: elasticsearch uri
- `ES_USER`: username
- `ES_PASS`: password
- `ES_TOKEN`: API token, instead of user+password
Or create a config file such as this:
```yaml
clusters:
foobar:
uri: https://es.foo.bar:9200/
user: elastic
pass: ******
other:
uri: https://myes.foo:9200/
token: ******
```
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, 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.
The following rules apply for default cluster selection:
1. If there's just one cluster configured (either via environment
variables or config), this one will be selected.
2. If a cluster have been specified with `-C <name>`, use this.
3. 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.
Please be aware, that prior to use a cluster and send API requests to
it, `esctl` checks the connectivity to it first.
## Usage
Once you have configured one or more (and marked one as the default,
see above) elasticsearch clusters, you can start using `esctl`. Since
there are just too many things you can do with it, we'll just outline
a couple of examples here.
To see the status of a cluster:
```console
$ esctl cluster status
DEFAULT STATUS
Cluster Name eck-cluster-dev-s1
ES Status green
ES Version 9.4.2
Is Leader true
Active Shards 447
Active Primary Shards 221
Unassigned Shards 0
Unassigned Primary Shards 0
Pending Tasks 0
Nodes 3
Red Indices 0
Long Running Tasks 2
```
you may also add the `-v` flag to see more:
```console
$ esctl cluster status -v
DEFAULT STATUS
Cluster Name eck-cluster-dev-s1
ES Status green
ES Version 9.4.2
Is Leader true
Active Shards 447
Active Primary Shards 221
Unassigned Shards 0
Unassigned Primary Shards 0
Pending Tasks 0
Nodes 3
Red Indices 0
Long Running Tasks 2
Indicies 221
Docs 9854777
Total Size 3.4 GB
Total Queries 4210411
Shards Primaries 824642680872
Shards Total 824642680808
Storage 6.9 GB/6.9 GB
JVM Heap Memory 16 GB/39 GB
JVM Threads 458
JVM Version OpenJDK 64-Bit Server VM 26.0.1+8-34
CPUs 18
CPU Usage 0%
Open FDs 1423
```
View cluster settings:
```console
$ esctl cluster settings list
SETTING VALUE
cluster.remote.svct13.mode proxy
cluster.remote.svct13.proxy_address 100.127.161.24:9300
cluster.remote.svct13.proxy_socket_connections 18
cluster.remote.svct13.skip_unavailable false
cluster.routing.allocation.enable all
indices.recovery.max_bytes_per_sec 320mb
```
Settings can be changed, use json path to specify a setting:
```console
$ esctl cluster settings set indices.recovery.max_bytes_per_sec:480mb
```
Searching is pretty easy. First check which indices exist:
```console
$ esctl index ls
NAME SIZE DOCSCOUNT
[..]
foo 249b 0
foo2 31.4kb 14
mock 9.6kb 20
mr-strange 249b 0
silo18 5.2kb 1
```
Like a dataview in kibana you can specify an index pattern for search:
```console
$ esctl search -i foo*
{"id":"1779873299", "score":1.0000, "index":"foo2", "source":{"message":"foo bar"}}{"id":"1779870997", "score":1.0000, "index":"foo2", "source":{"message":"start","time":"27.05.2026T10:55:33+0200","title":"zeitbuchung"}}{"id":"1779870750", "score":1.0000, "index":"foo2", "source":{"message":"pause","time":"27.05.2026T10:33:45+0200","title":"zeitbuchung"}}{"id":"1779870103", "score":1.0000, "index":"foo2", "source":{"message":"punktlich gebucht","time":"27.05.2026T10:20:01+0200","title":"zeitbuchuung"}}{"id":"1779432806", "score":1.0000, "index":"foo2", "source":{"message":"strange compound 2"}}{"id":"1779432750", "score":1.0000, "index":"foo2", "source":{"message":"strange compound 1"}}{"id":"1778600947", "score":1.0000, "index":"foo2", "source":{"id":"d8d8d","user":"scip"}}{"id":"1778600945", "score":1.0000, "index":"foo2", "source":{"id":"d8d8d","user":"scip"}}{"id":"1778600857", "score":1.0000, "index":"foo2", "source":{"id":"d8d8d","user":"scip"}}{"id":"1778601788", "score":1.0000, "index":"foo2", "source":{"id":"new1","message":"live long and prosper"}}{"id":"1778601422", "score":1.0000, "index":"foo2", "source":{"id":"new1","message":"love long and prosper"}}{"id":"1779447242", "score":1.0000, "index":"foo2", "source":{"message":"australian thai","title":"curry in a hurry"}}{"id":"1779447284", "score":1.0000, "index":"foo2", "source":{"message":"german killer food","title":"wuerger burger"}}{"id":"1779364394", "score":1.0000, "index":"foo2", "source":{"id":"1","message":"non-dead human looks curious at TargetOne"}}
```
Oops, that looks gibberish. It's because `esctl` just prints the raw
JSON search results. There are multiple ways to post-process them:
You can use `jq` (here, we're using `-l 1` to limit the output to 1 document):
```console
$ esctl search -i foo* -l 1 | jq
{
"id": "1779364394",
"score": 1.0000,
"index": "foo2",
"source": {
"id": "1",
"message": "non-dead human looks curious at TargetOne"
}
}
```
However, you might as well use internal filtering using jsonpaths:
```console
$ esctl search -i foo* -l 1 -p source.message
non-dead human looks curious at TargetOne
```
To get more help about internal jsonpath filtering, run `esctl
help-jsonpath`.
A full text search looks like this:
```console
$ esctl search -i foo* thai | jq
{
"id": "1779447242",
"score": 2.3842,
"index": "foo2",
"source": {
"message": "australian thai",
"title": "curry in a hurry"
}
}
```
Instead of a costly full text you might as well specify the field to
search within:
```console
$ esctl search -i foo* message=thai | jq
{
"id": "1779447242",
"score": 2.3842,
"index": "foo2",
"source": {
"message": "australian thai",
"title": "curry in a hurry"
}
}
```
Of course you can search for multiple fields (or multiple terms in
full text search). By default search terms are applied using logical
AND, which you can change with the option `--or`.
You can also change the sorting (`--sort-by`) and the time range
(`--timerange`).
To further narrow down search results you can add filters, which must
match literally:
```console
$ esctl search -i foo* -F title=zeitbuchung message=pause | jq
{
"id": "1779870750",
"score": 2.8426,
"index": "foo2",
"source": {
"message": "pause",
"time": "27.05.2026T10:33:45+0200",
"title": "zeitbuchung"
}
}
```
To check which field mappings are available for an index:
```console
$ esctl index show foo2
INDEX-PROPERTY VALUE
name foo2
replicas 1
shards 1
created 2026-05-12 17:47:37
uuid XmRtlh_JSreIzTB7jJNNJA
fields id,message,time,title,user
```
Now, let's create a new index:
```console
$ esctl index create hyperdrive @timestamp:date message:keyword tag:text
esctl index show hyperdrive
INDEX-PROPERTY VALUE
name hyperdrive
replicas 1
shards 1
created 2026-06-24 10:16:23
uuid gYfUj8TiR6aMB_BHmMYr3Q
fields @timestamp,message,tag
```
Once we have an index, we can insert documents into it (you'll need
[gfn](https://codeberg.org/scip/gfn) for this):
```console
$ for name in $(gfn JapaneseNamesDiverse -n 20); do \
esctl doc add -i hyperdrive \
"{\"message\":\"$name\",\"tag\":\"loop\",\"@timestamp\":\"$(date
--iso-8601=second)\"}"; \
sleep 1; \
done
3644770736267480972
1249004675657072369
5499556813583921546
947619563748335783
6804953496467561176
1716813071323879201
463762522269463473
4674152788118344945
7741776772191747414
8531321287239151007
3335734000533108391
7020487792409206103
4926995290680601261
374509677635815298
49895974689085266
7603943644788310
2102931816420108217
696238814931581311
2463257155346426021
596556586001412147
```
Now, let's search them:
```console
$ esctl search -i hyperdrive -l 2 | jq
{
"id": "2463257155346426021",
"score": 0.0000,
"index": "hyperdrive",
"source": {
"@timestamp": "2026-06-24T10:25:19+02:00",
"message": "inenen",
"tag": "loop"
}
}
{
"id": "596556586001412147",
"score": 0.0000,
"index": "hyperdrive",
"source": {
"@timestamp": "2026-06-24T10:25:20+02:00",
"message": "ominuhen",
"tag": "loop"
}
}
```
There are also some uniq features which are not directly available via
the ES API or GUI. Here's one example: say you have a number of
indices with ILM policies each. At some day there were a surge in
incoming data in some of them and you want to know, when the excess
data will be rolled over to cold storage:
```console
$ esctl ilm forecast sh -f warm -w 3d
1.1 TB bytes of data in warm phase will be rolled within 72h0m0s to the next phase
```
You may also look at a detailed ilm forecast list:
```console
$ esctl ilm forecast ls -f warm -w 3d
INDEX CURRENT-SIZE CURRENT-AGE VIRTUAL-AGE MIN-AGE MIN-SIZE CURRENT-PHASE NEXT-PHASE
foobar-n1-p01-elastic-000126 12 GB 2d:8h:12m 3d:5h:16m 7d 25 GB hot warm
foobar-n1-p01-misc-000070 16 GB 5d:16h:31m 4d:10h:31m 7d 25 GB hot warm
foobar-n1-q01-elastic-000122 23 GB 5d:0h:11m 6d:10h:33m 7d 25 GB hot warm
foobar-n1-q01-misc-000072 18 GB 6d:15h:21m 4d:21h:36m 7d 25 GB hot warm
delaware-f1-p01-kafka-000023 1.3 MB 15h:12m 0s 7d 25 GB hot warm
delaware-f1-p01-misc-000024 17 GB 6d:16h:1m 4d:16h:33m 7d 25 GB hot warm
delaware-f1-q01-kafka-000023 1.5 MB 15h:12m 0s 7d 25 GB hot warm
delaware-f1-q01-misc-000024 16 GB 6d:15h:31m 4d:13h:52m 7d 25 GB hot warm
delaware-n1-p01-elastic-000417 16 GB 12h:42m 4d:10h:31m 7d 25 GB hot warm
```
So you can see, which index will be rolled over when. Note the
**VIRTUAL-AGE** field however: it is calculated from the current
storage usage of the index in relation to rollover max shard size. So
you can see, when an index will be rolled over either because it aged
out or because its storage exceeded the limit.
---
Please note, that `esctl` is still in its early stages and things are
changing heavily every now and then. New commands are being added
constantly as well.
### Command tree:
```console
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
reroute - manually change the allocation of individual shards in the cluster.
move - move shard to another node
allocate-replica - allocate-replica replica to another node
cancel - cancel a reroute operation
allocate-empty-primary - allocate an empty primary shard to a node
allocate-stale-primary - allocate a stale primary shard to a node
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 new lifecycle policy
update - update an lifecycle policy
forecast - calculate index phase movements
list - list index rollover config
show - show rollover forecast over all indices
explain - explain ilm condition of an index
index - manage indicies
list - list indicies
show - show details about an index
create - create a new index
update - update an index
delete - delete an index
close - close an index
allocation - explain index allocation
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
update - update a new index template
delete - delete an index template
license - manage cluster license
show - show details about the cluster license
node - manage nodes
list - list nodes
show - show details about a node
clients - show node http clients
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
help-usage - show overview of all available commands
```
# Development
## To test completion

View File

@@ -28,7 +28,7 @@ import (
func Api(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "api",
Usage: "manage apiuments",
Usage: "api access and documentation",
Commands: []*cli.Command{
ApiList(conf),
@@ -83,6 +83,16 @@ func ApiRepl(conf *cfg.Config) *cli.Command {
Aliases: []string{"shell"},
Usage: "interactive API repl",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "pager",
Usage: "external viewer program (default:internal)",
Destination: &conf.Pager,
Aliases: []string{"p"},
Sources: cli.EnvVars("PAGER", "ES_JSON_PAGER"),
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
return es.ApiRepl(conf)
},

View File

@@ -18,6 +18,7 @@ package cmd
import (
"context"
"errors"
"codeberg.org/scip/esctl/pkg/cfg"
"codeberg.org/scip/esctl/pkg/es"
@@ -33,8 +34,10 @@ func Cluster(conf *cfg.Config) *cli.Command {
Commands: []*cli.Command{
ClusterStatus(conf),
ClusterSwitch(conf),
ClusterList(conf),
ClusterSettings(conf),
ClusterReroute(conf),
},
}
}
@@ -58,12 +61,6 @@ func ClusterStatus(conf *cfg.Config) *cli.Command {
Aliases: []string{"s"},
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "all",
Usage: "show status of all clusters",
Destination: &conf.All,
Aliases: []string{"a"},
},
&cli.BoolFlag{
Name: "verbose",
Usage: "include verbose statistics",
@@ -77,3 +74,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)
},
}
}

225
cmd/cluster_reroute.go Normal file
View File

@@ -0,0 +1,225 @@
/*
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 ClusterReroute(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "reroute",
Usage: "manually change the allocation of individual shards in the cluster.",
UsageText: "reroute <cluster-name>",
Aliases: []string{"ctx"},
Commands: []*cli.Command{
ClusterRerouteMove(conf),
ClusterRerouteAllocateReplica(conf),
ClusterRerouteCancel(conf),
ClusterRerouteAllocatePrimary(conf, false),
ClusterRerouteAllocatePrimary(conf, true),
},
}
}
func ClusterRerouteMove(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "move",
Usage: "move shard to another node",
UsageText: "move [options] <index>",
ShellComplete: func(ctx context.Context, cmd *cli.Command) {
complete(cmd, Cindex)
},
Flags: []cli.Flag{
&cli.IntFlag{
Name: "shard",
Usage: "shard to move",
Destination: &conf.Shards,
Aliases: []string{"s"},
Required: true,
},
&cli.StringFlag{
Name: "from-node",
Usage: "current node",
Destination: &conf.FromNode,
Aliases: []string{"f"},
Required: true,
},
&cli.StringFlag{
Name: "to-node",
Usage: "node to move to",
Destination: &conf.ToNode,
Aliases: []string{"t"},
Required: true,
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
index := cmd.Args().Get(0)
if index == "" {
return errors.New("no index specified")
}
return es.ClusterRerouteMove(conf, index)
},
}
}
func ClusterRerouteAllocateReplica(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "allocate-replica",
Usage: "allocate-replica replica to another node",
UsageText: "allocate-replica [options] <index>",
ShellComplete: func(ctx context.Context, cmd *cli.Command) {
complete(cmd, Cindex)
},
Flags: []cli.Flag{
&cli.IntFlag{
Name: "shard",
Usage: "shard to move",
Destination: &conf.Shards,
Aliases: []string{"s"},
Required: true,
},
&cli.StringFlag{
Name: "to-node",
Usage: "node to move to",
Destination: &conf.ToNode,
Aliases: []string{"t"},
Required: true,
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
index := cmd.Args().Get(0)
if index == "" {
return errors.New("no index specified")
}
return es.ClusterRerouteAllocateReplica(conf, index)
},
}
}
func ClusterRerouteCancel(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "cancel",
Usage: "cancel a reroute operation",
UsageText: "cancel [options] <index>",
ShellComplete: func(ctx context.Context, cmd *cli.Command) {
complete(cmd, Cindex)
},
Flags: []cli.Flag{
&cli.IntFlag{
Name: "shard",
Usage: "shard to move",
Destination: &conf.Shards,
Aliases: []string{"s"},
Required: true,
},
&cli.StringFlag{
Name: "to-node",
Usage: "node to move to",
Destination: &conf.ToNode,
Aliases: []string{"t"},
Required: true,
},
&cli.BoolFlag{
Name: "allow-primary",
Usage: "allow primary shard to cancel",
Destination: &conf.AllowPrimary,
Aliases: []string{"a"},
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
index := cmd.Args().Get(0)
if index == "" {
return errors.New("no index specified")
}
return es.ClusterRerouteCancel(conf, index)
},
}
}
func ClusterRerouteAllocatePrimary(conf *cfg.Config, stale bool) *cli.Command {
name := "allocate-empty-primary"
usage := "allocate an empty primary shard to a node"
if stale {
name = "allocate-stale-primary"
usage = "allocate a stale primary shard to a node"
}
return &cli.Command{
Name: name,
Usage: usage,
UsageText: name + " [options] <index>",
ShellComplete: func(ctx context.Context, cmd *cli.Command) {
complete(cmd, Cindex)
},
Flags: []cli.Flag{
&cli.IntFlag{
Name: "shard",
Usage: "shard to move",
Destination: &conf.Shards,
Aliases: []string{"s"},
Required: true,
},
&cli.StringFlag{
Name: "to-node",
Usage: "node to move to",
Destination: &conf.ToNode,
Aliases: []string{"t"},
Required: true,
},
&cli.BoolFlag{
Name: "accept-data-loss",
Usage: "",
Destination: &conf.AcceptDataLoss,
Aliases: []string{"a"},
Required: true,
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
index := cmd.Args().Get(0)
if index == "" {
return errors.New("no index specified")
}
return es.ClusterRerouteAllocatePrimary(conf, index, stale)
},
}
}

View File

@@ -104,6 +104,10 @@ func ClusterSettingsSet(conf *cfg.Config) *cli.Command {
},
},
ShellComplete: func(ctx context.Context, cmd *cli.Command) {
complete(cmd, Cclustersettings)
},
Action: func(ctx context.Context, cmd *cli.Command) error {
args := cmd.Args()

View File

@@ -32,6 +32,8 @@ const (
Ccluster
Capi
Cilm
Cnode
Cclustersettings
)
func complete(cmd *cli.Command, what int) {
@@ -64,6 +66,10 @@ func complete(cmd *cli.Command, what int) {
list = es.ApiPathNames()
case Cilm:
list, err = es.IlmNames(conf)
case Cnode:
list, err = es.NodeNames(conf)
case Cclustersettings:
list, err = es.ClusterSettingsNames(conf)
}
if err != nil {

View File

@@ -36,7 +36,10 @@ func Ilm(conf *cfg.Config) *cli.Command {
IlmStatus(conf),
IlmList(conf),
IlmShow(conf),
IlmCreate(conf),
IlmCreate(conf, false),
IlmCreate(conf, true),
IlmForecast(conf),
IlmExplain(conf),
},
}
}
@@ -53,6 +56,23 @@ func IlmRetry(conf *cfg.Config) *cli.Command {
}
}
func IlmExplain(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "explain",
Usage: "explain ilm condition of an index",
UsageText: "explain <index>",
Action: func(ctx context.Context, cmd *cli.Command) error {
index := cmd.Args().Get(0)
if index == "" {
return errors.New("no index specified")
}
return es.IlmExplain(conf, index)
},
}
}
func IlmStatus(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "status",
@@ -84,6 +104,15 @@ func IlmShow(conf *cfg.Config) *cli.Command {
Usage: "show details about an index lifecycle policy",
UsageText: "show <policy>",
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "tree",
Usage: "display policy as a tree",
Destination: &conf.Ilm.Tree,
Aliases: []string{"t"},
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
policy := cmd.Args().Get(0)
if policy == "" {
@@ -99,72 +128,22 @@ func IlmShow(conf *cfg.Config) *cli.Command {
}
}
/*
*
{
"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, modify bool) *cli.Command {
name := "create"
alias := "+"
usage := "create a new lifecycle policy"
*/
if modify {
name = "update"
usage = "update an lifecycle policy"
alias = "upd"
}
func IlmCreate(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "create",
Aliases: []string{"+"},
Usage: "create a index lifecycle policy",
UsageText: "create [options] <policy>",
Name: name,
Aliases: []string{alias},
Usage: usage,
UsageText: name + " [options] <policy>",
Flags: []cli.Flag{
&cli.StringFlag{
@@ -185,7 +164,7 @@ func IlmCreate(conf *cfg.Config) *cli.Command {
Usage: "hot phase: rollover after",
Destination: &conf.Ilm.HotRolloverMaxAge,
},
&cli.Int64Flag{
&cli.StringFlag{
Name: "hot-rollover-max-primary-shard-size",
Usage: "hot phase: max primary shard size",
Destination: &conf.Ilm.HotRolloverMaxPrimaryShardSize,
@@ -251,6 +230,11 @@ func IlmCreate(conf *cfg.Config) *cli.Command {
Usage: "frozen phase: min age",
Destination: &conf.Ilm.FrozenMinAge,
},
&cli.StringFlag{
Name: "frozen-searchable-snapshot-repo",
Usage: "frozen phase: searchable snapshot repo",
Destination: &conf.Ilm.FrozenSearchableSnapshotRepo,
},
// delete
&cli.StringFlag{

139
cmd/ilm_forecast.go Normal file
View File

@@ -0,0 +1,139 @@
/*
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"
"slices"
"codeberg.org/scip/esctl/pkg/cfg"
"codeberg.org/scip/esctl/pkg/es"
"github.com/urfave/cli/v3"
)
func IlmForecast(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "forecast",
Usage: "calculate index phase movements",
Commands: []*cli.Command{
IlmForecastList(conf),
IlmForecastShow(conf),
},
}
}
const IlmForeCastListReference = `<duration> must be a string which consists one or more of these elements:
d - days
h - hours
m - minutes
s - seconds
Examples:
58d12h - 58 days and 12 hours
122h30m - 122 hours and 30 minutes
Specs may be mixed:
8h12d - 12 days and 8 hours`
func IlmForecastList(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "list",
Aliases: []string{"ls"},
Usage: "list index rollover config",
UsageText: "list [options] [<filter>]",
CustomHelpTemplate: addReference(IlmForeCastListReference),
Flags: []cli.Flag{
&cli.StringFlag{
Name: "from-phase",
Usage: "which ilm phase to forecast from",
Destination: &conf.Ilm.FromPhase,
Aliases: []string{"f"},
},
&cli.StringFlag{
Name: "within",
Usage: "<duration> withing which to forecast",
Destination: &conf.Ilm.Within,
Aliases: []string{"w"},
},
&cli.StringFlag{
Name: "min-age",
Usage: "show only indices older than <duration>",
Destination: &conf.Ilm.MinAge,
Aliases: []string{"m"},
},
&cli.BoolFlag{
Name: "verbose",
Usage: "show ilm policy as well",
Destination: &conf.Verbose,
Aliases: []string{"v"},
},
&cli.BoolFlag{
Name: "hidden",
Usage: "include hidden indicies",
Destination: &conf.Hidden,
Aliases: []string{"H"},
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
valid := []string{"hot", "warm", "cold", "frozen"}
if !slices.Contains(valid, conf.Ilm.FromPhase) {
return errors.New("invalid from phase, allowed: hot, warm, cold, frozen")
}
return es.IlmForecastList(conf, cmd.Args().Get(0))
},
}
}
func IlmForecastShow(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "show",
Aliases: []string{"sh"},
Usage: "show rollover forecast over all indices",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "from-phase",
Usage: "which ilm phase to forecast from",
Destination: &conf.Ilm.FromPhase,
Aliases: []string{"f"},
},
&cli.StringFlag{
Name: "within",
Usage: "duration withing which to forecast",
Destination: &conf.Ilm.Within,
Aliases: []string{"w"},
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
valid := []string{"hot", "warm", "cold", "frozen"}
if !slices.Contains(valid, conf.Ilm.FromPhase) {
return errors.New("invalid from phase, allowed: hot, warm, cold, frozen")
}
return es.IlmForecastShow(conf)
},
}
}

View File

@@ -36,11 +36,11 @@ func Index(conf *cfg.Config) *cli.Command {
Commands: []*cli.Command{
IndexList(conf),
IndexShow(conf),
IndexCreate(conf),
IndexCreate(conf, false),
IndexCreate(conf, true),
IndexDelete(conf),
IndexClose(conf),
IndexAllocation(conf),
IndexModify(conf),
IndexFields(conf),
IndexIlm(conf),
@@ -70,6 +70,12 @@ func IndexList(conf *cfg.Config) *cli.Command {
Destination: &conf.Partials,
Aliases: []string{"p"},
},
&cli.BoolFlag{
Name: "hidden",
Usage: "include hidden indicies",
Destination: &conf.Hidden,
Aliases: []string{"H"},
},
&cli.BoolFlag{
Name: "reds",
Usage: "include only red failed indicies",
@@ -143,12 +149,20 @@ func IndexShow(conf *cfg.Config) *cli.Command {
}
}
func IndexCreate(conf *cfg.Config) *cli.Command {
func IndexCreate(conf *cfg.Config, modify bool) *cli.Command {
name := "create"
usage := "create a new index"
if modify {
name = "update"
usage = "update an index"
}
return &cli.Command{
Name: "create",
Name: name,
Aliases: []string{"+"},
Usage: "create a new index",
UsageText: `create <name> <fieldmapping:type>...
Usage: usage,
UsageText: name + ` <name> <fieldmapping:type>...
Valid field mapping types: integer, text, date, keyword`,
Flags: []cli.Flag{
@@ -171,6 +185,12 @@ Valid field mapping types: integer, text, date, keyword`,
Aliases: []string{"r"},
Value: 1,
},
&cli.StringFlag{
Name: "ilm-policy",
Usage: "ilm policy to apply",
Destination: &conf.Policy,
Aliases: []string{"p"},
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
@@ -229,37 +249,6 @@ func IndexClose(conf *cfg.Config) *cli.Command {
}
}
func IndexModify(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "modify",
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,
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
index := cmd.Args().Get(0)
if index == "" {
return errors.New("no index specified")
}
return es.IndexModify(conf, index)
},
}
}
func IndexFields(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "fields",

View File

@@ -83,8 +83,8 @@ func IndexTemplateCreate(conf *cfg.Config, modify bool) *cli.Command {
required := true
if modify {
name = "modify"
alias = "mod"
name = "update"
alias = "upd"
required = false
}
@@ -169,6 +169,12 @@ https://www.elastic.co/docs/reference/elasticsearch/index-settings
Destination: &conf.Aliases,
Aliases: []string{"A"},
},
&cli.StringFlag{
Name: "ilm-policy",
Usage: "ilm policy to apply",
Destination: &conf.Policy,
Aliases: []string{"p"},
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {

49
cmd/license.go Normal file
View File

@@ -0,0 +1,49 @@
/*
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 License(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "license",
Usage: "manage cluster license",
Commands: []*cli.Command{
LicenseShow(conf),
},
}
}
func LicenseShow(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "show",
Aliases: []string{"sh"},
Usage: "show details about the cluster license",
Action: func(ctx context.Context, cmd *cli.Command) error {
return es.LicenseShow(conf)
},
}
}

View File

@@ -18,6 +18,7 @@ package cmd
import (
"context"
"errors"
"codeberg.org/scip/esctl/pkg/cfg"
"codeberg.org/scip/esctl/pkg/es"
@@ -34,6 +35,7 @@ func Node(conf *cfg.Config) *cli.Command {
Commands: []*cli.Command{
NodeList(conf),
NodeShow(conf),
NodeClients(conf),
},
}
}
@@ -57,10 +59,47 @@ func NodeShow(conf *cfg.Config) *cli.Command {
Usage: "show details about a node",
UsageText: "show [options] <node>",
ShellComplete: func(ctx context.Context, cmd *cli.Command) {
complete(cmd, Cnode)
},
Action: func(ctx context.Context, cmd *cli.Command) error {
// FIXME: implement es.NodeShow()
// return es.NodeShow(conf, cmd.Args().Get(0))
return nil
node := cmd.Args().Get(0)
if node == "" {
return errors.New("no node specified")
}
return es.NodeShow(conf, node)
},
}
}
func NodeClients(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "clients",
Usage: "show node http clients",
UsageText: "clients [options] <node>",
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "query",
Usage: "include query parameters",
Destination: &conf.All,
Aliases: []string{"q"},
},
},
ShellComplete: func(ctx context.Context, cmd *cli.Command) {
complete(cmd, Cnode)
},
Action: func(ctx context.Context, cmd *cli.Command) error {
node := cmd.Args().Get(0)
if node == "" {
return errors.New("no node specified")
}
return es.NodeClients(conf, node)
},
}
}

View File

@@ -22,6 +22,7 @@ import (
golog "log"
"os"
"runtime/pprof"
"strings"
"codeberg.org/scip/esctl/pkg/cfg"
"codeberg.org/scip/esctl/pkg/es"
@@ -45,7 +46,6 @@ func Main() int {
cmd := &cli.Command{
Name: "esctl",
Usage: "manage elasticsearch from cli",
//Version: cfg.Version,
EnableShellCompletion: true,
Flags: []cli.Flag{
@@ -63,6 +63,13 @@ func Main() int {
Usage: "enable HTTP debugging",
Destination: &conf.DebugHTTP,
},
&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"},
@@ -94,22 +101,24 @@ func Main() int {
},
Commands: []*cli.Command{
Search(conf),
Cluster(conf),
Api(conf),
Ccr(conf),
Index(conf),
Ilm(conf),
Cluster(conf),
Datastream(conf),
Doc(conf),
Ilm(conf),
Index(conf),
License(conf),
Node(conf),
Roles(conf),
Search(conf),
Shard(conf),
Snapshot(conf),
Node(conf),
Doc(conf),
Task(conf),
Version(conf),
Debug(conf),
Roles(conf),
Task(conf),
HelpJsonPath(conf),
Api(conf),
HelpUsage(conf),
},
Before: func(ctx context.Context, cmd *cli.Command) (context.Context, error) {
@@ -237,3 +246,110 @@ func Debug(conf *cfg.Config) *cli.Command {
},
}
}
func HelpUsage(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "help-usage",
Usage: "show overview of all available commands",
UsageText: "help-usage [<filter>]",
Aliases: []string{"usage"},
CustomHelpTemplate: addReference(`<filter> implies -f`),
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "hidden",
Usage: "include hidden commands",
Destination: &conf.Hidden,
Aliases: []string{"H"},
},
&cli.BoolFlag{
Name: "full-commands",
Usage: "show full commands",
Destination: &conf.Force,
Aliases: []string{"f"},
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
maxCommandWidth := 0
filter := cmd.Args().Get(0)
if filter != "" {
conf.Force = true
}
// first pass, determine max command width
if err := walkVisible(conf, cmd.Root(), func(cmd *cli.Command) error {
path := cmd.Path()
size := len(path[len(path)-1])
if conf.Force {
path := strings.Join(cmd.Path(), " ")
size = len(path)
}
if size > maxCommandWidth {
maxCommandWidth = size
}
return nil
}); err != nil {
return err
}
maxCommandWidth += 4 // account for indent width
// second pass, build tree
return walkVisible(conf, cmd.Root(), func(cmd *cli.Command) error {
path := cmd.Path()
if filter != "" {
if !strings.Contains(strings.Join(path, " "), filter) {
return nil
}
}
command := path[len(path)-1]
if conf.Force {
command = strings.Join(cmd.Path(), " ")
}
if len(path) == 1 || strings.HasSuffix(command, "help") {
return nil
}
indent := strings.Repeat(" ", len(path[1:])-1)
space := strings.Repeat(" ", maxCommandWidth-(len(command)+len(indent)))
fmt.Printf("%s%s %s - %s\n", indent, command, space, cmd.Usage)
return nil
})
},
}
}
// copy of cmd.Walk() with the exception to skip hidden commands and its siblings
// see: https://github.com/urfave/cli/issues/2372
func walkVisible(conf *cfg.Config, cmd *cli.Command, fn func(*cli.Command) error) error {
if fn == nil {
return nil
}
if !conf.Hidden && cmd.Hidden {
return nil
}
if err := fn(cmd); err != nil {
return err
}
for _, sub := range cmd.Commands {
if err := walkVisible(conf, sub, fn); err != nil {
return err
}
}
return nil
}

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"

17
go.mod
View File

@@ -32,7 +32,7 @@ require (
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.1-0.20260524212652-be8b79d0c8de
github.com/urfave/cli/v3 v3.10.1-0.20260623012112-f980ca84bf65
gopkg.in/yaml.v3 v3.0.1
)
@@ -41,16 +41,19 @@ require (
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/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/x/ansi v0.8.0 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/term v0.2.1 // 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
@@ -72,13 +75,17 @@ require (
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/rivo/uniseg v0.4.7 // indirect
github.com/seeruk/go-wordwrap v0.0.0-20191208221741-14ec4aac9550 // 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

25
go.sum
View File

@@ -16,16 +16,28 @@ github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiE
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=
@@ -54,6 +66,8 @@ github.com/elastic/go-elasticsearch/v9 v9.3.4 h1:vnqXl6jnlA+ZwlfRaKF9BR9woZC1KSB
github.com/elastic/go-elasticsearch/v9 v9.3.4/go.mod h1:ubKUMJCJbX5V/gW5MIn2NQZyaEZ61ubXwJmD5UMNrM8=
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=
@@ -126,10 +140,16 @@ github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hd
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-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=
@@ -151,6 +171,8 @@ 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/seeruk/go-wordwrap v0.0.0-20191208221741-14ec4aac9550 h1:C3CfUXH/qmWuQFRqnPm3Sx8PFxa+pqACjhV5CaNO8pw=
github.com/seeruk/go-wordwrap v0.0.0-20191208221741-14ec4aac9550/go.mod h1:Sl541M2Em6rRG3V9WObycR7MYFZiERVkd/TJg0Gt0U4=
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=
@@ -169,6 +191,8 @@ 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.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=
@@ -197,6 +221,7 @@ golang.org/x/sys v0.0.0-20181128092732-4ed8d59d0b35/go.mod h1:STP8DvDyc/dI5b8T5h
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.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=

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

@@ -0,0 +1,232 @@
/*
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 (
"crypto/tls"
"errors"
"fmt"
"net"
"net/http"
"os"
"strings"
"syscall"
"time"
"github.com/elastic/elastic-transport-go/v8/elastictransport"
"github.com/elastic/go-elasticsearch/v9"
"golang.org/x/term"
"gopkg.in/yaml.v3"
)
// used in general config struct
type Cluster struct {
Name, Uri, User, Pass, Token string
client *elasticsearch.TypedClient
Default, DebugHTTP bool
}
// used just for writing back to the config file
type ClusterConfig struct {
Uri, User, Pass, Token string
Default bool
}
// to write the config, we avoid all other config settings
type WriteConfig struct {
Clusters map[string]*ClusterConfig
}
func (cluster *Cluster) SetClient(client *elasticsearch.TypedClient) {
cluster.client = client
}
func (cluster *Cluster) getTransport() elastictransport.Option {
transport := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
if cluster.DebugHTTP {
return elastictransport.WithTransport(
&DebugTransport{Transport: transport},
)
}
return elastictransport.WithTransport(transport)
}
func (cluster *Cluster) getDefaultOptions() []elasticsearch.Option {
// 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")
return []elasticsearch.Option{
elasticsearch.WithAddresses(cluster.Uri),
elasticsearch.WithTransportOptions(
cluster.getTransport(),
elastictransport.WithHeader(headers),
),
}
}
// return the go-elasticsearch client object but before doing that,
// check if we need to tune auth
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)
}
if err := cluster.CheckAuth(); err != nil {
fmt.Printf("Error: %s", err)
os.Exit(1)
}
return cluster.client
}
// add authentication to es client, if not yet done
func (cluster *Cluster) CheckAuth() error {
if cluster.Pass == "" && cluster.User != "" && cluster.Token == "" && cluster.Default {
// no token - user is set, but no password.
// check if the env var is set
pass := os.Getenv("ES_PASS")
if pass != "" {
cluster.Pass = pass
} else {
// k, try interactively
fmt.Fprintf(os.Stderr, "Enter password for elasticsearch user %s@%s: ", cluster.User, cluster.Name)
pass, err := term.ReadPassword(int(syscall.Stdin))
if err != nil {
return err
}
passwd := strings.TrimSpace(string(pass))
if passwd == "" {
return errors.New("password empty")
}
cluster.Pass = string(pass)
fmt.Println()
}
opts := cluster.getDefaultOptions()
opts = append(opts, elasticsearch.WithBasicAuth(cluster.User, cluster.Pass))
es, err := elasticsearch.NewTyped(opts...)
if err != nil {
return fmt.Errorf("failed to setup elasticsearch connection: %w", err)
}
cluster.SetClient(es)
}
return nil
}
// 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,
Token: cluster.Token,
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
}
// We do NOT use go-elasticsearch to check for cluster reachability,
// because at this stage, auth may not have been configured. So
// instead we just connect to the cluster using plan net/tcp
func (cluster *Cluster) IsReachable() (bool, error) {
timeout := 500 * time.Millisecond
url := strings.TrimPrefix(strings.TrimPrefix(cluster.Uri, "https://"), "http://")
host := strings.Split(url, "/")
if !strings.Contains(host[0], ":") {
host[0] += ":443"
}
conn, err := net.DialTimeout("tcp", host[0], timeout)
if err != nil {
return false, err
}
return true, conn.Close()
}
func (conf *Config) SetupES() error {
for name, cluster := range conf.Clusters {
cluster.Name = name
cluster.DebugHTTP = conf.DebugHTTP
opts := cluster.getDefaultOptions()
switch {
case cluster.Pass != "" && cluster.User != "":
opts = append(opts, elasticsearch.WithBasicAuth(cluster.User, cluster.Pass))
case cluster.Token != "":
opts = append(opts, elasticsearch.WithAPIKey(cluster.Token))
}
es, err := elasticsearch.NewTyped(opts...)
if err != nil {
return fmt.Errorf("failed to setup elasticsearch connection: %w", err)
}
cluster.SetClient(es)
}
return nil
}

View File

@@ -17,24 +17,18 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
package cfg
import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"log/slog"
"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.19`
Version string = `v0.0.26`
)
var (
@@ -42,11 +36,6 @@ 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
@@ -56,11 +45,13 @@ type Config struct {
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
Policy string // index create: -p
Primary bool // index allocation: -p
Searchable bool // index fields: -s
Aggretable bool // index fields: -a
@@ -112,19 +103,29 @@ type Config struct {
Tag string // api ls: -t
Ilm Ilm // ilm create
FromNode, ToNode string // cluster reroute move: -f + -t
AllowPrimary, AcceptDataLoss bool // cluster reroute cancel: -p,-a
Pager string // api repl: -p || PAGER
}
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()
if conf.ConfigFile == "" && fileExists(DefaultConfig) {
conf.ConfigFile = DefaultConfig
}
switch {
case fileExists(DefaultConfig):
conf.ConfigFile = DefaultConfig
fallthrough
case conf.ConfigFile != "":
if err := conf.LoadConfig(); err != nil {
return err
@@ -139,32 +140,8 @@ func (conf *Config) Init() error {
return err
}
if conf.CurrentCluster != "" {
current, exists := conf.Clusters[conf.CurrentCluster]
if !exists {
return fmt.Errorf("no cluster with alias %s configured", conf.CurrentCluster)
} else {
conf.DefaultCluster = current
}
} else {
if len(conf.Clusters) == 1 {
for name, cluster := range conf.Clusters {
conf.DefaultCluster = cluster
conf.CurrentCluster = name
}
} else {
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 {
conf.DefaultCluster = cluster
conf.CurrentCluster = name
}
}
}
if err := conf.determineDefaultCluster(); err != nil {
return err
}
conf.HaveJQ = isJQinstalled()
@@ -174,17 +151,72 @@ func (conf *Config) Init() error {
return nil
}
func (conf *Config) determineDefaultCluster() 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 {
if cluster.Default {
conf.DefaultCluster = cluster
conf.CurrentCluster = name
}
}
}
}
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)
@@ -195,15 +227,14 @@ func (conf *Config) LoadEnv() error {
Uri: os.Getenv("ES_URI"),
User: os.Getenv("ES_USER"),
Pass: os.Getenv("ES_PASS"),
Token: os.Getenv("ES_TOKEN"),
}
switch {
case cluster.Uri == "":
return errors.New("ES_URI unset")
case cluster.User == "":
return errors.New("ES_USER unset")
case cluster.Pass == "":
return errors.New("ES_PASS unset")
case cluster.User == "" || cluster.Token == "":
return errors.New("ES_USER and ES_TOKEN unset")
}
conf.Clusters["default"] = &cluster
@@ -232,86 +263,21 @@ 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 {
if cluster.Default {
conf.DefaultCluster = cluster
break
}
} else {
conf.DefaultCluster = newconf.Clusters["default"]
}
if conf.DefaultCluster == nil {
conf.DefaultCluster = &Cluster{}
}
}
return nil
}
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)
}
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(conf.getTransport()),
)
if err != nil {
return fmt.Errorf("failed to setup elasticsearch connection: %w", err)
}
cluster.ES = es
}
return nil
}
// 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)
return t.Transport.RoundTrip(req)
}
func fileExists(filename string) bool {
info, err := os.Stat(filename)

View File

@@ -21,7 +21,7 @@ type Ilm struct {
HotMinAge string
HotRolloverMaxAge string
HotRolloverMaxPrimaryShardSize int64
HotRolloverMaxPrimaryShardSize string
HotRolloverMaxDocs int64
HotReadonly bool
@@ -36,16 +36,20 @@ type Ilm struct {
ColdPriority int
FrozenMinAge string
FrozenSearchableSnapshotRepo string
DeleteMinAge string
DeleteSearchableSnapshots bool
MinAge, FromPhase, Within string // forecast
Tree bool
}
func (cfg *Ilm) HaveHot() bool {
return cfg.HotMinAge != "" ||
cfg.HotRolloverMaxAge != "" ||
cfg.HotRolloverMaxDocs > 0 ||
cfg.HotRolloverMaxPrimaryShardSize > 0 ||
cfg.HotRolloverMaxPrimaryShardSize != "" ||
cfg.HotReadonly
}

49
pkg/cfg/term.go Normal file
View File

@@ -0,0 +1,49 @@
/*
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 (
"os"
"golang.org/x/term"
)
const (
DefaultMargin = 4
)
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
}
func GetTermHeight() int {
if term.IsTerminal(int(os.Stdout.Fd())) {
_, height, err := term.GetSize(int(os.Stdout.Fd()))
if err == nil {
return height
}
}
return 25
}

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

@@ -0,0 +1,61 @@
/*
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"
"encoding/json"
"fmt"
"log/slog"
"net/http"
)
// 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)
}

View File

@@ -41,11 +41,9 @@ import (
"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:
@@ -143,14 +141,54 @@ func ApiRepl(conf *cfg.Config) error {
fmt.Printf("failed to call API: %s\n", esErrorString(err))
}
if err := prettyfiJson(conf, raw); err != nil {
fmt.Println(err)
}
pageJsonOutput(conf, raw)
}
return nil
}
func pageJsonOutput(conf *cfg.Config, raw []byte) {
tmpconf := &cfg.Config{HaveJQ: conf.HaveJQ}
if conf.Pager != "" {
tmpconf.HaveJQ = false
}
output, err := prettyfiJson(tmpconf, raw)
if err != nil {
fmt.Println(err)
}
lines := len(strings.Split(output, "\n"))
height := cfg.GetTermHeight()
if lines > height {
if conf.Pager != "" {
cmd := strings.Split(conf.Pager, " ")
pager := exec.Command(cmd[0], cmd[1:]...)
var buf bytes.Buffer
buf.WriteString(output)
pager.Stdout = os.Stdout
pager.Stdin = &buf
pager.Stderr = os.Stderr
err := pager.Run()
if err != nil {
fmt.Printf("failed to execute pager '%s': %s", conf.Pager, err)
}
} else {
printer.Pager("json output", output)
}
return
}
fmt.Println(output)
}
func encodeAuth(username, password string) string {
return base64.StdEncoding.EncodeToString([]byte(username + ":" + password))
}
@@ -165,6 +203,12 @@ func CallAPI(conf *cfg.Config, verb, path, data string) ([]byte, error) {
client := &http.Client{Transport: tr}
if conf.DebugHTTP {
client = &http.Client{
Transport: &cfg.DebugTransport{
Transport: tr}}
}
req, err := http.NewRequest(verb, conf.DefaultCluster.Uri+path, bytes.NewBuffer([]byte(data)))
if err != nil {
return nil, err
@@ -172,7 +216,17 @@ func CallAPI(conf *cfg.Config, verb, path, data string) ([]byte, error) {
req.Header.Add("Content-Type", "application/json")
req.Header.Add("accept", "application/json")
// make sure we have got all we need
if err := conf.DefaultCluster.CheckAuth(); err != nil {
return nil, err
}
if conf.DefaultCluster.Token != "" {
req.Header.Add("Authorization", "APIKey "+conf.DefaultCluster.Token)
} else {
req.Header.Add("Authorization", "Basic "+encodeAuth(conf.DefaultCluster.User, conf.DefaultCluster.Pass))
}
// actually execute the request
resp, err := client.Do(req)
@@ -189,7 +243,7 @@ func CallAPI(conf *cfg.Config, verb, path, data string) ([]byte, error) {
return body, nil
}
func prettyfiJson(conf *cfg.Config, raw []byte) error {
func prettyfiJson(conf *cfg.Config, raw []byte) (string, error) {
if conf.HaveJQ {
cmd := exec.CommandContext(context.Background(), "jq", "-C")
cmd.Stdin = bytes.NewReader(raw)
@@ -199,23 +253,18 @@ func prettyfiJson(conf *cfg.Config, raw []byte) error {
err := cmd.Run()
if err != nil {
return err
return "", err
}
fmt.Println(out.String())
return nil
return out.String(), nil
}
var pretty bytes.Buffer
err := json.Indent(&pretty, raw, "", "\t")
if err != nil {
return fmt.Errorf("json parse error: %s", err)
return "", fmt.Errorf("json parse error: %s", err)
}
fmt.Println(pretty.String())
return nil
return pretty.String(), nil
}
// interactively read arbitrary JSON data from STDIN, which is
@@ -307,6 +356,7 @@ func ApiPathNames() []string {
func ApiShow(conf *cfg.Config, showpath, verb string) error {
assets.LoadAssetOpenApi()
out := printer.Builder{}
op, err := matchOperation(showpath, verb)
if err != nil {
@@ -317,61 +367,64 @@ func ApiShow(conf *cfg.Config, showpath, verb string) error {
cleanMarkup := regexp.MustCompile(`<[^<>]+>`)
width := getTermWidth()
width := cfg.GetTermWidth()
params := getApiParameters(op, showpath, width)
sample := getApiExample(op)
description := markdown.Render(cleanMarkup.ReplaceAllString(op.Op.Description, ""), width, DefaultMargin)
description := markdown.Render(cleanMarkup.ReplaceAllString(op.Op.Description, ""), width, cfg.DefaultMargin)
var bold = lipgloss.NewStyle().
Bold(true)
var paragraph = lipgloss.NewStyle().
MarginBottom(1).
MarginLeft(DefaultMargin)
MarginLeft(cfg.DefaultMargin)
var boldparagraph = lipgloss.NewStyle().
MarginBottom(1).
MarginLeft(DefaultMargin).
MarginLeft(cfg.DefaultMargin).
Bold(true)
var indentparagraph = lipgloss.NewStyle().
MarginBottom(1).
MarginLeft(2)
fmt.Println(bold.Render("ID: " + op.Op.ID))
fmt.Println(paragraph.Render(fmt.Sprintf("%s %s", op.Verb, showpath)))
out.WriteStringLine(bold.Render("ID: " + op.Op.ID))
out.WriteStringLine(paragraph.Render(fmt.Sprintf("%s %s", op.Verb, showpath)))
fmt.Println(bold.Render("Tags"))
fmt.Println(paragraph.Render(strings.Join(op.Op.Tags, ", ")))
out.WriteStringLine(bold.Render("Tags"))
out.WriteStringLine(paragraph.Render(strings.Join(op.Op.Tags, ", ")))
fmt.Println(bold.Render("Summary"))
fmt.Println(paragraph.Render(strings.TrimSpace(op.Op.Summary)))
out.WriteStringLine(bold.Render("Summary"))
out.WriteStringLine(paragraph.Render(strings.TrimSpace(op.Op.Summary)))
fmt.Println(bold.Render("Description"))
fmt.Println(string(description)) // already indented
out.WriteStringLine(bold.Render("Description"))
out.WriteStringLine(string(description)) // already indented
if len(params.Path) > 0 {
fmt.Println(bold.Render("Path Parameters"))
out.WriteStringLine(bold.Render("Path Parameters"))
for _, param := range params.Path {
fmt.Println(boldparagraph.Render(param.Param))
out.WriteStringLine(boldparagraph.Render(param.Param))
if param.Description != "" {
fmt.Println(indentparagraph.Render(param.Description))
//out.WriteStringLine(indentparagraph.Render(param.Description))
out.WriteStringLine(param.Description)
}
}
}
if len(params.Query) > 0 {
fmt.Println(bold.Render("Query Parameters"))
out.WriteStringLine(bold.Render("Query Parameters"))
for _, param := range params.Query {
fmt.Println(boldparagraph.Render(param.Param))
out.WriteStringLine(boldparagraph.Render(param.Param))
if param.Description != "" {
fmt.Println(indentparagraph.Render(param.Description))
out.WriteStringLine(indentparagraph.Render(param.Description))
}
}
}
if sample != "" {
fmt.Println(bold.Render("Example"))
fmt.Println(paragraph.Render(sample))
out.WriteStringLine(bold.Render("Example"))
out.WriteStringLine(paragraph.Render(sample))
}
printer.Pager(showpath, out.String())
return nil
}
@@ -409,7 +462,7 @@ func getApiParameters(op *Op, path string, width int) Params {
}
} else {
par := Param{
Description: string(markdown.Render(param.Description, width, DefaultMargin)),
Description: string(markdown.Render(param.Description, width, cfg.DefaultMargin)),
Param: param.Name,
}
@@ -513,14 +566,3 @@ func findOperation(item spec.PathItemProps) []*Op {
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

@@ -55,12 +55,8 @@ 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, esErrorString(err))
}
@@ -88,9 +84,7 @@ 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", esErrorString(err))
@@ -108,7 +102,7 @@ func CcrRemoteInfo(conf *cfg.Config, index string) error {
}
if remote == "" {
return fmt.Errorf("cluster doesn't follow any other: %s", err)
return errors.New("cluster doesn't follow any other")
}
mode := "follower"

View File

@@ -26,9 +26,7 @@ import (
)
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", esErrorString(err))
@@ -89,11 +87,8 @@ 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", esErrorString(err))
@@ -103,11 +98,8 @@ func CcrFollowerResume(conf *cfg.Config, index string) error {
}
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", esErrorString(err))
@@ -117,11 +109,8 @@ func CcrFollowerPause(conf *cfg.Config, index string) error {
}
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", esErrorString(err))
@@ -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")
@@ -156,9 +143,7 @@ func CcrFollowerAdd(conf *cfg.Config, index string) error {
}
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", esErrorString(err))

View File

@@ -0,0 +1,59 @@
/*
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 (
"encoding/json"
"fmt"
"codeberg.org/scip/esctl/pkg/cfg"
)
type HealthReport struct {
Indicators map[string]HealthReportIndicator
}
type HealthReportIndicator struct {
Status, Symptom string
Diagnosis []HealthReportDiagnosis
}
type HealthReportDiagnosis struct {
Id, Cause, Action string
AffectedResources map[string][]string `json:"affected_resources"`
}
// we do not use the go-elasticsearch client API here but call the ES
// API directly, because the returned structure (a
// healthreport.Response) is not iterable, you'd have to explicitly
// call every indicator type and every cause etc which also have
// different types each. To check which is !green would result in a
// gigantic function.
func getHealthReport(conf *cfg.Config) (*HealthReport, error) {
raw, err := CallAPI(conf, "GET", "/_health_report", "")
if err != nil {
return nil, err
}
report := HealthReport{}
if err := json.Unmarshal(raw, &report); err != nil {
return nil, fmt.Errorf("failed to unmarshal healthreport response: %w", err)
}
return &report, nil
}

View File

@@ -17,171 +17,163 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
package es
import (
"context"
"errors"
"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"
"github.com/elastic/go-elasticsearch/v9/typedapi/core/info"
"github.com/elastic/go-elasticsearch/v9/typedapi/types"
)
const (
ResponseHealth = iota
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
indices *indices.Response
tasks *tasks.Response
which int
type clusterReachable struct {
reachable bool
err error
}
func ClusterList(conf *cfg.Config) error {
table := printer.NewTable(conf, 3, len(conf.Clusters))
var mu sync.Mutex
var wg sync.WaitGroup
reachable := make(map[string]clusterReachable, len(conf.Clusters))
table.Addheaders("cluster", "uri", "default")
// check endpoints in parallel to speed things up
for name, cluster := range conf.Clusters {
wg.Add(1)
go func() {
defer wg.Done()
online, err := cluster.IsReachable()
mu.Lock()
reachable[name] = clusterReachable{reachable: online, err: err}
mu.Unlock()
}()
}
wg.Wait()
table := printer.NewTable(conf, 5, len(conf.Clusters))
table.Addheaders("cluster", "uri", "reachable", "current", "error")
idx := 0
names := make([]string, len(conf.Clusters))
for name := range conf.Clusters {
names[idx] = name
for name, cluster := range conf.Clusters {
reachableStr := "no"
current := "no"
errmsg := ""
if reachable[name].reachable {
reachableStr = printer.Colorize(conf, "green", "reachable")
}
if cluster.Default {
current = printer.Colorize(conf, "green", "yes")
if !reachable[name].reachable {
reachableStr = printer.Colorize(conf, "red", "no")
errmsg = reachable[name].err.Error()
}
}
table.Entries[idx] = []string{name, cluster.Uri, reachableStr, current, errmsg}
idx++
}
slices.Sort(names)
table.Sort()
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").
Do(context.Background())
if err == nil {
name = printer.Colorize(conf, "green", name)
}
table.Entries[idx] = []string{name, cluster.Uri, fmt.Sprintf("%t", current)}
}
if err := table.Print(); err != nil {
return err
}
return nil
return table.Print()
}
// We're using goroutines here to parallelize API requests, since we
// have to do 3 of'em for each cluster. This speeds things up.
func ClusterStatus(conf *cfg.Config) error {
clusters := []string{}
gocount := 5
func getClusterStatus(conf *cfg.Config) (*apiResponse, error) {
gocount := 6
if conf.Verbose {
gocount++
}
if conf.All {
for key := range conf.Clusters {
clusters = append(clusters, key)
}
} else {
clusters = []string{"default"}
}
for _, cluster := range clusters {
es := conf.DefaultCluster.ES
if cluster != "default" {
es = conf.Clusters[cluster].ES
}
es := conf.DefaultCluster.ES()
responses := make(chan apiResponse, gocount)
wg := &sync.WaitGroup{}
wg.Add(gocount)
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")
go getApiData(conf, es, wg, responses, "health")
go getApiData(conf, es, wg, responses, "healthreport")
go getApiData(conf, es, wg, responses, "info")
go getApiData(conf, es, wg, responses, "ccr")
go getApiData(conf, es, wg, responses, "indices")
go getApiData(conf, es, wg, responses, "tasks")
if conf.Verbose {
go getClusterData(es, wg, responses, "stats")
go getApiData(conf, es, wg, responses, "stats")
}
wg.Wait()
var clusterhealth *health.Response
var info *info.Response
var ccrstats *stats.Response
var clusterstats *clusterstats.Response
var indexstats *indices.Response
var taskstatus *tasks.Response
all := apiResponse{}
var err error
for i := 0; i < gocount; i++ {
r := <-responses
if r.error != nil {
return r.error
}
err = errors.Join(err, r.error)
switch r.which {
case ResponseHealth:
clusterhealth = r.health
all.health = r.health
case ResponseCcr:
ccrstats = r.ccr
all.ccr = r.ccr
case ResponseInfo:
info = r.info
all.info = r.info
case ResponseStats:
clusterstats = r.stats
all.stats = r.stats
case ResponseIndices:
indexstats = r.indices
all.indices = r.indices
case ResponseTasks:
taskstatus = r.tasks
all.tasks = r.tasks
case ResponseHealthReport:
all.healthreport = r.healthreport
}
}
slog.Debug("ES result", "cluster health", clusterhealth)
return &all, err
}
isleader := len(ccrstats.AutoFollowStats.AutoFollowedClusters) == 0
func ClusterStatus(conf *cfg.Config) error {
res, err := getClusterStatus(conf)
if err != nil && !strings.Contains(err.Error(), "current license is non-compliant") {
return err
}
ccrfollowing := ""
if len(ccrstats.AutoFollowStats.AutoFollowedClusters) > 0 {
slog.Debug("ES result", "cluster health", res.health)
var isleader bool
var ccrfollowing string
if res.ccr != nil {
isleader = len(res.ccr.AutoFollowStats.AutoFollowedClusters) == 0
if len(res.ccr.AutoFollowStats.AutoFollowedClusters) > 0 {
// is following another cluster
ccrfollowing = fmt.Sprintf("%s (%d/%d)",
ccrstats.AutoFollowStats.AutoFollowedClusters[0].ClusterName,
ccrstats.AutoFollowStats.NumberOfSuccessfulFollowIndices,
ccrstats.AutoFollowStats.NumberOfFailedFollowIndices,
res.ccr.AutoFollowStats.AutoFollowedClusters[0].ClusterName,
res.ccr.AutoFollowStats.NumberOfSuccessfulFollowIndices,
res.ccr.AutoFollowStats.NumberOfFailedFollowIndices,
)
}
}
// look for red indices, if any
redindices := 0
for _, index := range *indexstats {
for _, index := range *res.indices {
if *index.Health == "red" {
redindices++
}
@@ -189,45 +181,62 @@ func ClusterStatus(conf *cfg.Config) error {
// look for long running tasks
longtasks := 0
for _, task := range *taskstatus {
for _, task := range *res.tasks {
if strings.Contains(*task.RunningTime, "d") {
longtasks++
}
}
table := printer.NewTable(conf, 2, 7)
table.Addheaders(cluster, "status")
table.Addheaders(conf.DefaultCluster.Name, "status")
table.Entries = [][]string{
{"Cluster Name", clusterhealth.ClusterName},
{"ES Status", printer.Colorize(conf, clusterhealth.Status.Name, clusterhealth.Status.Name)},
{"ES Version", info.Version.Int},
{"Cluster Name", res.health.ClusterName},
{"ES Status", printer.Colorize(conf, res.health.Status.Name, res.health.Status.Name)},
{"ES Version", res.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)},
{"Active Shards", fmt.Sprintf("%d", res.health.ActiveShards)},
{"Active Primary Shards", fmt.Sprintf("%d", res.health.ActivePrimaryShards)},
{"Unassigned Shards", fmt.Sprintf("%d", res.health.UnassignedShards)},
{"Unassigned Primary Shards", fmt.Sprintf("%d", res.health.UnassignedPrimaryShards)},
{"Pending Tasks", fmt.Sprintf("%d", res.health.NumberOfPendingTasks)},
{"Nodes", fmt.Sprintf("%d", res.health.NumberOfNodes)},
{"Red Indices", fmt.Sprintf("%d", redindices)},
{"Long Running Tasks", fmt.Sprintf("%d", longtasks)},
}
if !isleader {
if !isleader && res.ccr != nil {
table.Entries = append(table.Entries, [][]string{
{"AutoFollow (success/failed indices)", ccrfollowing},
{"Followed Indices", fmt.Sprintf("%d", len(ccrstats.FollowStats.Indices))},
{"Followed Indices", fmt.Sprintf("%d", len(res.ccr.FollowStats.Indices))},
}...)
}
if conf.Verbose {
table = gatherClusterStats(conf, clusterstats, table)
table = gatherClusterStats(conf, res.stats, table)
}
if res.health.Status.Name != "green" {
for name, indicator := range res.healthreport.Indicators {
if indicator.Status != "green" {
table.Entries = append(table.Entries, []string{
printer.Colorize(conf, indicator.Status, "Bad health "+name), indicator.Symptom,
})
for _, diag := range indicator.Diagnosis {
table.Entries = append(table.Entries, []string{" -> cause", diag.Cause})
for resource, items := range diag.AffectedResources {
table.Entries = append(table.Entries, []string{" -> affected " + resource, strings.Join(items, ",")})
}
}
}
}
}
if err := table.Print(); err != nil {
return err
}
}
return nil
}

125
pkg/es/cluster_reroute.go Normal file
View File

@@ -0,0 +1,125 @@
/*
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"
"codeberg.org/scip/esctl/pkg/cfg"
"github.com/elastic/go-elasticsearch/v9/typedapi/esdsl"
"github.com/elastic/go-elasticsearch/v9/typedapi/types"
)
func ClusterRerouteMove(conf *cfg.Config, index string) error {
move := conf.DefaultCluster.ES().Cluster.Reroute()
commands := esdsl.NewCommand()
moveCommand := &types.CommandMoveAction{
Shard: conf.Shards,
FromNode: conf.FromNode,
ToNode: conf.ToNode,
Index: index,
}
commands.CommandCaster().Move = moveCommand
move.Commands(commands)
_, err := move.Do(context.Background())
if err != nil {
return fmt.Errorf("failed to reroute move: %w", err)
}
return nil
}
func ClusterRerouteAllocateReplica(conf *cfg.Config, index string) error {
move := conf.DefaultCluster.ES().Cluster.Reroute()
commands := esdsl.NewCommand()
allocCommand := &types.CommandAllocateReplicaAction{
Shard: conf.Shards,
Node: conf.ToNode,
Index: index,
}
commands.CommandCaster().AllocateReplica = allocCommand
move.Commands(commands)
_, err := move.Do(context.Background())
if err != nil {
return fmt.Errorf("failed to allocate a replica shard: %w", err)
}
return nil
}
func ClusterRerouteCancel(conf *cfg.Config, index string) error {
move := conf.DefaultCluster.ES().Cluster.Reroute()
commands := esdsl.NewCommand()
cancelCommand := &types.CommandCancelAction{
Shard: conf.Shards,
Node: conf.ToNode,
Index: index,
AllowPrimary: &conf.AllowPrimary,
}
commands.CommandCaster().Cancel = cancelCommand
move.Commands(commands)
_, err := move.Do(context.Background())
if err != nil {
return fmt.Errorf("failed to cancel a reroute process: %w", err)
}
return nil
}
func ClusterRerouteAllocatePrimary(conf *cfg.Config, index string, stale bool) error {
move := conf.DefaultCluster.ES().Cluster.Reroute()
commands := esdsl.NewCommand()
allocCommand := &types.CommandAllocatePrimaryAction{
Shard: conf.Shards,
Node: conf.ToNode,
Index: index,
AcceptDataLoss: conf.AcceptDataLoss,
}
if stale {
commands.CommandCaster().AllocateStalePrimary = allocCommand
} else {
commands.CommandCaster().AllocateEmptyPrimary = allocCommand
}
move.Commands(commands)
_, err := move.Do(context.Background())
if err != nil {
which := "empty"
if stale {
which = "stale"
}
return fmt.Errorf("failed to allocate %s primary shard: %w", which, err)
}
return nil
}

View File

@@ -20,17 +20,19 @@ import (
"context"
"encoding/json"
"fmt"
"log/slog"
"codeberg.org/scip/esctl/pkg/cfg"
"codeberg.org/scip/esctl/pkg/printer"
"github.com/urfave/cli/v3"
)
func ClusterSettingsNames(conf *cfg.Config) ([]string, error) {
return validClusterSettings, nil
}
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().
FlatSettings(true).
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to get cluster settings: %s", esErrorString(err))
@@ -50,19 +52,7 @@ func ClusterSettingsList(conf *cfg.Config) error {
}
for topic, val := range settingshash {
data := map[string]any{}
err := json.Unmarshal(val, &data)
if err != nil {
return fmt.Errorf("failed to unmarshall setting for topic %s: %s", topic, err)
}
paths := getJsonPath(map[string]string{}, data, topic)
slog.Debug("settings", topic, paths)
for setting, value := range paths {
entries = append(entries, []string{setting, fmt.Sprintf("%v", value)})
}
entries = append(entries, []string{topic, string(val)})
}
table.Entries = entries
@@ -76,7 +66,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)
@@ -99,11 +89,7 @@ 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", esErrorString(err))
}
@@ -117,10 +103,8 @@ 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 {

View File

@@ -0,0 +1,914 @@
/*
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
// manually extracted from https://www.elastic.co/docs/reference/elasticsearch/configuration-reference
var validClusterSettings []string = []string{
"xpack.security.audit.enabled",
"xpack.security.audit.logfile.events.include",
"xpack.security.audit.logfile.events.exclude",
"xpack.security.audit.logfile.events.emit_request_body",
"xpack.security.audit.logfile.emit_node_name",
"xpack.security.audit.logfile.emit_node_host_address",
"xpack.security.audit.logfile.emit_node_host_name",
"xpack.security.audit.logfile.emit_node_id",
"indices.breaker.total.use_real_memory",
"indices.breaker.total.limit",
"indices.breaker.fielddata.limit",
"indices.breaker.fielddata.overhead",
"indices.breaker.request.limit",
"indices.breaker.request.overhead",
"network.breaker.inflight_requests.limit",
"network.breaker.inflight_requests.overhead",
"script.max_compilations_rate",
"script.painless.regex.enabled",
"breaker.eql_sequence.limit",
"breaker.eql_sequence.overhead",
"breaker.eql_sequence.type",
"breaker.model_inference.limit",
"breaker.model_inference.overhead",
"breaker.model_inference.type",
"cluster.routing.allocation.enable",
"cluster.routing.allocation.same_shard.host",
"cluster.routing.allocation.total_shards_per_node",
"cluster.routing.allocation.node_concurrent_incoming_recoveries",
"cluster.routing.allocation.node_concurrent_outgoing_recoveries",
"cluster.routing.allocation.node_concurrent_recoveries",
"cluster.routing.allocation.node_initial_primaries_recoveries",
"cluster.routing.allocation.allow_rebalance",
"cluster.routing.rebalance.enable",
"cluster.routing.allocation.cluster_concurrent_rebalance",
"cluster.routing.allocation.type",
"cluster.routing.allocation.balance.threshold",
"cluster.routing.allocation.balance.shard",
"cluster.routing.allocation.balance.index",
"cluster.routing.allocation.balance.disk_usage",
"cluster.routing.allocation.balance.write_load",
"cluster.routing.allocation.disk.threshold_enabled",
"cluster.routing.allocation.disk.watermark.low",
"cluster.routing.allocation.disk.watermark.low.max_headroom",
"cluster.routing.allocation.disk.watermark.high",
"cluster.routing.allocation.disk.watermark.high.max_headroom",
"cluster.routing.allocation.disk.watermark.enable_for_single_data_node",
"cluster.routing.allocation.disk.watermark.flood_stage",
"cluster.routing.allocation.disk.watermark.flood_stage.max_headroom",
"cluster.routing.allocation.disk.watermark.flood_stage.frozen",
"cluster.routing.allocation.disk.watermark.flood_stage.frozen.max_headroom",
"cluster.info.update.interval",
"cluster.routing.allocation.awareness.attributes",
"cluster.routing.allocation.awareness.force.*",
"cluster.routing.allocation.include.{{attribute}}",
"cluster.routing.allocation.require.{{attribute}}",
"cluster.routing.allocation.exclude.{{attribute}}",
"cluster.routing.allocation.stats.cache.ttl",
"ccr.indices.recovery.max_bytes_per_sec",
"ccr.indices.recovery.max_concurrent_file_chunks",
"ccr.indices.recovery.chunk_size",
"ccr.indices.recovery.recovery_activity_timeout",
"ccr.indices.recovery.internal_action_timeout",
"data_streams.lifecycle.retention.max",
"data_streams.lifecycle.retention.default",
"data_streams.lifecycle.poll_interval",
"cluster.lifecycle.default.rollover",
"data_streams.lifecycle.target.merge.policy.merge_factor",
"data_streams.lifecycle.target.merge.policy.floor_segment",
"data_streams.lifecycle.signalling.error_retry_interval",
"data_streams.lifecycle.downsampling.max_indices_in_progress",
"dlm.frozen.transition.poll_interval",
"dlm.frozen.transition.thread_pool.size",
"dlm.frozen.transition.thread_pool.queue_size",
"dlm.frozen.cleanup.poll_interval",
"index.lifecycle.prefer_ilm",
"index.lifecycle.origination_date",
"index.dlm.frozen.created",
"discovery.seed_hosts",
"discovery.seed_providers",
"discovery.type",
"cluster.initial_master_nodes",
"discovery.cluster_formation_warning_timeout",
"discovery.find_peers_interval",
"discovery.probe.connect_timeout",
"discovery.probe.handshake_timeout",
"discovery.request_peers_timeout",
"discovery.find_peers_warning_timeout",
"discovery.seed_resolver.max_concurrent_resolvers",
"discovery.seed_resolver.timeout",
"cluster.auto_shrink_voting_configuration",
"cluster.election.duration",
"cluster.election.initial_timeout",
"cluster.election.max_timeout",
"cluster.fault_detection.follower_check.timeout",
"cluster.fault_detection.follower_check.retry_count",
"cluster.fault_detection.leader_check.interval",
"cluster.fault_detection.leader_check.timeout",
"cluster.fault_detection.leader_check.retry_count",
"cluster.follower_lag.timeout",
"cluster.max_voting_config_exclusions",
"cluster.publish.info_timeout",
"cluster.publish.timeout",
"cluster.discovery_configuration_check.interval",
"cluster.join_validation.cache_timeout",
"cluster.no_master_block",
"monitor.fs.health.enabled",
"monitor.fs.health.refresh_interval",
"monitor.fs.health.slow_path_logging_threshold",
"enrich.cache_size",
"enrich.coordinator_proxy.max_concurrent_requests",
"enrich.coordinator_proxy.max_lookups_per_request",
"enrich.coordinator_proxy.queue_capacity",
"enrich.fetch_size",
"enrich.max_force_merge_attempts",
"enrich.cleanup_period",
"enrich.max_concurrent_policy_executions",
"indices.fielddata.cache.size",
"health.master_history.has_master_lookup_timeframe",
"master_history.max_age",
"health.master_history.identity_changes_threshold",
"health.master_history.no_master_transitions_threshold",
"health.node.enabled",
"health.reporting.local.monitor.interval",
"health.ilm.max_time_on_action",
"health.ilm.max_time_on_step",
"health.ilm.max_retries_per_step",
"health.periodic_logger.enabled",
"health.periodic_logger.poll_interval",
"health.shard_capacity.unhealthy_threshold.yellow",
"health.shard_capacity.unhealthy_threshold.red",
"health.master_history.has_master_lookup_timeframe",
"master_history.max_age",
"health.master_history.identity_changes_threshold",
"health.master_history.no_master_transitions_threshold",
"health.node.enabled",
"health.reporting.local.monitor.interval",
"health.ilm.max_time_on_action",
"health.ilm.max_time_on_step",
"health.ilm.max_retries_per_step",
"health.periodic_logger.enabled",
"health.periodic_logger.poll_interval",
"health.shard_capacity.unhealthy_threshold.yellow",
"health.shard_capacity.unhealthy_threshold.red",
"indices.memory.index_buffer_size",
"indices.memory.min_index_buffer_size",
"indices.memory.max_index_buffer_size",
"indexing_pressure.memory.limit",
"xpack.ilm.enabled",
"indices.lifecycle.history_index_enabled",
"indices.lifecycle.poll_interval",
"indices.lifecycle.rollover.only_if_has_documents",
"index.lifecycle.indexing_complete",
"index.lifecycle.name",
"index.lifecycle.origination_date",
"index.lifecycle.parse_origination_date",
"index.lifecycle.step.wait_time_threshold",
"index.lifecycle.rollover_alias",
"action.auto_create_index",
"action.destructive_requires_name",
"cluster.indices.close.enable",
"stack.templates.enabled",
"xpack.profiling.enabled",
"xpack.profiling.templates.enabled",
"xpack.otel_data.registry.enabled",
"xpack.otel_data.histogram_field_type",
"reindex.remote.whitelist",
"reindex.remote.blocklist",
"cluster.reindex.pit.keep_alive",
"reindex.ssl.certificate",
"reindex.ssl.certificate_authorities",
"reindex.ssl.key",
"reindex.ssl.key_passphrase",
"reindex.ssl.keystore.key_password",
"reindex.ssl.keystore.password",
"reindex.ssl.keystore.path",
"reindex.ssl.keystore.type",
"reindex.ssl.secure_key_passphrase",
"reindex.ssl.keystore.secure_key_password",
"reindex.ssl.keystore.secure_password",
"reindex.ssl.truststore.password",
"reindex.ssl.truststore.path",
"reindex.ssl.truststore.secure_password",
"reindex.ssl.truststore.type",
"reindex.ssl.verification_mode",
"indices.recovery.max_bytes_per_sec",
"indices.recovery.max_concurrent_file_chunks",
"indices.recovery.max_concurrent_operations",
"indices.recovery.use_snapshots",
"indices.recovery.max_concurrent_snapshot_file_downloads",
"indices.recovery.max_concurrent_snapshot_file_downloads_per_node",
"node.bandwidth.recovery.disk.read",
"node.bandwidth.recovery.disk.write",
"node.bandwidth.recovery.network",
"node.bandwidth.recovery.factor.read",
"node.bandwidth.recovery.factor.write",
"node.bandwidth.recovery.operator.factor.read",
"node.bandwidth.recovery.operator.factor.write",
"node.bandwidth.recovery.operator.factor",
"node.bandwidth.recovery.operator.factor.max_overcommit",
"xpack.inference.query_timeout",
"xpack.inference.logging.reset_interval",
"xpack.inference.logging.wait_duration",
"xpack.inference.http.max_response_size",
"xpack.inference.http.max_total_connections",
"xpack.inference.http.max_route_connections",
"xpack.inference.http.connection_eviction_interval",
"xpack.inference.http.connection_eviction_max_idle_time",
"xpack.inference.http.request_executor.queue_capacity",
"xpack.inference.http.retry.initial_delay",
"xpack.inference.http.retry.max_delay_bound",
"xpack.inference.http.retry.timeout",
"xpack.inference.truncator.reduction_percentage",
"xpack.inference.endpoint.cache.enabled",
"xpack.inference.endpoint.cache.weight",
"xpack.inference.endpoint.cache.expiry_time",
"xpack.inference.oauth2.token_cache.enabled",
"xpack.inference.oauth2.token_cache.weight",
"xpack.inference.oauth2.token_cache.expiry_time",
"xpack.inference.ccm.cache.weight",
"xpack.inference.ccm.cache.expiry_time",
"xpack.license.self_generated.type",
"gateway.expected_data_nodes",
"gateway.recover_after_time",
"gateway.recover_after_data_nodes",
"node.roles: [ ml ]",
"xpack.ml.enabled",
"xpack.ml.inference_model.cache_size",
"xpack.ml.inference_model.time_to_live",
"xpack.ml.max_inference_processors",
"xpack.ml.max_machine_memory_percent",
"xpack.ml.max_model_memory_limit",
"xpack.ml.max_open_jobs",
"xpack.ml.nightly_maintenance_requests_per_second",
"xpack.ml.results_index_rollover_max_size",
"xpack.ml.anomalies.heal_reindexed_v7.enabled",
"xpack.ml.idle_job_auto_close_timeout",
"xpack.ml.node_concurrent_job_allocations",
"xpack.ml.enable_config_migration",
"xpack.ml.max_anomaly_records",
"xpack.ml.max_lazy_ml_nodes",
"xpack.ml.max_ml_node_size",
"xpack.ml.trained_models.graph_validation_enabled",
"xpack.ml.model_repository",
"xpack.ml.persist_results_max_retries",
"xpack.ml.process_connect_timeout",
"xpack.ml.use_auto_machine_memory_percent",
"xpack.monitoring.enabled",
"xpack.monitoring.collection.enabled",
"xpack.monitoring.collection.interval",
"xpack.monitoring.elasticsearch.collection.enabled",
"xpack.monitoring.collection.cluster.stats.timeout",
"xpack.monitoring.collection.node.stats.timeout",
"xpack.monitoring.collection.indices",
"xpack.monitoring.collection.index.stats.timeout",
"xpack.monitoring.collection.index.recovery.active_only",
"xpack.monitoring.collection.index.recovery.timeout",
"xpack.monitoring.history.duration",
"xpack.monitoring.exporters",
"cluster_alerts.management.enabled",
"wait_master.timeout",
"auth.username",
"auth.secure_password",
"connection.timeout",
"connection.read_timeout",
"proxy.base_path",
"index.name.time_format",
"cluster_alerts.management.enabled",
"cluster_alerts.management.blacklist",
"xpack.monitoring.exporters.$NAME.ssl.supported_protocols",
"xpack.monitoring.exporters.$NAME.ssl.verification_mode",
"xpack.monitoring.exporters.$NAME.ssl.cipher_suites",
"xpack.monitoring.exporters.$NAME.ssl.key",
"xpack.monitoring.exporters.$NAME.ssl.key_passphrase",
"xpack.monitoring.exporters.$NAME.ssl.secure_key_passphrase",
"xpack.monitoring.exporters.$NAME.ssl.certificate",
"xpack.monitoring.exporters.$NAME.ssl.certificate_authorities",
"xpack.monitoring.exporters.$NAME.ssl.keystore.path",
"xpack.monitoring.exporters.$NAME.ssl.keystore.password",
"xpack.monitoring.exporters.$NAME.ssl.keystore.secure_password",
"xpack.monitoring.exporters.$NAME.ssl.keystore.key_password",
"xpack.monitoring.exporters.$NAME.ssl.keystore.secure_key_password",
"xpack.monitoring.exporters.$NAME.ssl.truststore.path",
"xpack.monitoring.exporters.$NAME.ssl.truststore.password",
"xpack.monitoring.exporters.$NAME.ssl.truststore.secure_password",
"xpack.monitoring.exporters.$NAME.ssl.keystore.path",
"xpack.monitoring.exporters.$NAME.ssl.keystore.type",
"xpack.monitoring.exporters.$NAME.ssl.keystore.password",
"xpack.monitoring.exporters.$NAME.ssl.keystore.secure_password",
"xpack.monitoring.exporters.$NAME.ssl.keystore.key_password",
"xpack.monitoring.exporters.$NAME.ssl.keystore.secure_key_password",
"xpack.monitoring.exporters.$NAME.ssl.truststore.path",
"xpack.monitoring.exporters.$NAME.ssl.truststore.type",
"xpack.monitoring.exporters.$NAME.ssl.truststore.password",
"xpack.monitoring.exporters.$NAME.ssl.truststore.secure_password",
"network.host",
"http.port",
"transport.port",
"remote_cluster.port",
"0.0.0.0",
"network.bind_host",
"network.publish_host",
"network.tcp.keep_alive",
"network.tcp.keep_idle",
"network.tcp.keep_interval",
"network.tcp.keep_count",
"network.tcp.no_delay",
"network.tcp.reuse_address",
"network.tcp.send_buffer_size",
"network.tcp.receive_buffer_size",
"http.host",
"http.bind_host",
"http.publish_host",
"http.publish_port",
"http.max_content_length",
"http.max_initial_line_length",
"http.max_header_size",
"http.compression",
"http.compression_level",
"http.cors.enabled",
"http.detailed_errors.enabled",
"http.pipelining.max_events",
"http.max_warning_header_count",
"http.max_warning_header_size",
"http.tcp.keep_alive",
"http.tcp.keep_idle",
"http.tcp.keep_interval",
"http.tcp.keep_count",
"http.tcp.no_delay",
"http.tcp.reuse_address",
"http.tcp.send_buffer_size",
"http.tcp.receive_buffer_size",
"http.client_stats.enabled",
"http.client_stats.closed_channels.max_count",
"http.client_stats.closed_channels.max_age",
"transport.host",
"transport.bind_host",
"transport.publish_host",
"transport.publish_port",
"transport.connect_timeout",
"transport.compress",
"transport.compression_scheme",
"transport.tcp.keep_alive",
"transport.tcp.keep_idle",
"transport.tcp.keep_interval",
"transport.tcp.keep_count",
"transport.tcp.no_delay",
"transport.tcp.reuse_address",
"transport.tcp.send_buffer_size",
"transport.tcp.receive_buffer_size",
"transport.ping_schedule",
"remote_cluster_server.enabled",
"remote_cluster.host",
"remote_cluster.bind_host",
"remote_cluster.publish_host",
"remote_cluster.publish_port",
"remote_cluster.tcp.keep_alive",
"remote_cluster.tcp.keep_idle",
"remote_cluster.tcp.keep_interval",
"remote_cluster.tcp.keep_count",
"remote_cluster.tcp.no_delay",
"remote_cluster.tcp.reuse_address",
"remote_cluster.tcp.send_buffer_size",
"remote_cluster.tcp.receive_buffer_size",
"org.elasticsearch.transport.InboundHandler",
"org.elasticsearch.transport.OutboundHandler",
"org.elasticsearch.common.network.ThreadWatchdog",
"network.thread.watchdog.interval",
"network.thread.watchdog.quiet_time",
"indices.queries.cache.size",
"index.queries.cache.enabled",
"vectors.indexing.use_gpu",
"cluster.remote.initial_connect_timeout",
"cluster.remote.node.attr",
"cluster.remote.signing.certificate_authorities",
"cluster.remote.signing.truststore.path",
"cluster.remote.signing.truststore.secure_password",
"cluster.remote.signing.truststore.algorithm",
"cluster.remote.signing.truststore.type",
"cluster.remote.signing.diagnose.trust",
"indices.query.bool.max_clause_count",
"search.max_buckets",
"search.aggs.only_allowed_metric_scripts",
"search.aggs.allowed_inline_metric_scripts",
"search.aggs.allowed_stored_metric_scripts",
"indices.query.bool.max_nested_depth",
"search.task_watchdog.enabled",
"search.task_watchdog.coordinator_threshold",
"search.task_watchdog.data_node_threshold",
"search.task_watchdog.interval",
"search.task_watchdog.cooldown_period",
"xpack.security.enabled",
"xpack.security.autoconfiguration.enabled",
"xpack.security.enrollment.enabled",
"xpack.security.hide_settings",
"xpack.security.fips_mode.enabled",
"xpack.security.fips_mode.required_providers",
"xpack.security.authc.password_hashing.algorithm",
"xpack.security.authc.anonymous.username",
"xpack.security.authc.anonymous.roles",
"xpack.security.authc.anonymous.authz_exception",
"xpack.security.automata.max_determinized_states",
"xpack.security.automata.cache.enabled",
"xpack.security.automata.cache.size",
"xpack.security.automata.cache.ttl",
"xpack.security.dls_fls.enabled",
"xpack.security.dls.bitset.cache.ttl",
"xpack.security.dls.bitset.cache.size",
"xpack.security.authc.token.enabled",
"xpack.security.authc.token.timeout",
"xpack.security.authc.api_key.enabled",
"xpack.security.authc.api_key.cache.ttl",
"xpack.security.authc.api_key.cache.max_keys",
"xpack.security.authc.api_key.cache.hash_algo",
"xpack.security.authc.api_key.delete.retention_period",
"xpack.security.authc.api_key.delete.interval",
"xpack.security.authc.api_key.delete.timeout",
"xpack.security.authc.api_key.hashing.algorithm",
"xpack.security.authc.realms.saml.*",
"xpack.security.authc.realms.oidc.*",
"xpack.security.authc.realms.kerberos.*",
"xpack.security.authc.realms.jwt.*",
"cache.ttl",
"cache.max_users",
"cache.hash_algo",
"authentication.enabled",
"cache.ttl",
"cache.max_users",
"cache.hash_algo",
"authentication.enabled",
"load_balance.type",
"load_balance.cache_ttl",
"user_search.base_dn",
"user_search.scope",
"user_search.filter",
"user_search.attribute",
"user_search.pool.enabled",
"user_search.pool.size",
"user_search.pool.initial_size",
"user_search.pool.health_check.enabled",
"user_search.pool.health_check.dn",
"user_search.pool.health_check.interval",
"group_search.base_dn",
"group_search.scope",
"group_search.filter",
"group_search.user_attribute",
"files.role_mapping",
"timeout.tcp_connect",
"timeout.tcp_read",
"timeout.response",
"timeout.ldap_search",
"ssl.key",
"ssl.key_passphrase",
"ssl.secure_key_passphrase",
"ssl.certificate",
"ssl.certificate_authorities",
"ssl.keystore.path",
"ssl.keystore.type",
"ssl.keystore.password",
"ssl.keystore.secure_password",
"ssl.keystore.key_password",
"ssl.keystore.secure_key_password",
"ssl.truststore.path",
"ssl.truststore.password",
"ssl.truststore.secure_password",
"ssl.truststore.type",
"ssl.verification_mode",
"ssl.supported_protocols",
"ssl.cipher_suites",
"cache.ttl",
"cache.max_users",
"cache.hash_algo",
"authentication.enabled",
"load_balance.type",
"load_balance.cache_ttl",
"files.role_mapping",
"user_search.base_dn",
"user_search.scope",
"user_search.filter",
"user_search.upn_filter",
"user_search.down_level_filter",
"user_search.pool.enabled",
"user_search.pool.size",
"user_search.pool.initial_size",
"user_search.pool.health_check.enabled",
"user_search.pool.health_check.dn",
"user_search.pool.health_check.interval",
"group_search.base_dn",
"group_search.scope",
"timeout.tcp_connect",
"timeout.tcp_read",
"timeout.response",
"timeout.ldap_search",
"ssl.certificate",
"ssl.certificate_authorities",
"ssl.key",
"ssl.key_passphrase",
"ssl.secure_key_passphrase",
"ssl.keystore.key_password",
"ssl.keystore.secure_key_password",
"ssl.keystore.password",
"ssl.secure_keystore.password",
"ssl.keystore.path",
"ssl.keystore.type",
"ssl.truststore.password",
"ssl.truststore.secure_password",
"ssl.truststore.path",
"ssl.truststore.type",
"ssl.verification_mode",
"ssl.supported_protocols",
"ssl.cipher_suites",
"cache.ttl",
"cache.max_users",
"cache.hash_algo",
"authentication.enabled",
"truststore.algorithm",
"truststore.password",
"truststore.secure_password",
"truststore.path",
"files.role_mapping",
"cache.ttl",
"cache.max_users",
"delegation.enabled",
"idp.entity_id",
"idp.metadata.path",
"idp.metadata.http.fail_on_error",
"idp.metadata.http.connect_timeout",
"idp.metadata.http.read_timeout",
"idp.metadata.http.refresh",
"idp.metadata.http.minimum_refresh",
"idp.use_single_logout",
"sp.entity_id",
"sp.acs",
"sp.logout",
"attributes.principal",
"attributes.groups",
"attributes.name",
"attributes.mail",
"attributes.dn",
"attribute_patterns.principal",
"attribute_patterns.groups",
"attribute_patterns.name",
"attribute_patterns.mail",
"attribute_patterns.dn",
"attribute_delimiters.groups",
"nameid.allow_create",
"nameid.sp_qualifier",
"signing.saml_messages",
"signing.key",
"signing.secure_key_passphrase",
"signing.certificate",
"signing.keystore.path",
"signing.keystore.type",
"signing.keystore.alias",
"signing.keystore.secure_password",
"signing.keystore.secure_key_password",
"encryption.key",
"encryption.secure_key_passphrase",
"encryption.certificate",
"encryption.keystore.path",
"encryption.keystore.type",
"encryption.keystore.alias",
"encryption.keystore.secure_password",
"encryption.keystore.secure_key_password",
"ssl.key",
"ssl.key_passphrase",
"ssl.secure_key_passphrase",
"ssl.certificate",
"ssl.certificate_authorities",
"ssl.keystore.path",
"ssl.keystore.type",
"ssl.keystore.password",
"ssl.keystore.secure_password",
"ssl.keystore.key_password",
"ssl.keystore.secure_key_password",
"ssl.truststore.path",
"ssl.truststore.type",
"ssl.truststore.password",
"ssl.truststore.secure_password",
"ssl.verification_mode",
"ssl.supported_protocols",
"ssl.cipher_suites",
"keytab.path",
"krb.debug",
"cache.ttl",
"cache.max_users",
"op.issuer",
"op.authorization_endpoint",
"op.token_endpoint",
"op.userinfo_endpoint",
"op.endsession_endpoint",
"op.jwkset_path",
"rp.client_id",
"rp.client_secret",
"rp.client_auth_method",
"rp.client_auth_jwt_signature_algorithm",
"rp.redirect_uri",
"rp.response_type",
"rp.signature_algorithm",
"rp.requested_scopes",
"rp.post_logout_redirect_uri",
"claims.principal",
"claims.groups",
"claims.name",
"claims.mail",
"claims.dn",
"claim_patterns.principal",
"claim_patterns.groups",
"claim_patterns.name",
"claim_patterns.mail",
"claim_patterns.dn",
"http.proxy.host",
"http.proxy.scheme",
"http.proxy.port",
"http.connect_timeout",
"http.connection_read_timeout",
"http.socket_timeout",
"http.max_connections",
"http.max_endpoint_connections",
"http.tcp.keep_alive",
"http.connection_pool_ttl",
"ssl.key",
"ssl.key_passphrase",
"ssl.secure_key_passphrase",
"ssl.certificate",
"ssl.certificate_authorities",
"ssl.keystore.path",
"ssl.keystore.type",
"ssl.keystore.password",
"ssl.keystore.secure_password",
"ssl.keystore.key_password",
"ssl.keystore.secure_key_password",
"ssl.truststore.path",
"ssl.truststore.type",
"ssl.truststore.password",
"ssl.truststore.secure_password",
"ssl.verification_mode",
"ssl.supported_protocols",
"ssl.cipher_suites",
"fallback_claims.sub",
"fallback_claims.aud",
"claims.dn",
"claim_patterns.dn",
"claims.groups",
"claim_patterns.group",
"claims.mail",
"claim_patterns.mail",
"claims.name",
"claim_patterns.name",
"claims.principal",
"claim_patterns.principal",
"client_authentication.type",
"client_authentication.shared_secret",
"client_authentication.rotation_grace_period",
"http.proxy.host",
"http.proxy.scheme",
"http.proxy.port",
"http.connect_timeout",
"http.connection_read_timeout",
"http.socket_timeout",
"http.max_connections",
"http.max_endpoint_connections",
"jwt.cache.size",
"jwt.cache.ttl",
"pkc_jwkset_reload.enabled",
"pkc_jwkset_reload.file_interval",
"pkc_jwkset_reload.url_interval_min",
"pkc_jwkset_reload.url_interval_max",
"ssl.key",
"ssl.key_passphrase",
"ssl.secure_key_passphrase",
"ssl.certificate",
"ssl.certificate_authorities",
"ssl.keystore.path",
"ssl.keystore.type",
"ssl.keystore.password",
"ssl.keystore.secure_password",
"ssl.keystore.key_password",
"ssl.keystore.secure_key_password",
"ssl.truststore.path",
"ssl.truststore.type",
"ssl.truststore.password",
"ssl.truststore.secure_password",
"ssl.verification_mode",
"ssl.supported_protocols",
"ssl.cipher_suites",
"xpack.security.ssl.diagnose.trust",
"xpack.security.http.ssl.enabled",
"xpack.security.http.ssl.supported_protocols",
"xpack.security.http.ssl.client_authentication",
"xpack.security.http.ssl.verification_mode",
"xpack.security.http.ssl.cipher_suites",
"xpack.security.http.ssl.key",
"xpack.security.http.ssl.key_passphrase",
"xpack.security.http.ssl.secure_key_passphrase",
"xpack.security.http.ssl.certificate",
"xpack.security.http.ssl.certificate_authorities",
"xpack.security.http.ssl.keystore.path",
"xpack.security.http.ssl.keystore.password",
"xpack.security.http.ssl.keystore.secure_password",
"xpack.security.http.ssl.keystore.key_password",
"xpack.security.http.ssl.keystore.secure_key_password",
"xpack.security.http.ssl.truststore.path",
"xpack.security.http.ssl.truststore.password",
"xpack.security.http.ssl.truststore.secure_password",
"xpack.security.http.ssl.keystore.path",
"xpack.security.http.ssl.keystore.type",
"xpack.security.http.ssl.keystore.password",
"xpack.security.http.ssl.keystore.secure_password",
"xpack.security.http.ssl.keystore.key_password",
"xpack.security.http.ssl.keystore.secure_key_password",
"xpack.security.http.ssl.truststore.path",
"xpack.security.http.ssl.truststore.type",
"xpack.security.http.ssl.truststore.password",
"xpack.security.http.ssl.truststore.secure_password",
"xpack.security.transport.ssl.enabled",
"xpack.security.transport.ssl.supported_protocols",
"xpack.security.transport.ssl.client_authentication",
"xpack.security.transport.ssl.verification_mode",
"xpack.security.transport.ssl.cipher_suites",
"xpack.security.transport.ssl.trust_restrictions.x509_fields",
"xpack.security.transport.ssl.handshake_timeout",
"xpack.security.transport.ssl.key",
"xpack.security.transport.ssl.key_passphrase",
"xpack.security.transport.ssl.secure_key_passphrase",
"xpack.security.transport.ssl.certificate",
"xpack.security.transport.ssl.certificate_authorities",
"xpack.security.loginAssistanceMessage",
"xpack.security.transport.ssl.keystore.path",
"xpack.security.transport.ssl.keystore.password",
"xpack.security.transport.ssl.keystore.secure_password",
"xpack.security.transport.ssl.keystore.key_password",
"xpack.security.transport.ssl.keystore.secure_key_password",
"xpack.security.transport.ssl.truststore.path",
"xpack.security.transport.ssl.truststore.password",
"xpack.security.transport.ssl.truststore.secure_password",
"xpack.security.transport.ssl.keystore.path",
"xpack.security.transport.ssl.keystore.type",
"xpack.security.transport.ssl.keystore.password",
"xpack.security.transport.ssl.keystore.secure_password",
"xpack.security.transport.ssl.keystore.key_password",
"xpack.security.transport.ssl.keystore.secure_key_password",
"xpack.security.transport.ssl.truststore.path",
"xpack.security.transport.ssl.truststore.type",
"xpack.security.transport.ssl.truststore.password",
"xpack.security.transport.ssl.truststore.secure_password",
"xpack.security.remote_cluster_server.ssl.enabled",
"xpack.security.remote_cluster_server.ssl.supported_protocols",
"xpack.security.remote_cluster_server.ssl.client_authentication",
"xpack.security.remote_cluster_server.ssl.verification_mode",
"xpack.security.remote_cluster_server.ssl.cipher_suites",
"xpack.security.remote_cluster_server.ssl.handshake_timeout",
"xpack.security.remote_cluster_server.ssl.key",
"xpack.security.remote_cluster_server.ssl.secure_key_passphrase",
"xpack.security.remote_cluster_server.ssl.certificate",
"xpack.security.remote_cluster_server.ssl.certificate_authorities",
"xpack.security.remote_cluster_server.ssl.keystore.path",
"xpack.security.remote_cluster_server.ssl.keystore.secure_password",
"xpack.security.remote_cluster_server.ssl.keystore.secure_key_password",
"xpack.security.remote_cluster_server.ssl.truststore.path",
"xpack.security.remote_cluster_server.ssl.truststore.secure_password",
"xpack.security.remote_cluster_server.ssl.keystore.path",
"xpack.security.remote_cluster_server.ssl.keystore.type",
"xpack.security.remote_cluster_server.ssl.keystore.secure_password",
"xpack.security.remote_cluster_server.ssl.keystore.secure_key_password",
"xpack.security.remote_cluster_server.ssl.truststore.path",
"xpack.security.remote_cluster_server.ssl.truststore.type",
"xpack.security.remote_cluster_server.ssl.truststore.secure_password",
"xpack.security.remote_cluster_client.ssl.enabled",
"xpack.security.remote_cluster_client.ssl.supported_protocols",
"xpack.security.remote_cluster_client.ssl.verification_mode",
"xpack.security.remote_cluster_client.ssl.cipher_suites",
"xpack.security.remote_cluster_client.ssl.handshake_timeout",
"xpack.security.remote_cluster_client.ssl.key",
"xpack.security.remote_cluster_client.ssl.secure_key_passphrase",
"xpack.security.remote_cluster_client.ssl.certificate",
"xpack.security.remote_cluster_client.ssl.certificate_authorities",
"xpack.security.remote_cluster_client.ssl.keystore.path",
"xpack.security.remote_cluster_client.ssl.keystore.secure_password",
"xpack.security.remote_cluster_client.ssl.keystore.secure_key_password",
"xpack.security.remote_cluster_client.ssl.truststore.path",
"xpack.security.remote_cluster_client.ssl.truststore.secure_password",
"xpack.security.remote_cluster_client.ssl.keystore.path",
"xpack.security.remote_cluster_client.ssl.keystore.type",
"xpack.security.remote_cluster_client.ssl.keystore.secure_password",
"xpack.security.remote_cluster_client.ssl.keystore.secure_key_password",
"xpack.security.remote_cluster_client.ssl.truststore.path",
"xpack.security.remote_cluster_client.ssl.truststore.type",
"xpack.security.remote_cluster_client.ssl.truststore.secure_password",
"xpack.security.transport.filter.allow",
"xpack.security.transport.filter.deny",
"xpack.security.http.filter.allow",
"xpack.security.http.filter.deny",
"transport.profiles.$PROFILE.xpack.security.filter.allow",
"transport.profiles.$PROFILE.xpack.security.filter.deny",
"xpack.security.remote_cluster.filter.allow",
"xpack.security.remote_cluster.filter.deny",
"indices.requests.cache.size",
"indices.requests.cache.expire",
"snapshot.max_concurrent_operations",
"repositories.default_repository",
"slm.history_index_enabled",
"slm.retention_schedule",
"slm.retention_duration",
"slm.health.failed_snapshot_warn_threshold",
"node.roles: [ transform ]",
"xpack.transform.enabled",
"xpack.transform.num_transform_failure_retries",
"xpack.watcher.enabled",
"xpack.watcher.encrypt_sensitive_data",
"xpack.watcher.encryption_key",
"xpack.watcher.max.history.record.size",
"xpack.watcher.trigger.schedule.engine",
"xpack.watcher.history.cleaner_service.enabled",
"xpack.http.proxy.host",
"xpack.http.proxy.port",
"xpack.http.proxy.scheme",
"xpack.http.default_connection_timeout",
"xpack.http.default_read_timeout",
"xpack.http.tcp.keep_alive",
"xpack.http.connection_pool_ttl",
"xpack.http.max_response_size",
"xpack.http.whitelist",
"xpack.http.ssl.supported_protocols",
"xpack.http.ssl.verification_mode",
"xpack.http.ssl.cipher_suites",
"xpack.http.ssl.key",
"xpack.http.ssl.secure_key_passphrase",
"xpack.http.ssl.certificate",
"xpack.http.ssl.certificate_authorities",
"xpack.http.ssl.keystore.path",
"xpack.http.ssl.keystore.secure_password",
"xpack.http.ssl.keystore.secure_key_password",
"xpack.http.ssl.truststore.path",
"xpack.http.ssl.truststore.secure_password",
"xpack.http.ssl.keystore.path",
"xpack.http.ssl.keystore.type",
"xpack.http.ssl.keystore.secure_password",
"xpack.http.ssl.keystore.secure_key_password",
"xpack.http.ssl.truststore.path",
"xpack.http.ssl.truststore.type",
"xpack.http.ssl.truststore.secure_password",
"xpack.notification.email.default_account",
"xpack.notification.email.recipient_allowlist",
"xpack.notification.email.account",
"xpack.notification.email.account.domain_allowlist",
"email_defaults.*",
"smtp.auth",
"smtp.host",
"smtp.port",
"smtp.user",
"smtp.secure_password",
"smtp.starttls.enable",
"smtp.starttls.required",
"smtp.ssl.trust",
"smtp.timeout",
"smtp.connection_timeout",
"smtp.write_timeout",
"smtp.local_address",
"smtp.local_port",
"smtp.send_partial",
"smtp.wait_on_quit",
"xpack.notification.email.html.sanitization.allow",
"xpack.notification.email.html.sanitization.disallow",
"xpack.notification.email.html.sanitization.enabled",
"xpack.notification.email.ssl.supported_protocols",
"xpack.notification.email.ssl.verification_mode",
"xpack.notification.email.ssl.cipher_suites",
"xpack.notification.email.ssl.key",
"xpack.notification.email.ssl.secure_key_passphrase",
"xpack.notification.email.ssl.certificate",
"xpack.notification.email.ssl.certificate_authorities",
"xpack.notification.email.ssl.keystore.path",
"xpack.notification.email.ssl.keystore.secure_password",
"xpack.notification.email.ssl.keystore.secure_key_password",
"xpack.notification.email.ssl.truststore.path",
"xpack.notification.email.ssl.truststore.secure_password",
"xpack.notification.email.ssl.keystore.path",
"xpack.notification.email.ssl.keystore.type",
"xpack.notification.email.ssl.keystore.secure_password",
"xpack.notification.email.ssl.keystore.secure_key_password",
"xpack.notification.email.ssl.truststore.path",
"xpack.notification.email.ssl.truststore.type",
"xpack.notification.email.ssl.truststore.secure_password",
"xpack.notification.slack",
"xpack.notification.slack.default_account",
"xpack.notification.slack.account",
"xpack.notification.jira.default_account",
"xpack.notification.jira.account",
"xpack.notification.pagerduty",
"xpack.notification.pagerduty.default_account",
"xpack.notification.pagerduty.account",
"xpack.notification.webhook.additional_token_enabled",
}

View File

@@ -18,16 +18,12 @@ package es
import (
"context"
"errors"
"fmt"
"regexp"
"strconv"
"strings"
"sync"
"codeberg.org/scip/esctl/pkg/cfg"
"codeberg.org/scip/esctl/pkg/printer"
"github.com/elastic/go-elasticsearch/v9"
"github.com/elastic/go-elasticsearch/v9/typedapi/cluster/health"
"github.com/elastic/go-elasticsearch/v9/typedapi/types"
)
@@ -37,9 +33,7 @@ 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, esErrorString(err))
@@ -64,9 +58,7 @@ 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, esErrorString(err))
@@ -123,10 +115,8 @@ 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, esErrorString(err))
@@ -192,9 +182,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
@@ -255,9 +243,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
@@ -343,119 +329,3 @@ func splitArg(arg string) (string, string) {
return parts[0], parts[1]
}
}
func getClusterData(es *elasticsearch.TypedClient, wg *sync.WaitGroup, reschan chan apiResponse, which string) {
defer wg.Done()
ar := apiResponse{}
arerr := errors.New("")
switch which {
case "health":
res, err := es.Cluster.Health().
Header("content-type", "application/json").
Header("accept", "application/json").
Do(context.Background())
ar.health = res
ar.which = ResponseHealth
arerr = err
case "info":
res, err := es.Info().
Header("content-type", "application/json").
Header("accept", "application/json").
Do(context.Background())
ar.info = res
ar.which = ResponseInfo
arerr = err
case "ccrstats":
res, err := es.Ccr.Stats().
Header("content-type", "application/json").
Header("accept", "application/json").
Do(context.Background())
ar.ccr = res
ar.which = ResponseCcr
arerr = err
case "stats":
res, err := es.Cluster.Stats().
Header("content-type", "application/json").
Header("accept", "application/json").
Do(context.Background())
ar.stats = res
ar.which = ResponseStats
arerr = err
case "indices":
res, err := es.Cat.Indices().
Header("content-type", "application/json").
Header("accept", "application/json").
Do(context.Background())
ar.indices = &res
ar.which = ResponseIndices
arerr = err
case "tasks":
res, err := es.Cat.Tasks().
Header("content-type", "application/json").
Header("accept", "application/json").
Do(context.Background())
ar.tasks = &res
ar.which = ResponseTasks
arerr = err
}
if arerr != nil {
ar.error = fmt.Errorf("failed to get cluster health: %s", arerr)
}
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
}

View File

@@ -30,9 +30,7 @@ import (
)
func DatastreamNames(conf *cfg.Config) ([]string, error) {
res, err := conf.DefaultCluster.ES.Indices.GetDataStream().
Header("content-type", "application/json").
Header("accept", "application/json").
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))
@@ -47,9 +45,7 @@ func DatastreamNames(conf *cfg.Config) ([]string, error) {
}
func DatastreamList(conf *cfg.Config) error {
res, err := conf.DefaultCluster.ES.Indices.GetDataStream().
Header("content-type", "application/json").
Header("accept", "application/json").
res, err := conf.DefaultCluster.ES().Indices.GetDataStream().
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to get data streams: %s", esErrorString(err))
@@ -129,10 +125,8 @@ func filterDatastreams(conf *cfg.Config, list []types.DataStream) []types.DataSt
}
func DatastreamShow(conf *cfg.Config, dsname string) error {
res, err := conf.DefaultCluster.ES.Indices.GetDataStream().
res, err := conf.DefaultCluster.ES().Indices.GetDataStream().
Name(dsname).
Header("content-type", "application/json").
Header("accept", "application/json").
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to get data stream: %s", esErrorString(err))
@@ -144,10 +138,8 @@ func DatastreamShow(conf *cfg.Config, dsname string) error {
return errors.New("no data stream found with that name")
}
stats, err := conf.DefaultCluster.ES.Indices.DataStreamsStats().
stats, err := conf.DefaultCluster.ES().Indices.DataStreamsStats().
Name(dsname).
Header("content-type", "application/json").
Header("accept", "application/json").
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to get data stream stats: %s", esErrorString(err))
@@ -213,9 +205,7 @@ func DatastreamShow(conf *cfg.Config, dsname string) error {
}
func DatastreamCreate(conf *cfg.Config, dsname string) error {
_, err := conf.DefaultCluster.ES.Indices.CreateDataStream(dsname).
Header("content-type", "application/json").
Header("accept", "application/json").
_, err := conf.DefaultCluster.ES().Indices.CreateDataStream(dsname).
Do(context.Background())
if err != nil {
@@ -226,9 +216,7 @@ func DatastreamCreate(conf *cfg.Config, dsname string) error {
}
func DatastreamDelete(conf *cfg.Config, dsname string) error {
_, err := conf.DefaultCluster.ES.Indices.DeleteDataStream(dsname).
Header("content-type", "application/json").
Header("accept", "application/json").
_, err := conf.DefaultCluster.ES().Indices.DeleteDataStream(dsname).
Do(context.Background())
if err != nil {

48
pkg/es/debug.go Normal file
View File

@@ -0,0 +1,48 @@
/*
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 (
"codeberg.org/scip/esctl/pkg/cfg"
"github.com/alecthomas/repr"
)
func Debug(conf *cfg.Config) error {
report, err := getHealthReport(conf)
if err != nil {
return err
}
repr.Println(report)
/*
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
}

View File

@@ -47,10 +47,8 @@ func DocAdd(conf *cfg.Config, jsondoc string) error {
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, esErrorString(err))
@@ -62,9 +60,7 @@ 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, esErrorString(err))
@@ -95,9 +91,7 @@ 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).
Header("content-type", "application/json").
Header("accept", "application/json").
_, 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))
@@ -119,10 +113,8 @@ func DocDelete(conf *cfg.Config, queries []string) error {
req.Query = queryCaster
}
_, err := conf.DefaultCluster.ES.DeleteByQuery(conf.Index).
_, err := conf.DefaultCluster.ES().DeleteByQuery(conf.Index).
Request(req).
Header("content-type", "application/json").
Header("accept", "application/json").
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to delete docs in index %s: %s", conf.Index, esErrorString(err))

126
pkg/es/duration.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 es
import (
"fmt"
"math"
"regexp"
"strconv"
"time"
)
var (
matchDuration = regexp.MustCompile(`(\d+)([dhms])`)
)
type esDuration struct {
days, hours, minutes, seconds int
}
/*
We could use time.ParseDuration(), but this doesn't support days.
We could also use github.com/xhit/go-str2duration/v2, which does
the job, but it's just another dependency, just for this little
gem. And we don't need a time.Time value. And int is good enough
for duration comparison.
Convert a duration into an integer. Valid time units are "s", "m", "h" and "d".
via https://codeberg.org/scip/tablizer/src/branch/main/lib/sort.go#L113
*/
func parseDuration(duration string) time.Duration {
seconds := 0
for _, match := range matchDuration.FindAllStringSubmatch(duration, -1) {
if len(match) == 3 {
durationvalue, _ := strconv.Atoi(match[1])
switch match[2][0] {
case 'd':
seconds += durationvalue * 86400
case 'h':
seconds += durationvalue * 3600
case 'm':
seconds += durationvalue * 60
case 's':
seconds += durationvalue
}
}
}
return time.Duration(seconds) * time.Second
}
// pretty much the same as above, but return a struct filled with int duration values
func extractDuration(duration string) esDuration {
esd := esDuration{}
for _, match := range matchDuration.FindAllStringSubmatch(duration, -1) {
if len(match) == 3 {
durationvalue, _ := strconv.Atoi(match[1])
switch match[2][0] {
case 'd':
esd.days = durationvalue
case 'h':
esd.hours = durationvalue
case 'm':
esd.minutes = durationvalue
case 's':
esd.seconds = durationvalue
}
}
}
return esd
}
// convert a duration like 156h6m15.095s-6d/12h/6m => 6d:12h:6m
func formatDuration(val time.Duration) string {
esd := extractDuration(val.String())
daysF := float64(esd.hours) / 24.0
days := math.Floor(daysF)
hoursLeft := (daysF - days) * 24
switch {
case val.Minutes() < 1:
return fmt.Sprintf("%ds", int64(esd.seconds))
case val.Hours() < 1:
return fmt.Sprintf("%dm", int64(esd.minutes))
case val.Hours() < 24:
if esd.minutes == 0 {
return fmt.Sprintf("%dh", esd.hours)
}
return fmt.Sprintf("%dh:%dm", esd.hours, int64(esd.minutes))
default:
if esd.minutes == 0 && hoursLeft == 0 {
return fmt.Sprintf("%dd", int64(days))
}
return fmt.Sprintf("%dd:%dh:%dm",
int64(days),
int64(hoursLeft),
esd.minutes,
)
}
}

View File

@@ -18,6 +18,7 @@ package es
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
@@ -26,13 +27,14 @@ import (
"codeberg.org/scip/esctl/pkg/cfg"
"codeberg.org/scip/esctl/pkg/printer"
"github.com/alecthomas/repr"
"github.com/charmbracelet/lipgloss"
"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).
_, err := conf.DefaultCluster.ES().Ilm.Retry(index).
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to retry ilm: %s", esErrorString(err))
@@ -42,7 +44,7 @@ func IlmRetry(conf *cfg.Config, index string) error {
}
func IlmStatus(conf *cfg.Config) error {
res, err := conf.DefaultCluster.ES.Ilm.GetStatus().
res, err := conf.DefaultCluster.ES().Ilm.GetStatus().
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to get ilm status: %s", esErrorString(err))
@@ -54,7 +56,7 @@ func IlmStatus(conf *cfg.Config) error {
}
func IlmNames(conf *cfg.Config) ([]string, error) {
res, err := conf.DefaultCluster.ES.Ilm.GetLifecycle().
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))
@@ -65,13 +67,14 @@ func IlmNames(conf *cfg.Config) ([]string, error) {
for name := range res {
names[idx] = name
idx++
}
return names, nil
}
func IlmList(conf *cfg.Config, pattern string) error {
ilm := conf.DefaultCluster.ES.Ilm.GetLifecycle()
ilm := conf.DefaultCluster.ES().Ilm.GetLifecycle()
if pattern != "" {
ilm.FilterPath(pattern)
@@ -103,7 +106,7 @@ func IlmList(conf *cfg.Config, pattern string) error {
}
func IlmShow(conf *cfg.Config, policy string) error {
res, err := conf.DefaultCluster.ES.Ilm.GetLifecycle().
res, err := conf.DefaultCluster.ES().Ilm.GetLifecycle().
Policy(policy).
Do(context.Background())
if err != nil {
@@ -119,6 +122,10 @@ func IlmShow(conf *cfg.Config, policy string) error {
return errors.New("no ilm policy retrieved")
}
if conf.Ilm.Tree {
return IlmShowTree(conf, ilm.Policy)
}
table := printer.NewTable(conf, 2, 5)
table.Addheaders("ilm policy setting", "value")
@@ -133,6 +140,101 @@ func IlmShow(conf *cfg.Config, policy string) error {
return table.Print()
}
// Visualize phases or a ILM policy, taking into account that the
// minAge for the current phases is set in the next phase
func IlmShowTree(conf *cfg.Config, ilm types.IlmPolicy) error {
indent := ""
table := printer.NewTable(conf, 4, 0)
table.Addheaders("phase", "min age", "min size", "snapshot repo")
for _, phase := range IlmPhaseOrder {
if phase == "hot" {
fmt.Printf("%s%s phase:\n%s rollover after %s\n",
indent, phase,
indent, formatDuration(parseDuration(ilm.Phases.Hot.Actions.Rollover.MaxAge.(string))),
)
if ilm.Phases.Hot.Actions.Rollover.MaxPrimaryShardSize != "" {
fmt.Printf("%s rollover when storage > %s\n",
indent, ilm.Phases.Hot.Actions.Rollover.MaxPrimaryShardSize)
}
if ilm.Phases.Hot.Actions.Rollover.MaxDocs != nil {
fmt.Printf("%s rollover docs > %d\n",
indent, *ilm.Phases.Hot.Actions.Rollover.MaxDocs)
}
indent += " "
continue
}
current := getIlmCurrentPhase(ilm, phase)
if current == nil {
continue
}
next := findNextPhase(ilm, phase)
fmt.Printf("%s%s phase:\n", indent, phase)
if next != nil {
fmt.Printf("%s rollover after %s\n",
indent, formatDuration(next.minage),
)
if current.Actions.SearchableSnapshot != nil {
fmt.Printf("%s roll to snapshot repo: %s\n",
indent, current.Actions.SearchableSnapshot.SnapshotRepository)
}
if current.Actions.Forcemerge != nil {
fmt.Printf("%s force merge segments: %d\n",
indent, current.Actions.Forcemerge.MaxNumSegments)
}
if current.Actions.Shrink != nil {
fmt.Printf("%s shrink shards: %d\n",
indent, *current.Actions.Shrink.NumberOfShards)
}
if current.Actions.SetPriority != nil {
fmt.Printf("%s priority: %d\n",
indent, *current.Actions.SetPriority.Priority)
}
if current.Actions.Delete != nil && current.Actions.Delete.DeleteSearchableSnapshot != nil {
fmt.Printf("%s delete searchable snapshots: %t\n",
indent, *current.Actions.Delete.DeleteSearchableSnapshot)
}
}
if phase == "delete" {
fmt.Printf("%s delete immediately\n", indent)
}
indent += " "
}
return nil
}
func getIlmCurrentPhase(policy types.IlmPolicy, currentPhase string) *types.Phase {
switch currentPhase {
case "hot":
return policy.Phases.Hot
case "warm":
return policy.Phases.Warm
case "cold":
return policy.Phases.Cold
case "frozen":
return policy.Phases.Frozen
default:
return policy.Phases.Delete
}
}
func ilmPhaseString(phase *types.Phase, short bool) string {
if phase == nil {
return ""
@@ -165,7 +267,7 @@ func ilmPhaseString(phase *types.Phase, short bool) string {
}
func IlmExplain(conf *cfg.Config, index string) error {
res, err := conf.DefaultCluster.ES.Ilm.ExplainLifecycle(index).
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))
@@ -183,22 +285,60 @@ func IlmExplain(conf *cfg.Config, index string) error {
table := printer.NewTable(conf, 2, 0)
table.Addheaders("ilm status field", "value")
info := ""
err = json.Unmarshal(ilm.StepInfo["reason"], &info)
if err != nil {
return fmt.Errorf("failed to unmarshal step info: %w", err)
}
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)},
{"step", *ilm.Step},
{"failed step", *ilm.FailedStep},
{"failed step retry count", fmt.Sprintf("%d", ilm.FailedStepRetryCount)},
}
return table.Print()
if err := table.Print(); err != nil {
return err
}
if info != "" {
var bold = lipgloss.NewStyle().Bold(true)
fmt.Printf("\n%s:\n%s\n",
bold.Render("Step Reason"),
info)
}
return nil
}
func IlmCreate(conf *cfg.Config, policyname string) error {
ilm := conf.DefaultCluster.ES.Ilm.PutLifecycle(policyname)
var policy *types.IlmPolicy = nil
res, err := conf.DefaultCluster.ES().Ilm.GetLifecycle().
Policy(policyname).
Do(context.Background())
if err == nil {
if conf.Debug {
repr.Println(res)
}
ilm, exists := res[policyname]
if !exists {
return errors.New("no ilm policy retrieved")
}
policy = &ilm.Policy
}
ilm := conf.DefaultCluster.ES().Ilm.PutLifecycle(policyname)
cfg := conf.Ilm
var phases types.PhasesVariant = esdsl.NewPhases()
@@ -209,6 +349,16 @@ func IlmCreate(conf *cfg.Config, policyname string) error {
rollover := &types.RolloverAction{}
haveroll := false
if policy != nil {
// update
actions = policy.Phases.Hot.Actions
if policy.Phases.Hot.Actions.Rollover != nil {
rollover = policy.Phases.Hot.Actions.Rollover
haveroll = true
}
}
if cfg.HotMinAge != "" {
hot.MinAge = cfg.HotMinAge
}
@@ -223,7 +373,7 @@ func IlmCreate(conf *cfg.Config, policyname string) error {
haveroll = true
}
if cfg.HotRolloverMaxPrimaryShardSize != 0 {
if cfg.HotRolloverMaxPrimaryShardSize != "" {
rollover.MaxPrimaryShardSize = &cfg.HotRolloverMaxPrimaryShardSize
}
@@ -233,12 +383,22 @@ func IlmCreate(conf *cfg.Config, policyname string) error {
hot.Actions = actions.IlmActionsCaster()
phases.PhasesCaster().Hot = &hot
} else {
if policy != nil {
// update
phases.PhasesCaster().Hot = policy.Phases.Hot
}
}
if cfg.HaveWarm() {
warm := types.Phase{}
var actions types.IlmActionsVariant = esdsl.NewIlmActions()
if policy != nil {
// update
actions = policy.Phases.Warm.Actions
}
if cfg.WarmForceMerge != 0 {
actions.IlmActionsCaster().Forcemerge = &types.ForceMergeAction{MaxNumSegments: cfg.WarmForceMerge}
}
@@ -257,21 +417,85 @@ func IlmCreate(conf *cfg.Config, policyname string) error {
warm.Actions = actions.IlmActionsCaster()
phases.PhasesCaster().Warm = &warm
} else {
if policy != nil && policy.Phases.Warm != nil {
// update
phases.PhasesCaster().Warm = policy.Phases.Warm
}
}
if cfg.HaveCold() {
cold := types.Phase{}
var actions types.IlmActionsVariant = esdsl.NewIlmActions()
if policy != nil {
// update
actions = policy.Phases.Cold.Actions
}
if cfg.ColdForceMerge != 0 {
actions.IlmActionsCaster().Forcemerge = &types.ForceMergeAction{MaxNumSegments: cfg.ColdForceMerge}
}
if cfg.ColdMinAge != "" {
cold.MinAge = cfg.ColdMinAge
}
if cfg.ColdPriority != 0 {
actions.IlmActionsCaster().SetPriority = &types.SetPriorityAction{Priority: &cfg.ColdPriority}
}
if cfg.ColdSearchableSnapshotRepo != "" {
actions.IlmActionsCaster().SearchableSnapshot =
&types.SearchableSnapshotAction{SnapshotRepository: cfg.ColdSearchableSnapshotRepo}
}
cold.Actions = actions.IlmActionsCaster()
phases.PhasesCaster().Cold = &cold
} else {
if policy != nil && policy.Phases.Cold != nil {
// update
phases.PhasesCaster().Cold = policy.Phases.Cold
}
}
if cfg.HaveFrozen() {
froze := phases.PhasesCaster().Frozen
froze := types.Phase{}
var actions types.IlmActionsVariant = esdsl.NewIlmActions()
if policy != nil {
// update
actions = policy.Phases.Frozen.Actions
}
if cfg.FrozenMinAge != "" {
froze.MinAge = cfg.FrozenMinAge
}
if cfg.FrozenSearchableSnapshotRepo != "" {
actions.IlmActionsCaster().SearchableSnapshot =
&types.SearchableSnapshotAction{SnapshotRepository: cfg.FrozenSearchableSnapshotRepo}
}
froze.Actions = actions.IlmActionsCaster()
phases.PhasesCaster().Frozen = &froze
} else {
if policy != nil && policy.Phases.Frozen != nil {
// update
phases.PhasesCaster().Frozen = policy.Phases.Frozen
}
}
if cfg.HaveDelete() {
del := phases.PhasesCaster().Delete
del := types.Phase{}
delete := types.DeleteAction{}
var actions types.IlmActionsVariant = esdsl.NewIlmActions()
if policy != nil {
// update
actions = policy.Phases.Delete.Actions
}
if cfg.DeleteMinAge != "" {
del.MinAge = cfg.DeleteMinAge
}
@@ -282,17 +506,22 @@ func IlmCreate(conf *cfg.Config, policyname string) error {
actions.IlmActionsCaster().Delete = &delete
del.Actions = actions.IlmActionsCaster()
phases.PhasesCaster().Delete = del
phases.PhasesCaster().Delete = &del
} else {
if policy != nil && policy.Phases.Delete != nil {
// update
phases.PhasesCaster().Delete = policy.Phases.Delete
}
}
put := &putlifecycle.Request{}
policy := &types.IlmPolicy{}
policy.IlmPolicyCaster().Phases = *phases.PhasesCaster()
put.Policy = policy
newpolicy := &types.IlmPolicy{}
newpolicy.IlmPolicyCaster().Phases = *phases.PhasesCaster()
put.Policy = newpolicy
ilm.Request(put)
_, err := ilm.Do(context.Background())
_, err = ilm.Do(context.Background())
if err != nil {
return fmt.Errorf("failed create ilm policy: %s", esErrorString(err))
}

391
pkg/es/ilm_forecast.go Normal file
View File

@@ -0,0 +1,391 @@
/*
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"
"regexp"
"strconv"
"strings"
"sync"
"time"
"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/ilm/explainlifecycle"
"github.com/elastic/go-elasticsearch/v9/typedapi/ilm/getlifecycle"
"github.com/elastic/go-elasticsearch/v9/typedapi/types"
)
var (
IlmPhaseOrder = []string{"hot", "warm", "cold", "frozen", "delete"}
)
type NextPhase struct {
minage time.Duration
minsize int64
phase string
previousHot bool
}
type PhaseData struct {
index string
policy string
size int64
age time.Duration
minage time.Duration
minsize int64
currentPhase, nextPhase string
}
/*
ilm stages:
types.Lifecycle{
Policy: types.IlmPolicy{
Phases: types.Phases{
Hot: &types.Phase{
Actions: &types.IlmActions{
Rollover: &types.RolloverAction{
MaxAge: "7d",
MaxPrimaryShardSize: "25gb",
},
},
MinAge: "0ms",
},
Warm: &types.Phase{
MinAge: "0d",
},
Frozen: &types.Phase{
MinAge: "14d",
},
Delete: &types.Phase{
MinAge: "60d",
},
},
},
}
hot for 7 days or 25Gig, then Rollover => warm 14 days => frozen 60 days => delete
*/
func IlmForecastList(conf *cfg.Config, filter string) error {
phaseData, err := getIlmPhaseData(conf)
if err != nil {
return err
}
flt := regexp.MustCompile(filter)
minage := time.Duration(0)
if conf.Ilm.MinAge != "" {
minage = parseDuration(conf.Ilm.MinAge)
}
headers := []string{"index", "current size", "current age",
"virtual age", "min age", "min size", "current phase", "next phase"}
if conf.Verbose {
headers = append(headers, "ilm policy")
}
table := printer.NewTable(conf, len(headers), 0)
table.Addheaders(headers...)
for _, phase := range phaseData {
if filter != "" && !flt.MatchString(phase.index) {
continue
}
if conf.Ilm.MinAge != "" && phase.age < minage {
continue
}
virtualAge := virtualAge(&phase)
row := []string{
phase.index,
humanize.Bytes(uint64(phase.size)),
formatDuration(phase.age),
formatDuration(virtualAge),
formatDuration(phase.minage),
humanize.Bytes(uint64(phase.minsize)),
phase.currentPhase,
phase.nextPhase,
}
if conf.Verbose {
row = append(row, phase.policy)
}
table.AddRow(row...)
}
table.Sort()
return table.Print()
}
// Loop over all current index phases of the current phase
// (conf.Ilm.FromPhase). Calculate virtualAge if phase.minsize >0. If
// the actual age is smaller than the virtual age, use this as a base,
// otherwise use virtual age. Then look if the rollover would happen
// within the phase.minage window (conf.Ilm.Within + current age) and
// add the size.
func IlmForecastShow(conf *cfg.Config) error {
phaseData, err := getIlmPhaseData(conf)
if err != nil {
return err
}
within := parseDuration(conf.Ilm.Within)
var toBeFreed int64 = 0
for _, phase := range phaseData {
age := virtualAge(&phase)
if age < phase.age {
age = phase.age
}
if age+within >= phase.minage {
toBeFreed += phase.size
}
}
fmt.Printf("%s bytes of data in %s phase will be rolled within %s to the next phase\n",
humanize.Bytes(uint64(toBeFreed)),
conf.Ilm.FromPhase,
within)
return nil
}
func virtualAge(phase *PhaseData) time.Duration {
if phase.minsize == 0 {
return time.Duration(0)
}
return phase.minage * time.Duration(100*phase.size/phase.minsize) / 100
}
// Retrieve all index, ilm-explain and ilm-policies in parallel
func getIlmPhaseData(conf *cfg.Config) ([]PhaseData, error) {
responses := make(chan apiResponse, 3)
wg := &sync.WaitGroup{}
wg.Add(3)
go getApiData(conf, conf.DefaultCluster.ES(), wg, responses, "indicesbytes")
go getApiData(conf, conf.DefaultCluster.ES(), wg, responses, "explain")
go getApiData(conf, conf.DefaultCluster.ES(), wg, responses, "policies")
wg.Wait()
var ilmdetails *explainlifecycle.Response
var indicesres *indices.Response
var ilmpolicies getlifecycle.Response
for i := 0; i < 3; i++ {
r := <-responses
if r.error != nil {
return nil, r.error
}
switch r.which {
case ResponseExplain:
ilmdetails = r.explainlifecycle
case ResponseIndices:
indicesres = r.indicesbytes
case ResponseLifecycle:
ilmpolicies = *r.lifecycle
}
}
list := []PhaseData{}
indices := make(map[string]types.IndicesRecord)
for _, index := range *indicesres {
indices[*index.Index] = index
}
for indexName, explain := range ilmdetails.Indices {
if !conf.Hidden && strings.HasPrefix(indexName, ".") {
continue
}
// avoid types.LifecycleExplainUnmanaged casting error
switch explain.(type) {
case *types.LifecycleExplainManaged:
default:
continue
}
explain := explain.(*types.LifecycleExplainManaged)
if explain.Phase == nil {
continue
}
phase := *explain.Phase
if phase != conf.Ilm.FromPhase {
continue
}
size, err := strconv.ParseInt(*indices[indexName].DatasetSize, 10, 64)
if err != nil {
return nil, fmt.Errorf("failed to convert index.DatasetSize to int: %w", err)
}
age := time.Duration(*explain.AgeInMillis * int64(time.Millisecond))
policy := ilmpolicies[*explain.Policy].Policy
nextPhase := findNextPhase(policy, phase)
if nextPhase == nil {
continue
}
list = append(list, PhaseData{
index: indexName,
policy: *explain.Policy,
size: size,
age: age,
minage: nextPhase.minage,
minsize: nextPhase.minsize,
currentPhase: conf.Ilm.FromPhase,
nextPhase: nextPhase.phase,
})
}
return list, nil
}
func registerPhases(policy types.IlmPolicy, currentPhase string) (map[string]*types.Phase, int) {
// register phases configured in the policy
phases := map[string]*types.Phase{
"hot": nil,
"warm": nil,
"cold": nil,
"frozen": nil,
"delete": nil,
}
// looking for a defined previous phase
start := 0
// register phases
for idx, phase := range IlmPhaseOrder {
switch phase {
case "hot":
if policy.Phases.Hot != nil {
phases[phase] = policy.Phases.Hot
}
case "warm":
if policy.Phases.Warm != nil {
phases[phase] = policy.Phases.Warm
}
case "cold":
if policy.Phases.Cold != nil {
phases[phase] = policy.Phases.Cold
}
case "frozen":
if policy.Phases.Frozen != nil {
phases[phase] = policy.Phases.Frozen
}
case "delete":
if policy.Phases.Delete != nil {
phases[phase] = policy.Phases.Delete
}
}
if phase == currentPhase {
start = idx
}
}
return phases, start
}
func findNextPhase(policy types.IlmPolicy, currentPhase string) *NextPhase {
// phase list to determine which comes next
phases, start := registerPhases(policy, currentPhase)
nextPhase := &NextPhase{}
// finally determine which phase comes next
// exception: hot, where we look for rollover rules
for idx, phase := range IlmPhaseOrder {
if idx < start {
continue
}
if phase == "hot" {
// if we're starting here, do not look for the next phase
maxage := parseDuration(phases[phase].Actions.Rollover.MaxAge.(string))
var maxsize int64 = 0
switch val := phases[phase].Actions.Rollover.MaxPrimaryShardSize.(type) {
case int64:
maxsize = val
case string:
size, err := humanize.ParseBytes(val)
if err != nil {
panic(err)
}
maxsize = int64(size)
}
nextPhase.minage = maxage
nextPhase.minsize = maxsize
nextPhase.previousHot = true
continue
}
if phase == currentPhase {
continue // use the next one
}
if phases[phase] == nil {
continue // skip it, since empty
}
nextPhase.phase = phase
if nextPhase.previousHot {
return nextPhase
}
minage := parseDuration(phases[phase].MinAge.(string))
nextPhase.minage = minage
nextPhase.minsize = 0
return nextPhase
}
return nil
}

View File

@@ -35,9 +35,7 @@ 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 {
@@ -55,11 +53,16 @@ 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 !conf.Hidden && strings.HasPrefix(*index.Index, ".") {
continue
}
if strings.HasPrefix(*index.Index, ".ds-") {
// ignore data stream backing indicies
continue
@@ -86,10 +89,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)
@@ -130,15 +130,14 @@ func IndexList(conf *cfg.Config) error {
}
func IndexShow(conf *cfg.Config, indexpattern string) error {
res, err := conf.DefaultCluster.ES.Indices.Get(indexpattern).
// we need to add custom request headers, required for older ES instances
Header("content-type", "application/json").
Header("accept", "application/json").
res, err := conf.DefaultCluster.ES().Indices.Get(indexpattern).
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to get index: %s", esErrorString(err))
}
slog.Debug("index show", "index", res)
for name, index := range res {
fields := make([]string, len(index.Mappings.Properties))
idx := 0
@@ -147,7 +146,7 @@ func IndexShow(conf *cfg.Config, indexpattern string) error {
idx++
}
table := printer.NewTable(conf, 2, 5)
table := printer.NewTable(conf, 2, 7)
table.Addheaders("index property", "value")
ts, err := strconv.ParseInt(index.Settings.Index.CreationDate.(string), 10, 64)
@@ -163,9 +162,17 @@ func IndexShow(conf *cfg.Config, indexpattern string) error {
{"shards", *index.Settings.Index.NumberOfShards},
{"created", created.Format("2006-01-02 15:04:05")},
{"uuid", *index.Settings.Index.Uuid},
{"version", *index.Settings.Index.Version.Created},
{"fields", strings.Join(fields, ",")},
}
if index.Settings.Index.Lifecycle != nil {
table.Entries = append(table.Entries, [][]string{
{"ilm policy", *index.Settings.Index.Lifecycle.Name},
{"ilm rollover alias", *index.Settings.Index.Lifecycle.RolloverAlias},
}...)
}
if err := table.Print(); err != nil {
return err
}
@@ -179,19 +186,17 @@ func IndexShow(conf *cfg.Config, indexpattern 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")
}
if conf.Shards > 0 {
settings = settings.NumberOfShards(strconv.Itoa(conf.Shards))
settings.NumberOfShards(strconv.Itoa(conf.Shards))
}
if conf.Replicas > 0 {
settings = settings.NumberOfReplicas(strconv.Itoa(conf.Replicas))
settings.NumberOfReplicas(strconv.Itoa(conf.Replicas))
}
if len(mappings) > 0 {
@@ -218,6 +223,13 @@ func IndexCreate(conf *cfg.Config, index string, mappings []string) error {
create.Mappings(maps)
}
if conf.Policy != "" {
settings.Lifecycle(
esdsl.NewIndexSettingsLifecycle().
Name(conf.Policy).
RolloverAlias(index))
}
_, err := create.Settings(settings).
Do(context.Background())
@@ -229,9 +241,7 @@ func IndexCreate(conf *cfg.Config, index string, mappings []string) error {
}
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", esErrorString(err))
@@ -241,11 +251,8 @@ func IndexDelete(conf *cfg.Config, index string) error {
}
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", esErrorString(err))
@@ -255,12 +262,10 @@ func IndexClose(conf *cfg.Config, index string) error {
}
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", esErrorString(err))
@@ -298,14 +303,13 @@ func IndexAllocation(conf *cfg.Config, index string) error {
return nil
}
/*
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", esErrorString(err))
@@ -313,13 +317,12 @@ func IndexModify(conf *cfg.Config, index string) error {
return nil
}
*/
func IndexFields(conf *cfg.Config, index string) error {
res, err := conf.DefaultCluster.ES.FieldCaps().
res, err := conf.DefaultCluster.ES().FieldCaps().
Index(index).
Fields("*").
Header("content-type", "application/json").
Header("accept", "application/json").
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to retrieve field capabilties: %s", esErrorString(err))

View File

@@ -29,9 +29,7 @@ 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)
@@ -51,10 +49,8 @@ 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 {
@@ -100,9 +96,7 @@ 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)

View File

@@ -33,9 +33,7 @@ import (
// used for completion
func IndexTemplateList(conf *cfg.Config) error {
res, err := conf.DefaultCluster.ES.Indices.GetIndexTemplate().
Header("content-type", "application/json").
Header("accept", "application/json").
res, err := conf.DefaultCluster.ES().Indices.GetIndexTemplate().
Do(context.Background())
if err != nil {
@@ -65,10 +63,8 @@ func IndexTemplateList(conf *cfg.Config) error {
}
func IndexTemplateShow(conf *cfg.Config, tplname string) error {
res, err := conf.DefaultCluster.ES.Indices.GetIndexTemplate().
res, err := conf.DefaultCluster.ES().Indices.GetIndexTemplate().
Name(tplname).
Header("content-type", "application/json").
Header("accept", "application/json").
Do(context.Background())
if err != nil {
@@ -158,16 +154,20 @@ func IndexTemplateCreate(conf *cfg.Config, name string, mappings []string) error
settings := esdsl.NewIndexSettings()
maps := esdsl.NewIndexTemplateMapping()
create := conf.DefaultCluster.ES.Indices.PutIndexTemplate(name).
Header("content-type", "application/json").
Header("accept", "application/json")
create := conf.DefaultCluster.ES().Indices.PutIndexTemplate(name)
if conf.Shards > 0 {
settings = settings.NumberOfShards(strconv.Itoa(conf.Shards))
settings.NumberOfShards(strconv.Itoa(conf.Shards))
}
if conf.Replicas > 0 {
settings = settings.NumberOfReplicas(strconv.Itoa(conf.Replicas))
settings.NumberOfReplicas(strconv.Itoa(conf.Replicas))
}
if conf.Policy != "" {
settings.Lifecycle(
esdsl.NewIndexSettingsLifecycle().
Name(conf.Policy))
}
if conf.Stream {
@@ -243,10 +243,8 @@ func IndexTemplateModify(conf *cfg.Config, name string, mappings []string) error
maps := esdsl.NewIndexTemplateMapping()
// load existing index mapping
res, err := conf.DefaultCluster.ES.Indices.GetIndexTemplate().
res, err := conf.DefaultCluster.ES().Indices.GetIndexTemplate().
Name(name).
Header("content-type", "application/json").
Header("accept", "application/json").
Do(context.Background())
if err != nil {
@@ -262,9 +260,7 @@ func IndexTemplateModify(conf *cfg.Config, name string, mappings []string) error
tpl := res.IndexTemplates[0]
// our modify PUT request
modify := conf.DefaultCluster.ES.Indices.PutIndexTemplate(name).
Header("content-type", "application/json").
Header("accept", "application/json")
modify := conf.DefaultCluster.ES().Indices.PutIndexTemplate(name)
// load existing settings, if any
settings := tpl.IndexTemplate.Template.Settings
@@ -294,6 +290,10 @@ func IndexTemplateModify(conf *cfg.Config, name string, mappings []string) error
*settings.Mode = conf.Mode
}
if conf.Policy != "" {
settings.Lifecycle.Name = &conf.Policy
}
if len(mappings) > 0 {
typemaps, err := modMappings(mappings)
if err != nil {
@@ -355,9 +355,7 @@ func IndexTemplateModify(conf *cfg.Config, name string, mappings []string) error
}
func IndexTemplateDelete(conf *cfg.Config, name string) error {
_, err := conf.DefaultCluster.ES.Indices.DeleteIndexTemplate(name).
Header("content-type", "application/json").
Header("accept", "application/json").
_, err := conf.DefaultCluster.ES().Indices.DeleteIndexTemplate(name).
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to delete index template: %s", esErrorString(err))
@@ -370,7 +368,7 @@ func IndexTemplateDelete(conf *cfg.Config, name string) error {
// 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().
res, err := conf.DefaultCluster.ES().Indices.GetIndexTemplate().
Name(name).
Do(context.Background())
@@ -389,7 +387,7 @@ func rolloverAliasIndexTemplate(conf *cfg.Config, name string) error {
// find all aliases matching the patterns
for _, pattern := range patterns {
res, err := conf.DefaultCluster.ES.Indices.ResolveIndex(pattern).
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))

View File

@@ -28,7 +28,7 @@ type Tpl struct {
}
func getIndexTemplateSettings(conf *cfg.Config, tplname string, table *printer.Table) error {
raw, err := CallAPI(conf, "GET", "/_index_template/"+tplname, "")
raw, err := CallAPI(conf, "GET", "/_index_template/"+tplname+"?flat_settings", "")
if err != nil {
return err
}
@@ -39,9 +39,11 @@ func getIndexTemplateSettings(conf *cfg.Config, tplname string, table *printer.T
}
if conf.Debug {
if err := prettyfiJson(conf, raw); err != nil {
output, err := prettyfiJson(conf, raw)
if err != nil {
return err
}
fmt.Println(output)
}
if len(data.IndexTemplates) == 0 {
@@ -50,11 +52,7 @@ func getIndexTemplateSettings(conf *cfg.Config, tplname string, table *printer.T
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)})
}
table.Entries = append(table.Entries, []string{topic, fmt.Sprintf("%v", val)})
}
return nil

69
pkg/es/license.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 es
import (
"context"
"fmt"
"log/slog"
"codeberg.org/scip/esctl/pkg/cfg"
"codeberg.org/scip/esctl/pkg/printer"
)
func LicenseShow(conf *cfg.Config) error {
res, err := conf.DefaultCluster.ES().License.Get().
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to get license: %s", esErrorString(err))
}
slog.Debug("license show", "license", res)
table := printer.NewTableEmpty(conf).WithHeaders("license setting", "value")
lic := res.License
var maxnodes = "infinite"
var maxunits = "infinite"
var expire = "never"
if lic.MaxNodes != nil {
maxnodes = fmt.Sprintf("%d", *lic.MaxNodes)
}
if lic.ExpiryDate != nil {
expire = lic.ExpiryDate.(string)
}
if lic.MaxResourceUnits != nil {
maxunits = fmt.Sprintf("%d", *lic.MaxResourceUnits)
}
table.Entries = [][]string{
{"UID", lic.Uid},
{"Issued to", lic.IssuedTo},
{"Expires", expire},
{"Issued", lic.IssueDate.(string)},
{"Max nodes", maxnodes},
{"Max resource units", maxunits},
{"Type", lic.Type.Name},
{"Status", lic.Status.Name},
}
return table.Print()
}

View File

@@ -20,14 +20,17 @@ import (
"context"
"fmt"
"log/slog"
"strings"
"time"
"codeberg.org/scip/esctl/pkg/cfg"
"codeberg.org/scip/esctl/pkg/printer"
"github.com/dustin/go-humanize"
)
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", esErrorString(err))
}
@@ -56,3 +59,145 @@ func NodeList(conf *cfg.Config) error {
return nil
}
func NodeNames(conf *cfg.Config) ([]string, error) {
nodes, err := conf.DefaultCluster.ES().Cat.Nodes().
Do(context.Background())
if err != nil {
return nil, fmt.Errorf("failed to get nodes: %s", esErrorString(err))
}
slog.Debug("ES result", "nodes", nodes)
nodelist := make([]string, len(nodes))
for idx, node := range nodes {
nodelist[idx] = *node.Name
}
return nodelist, err
}
// FIXME: adding the settings metric leads to json unmarshall error:
// https://github.com/elastic/go-elasticsearch/issues/1524
func NodeShow(conf *cfg.Config, nodename string) error {
res, err := conf.DefaultCluster.ES().Nodes.Info().
NodeId(nodename).
Metric("os, jvm, thread_pool, remote_cluster_server").
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to get node info: %s", esErrorString(err))
}
slog.Debug("ES result", "node", res)
stats, err := conf.DefaultCluster.ES().Nodes.Stats().
NodeId(nodename).
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to get node stats: %s", esErrorString(err))
}
slog.Debug("ES result", "stat", stats)
for id, info := range res.Nodes {
stat := stats.Nodes[id]
table := printer.NewTable(conf, 2, 0)
table.Addheaders(nodename+" property", "value")
roles := make([]string, len(info.Roles))
for idx, role := range info.Roles {
roles[idx] = role.Name
}
k8snode := info.Attributes["k8s_node_name"]
table.Entries = [][]string{
{"Id", id},
{"Name", nodename},
{"Kubernetes node", k8snode},
{"Ip address", info.Ip},
{"Node rank", *stat.AdaptiveSelection[id].Rank},
{"JVM", info.Jvm.VmName + " " + info.Jvm.Version},
{"JVM Started", time.UnixMilli(info.Jvm.StartTimeInMillis).String()},
{"OS", info.Os.PrettyName + " " + info.Os.Version},
{"Node roles", strings.Join(roles, ",")},
{"Node version", info.Version},
{"HTTP clients", fmt.Sprintf("%d", *stat.Http.CurrentOpen)},
{"CPUs", fmt.Sprintf("%d", *info.Os.AllocatedProcessors)},
{"Load 15m/5m/1m", fmt.Sprintf("%.2f/%.2f/%.2f",
stat.Os.Cpu.LoadAverage["15m"],
stat.Os.Cpu.LoadAverage["5m"],
stat.Os.Cpu.LoadAverage["1m"],
)},
{"Open FD's", fmt.Sprintf("%d", *stat.Process.OpenFileDescriptors)},
{"Response time avg", fmt.Sprintf("%dns", *stat.AdaptiveSelection[id].AvgResponseTimeNs)},
{"Memory usage (used/avail)",
humanize.Bytes(uint64(*stat.Os.Mem.UsedInBytes)) + " / " + humanize.Bytes(uint64(*stat.Os.Mem.TotalInBytes))},
}
if len(stat.Fs.Data) > 0 {
fs := stat.Fs.Data[0]
table.Entries = append(table.Entries, [][]string{
{"Storage usage (used/avail)",
humanize.Bytes(uint64(*fs.AvailableInBytes)) + " / " + humanize.Bytes(uint64(*fs.TotalInBytes))},
{"Storage mount", *fs.Mount},
}...)
}
if err := table.Print(); err != nil {
return err
}
}
return nil
}
func NodeClients(conf *cfg.Config, nodename string) error {
stats, err := conf.DefaultCluster.ES().Nodes.Stats().
NodeId(nodename).
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to get node stats: %s", esErrorString(err))
}
slog.Debug("ES result", "stat", stats)
table := printer.NewTable(conf, 5, 0)
table.Addheaders("agent", "id", "when", "from host", "url")
for _, stat := range stats.Nodes {
for _, client := range stat.Http.Clients {
if client.ClosedTimeMillis == nil {
agent := ""
if client.Agent != nil {
agent = *client.Agent
}
uri := ""
if client.LastUri != nil {
uri = *client.LastUri
if !conf.All {
parts := strings.Split(uri, "?")
uri = parts[0]
}
}
table.AddRow(
agent,
fmt.Sprintf("%d", *client.Id),
time.UnixMilli(*client.LastRequestTimeMillis).String(),
*client.RemoteAddress,
uri,
)
}
}
break
}
table.Sort()
return table.Print()
}

165
pkg/es/parallel.go Normal file
View File

@@ -0,0 +1,165 @@
/*
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"
"sync"
"codeberg.org/scip/esctl/pkg/cfg"
"github.com/elastic/go-elasticsearch/v9"
"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"
"github.com/elastic/go-elasticsearch/v9/typedapi/core/info"
"github.com/elastic/go-elasticsearch/v9/typedapi/ilm/explainlifecycle"
"github.com/elastic/go-elasticsearch/v9/typedapi/ilm/getlifecycle"
"github.com/elastic/go-elasticsearch/v9/typedapi/types/enums/bytes"
)
const (
ResponseHealth = iota
ResponseInfo
ResponseCcr
ResponseStats
ResponseIndices
ResponseTasks
ResponseExplain
ResponseLifecycle
ResponseHealthReport
)
type apiResponse struct {
error error
info *info.Response
health *health.Response
healthreport *HealthReport
ccr *stats.Response
stats *clusterstats.Response
indices *indices.Response
indicesbytes *indices.Response
tasks *tasks.Response
lifecycle *getlifecycle.Response
explainlifecycle *explainlifecycle.Response
which int
}
func getApiData(
conf *cfg.Config,
es *elasticsearch.TypedClient,
wg *sync.WaitGroup,
reschan chan apiResponse,
which string) {
defer wg.Done()
ar := apiResponse{}
var arerr error
switch which {
case "health":
res, err := es.Cluster.Health().
Do(context.Background())
ar.health = res
ar.which = ResponseHealth
arerr = err
case "healthreport":
report, err := getHealthReport(conf)
ar.healthreport = report
ar.which = ResponseHealthReport
arerr = err
case "info":
res, err := es.Info().
Do(context.Background())
ar.info = res
ar.which = ResponseInfo
arerr = err
case "ccr":
res, err := es.Ccr.Stats().
Do(context.Background())
ar.ccr = res
ar.which = ResponseCcr
arerr = err
case "stats":
res, err := es.Cluster.Stats().
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 "indicesbytes":
res, err := es.Cat.
Indices().
Bytes(bytes.Bytes{Name: "b"}).
Do(context.Background())
ar.indicesbytes = &res
ar.which = ResponseIndices
arerr = err
case "tasks":
res, err := es.Cat.Tasks().
Do(context.Background())
ar.tasks = &res
ar.which = ResponseTasks
arerr = err
case "explain":
res, err := es.Ilm.
ExplainLifecycle("_all").
Do(context.Background())
ar.explainlifecycle = res
ar.which = ResponseExplain
arerr = err
case "policies":
res, err := es.Ilm.
GetLifecycle().
Do(context.Background())
ar.lifecycle = &res
ar.which = ResponseLifecycle
arerr = err
}
if arerr != nil {
ar.error = fmt.Errorf("failed to get data from API: %s", arerr)
}
reschan <- ar
}

View File

@@ -28,7 +28,7 @@ import (
)
func RoleNames(conf *cfg.Config) ([]string, error) {
res, err := conf.DefaultCluster.ES.Security.GetRole().
res, err := conf.DefaultCluster.ES().Security.GetRole().
Do(context.Background())
if err != nil {
return nil, fmt.Errorf("failed to get roles: %s", esErrorString(err))
@@ -45,7 +45,7 @@ func RoleNames(conf *cfg.Config) ([]string, error) {
}
func RoleList(conf *cfg.Config) error {
res, err := conf.DefaultCluster.ES.Security.GetRole().
res, err := conf.DefaultCluster.ES().Security.GetRole().
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to get roles: %s", esErrorString(err))
@@ -71,7 +71,7 @@ func RoleList(conf *cfg.Config) error {
}
func RoleShow(conf *cfg.Config, rolename string) error {
res, err := conf.DefaultCluster.ES.Security.GetRole().
res, err := conf.DefaultCluster.ES().Security.GetRole().
Name(rolename).
Do(context.Background())
if err != nil {

View File

@@ -207,7 +207,7 @@ func RoleDiff(conf *cfg.Config, csvfile, role string) error {
return RoleDiffSingle(conf, csvfile, role)
}
res, err := conf.DefaultCluster.ES.Security.GetRole().
res, err := conf.DefaultCluster.ES().Security.GetRole().
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to get roles: %s", esErrorString(err))
@@ -242,7 +242,7 @@ func RoleDiff(conf *cfg.Config, csvfile, role string) error {
}
func getRoleMappingGroups(conf *cfg.Config, rolename string) ([]string, error) {
mappings, err := conf.DefaultCluster.ES.Security.GetRoleMapping().
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))
@@ -275,7 +275,7 @@ func compareSlices(name string, a, b []string) {
}
func RoleDiffSingle(conf *cfg.Config, csvfile, rolename string) error {
res, err := conf.DefaultCluster.ES.Security.GetRole().
res, err := conf.DefaultCluster.ES().Security.GetRole().
Name(rolename).
Do(context.Background())
if err != nil {

View File

@@ -47,7 +47,7 @@ func RolloverConditions(conf *cfg.Config) types.RolloverConditionsVariant {
}
func RolloverAlias(conf *cfg.Config, alias string) (*rollover.Response, error) {
roll := conf.DefaultCluster.ES.Indices.Rollover(alias)
roll := conf.DefaultCluster.ES().Indices.Rollover(alias)
if conf.Wait {
roll.WaitForActiveShards("all")

View File

@@ -26,7 +26,6 @@ import (
"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"
@@ -50,7 +49,7 @@ func Search(conf *cfg.Config, queries []string) error {
return validateSearch(conf, queries)
}
searchEs := conf.DefaultCluster.ES.Search().Index(conf.Index)
searchEs := conf.DefaultCluster.ES().Search().Index(conf.Index)
queryCaster, err := prepareQuery(conf, queries)
if err != nil {
@@ -121,7 +120,7 @@ func explain(res *types.ExplanationDetail, indent string) {
}
func validateSearch(conf *cfg.Config, queries []string) error {
validate := conf.DefaultCluster.ES.Indices.ValidateQuery()
validate := conf.DefaultCluster.ES().Indices.ValidateQuery()
queryCaster, err := prepareQuery(conf, queries)
if err != nil {
@@ -149,25 +148,6 @@ func validateSearch(conf *cfg.Config, queries []string) error {
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).
@@ -187,18 +167,18 @@ func searchOnce(conf *cfg.Config, search *search.Search) error {
// 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)
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)
_, 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().
search := conf.DefaultCluster.ES().Search().
Request(req).
Pit(esdsl.NewPointInTimeReference().
Id(pit.Id).

View File

@@ -46,7 +46,7 @@ type filter struct {
}
// Build a new filter object. We use this to build our elastic query
// out of it. We support differnt types of queries:
// out of it. We support different types of queries:
//
// - nop filter: no query at all, just return the first N documents.
//
@@ -243,7 +243,7 @@ func addFilters(conf *cfg.Config) ([]types.QueryVariant, error) {
// 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().
res, err := conf.DefaultCluster.ES().FieldCaps().
Index(conf.Index).
Fields(conf.SortBy).
Do(context.Background())

View File

@@ -83,9 +83,7 @@ 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", esErrorString(err))
@@ -135,9 +133,7 @@ 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", esErrorString(err))

View File

@@ -43,7 +43,7 @@ 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", esErrorString(err))
}
@@ -56,7 +56,7 @@ 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", esErrorString(err))
}
@@ -106,7 +106,7 @@ 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", esErrorString(err))
}

View File

@@ -28,9 +28,7 @@ import (
)
func TaskList(conf *cfg.Config) error {
res, err := conf.DefaultCluster.ES.Cat.Tasks().
Header("content-type", "application/json").
Header("accept", "application/json").
res, err := conf.DefaultCluster.ES().Cat.Tasks().
Do(context.Background())
if err != nil {
@@ -65,10 +63,8 @@ func TaskList(conf *cfg.Config) error {
}
func TaskCancel(conf *cfg.Config, taskid string) error {
_, err := conf.DefaultCluster.ES.Tasks.Cancel().
_, err := conf.DefaultCluster.ES().Tasks.Cancel().
TaskId(taskid).
Header("content-type", "application/json").
Header("accept", "application/json").
Do(context.Background())
if err != 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

@@ -52,6 +52,6 @@ func PrintDoc(conf *cfg.Config, hit types.Hit) {
value := gjson.Get(docjson, conf.Path)
fmt.Println(value.String())
} else {
fmt.Print(docjson)
fmt.Println(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

@@ -28,6 +28,7 @@ import (
"github.com/olekukonko/tablewriter"
"github.com/olekukonko/tablewriter/renderer"
"github.com/olekukonko/tablewriter/tw"
"github.com/seeruk/go-wordwrap"
"gopkg.in/yaml.v3"
)
@@ -37,18 +38,39 @@ type Table struct {
Entries [][]string
lenHeaders []int
alignInts bool
maxwidth int
}
func NewTable(conf *cfg.Config, columns, rows int) *Table {
table := Table{Mode: conf.Output}
table := Table{Mode: conf.Output, maxwidth: cfg.GetTermWidth()}
table.Headers = make([]string, columns)
table.Entries = make([][]string, rows)
table.lenHeaders = make([]int, columns)
table.alignInts = conf.AlignInts
return &table
}
func NewTableEmpty(conf *cfg.Config) *Table {
table := Table{Mode: conf.Output, maxwidth: cfg.GetTermWidth()}
table.alignInts = conf.AlignInts
return &table
}
func (table *Table) WithHeaders(headers ...string) *Table {
count := len(headers)
table.Entries = [][]string{}
table.lenHeaders = make([]int, count)
table.Headers = make([]string, count)
table.Addheaders(headers...)
return table
}
func (data *Table) Print() error {
switch data.Mode {
case "markdown", "md":
@@ -116,17 +138,31 @@ func (data *Table) PrintTSV() error {
}
for _, entries := range data.Entries {
currentWidth := 0
for idx, entry := range entries {
length := visibleLen(entry)
if data.lenHeaders[idx] < length {
if length > currentWidth+data.maxwidth {
data.lenHeaders[idx] = data.maxwidth - currentWidth
} else {
data.lenHeaders[idx] = length
}
}
currentWidth += data.lenHeaders[idx]
}
}
// output
// output headers
for idx, header := range data.Headers {
if idx+1 != len(data.Headers) {
fmt.Print(header, strings.Repeat(" ", data.lenHeaders[idx]-visibleLen(header)))
} else {
// no padding for last header
fmt.Print(header)
}
if idx < len(data.Headers)-1 {
fmt.Print(" ")
}
@@ -134,14 +170,37 @@ func (data *Table) PrintTSV() error {
fmt.Println()
for _, entries := range data.Entries {
currentWidth := 0
for idx, entry := range entries {
length := visibleLen(entry)
if isInt(entry) {
if length+currentWidth > data.maxwidth && data.maxwidth-currentWidth > 1 {
// text is too wide to be put into one line, wrap it
wrapper := wordwrap.Wrapper(data.maxwidth-currentWidth, false)
wrapped := wrapper(entry)
// and indent it
for idx, line := range strings.Split(wrapped, "\n") {
if idx == 0 {
entry = line
} else {
entry += "\n " + strings.Repeat(" ", currentWidth) + line
}
}
}
currentWidth += data.lenHeaders[idx]
if isInt(entry) && data.alignInts {
// align right
fmt.Print(strings.Repeat(" ", data.lenHeaders[idx]-length), entry)
} else {
} else if length < data.lenHeaders[idx] && idx+1 != len(entries) {
// pad right, if required
fmt.Print(entry, strings.Repeat(" ", data.lenHeaders[idx]-length))
} else {
// no padding for last entry
fmt.Print(entry)
}
if idx < len(data.Headers)-1 {

6
t/.env Normal file
View File

@@ -0,0 +1,6 @@
ES_PASS=tuscador1
STACK_VERSION=9.4.2
CLUSTER_NAME=domdoc
LICENSE=basic
ES_PORT=9200
MEM_LIMIT=1073741824

46
t/Makefile Normal file
View File

@@ -0,0 +1,46 @@
.PHONY: test clean cluster docs search up down delete
# docker stuff
up:
@echo "booting up docker containers"
docker compose up -d --remove-orphans
waitup:
@echo "wait til es cluster is up and running"
mosscap msc-actions.yaml msc-docker.yaml -n -t 300
down:
@echo "shutting down docker containers"
docker compose down
delete:
@echo "delete docker volumes"
docker volume rm t_certs t_esdata01 t_esdata02 t_esdata03
clean-docker: down delete
# mosscap esctl stuff
test: up waitup cluster docs wait search
cluster:
@echo "setting up elastisearch cluster"
mosscap msc-actions.yaml msc-cluster.yaml -n
docs:
@echo "put some docs"
mosscap msc-actions.yaml msc-docs.yaml -n -c 1000 -p 100
wait:
sleep 10
search:
@echo "make some searches"
mosscap msc-actions.yaml msc-search.yaml -n -t 60
clean:
@echo "cleaning up cluster"
mosscap msc-actions.yaml msc-clean.yaml -n
rm -f *.state debug.log

76
t/README.md Normal file
View File

@@ -0,0 +1,76 @@
# Testing
Building regular unit tests would require to create a mock
elasticsearch cluster, which is too much a chrore for a one man
show. Running a regular elasticsearch cluster on Codeberg CI is not
economically reasonable.
Therefore I run tests manually. Here's how.
# Dependencies
The following is required:
- linux, any distro will do
- docker in a decent version
- [mosscap](https://codeberg.org/scip/mosscap) version `0.0.17` or higher.
# Docker
There's a ready to use docker compose file, which fires up an
elasticsearch cluster with 3 nodes. To start it manually just execute
`make up`.
When it's up and running it should look like this:
```console
NAMES STATUS PORTS
t-es03-1 Up 16 minutes (healthy) 9200/tcp, 9300/tcp
t-es02-1 Up 16 minutes (healthy) 9200/tcp, 9300/tcp
t-es01-1 Up 16 minutes (healthy) 0.0.0.0:9200->9200/tcp, [::]:9200->9200/tcp, 9300/tcp
```
There's an esctl config file `cluster.yaml`, which you can use to
access that elasticsearch cluster, e.g.:
```console
esctl -c cluster.yaml cluster status
DOCKER/DOMDOC STATUS
Cluster Name domdoc
ES Status green
ES Version 9.4.2
Is Leader false
Active Shards 8
Active Primary Shards 4
Unassigned Shards 0
Unassigned Primary Shards 0
Pending Tasks 0
Nodes 3
Red Indices 0
Long Running Tasks 0
```
# Automated Tests
To execute the automated tests, just execute `make test`. This boots
up the elasticsearch containers, waits until they are up and running,
creates data on it and queries it.
# Cleanup
To just clean up the elasticsearch cluster run `make clean`.
To remove everything, run `make clean-docker`.
# Reference
## Mosscap configs
| CONFIG | DESCRIPTION |
|------------------|----------------------------------------------------------------------|
| msc-actions.yaml | contains all actions, used by all item configs |
| msc-docker.yaml | single item: used to wait until elasticsearch containers are healthy |
| msc-cluster.yaml | single item: create indices etc |
| msc-docs.yaml | generator items: put docs into the index |
| msc-search.yaml | single item: do some searches |
| msc-clean.yaml | single item: clean up the elasticsearch cluster |

7
t/cluster.yaml Normal file
View File

@@ -0,0 +1,7 @@
# esctl config
clusters:
docker/domdoc:
uri: https://elastic:9200
user: elastic
token: ""
default: false

207
t/docker-compose.yaml Normal file
View File

@@ -0,0 +1,207 @@
services:
setup:
image: docker.elastic.co/elasticsearch/elasticsearch:${STACK_VERSION}
volumes:
- certs:/usr/share/elasticsearch/config/certs
user: "0"
command: >
bash -c '
if [ x${ES_PASS} == x ]; then
echo "Set the ES_PASS environment variable in the .env file";
exit 1;
fi;
if [ ! -f config/certs/ca.zip ]; then
echo "Creating CA";
bin/elasticsearch-certutil ca --silent --pem -out config/certs/ca.zip;
unzip config/certs/ca.zip -d config/certs;
fi;
if [ ! -f config/certs/certs.zip ]; then
echo "Creating certs";
echo -ne \
"instances:\n"\
" - name: es01\n"\
" dns:\n"\
" - es01\n"\
" - localhost\n"\
" ip:\n"\
" - 127.0.0.1\n"\
" - name: es02\n"\
" dns:\n"\
" - es02\n"\
" - localhost\n"\
" ip:\n"\
" - 127.0.0.1\n"\
" - name: es03\n"\
" dns:\n"\
" - es03\n"\
" - localhost\n"\
" ip:\n"\
" - 127.0.0.1\n"\
> config/certs/instances.yml;
bin/elasticsearch-certutil cert --silent --pem -out config/certs/certs.zip --in config/certs/instances.yml --ca-cert config/certs/ca/ca.crt --ca-key config/certs/ca/ca.key;
unzip config/certs/certs.zip -d config/certs;
fi;
echo "Setting file permissions"
chown -R root:root config/certs;
find . -type d -exec chmod 750 \{\} \;;
find . -type f -exec chmod 640 \{\} \;;
echo "Waiting for Elasticsearch availability";
until curl -s --cacert config/certs/ca/ca.crt https://es01:9200 | grep -q "missing authentication credentials"; do sleep 30; done;
echo "All done!";
'
healthcheck:
test: ["CMD-SHELL", "[ -f config/certs/es01/es01.crt ]"]
interval: 1s
timeout: 5s
retries: 120
es01:
depends_on:
setup:
condition: service_healthy
image: docker.elastic.co/elasticsearch/elasticsearch:${STACK_VERSION}
volumes:
- certs:/usr/share/elasticsearch/config/certs
- esdata01:/usr/share/elasticsearch/data
ports:
- ${ES_PORT}:9200
environment:
- node.name=es01
- cluster.name=${CLUSTER_NAME}
- cluster.initial_master_nodes=es01
- discovery.seed_hosts=es02
- ELASTIC_PASSWORD=${ES_PASS}
- bootstrap.memory_lock=true
- xpack.security.enabled=true
- xpack.security.http.ssl.enabled=true
- xpack.security.http.ssl.key=certs/es01/es01.key
- xpack.security.http.ssl.certificate=certs/es01/es01.crt
- xpack.security.http.ssl.certificate_authorities=certs/ca/ca.crt
- xpack.security.transport.ssl.enabled=true
- xpack.security.transport.ssl.key=certs/es01/es01.key
- xpack.security.transport.ssl.certificate=certs/es01/es01.crt
- xpack.security.transport.ssl.certificate_authorities=certs/ca/ca.crt
- xpack.security.transport.ssl.verification_mode=certificate
- xpack.license.self_generated.type=${LICENSE}
- xpack.ml.use_auto_machine_memory_percent=true
- node_roles=data_content
- xpack.searchable.snapshot.shared_cache.size="1gb"
- cluster.routing.allocation.disk.threshold_enabled=false
- cluster.routing.allocation.disk.watermark.low="2gb"
- cluster.routing.allocation.disk.watermark.high="1gb"
- cluster.routing.allocation.disk.watermark.flood_stage="500mb"
mem_limit: ${MEM_LIMIT}
ulimits:
memlock:
soft: -1
hard: -1
healthcheck:
test:
[
"CMD-SHELL",
"curl -s --cacert config/certs/ca/ca.crt https://localhost:9200 | grep -q 'missing authentication credentials'",
]
interval: 10s
timeout: 10s
retries: 120
es02:
depends_on:
- es01
image: docker.elastic.co/elasticsearch/elasticsearch:${STACK_VERSION}
volumes:
- certs:/usr/share/elasticsearch/config/certs
- esdata02:/usr/share/elasticsearch/data
environment:
- node.name=es02
- cluster.name=${CLUSTER_NAME}
- cluster.initial_master_nodes=es01,es02
- discovery.seed_hosts=es01
- ELASTIC_PASSWORD=${ES_PASS}
- bootstrap.memory_lock=true
- xpack.security.enabled=true
- xpack.security.http.ssl.enabled=true
- xpack.security.http.ssl.key=certs/es02/es02.key
- xpack.security.http.ssl.certificate=certs/es02/es02.crt
- xpack.security.http.ssl.certificate_authorities=certs/ca/ca.crt
- xpack.security.transport.ssl.enabled=true
- xpack.security.transport.ssl.key=certs/es02/es02.key
- xpack.security.transport.ssl.certificate=certs/es02/es02.crt
- xpack.security.transport.ssl.certificate_authorities=certs/ca/ca.crt
- xpack.security.transport.ssl.verification_mode=certificate
- xpack.license.self_generated.type=${LICENSE}
- xpack.ml.use_auto_machine_memory_percent=true
- cluster.routing.allocation.disk.threshold_enabled=false
- cluster.routing.allocation.disk.watermark.low="2gb"
- cluster.routing.allocation.disk.watermark.high="1gb"
- cluster.routing.allocation.disk.watermark.flood_stage="500mb"
mem_limit: ${MEM_LIMIT}
ulimits:
memlock:
soft: -1
hard: -1
healthcheck:
test:
[
"CMD-SHELL",
"curl -s --cacert config/certs/ca/ca.crt https://localhost:9200 | grep -q 'missing authentication credentials'",
]
interval: 10s
timeout: 10s
retries: 120
es03:
depends_on:
- es01
image: docker.elastic.co/elasticsearch/elasticsearch:${STACK_VERSION}
volumes:
- certs:/usr/share/elasticsearch/config/certs
- esdata03:/usr/share/elasticsearch/data
environment:
- node.name=es03
- cluster.name=${CLUSTER_NAME}
- cluster.initial_master_nodes=es01,es03
- discovery.seed_hosts=es01
- ELASTIC_PASSWORD=${ES_PASS}
- bootstrap.memory_lock=true
- xpack.security.enabled=true
- xpack.security.http.ssl.enabled=true
- xpack.security.http.ssl.key=certs/es03/es03.key
- xpack.security.http.ssl.certificate=certs/es03/es03.crt
- xpack.security.http.ssl.certificate_authorities=certs/ca/ca.crt
- xpack.security.transport.ssl.enabled=true
- xpack.security.transport.ssl.key=certs/es03/es03.key
- xpack.security.transport.ssl.certificate=certs/es03/es03.crt
- xpack.security.transport.ssl.certificate_authorities=certs/ca/ca.crt
- xpack.security.transport.ssl.verification_mode=certificate
- xpack.license.self_generated.type=${LICENSE}
- xpack.ml.use_auto_machine_memory_percent=true
- cluster.routing.allocation.disk.threshold_enabled=false
- cluster.routing.allocation.disk.watermark.low="2gb"
- cluster.routing.allocation.disk.watermark.high="1gb"
- cluster.routing.allocation.disk.watermark.flood_stage="500mb"
mem_limit: ${MEM_LIMIT}
ulimits:
memlock:
soft: -1
hard: -1
healthcheck:
test:
[
"CMD-SHELL",
"curl -s --cacert config/certs/ca/ca.crt https://localhost:9200 | grep -q 'missing authentication credentials'",
]
interval: 10s
timeout: 10s
retries: 120
volumes:
certs:
driver: local
esdata01:
driver: local
esdata02:
driver: local
esdata03:
driver: local

96
t/msc-actions.yaml Normal file
View File

@@ -0,0 +1,96 @@
#
# this is a mosscap action file, see: https://codeberg.org/scip/mosscap
vars:
esctl: ../esctl -c cluster.yaml
actions:
wait: |
sleep [[.time]]
docker_wait: |
for i in {1..20}; do
count=$(docker ps | grep healthy | wc -l)
if test $count -eq 3; then
echo "all containers are healthy"
exit
fi
sleep 5s
done
echo "not all containers are healthy"
docker ps
cluster_ls: |
[[.esctl]] cluster ls | grep -E domdoc.*reachable.*yes
cluster_status: |
[[.esctl]] cluster status | grep green
create_ilm: |
[[.esctl]] ilm create --hot-rollover-max-age 7d \
--hot-rollover-max-primary-shard-size 25g \
--warm-min-age 0 \
--delete-min-age 14d [[.policy]]
describe_ilm: |
[[.esctl]] ilm show [[.policy]] -t | grep -E "rollover when storage > 25g"
create_index_template: |
[[.esctl]] index template create [[.index]] -i [[.index]] \
-s number_of_shards:3 \
-s index.sort.field:@timestamp \
-r 1m --ilm-policy [[.policy]] \
user:keyword message:text @timestamp:date
describe_index_template: |
[[.esctl]] index template show [[.index]] | grep -E "index.sort.field.*@timestamp"
create_index: |
[[.esctl]] index create -s 2 -r 2 [[.index]]
describe_index: |
[[.esctl]] index show [[.index]] | grep -E "fields.*@timestamp"
index_ls: |
[[.esctl]] index ls | grep [[.index]]
index_template_rm: |
[[.esctl]] index template rm [[.index]]
index_rm: |
[[.esctl]] index rm [[.index]]
timestamp: |
date --iso-8601=second
date: |
date +%Y-%m-%d
doc_add: |
[[.esctl]] doc add -i [[.index]] '{"user": "[[.user]]", "message":"[[.message]]", "@timestamp":"[[.ts]]"}'
search_any: |
[[.esctl]] search -i [[.index]] -l 1 | jq .source.message
search_date: |
[[.esctl]] search -i [[.index]] -l 1 | jq '.source."@timestamp"' | grep [[.today]]
search_count: |
[[.esctl]] search -i [[.index]] -l 5000 | wc -l | grep 1000
search_content: |
[[.esctl]] search -i [[.index]] [[.content_pattern]] -l 1 | jq .source.message
search_content_and: |
[[.esctl]] search -i [[.index]] [[.content_and_pattern]] -l 1 | jq .source.message
nodes: |
[[.esctl]] node ls | grep es01
describe_node: |
[[.esctl]] node show [[.node]] | grep "Node version"
license: |
[[.esctl]] license show | grep -E "Expires.*never"

14
t/msc-clean.yaml Normal file
View File

@@ -0,0 +1,14 @@
#
# this is a mosscap item file, see: https://codeberg.org/scip/mosscap
items:
- name: docker/domdoc
vars:
index: test
tasks:
- name: delete_index
action: index_rm
- name: delete_template
action: index_template_rm

48
t/msc-cluster.yaml Normal file
View File

@@ -0,0 +1,48 @@
#
# this is a mosscap item file, see: https://codeberg.org/scip/mosscap
statefile: cluster.state
items:
- name: docker/domdoc
vars:
index: test
node: es01
policy: testpolicy
tasks:
- name: ls
action: cluster_ls
- name: license
action: license
- name: status
action: cluster_status
- name: ilm
action: create_ilm
- name: check_ilm
action: describe_ilm
- name: template
action: create_index_template
- name: check_template
action: describe_index_template
- name: index
action: create_index
- name: indexls
action: index_ls
- name: describe
action: describe_index
- name: nodes
action: nodes
- name: check_node
action: describe_node

12
t/msc-docker.yaml Normal file
View File

@@ -0,0 +1,12 @@
#
# this is a mosscap item file, see: https://codeberg.org/scip/mosscap
items:
- name: docker/domdoc
vars:
index: test
time: 5s
tasks:
- name: wait
action: docker_wait

21
t/msc-docs.yaml Normal file
View File

@@ -0,0 +1,21 @@
#
# this is a mosscap generator item file, see: https://codeberg.org/scip/mosscap
vars:
index: test
tasks:
- name: ts
action: timestamp
- name: add
action: doc_add
args:
ts: ts.result
generate:
user: $name
# will become something like:
# "content34 iBAXQ2Va Ko5gMwwu FEH2O99B"
# so we can search for "content3*" and get multiple matches
message: "content$int8 $rand make$int8 $rand"

31
t/msc-search.yaml Normal file
View File

@@ -0,0 +1,31 @@
#
# this is a mosscap item file, see: https://codeberg.org/scip/mosscap
items:
- name: docker/domdoc
vars:
index: test
time: 10s
content_pattern: "content3*"
content_and_pattern: "content3* make"
tasks:
- name: today
action: date
- name: search_any
action: search_any
- name: date
action: search_date
args:
today: today.result
- name: count
action: search_count
- name: content
action: search_content
- name: content_and
action: search_content_and