Compare commits

...

54 Commits

Author SHA1 Message Date
925b21823a marshall explain output only in debug mode 2026-06-08 14:11:26 +02:00
9b9b394539 upd tree 2026-06-08 14:05:25 +02:00
T. von Dein
b9eb3e3e2f add search validate+explain (#31) 2026-06-08 14:00:22 +02:00
T. von Dein
f30c837d53 fix search output order (last on bottom) and index ls output partials (#30) 2026-06-08 12:09:01 +02:00
c4143093fd typo 2026-06-04 17:09:23 +02:00
7e9a9f82a7 add intro and feature list 2026-06-04 17:07:34 +02:00
0f48a17536 +todos 2026-06-03 13:13:44 +02:00
T. von Dein
01e0fd024b fix-index-show-completion, add more completions (#29) 2026-06-03 13:07:11 +02:00
T. von Dein
5ecba5abe6 add index fields filter flags (#27) 2026-06-03 10:14:41 +02:00
T. von Dein
18af1b6073 add role diff (#26) 2026-06-03 08:47:54 +02:00
0696095bb0 add roles ls+sh 2026-06-01 13:20:06 +02:00
T. von Dein
504db9835d add sort support to search, add index fields command, fix error messages (#25) 2026-06-01 12:30:41 +02:00
f1877c6903 fix doc add id creation, use rand.Int64() 2026-05-29 14:42:36 +02:00
T. von Dein
2b2094b155 allow multiline json data in repl (#24) 2026-05-29 12:42:02 +02:00
1c72b5ec9a Merge branch 'main' of ssh://codeberg.org/scip/esctl 2026-05-29 11:28:17 +02:00
aeded16b89 catch out-of-bounds error 2026-05-29 11:27:48 +02:00
57bbe680a7 fix http debugger 2026-05-29 11:27:40 +02:00
T. von Dein
130d0b879d add doc delete (#23) 2026-05-29 11:05:57 +02:00
T. von Dein
f7302ea506 more search enhancements (#22) 2026-05-29 10:19:18 +02:00
b2da6f0f29 also fix color status in ccr status 2026-05-27 09:59:56 +02:00
c92f10fb5d show cluster color status directly to make it clearer 2026-05-27 09:25:55 +02:00
501deec538 fix tab completion 2026-05-26 08:01:28 +02:00
T. von Dein
98a577f68a add doc show, enhance search, add jsonpath to search and doc show 2026-05-22 13:50:17 +02:00
T. von Dein
89e2498aad add index alias delete (#20) 2026-05-21 14:00:35 +02:00
T. von Dein
0870ceb3df Refactor output code, add JSON, TSV and YAML output modes, add -o (#19), add version subcommand 2026-05-20 13:06:39 +02:00
32de4863af upd command tree 2026-05-19 13:59:19 +02:00
T. von Dein
58d94a4f56 shards, aliases and filtering (#18) 2026-05-19 13:58:08 +02:00
565d92b491 add command tree 2026-05-18 14:29:46 +02:00
T. von Dein
40e0612bef add explain index allocation 2026-05-18 13:58:08 +02:00
T. von Dein
db50072a7b add support for json color output in repl using jq, if installed (#16) 2026-05-18 13:56:07 +02:00
T. von Dein
b71819f9e8 update deps: cli v3.9.0 and some minor updates (#15) 2026-05-18 12:19:45 +02:00
T. von Dein
15e0f9dc90 Additions and enhancements (#14)
- add interactive API repl
- enhance cluster status output (use -v)
- add more ccr commands
- refactoring
2026-05-13 13:51:09 +02:00
T. von Dein
dd619ab815 Add CCR stuff, fix search, add docs support, support index maps, refactor (#13) 2026-05-12 18:28:57 +02:00
327bc84bd8 fix cluster list sorting 2026-05-11 14:03:30 +02:00
a785eb642f add settings reference to usage 2026-05-11 14:03:11 +02:00
a209debe09 fix linting issues 2026-05-11 10:46:17 +02:00
d1d97fed1b enhanced config debug print (dump w/o ES structs) 2026-05-11 10:42:58 +02:00
4194a004c0 separate cluster settings code 2026-05-11 10:28:25 +02:00
ceab7698e2 dont debug the whole confi, bump version 2026-05-11 10:23:44 +02:00
d22352da12 fix settings output, recursively determine the json path of settings 2026-05-11 10:22:17 +02:00
3bd26fde7a fix coredump, register default cluster name 2026-05-11 10:21:43 +02:00
T. von Dein
38ed9de0b4 Add CI pipelines, fix linting issues (#12) 2026-05-07 15:01:18 +02:00
T. von Dein
5e82a3706b enhance cluster status (#11) 2026-05-07 12:54:27 +02:00
0122009774 add es version to status output 2026-05-06 13:10:02 +02:00
8109481e23 check at bootstrap which cluster is reachable, fix current cluster 2026-05-06 13:02:28 +02:00
T. von Dein
af75bfa192 add -p -t -D flags to cluster settings ls (#10) 2026-05-06 07:25:27 +02:00
a71be5001a add missing utilities.go 2026-05-05 12:07:31 +02:00
T. von Dein
fcfeaf66a9 Add nodes and cluster settings support (#9) 2026-05-05 12:06:34 +02:00
T. von Dein
a169d23b90 add index create,delete, enhance index show, catch empty index arg 2026-04-29 13:44:21 +02:00
T. von Dein
f89a09d1d2 add support for a standard config in ~/.config/esctl/config.yaml (#7) 2026-04-29 10:26:53 +02:00
T. von Dein
01853c3299 add more cluster comparision funcs (#6) 2026-04-29 09:56:31 +02:00
d6a96ee61f upd help 2026-04-28 13:46:28 +02:00
T. von Dein
f27f9157fb add "cluster ls", put "status" into "cluster status" (#5) 2026-04-28 13:43:13 +02:00
T. von Dein
3cbc67567b add "cluster compare" command and add 1st check (unsync indices) (#2) 2026-04-27 13:05:30 +02:00
50 changed files with 5856 additions and 276 deletions

4
.gitignore vendored
View File

@@ -26,3 +26,7 @@ go.work.sum
.env .env
esctl esctl
*.sh
*.json
*.log

42
.goreleaser.yaml Normal file
View File

@@ -0,0 +1,42 @@
# vim: set ts=2 sw=2 tw=0 fo=cnqoj
version: 2
before:
hooks:
- go mod tidy
gitea_urls:
api: https://codeberg.org/api/v1
download: https://codeberg.org
builds:
- env:
- CGO_ENABLED=0
goos:
- linux
- darwin
changelog:
sort: asc
filters:
exclude:
- "^docs:"
- "^test:"
groups:
- title: Improved
regexp: '^.*?(feat|add|new)(\([[:word:]]+\))??!?:.+$'
order: 0
- title: Fixed
regexp: '^.*?(bug|fix)(\([[:word:]]+\))??!?:.+$'
order: 1
- title: Changed
order: 999
release:
header: "# Release Notes"
footer: >-
---
Full Changelog: [{{ .PreviousTag }}...{{ .Tag }}](https://codeberg.org/scip/epuppy/compare/{{ .PreviousTag }}...{{ .Tag }})

26
.woodpecker/build.yaml Normal file
View File

@@ -0,0 +1,26 @@
matrix:
platform:
- linux/amd64
goversion:
- 1.25
labels:
platform: ${platform}
steps:
build:
when:
event: [push,manual]
image: golang:${goversion}
commands:
- go get
- go build
linter:
when:
event: [push,manual]
image: golang:${goversion}
commands:
- curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s -- -b $(go env GOPATH)/bin v2.5.0
- golangci-lint --version
- golangci-lint run ./...

15
.woodpecker/release.yaml Normal file
View File

@@ -0,0 +1,15 @@
# build release
labels:
platform: linux/amd64
steps:
goreleaser:
image: goreleaser/goreleaser
when:
event: [tag,manual]
environment:
GITEA_TOKEN:
from_secret: DEPLOY_TOKEN
commands:
- goreleaser release --clean --verbose

18
CODE_OF_CONDUCT.md Normal file
View File

@@ -0,0 +1,18 @@
# CODE_OF_CONDUCT.md (compact model)
Purpose: Maintain a collaborative, harassment-free environment focused
on shipping quality software.
Standards: Be respectful; assume good intent; no harassment; no
discrimination; keep critiques technical.
Scope: All project spaces (issues, PRs, forums, events).
Reporting: Email tom AT vondein DOT org. Acknowledge within 72 hours.
Enforcement: Two maintainers review, one recuses on
conflict. Sanctions range from warning to removal. Summary posted (no
personal details).
Escalation: If you believe maintainers handled a report in bad faith,
escalate to our foundation committee (link) for independent review.

91
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,91 @@
## Project Goals
The idea behind this project is to build a small commandline tool to
manage an ElasticSearch cluster.
There will be no GUI, no web interface, no public API of some
sort. TUI subcommands maybe added.
The programming language used for this project will always be
[GOLANG](https://go.dev/) with the exception of the documentation
([Perl POD](https://perldoc.perl.org/perlpod)) and the Makefile.
# Contributing
You can contribute to this project in various ways:
## Open an issue
If you encounter a problem or don't understand how the program works
or if you think the documentation is unclear, please don't hesitate to
open an issue.
Please add as much information about the case as possible, such as:
- Your environment (operating system etc)
- program version
- Input data. Please replace sensitive information with mock data!
- Actual program output.
- Expected program output.
- Error message - if any.
Be aware that I am working on this (and some other) project in my
spare time which is scarce. Therefore please don't expect me to
respond to your query within hours or even days. Be patient, but I
WILL respond.
## Pull Requests
Code and documentation help is always much appreciated! Please follow
thes guidelines to successfully contribute:
- Every pull request shall be based on latest `development`
branch. `main` is only used for releases.
- Execute the unit tests before committing: `make test`. There shall
be no errors.
- Strive to be backwards compatible so that users who are already
using the program don't have to change their habits - unless it is
really neccessary.
- Try to add a unit test for your addition.
- Don't ever change existing unit tests!
- Add a meaningful and comprehensive rationale about your contribution:
- Why do you think it might be useful for others?
- What did you actually change or add?
- Is there an open issue which this PR fixes and if so, please link
to that issue.
- [Re-]format your code with `gofmt -s`.
- Avoid unneccesary dependencies, especially for very small functions.
- **If** a new dependency is being added, it must be compatible with
our [license agreement](LICENSE).
- You need to accept that the code or documentation you contribute
will be redistributed under the terms of said license agreement. If
your contribution is considerably large or if you contribute
regularly, then feel free to add your name and if you want your
email address to the *AUTHORS* section of the
manual page.
- Adhere to the above mentioned project goals.
- If you are unsure if your addition or change will be accepted,
better ask before starting coding. Open an issue about your proposal
and let's discuss it! That way we avoid doing unnessesary work on
both sides.
Each pull request will be carefully reviewed and if it is a useful
addition it will be accepted. However, please be prepared that
sometimes a PR will be rejected. The reasons may vary and will be
documented. Perhaps the above guidelines are not matched, or the
addition seems to be not so useful from my perspective, maybe there
are too much changes or there might be changes I don't even
understand.
But whatever happens: your contribution is always welcome!

View File

@@ -23,11 +23,24 @@ UID = root
GID = 0 GID = 0
HAVE_POD := $(shell pod2text -h 2>/dev/null) HAVE_POD := $(shell pod2text -h 2>/dev/null)
BRANCH = $(shell git branch --show-current)
COMMIT = $(shell git rev-parse --short=8 HEAD)
BUILD = $(shell date +%Y.%m.%d.%H%M%S)
GOVERSION = $(shell grep -E "^go" go.mod | cut -d' ' -f2)
APIVERSION = $(shell grep github.com/elastic/go-elasticsearch go.mod | cut -d' ' -f2)
all: buildlocal all: buildlocal
buildlocal: buildlocal:
CGO_LDFLAGS='-static' go build -tags osusergo,netgo -ldflags "-extldflags=-static" -o $(tool) CGO_LDFLAGS='-static' go build -tags osusergo,netgo -ldflags "-extldflags=-static" \
-ldflags "-X codeberg.org/scip/esctl/pkg/cfg.BRANCH=$(BRANCH) \
-X codeberg.org/scip/esctl/pkg/cfg.COMMIT=$(COMMIT) \
-X codeberg.org/scip/esctl/pkg/cfg.GOVERSION=$(GOVERSION) \
-X codeberg.org/scip/esctl/pkg/cfg.APIVERSION=$(APIVERSION) \
-X codeberg.org/scip/esctl/pkg/cfg.BUILD=$(BUILD)" \
-o $(tool)
strip $(tool) strip $(tool)
install: buildlocal install: buildlocal

164
README.md
View File

@@ -1,28 +1,111 @@
[![status-badge](https://ci.codeberg.org/api/badges/16999/status.svg)](https://ci.codeberg.org/repos/16999)
# esctl # esctl
Elasticsearch CLI Elasticsearch CLI
## Introduction
This is a handy cli tool which interfaces to an elasticsearch cluster
(or two of them if you're using cross cluster replication). It is a
work-in-progress project yet, things might change occasionally. Expect
a stable release once we reach major version 1.0.0.
Features:
- Configuration of cluster credentials using environment vars or
config file. Multiple clusters can be configured. `esctl cluster ls`
shows which one is reachable.
- Shell completion support (bash, zsh and fish). Put this into your
rc: `source <(esctl completion bash)`.
- Cluster settings can be viewed and modified.
- Search: you can search indices using full text or by fields, select
logical condition (OR, AND), use PIT, limit datetime (ES date math
can be used), etc. It is however not yet possible to create
recursive searches like: `(cond1 AND cond2) OR (cond3 OR cond4)`.
- Cross cluster replication (ccr): view, pause, resume, delete
replication. You can also manage follower configuration.
- Index management: manage aliases, create, modify, delete indices,
display field mappings etc.
- Node management: only list nodes yet.
- Shard management: only list shards yet.
- Snapshot management: only list snapshots yet.
- Role management: only list roles yet. There's also a `role diff`
subcommand, which is for internal use. It can be used to verify if
role defs in a CSV match the deployed roles.
- Repl: this is an interactive REPL (read eval print loop) towards the
elasticsearch API. You can run API calls on the current selected
cluster w/o the hassle to specify the whole url, credentials etc. It
has line editing and history support. If `jq` is installed output
JSON will be syntax highlighted.
- Doc support. You can put, delete and show docs for an index. Very
handy if you want to play with it. Just create a new index:
`esctl index create foo` and then insert docs into it for search
experiments:
```console
esctl doc add -i foo '{"title":"curry in a hurry", "message":"australian thai"}'
```
## Usage ## Usage
Command tree:
```console ```console
NAME: ccr
esctl - manage elasticsearch from cli follower
add
USAGE: delete
esctl [global options] [command [command options]] pause
renew
VERSION: resume
v0.0.1 show
unfollow
COMMANDS: info
health show ES health pause
search, / search within an index resume
help, h Shows a list of commands or help for one command status
cluster
GLOBAL OPTIONS: list
--debug, -d enable debugging [$ES_DEBUG] settings
--help, -h show help list
--version, -v print the version set
status
debug
doc
add
delete
show
help
help-jsonpath
index
alias
create
delete
list
allocation
close
create
delete
fields
list
modify
show
node
list
show
repl
role
diff
list
show
search
shard
list
show
snapshot
list
show
version
``` ```
Configure `esctl` with environment variables: Configure `esctl` with environment variables:
@@ -31,9 +114,26 @@ Configure `esctl` with environment variables:
- `ES_USER`: username - `ES_USER`: username
- `ES_PASS`: password - `ES_PASS`: password
## Introduction Or create a config file such as this:
FIXME ```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 ## Installation
@@ -85,6 +185,30 @@ make
sudo make install sudo make install
``` ```
# Development
## To test completion
Add the flag `--generate-shell-completion` to any command, e.g.:
```console
./esctl role show --generate-shell-completion
machine_learning_admin
rollup_admin
editor
reporting_user
snapshot_user
fcn_admin
machine_learning_user
kibana_system
beats_admin
kibana_user
fcns_space
transport_client
transform_user
[..]
```
# Report bugs # Report bugs
[Please open an issue](https://codeberg.org/scip/esctl/issues). Thanks! [Please open an issue](https://codeberg.org/scip/esctl/issues). Thanks!

11
TODO.md
View File

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

125
cmd/ccr.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 cmd
import (
"context"
"errors"
"codeberg.org/scip/esctl/pkg/cfg"
"codeberg.org/scip/esctl/pkg/es"
"github.com/urfave/cli/v3"
)
func Ccr(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "ccr",
Aliases: []string{"replication", "rep"},
Usage: "manage cross cluster replication",
Commands: []*cli.Command{
CcrStatus(conf),
CcrShardPause(conf),
CcrShardResume(conf),
CcrFollower(conf),
CcrRemoteInfo(conf),
},
}
}
func CcrStatus(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "status",
Aliases: []string{"st"},
Usage: "cross cluster replication status (yaml config with 2 clusters required)",
UsageText: "status <leader> <follower>",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "exclude",
Usage: "regexp of indicies to exclude",
Destination: &conf.Exclude,
Aliases: []string{"e"},
},
},
ShellComplete: func(ctx context.Context, cmd *cli.Command) {
completeCluster(cmd)
},
Action: func(ctx context.Context, cmd *cli.Command) error {
leader := cmd.Args().Get(0)
follower := cmd.Args().Get(1)
if leader == "" || follower == "" {
return errors.New("no leader and follower aliases specified")
}
_, hasLeader := conf.Clusters[leader]
_, hasFollower := conf.Clusters[follower]
if !hasLeader || !hasFollower {
return errors.New("either leader or follower alias not configured")
}
if err := es.CcrStatus(conf, leader, follower); err != nil {
return err
}
return nil
},
}
}
func CcrShardPause(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "pause",
Usage: "pause shard allocation",
Action: func(ctx context.Context, cmd *cli.Command) error {
return es.CcrShardPause(conf)
},
}
}
func CcrShardResume(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "resume",
Usage: "resume shard allocation",
Action: func(ctx context.Context, cmd *cli.Command) error {
return es.CcrShardResume(conf)
},
}
}
func CcrRemoteInfo(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "info",
Usage: "show ccr remote info",
UsageText: "info [options] [<index>]",
ShellComplete: func(ctx context.Context, cmd *cli.Command) {
completeIndex(cmd)
},
Action: func(ctx context.Context, cmd *cli.Command) error {
return es.CcrRemoteInfo(conf, cmd.Args().Get(0))
},
}
}

221
cmd/ccr_follower.go Normal file
View File

@@ -0,0 +1,221 @@
/*
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 CcrFollower(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "follower",
Aliases: []string{"f"},
Usage: "manage ccr follower indices",
Commands: []*cli.Command{
CcrFollowerShow(conf),
CcrFollowerAdd(conf),
CcrFollowerDelete(conf),
CcrFollowerUnfollow(conf),
CcrFollowerPause(conf),
CcrFollowerResume(conf),
CcrFollowerRenew(conf),
},
}
}
func CcrFollowerRenew(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "renew",
Usage: "renew ccr follower index",
UsageText: "renew [options] <index>",
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "force",
Usage: "force even if unfollow fails (e.g. because following is red anyway)",
Destination: &conf.Force,
Aliases: []string{"f"},
},
},
ShellComplete: func(ctx context.Context, cmd *cli.Command) {
completeIndex(cmd)
},
Action: func(ctx context.Context, cmd *cli.Command) error {
args := cmd.Args()
if args.Len() != 1 {
return errors.New("missing arguments: <index>")
}
return es.CcrFollowerRenew(conf, cmd.Args().Get(0))
},
}
}
func CcrFollowerResume(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "resume",
Usage: "resume ccr index to follow",
UsageText: "resume [options] <index>",
ShellComplete: func(ctx context.Context, cmd *cli.Command) {
completeIndex(cmd)
},
Action: func(ctx context.Context, cmd *cli.Command) error {
args := cmd.Args()
if args.Len() != 1 {
return errors.New("missing arguments: <index>")
}
return es.CcrFollowerResume(conf, cmd.Args().Get(0))
},
}
}
func CcrFollowerPause(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "pause",
Usage: "pause ccr index to follow",
UsageText: "pause [options] <index>",
ShellComplete: func(ctx context.Context, cmd *cli.Command) {
completeIndex(cmd)
},
Action: func(ctx context.Context, cmd *cli.Command) error {
args := cmd.Args()
if args.Len() != 1 {
return errors.New("missing arguments: <index>")
}
return es.CcrFollowerPause(conf, cmd.Args().Get(0))
},
}
}
func CcrFollowerUnfollow(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "unfollow",
Usage: "unfollow ccr follower index",
UsageText: "unfollow [options] <index>",
ShellComplete: func(ctx context.Context, cmd *cli.Command) {
completeIndex(cmd)
},
Action: func(ctx context.Context, cmd *cli.Command) error {
args := cmd.Args()
if args.Len() != 1 {
return errors.New("missing arguments: <index>")
}
return es.CcrFollowerUnfollow(conf, cmd.Args().Get(0))
},
}
}
func CcrFollowerAdd(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "add",
Aliases: []string{"+"},
Usage: "add ccr follower index",
UsageText: "add [options] <index>",
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "wait",
Usage: "wait for active shards",
Destination: &conf.Wait,
Aliases: []string{"w"},
},
},
ShellComplete: func(ctx context.Context, cmd *cli.Command) {
completeIndex(cmd)
},
Action: func(ctx context.Context, cmd *cli.Command) error {
args := cmd.Args()
if args.Len() != 1 {
return errors.New("missing arguments: <index>")
}
return es.CcrFollowerAdd(conf, cmd.Args().Get(0))
},
}
}
func CcrFollowerDelete(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "delete",
Aliases: []string{"rm"},
Usage: "delete ccr follower index",
UsageText: "delete <index>",
ShellComplete: func(ctx context.Context, cmd *cli.Command) {
completeIndex(cmd)
},
Action: func(ctx context.Context, cmd *cli.Command) error {
args := cmd.Args()
if args.Len() != 1 {
return errors.New("missing arguments: <index>")
}
// just an alias to index delete
return es.IndexDelete(conf, cmd.Args().Get(0))
},
}
}
func CcrFollowerShow(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "show",
Aliases: []string{"sh"},
Usage: "show ccr follower index details",
UsageText: "show <index>",
ShellComplete: func(ctx context.Context, cmd *cli.Command) {
completeIndex(cmd)
},
Action: func(ctx context.Context, cmd *cli.Command) error {
args := cmd.Args()
if args.Len() != 1 {
return errors.New("missing arguments: <index>")
}
return es.CcrFollowerShow(conf, cmd.Args().Get(0))
},
}
}

79
cmd/cluster.go Normal file
View File

@@ -0,0 +1,79 @@
/*
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 Cluster(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "cluster",
Aliases: []string{"c"},
Usage: "manage cluster[s]",
Commands: []*cli.Command{
ClusterStatus(conf),
ClusterList(conf),
ClusterSettings(conf),
},
}
}
func ClusterList(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "list",
Usage: "list configured clusters",
Aliases: []string{"ls"},
Action: func(ctx context.Context, cmd *cli.Command) error {
return es.ClusterList(conf)
},
}
}
func ClusterStatus(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "status",
Usage: "show cluster status",
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",
Destination: &conf.Verbose,
Aliases: []string{"v"},
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
return es.ClusterStatus(conf)
},
}
}

119
cmd/cluster_settings.go Normal file
View File

@@ -0,0 +1,119 @@
/*
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"
)
const (
SETTINGS = `https://www.elastic.co/docs/reference/elasticsearch/configuration-reference`
)
func ClusterSettings(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "settings",
Usage: "cluster settings management",
Aliases: []string{"config"},
Commands: []*cli.Command{
ClusterSettingsList(conf),
ClusterSettingsSet(conf),
},
}
}
func ClusterSettingsList(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "list",
Usage: "show cluster settings",
Aliases: []string{"ls", "get"},
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "persistent",
Usage: "only show persistent setting[s] (default)",
Destination: &conf.Persistent,
Aliases: []string{"p"},
},
&cli.BoolFlag{
Name: "transient",
Usage: "only show transient setting[s]",
Destination: &conf.Transient,
Aliases: []string{"t"},
},
&cli.BoolFlag{
Name: "default",
Usage: "only show default setting[s]",
Destination: &conf.Default,
Aliases: []string{"D"},
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
if err := es.ClusterSettingsList(conf); err != nil {
return err
}
return nil
},
}
}
func ClusterSettingsSet(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "set",
Usage: "set|update cluster settings",
Aliases: []string{"set", "update"},
UsageText: "set [options] setting:value [setting:value ...]\n\nReference: " + SETTINGS,
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "persistent",
Usage: "add persistent setting[s] (default)",
Destination: &conf.Persistent,
Aliases: []string{"p"},
},
&cli.BoolFlag{
Name: "transient",
Usage: "add transient setting[s]",
Destination: &conf.Transient,
Aliases: []string{"t"},
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
args := cmd.Args()
if args.Len() == 0 {
return errors.New("at least one setting must be specified (format: setting:value)")
}
if err := es.ClusterSettingsSet(conf, cmd.Args()); err != nil {
return err
}
return nil
},
}
}

89
cmd/completion.go Normal file
View File

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

163
cmd/doc.go Normal file
View File

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

View File

@@ -18,6 +18,8 @@ package cmd
import ( import (
"context" "context"
"errors"
"fmt"
"codeberg.org/scip/esctl/pkg/cfg" "codeberg.org/scip/esctl/pkg/cfg"
"codeberg.org/scip/esctl/pkg/es" "codeberg.org/scip/esctl/pkg/es"
@@ -34,6 +36,13 @@ func Index(conf *cfg.Config) *cli.Command {
Commands: []*cli.Command{ Commands: []*cli.Command{
IndexList(conf), IndexList(conf),
IndexShow(conf), IndexShow(conf),
IndexCreate(conf),
IndexDelete(conf),
IndexClose(conf),
IndexAllocation(conf),
IndexModify(conf),
IndexAlias(conf),
IndexFields(conf),
}, },
} }
} }
@@ -63,14 +72,48 @@ func IndexList(conf *cfg.Config) *cli.Command {
Destination: &conf.Failed, Destination: &conf.Failed,
Aliases: []string{"r"}, Aliases: []string{"r"},
}, },
&cli.StringSliceFlag{
Name: "filter",
Usage: "show only indicies matching the filter",
Destination: &conf.Filter,
Aliases: []string{"F"},
},
}, },
Action: func(ctx context.Context, cmd *cli.Command) error { Action: func(ctx context.Context, cmd *cli.Command) error {
if err := es.IndexList(conf); err != nil { return es.IndexList(conf)
return err },
}
}
func IndexAllocation(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "allocation",
Aliases: []string{"a"},
Usage: "explain index allocation",
Flags: []cli.Flag{
&cli.IntFlag{
Name: "shard",
Usage: "shard number to explain for",
Destination: &conf.Shards,
Aliases: []string{"s"},
},
&cli.BoolFlag{
Name: "primary",
Usage: "explain primary allocation (default true)",
Destination: &conf.Primary,
Aliases: []string{"p"},
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
index := cmd.Args().Get(0)
if index == "" {
return errors.New("no index specified")
} }
return nil return es.IndexAllocation(conf, cmd.Args().Get(0))
}, },
} }
} }
@@ -82,11 +125,173 @@ func IndexShow(conf *cfg.Config) *cli.Command {
Usage: "show details about an index", Usage: "show details about an index",
Action: func(ctx context.Context, cmd *cli.Command) error { Action: func(ctx context.Context, cmd *cli.Command) error {
if err := es.IndexShow(conf, cmd.Args().Get(0)); err != nil { index := cmd.Args().Get(0)
return err if index == "" {
return errors.New("no index specified")
} }
return nil return es.IndexShow(conf, cmd.Args().Get(0))
},
ShellComplete: func(ctx context.Context, cmd *cli.Command) {
completeIndex(cmd)
},
}
}
func IndexCreate(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "create",
Aliases: []string{"+"},
Usage: "create a new index",
UsageText: `create <name> <fieldmapping:type>...
Valid field mapping types: integer, text, date, keyword`,
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "wait",
Usage: "wait for active shards",
Destination: &conf.Wait,
Aliases: []string{"w"},
},
&cli.IntFlag{
Name: "shards",
Usage: "number of shards to create",
Destination: &conf.Shards,
Aliases: []string{"s"},
},
&cli.IntFlag{
Name: "replicas",
Usage: "number of replicas to create",
Destination: &conf.Replicas,
Aliases: []string{"r"},
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
args := cmd.Args()
if args.Len() == 0 {
return fmt.Errorf("no index specified")
}
mappings := args.Slice()[1:]
return es.IndexCreate(conf, args.Get(0), mappings)
},
}
}
func IndexDelete(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "delete",
Aliases: []string{"rm"},
Usage: "delete an index",
UsageText: "delete <index>",
ShellComplete: func(ctx context.Context, cmd *cli.Command) {
completeIndex(cmd)
},
Action: func(ctx context.Context, cmd *cli.Command) error {
index := cmd.Args().Get(0)
if index == "" {
return errors.New("no index specified")
}
return es.IndexDelete(conf, cmd.Args().Get(0))
},
}
}
func IndexClose(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "close",
Usage: "close an index",
UsageText: "close <index>",
ShellComplete: func(ctx context.Context, cmd *cli.Command) {
completeIndex(cmd)
},
Action: func(ctx context.Context, cmd *cli.Command) error {
index := cmd.Args().Get(0)
if index == "" {
return errors.New("no index specified")
}
return es.IndexClose(conf, cmd.Args().Get(0))
},
}
}
func IndexModify(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "modify",
Usage: "modify an index",
UsageText: "modify <index[,index,...]|_all>",
ShellComplete: func(ctx context.Context, cmd *cli.Command) {
completeIndex(cmd)
},
Flags: []cli.Flag{
&cli.IntFlag{
Name: "replicas",
Usage: "number of replicas",
Destination: &conf.Replicas,
Aliases: []string{"r"},
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
index := cmd.Args().Get(0)
if index == "" {
return errors.New("no index specified")
}
return es.IndexModify(conf, index)
},
}
}
func IndexFields(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "fields",
Usage: "show info about field capabilities",
UsageText: "index fields <index>",
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "aggretable",
Usage: "include only aggretable fields",
Destination: &conf.Aggretable,
Aliases: []string{"a"},
},
&cli.BoolFlag{
Name: "searchable",
Usage: "include only searchable fields",
Destination: &conf.Searchable,
Aliases: []string{"s"},
},
&cli.StringSliceFlag{
Name: "type",
Usage: "show only fields of this type",
Destination: &conf.Filter,
Aliases: []string{"t"},
},
},
ShellComplete: func(ctx context.Context, cmd *cli.Command) {
completeIndex(cmd)
},
Action: func(ctx context.Context, cmd *cli.Command) error {
index := cmd.Args().Get(0)
if index == "" {
return errors.New("no index specified")
}
return es.IndexFields(conf, index)
}, },
} }
} }

113
cmd/index_alias.go Normal file
View File

@@ -0,0 +1,113 @@
/*
Copyright © 2026 Thomas von Dein
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package cmd
import (
"context"
"errors"
"codeberg.org/scip/esctl/pkg/cfg"
"codeberg.org/scip/esctl/pkg/es"
"github.com/urfave/cli/v3"
)
func IndexAlias(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "alias",
Aliases: []string{"a"},
Usage: "manage index aliases",
Commands: []*cli.Command{
IndexAliasCreate(conf),
IndexAliasList(conf),
IndexAliasDelete(conf),
// FIXME: implement IndexAliasShow + IndexAliasAdd
//IndexAliasShow(conf),
//IndexAliasAdd(conf), // see https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-indices-update-aliases
},
}
}
func IndexAliasCreate(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "create",
Aliases: []string{"+"},
Usage: "create an index alias",
UsageText: "create <index> <alias>",
ShellComplete: func(ctx context.Context, cmd *cli.Command) {
completeIndex(cmd)
},
Action: func(ctx context.Context, cmd *cli.Command) error {
index := cmd.Args().Get(0)
alias := cmd.Args().Get(1)
if index == "" || alias == "" {
return errors.New("no index or alias specified")
}
return es.IndexAliasCreate(conf, index, alias)
},
}
}
func IndexAliasDelete(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "delete",
Aliases: []string{"rm"},
Usage: "delete an index alias",
UsageText: "delete <index> <alias>",
ShellComplete: func(ctx context.Context, cmd *cli.Command) {
completeIndex(cmd)
},
Action: func(ctx context.Context, cmd *cli.Command) error {
index := cmd.Args().Get(0)
alias := cmd.Args().Get(1)
if index == "" || alias == "" {
return errors.New("no index or alias specified")
}
return es.IndexAliasDelete(conf, index, alias)
},
}
}
func IndexAliasList(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "list",
Aliases: []string{"ls"},
Usage: "list index aliases",
Flags: []cli.Flag{
&cli.StringSliceFlag{
Name: "filter",
Usage: "show only aliases for indicies matching the filter",
Destination: &conf.Filter,
Aliases: []string{"F"},
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
return es.IndexAliasList(conf)
},
}
}

66
cmd/node.go Normal file
View File

@@ -0,0 +1,66 @@
/*
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 Node(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "node",
Aliases: []string{"snap"},
Usage: "manage nodes",
Commands: []*cli.Command{
NodeList(conf),
NodeShow(conf),
},
}
}
func NodeList(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "list",
Aliases: []string{"ls"},
Usage: "list nodes",
Action: func(ctx context.Context, cmd *cli.Command) error {
return es.NodeList(conf)
},
}
}
func NodeShow(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "show",
Aliases: []string{"sh"},
Usage: "show details about a node",
UsageText: "show [options] <node>",
Action: func(ctx context.Context, cmd *cli.Command) error {
// FIXME: implement es.NodeShow()
// return es.NodeShow(conf, cmd.Args().Get(0))
return nil
},
}
}

View File

@@ -14,7 +14,6 @@ GNU General Public License for more details.
You should have received a copy of the GNU General Public License You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>. along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
package cmd package cmd
import ( import (
@@ -26,17 +25,14 @@ import (
"github.com/urfave/cli/v3" "github.com/urfave/cli/v3"
) )
func Status(conf *cfg.Config) *cli.Command { func Repl(conf *cfg.Config) *cli.Command {
return &cli.Command{ return &cli.Command{
Name: "status", Name: "repl",
Usage: "show ES status", Aliases: []string{"shell"},
Aliases: []string{"s"}, Usage: "interactive API repl",
Action: func(ctx context.Context, cmd *cli.Command) error {
if err := es.Health(conf); err != nil {
return err
}
return nil Action: func(ctx context.Context, cmd *cli.Command) error {
return es.Repl(conf)
}, },
} }
} }

141
cmd/roles.go Normal file
View File

@@ -0,0 +1,141 @@
/*
Copyright © 2026 Thomas von Dein
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package cmd
import (
"context"
"errors"
"codeberg.org/scip/esctl/pkg/cfg"
"codeberg.org/scip/esctl/pkg/es"
"github.com/urfave/cli/v3"
)
func Roles(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "role",
Usage: "manage roles",
Commands: []*cli.Command{
RoleList(conf),
RoleShow(conf),
RoleDiff(conf),
},
}
}
func RoleList(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "list",
Aliases: []string{"ls"},
Usage: "list roles",
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "orphaned",
Usage: "include only orphaned roles",
Destination: &conf.Failed,
Aliases: []string{"o"},
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
return es.RoleList(conf)
},
}
}
func RoleShow(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "show",
Aliases: []string{"sh"},
Usage: "show details about a role",
UsageText: "show [options] <role>",
ShellComplete: func(ctx context.Context, cmd *cli.Command) {
completeRole(cmd)
},
Action: func(ctx context.Context, cmd *cli.Command) error {
index := cmd.Args().Get(0)
if index == "" {
return errors.New("no role specified")
}
return es.RoleShow(conf, cmd.Args().Get(0))
},
}
}
func RoleDiff(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "diff",
Usage: "show differences between roles and CSV baseline",
UsageText: `diff [options] <file.csv> [<role>]
CSV format:
index_name;role;index_privilege;cluster_privilege;ad_group;space;retention;kibana_privilege;field_privilege`,
MutuallyExclusiveFlags: []cli.MutuallyExclusiveFlags{{
Flags: [][]cli.Flag{
{
&cli.BoolFlag{
Name: "not-deployed",
Usage: "include only not deployed but defined roles",
Destination: &conf.NotDeployed,
Aliases: []string{"n"},
},
},
{
&cli.BoolFlag{
Name: "undefined",
Usage: "include only deployed but undefined roles",
Destination: &conf.Undefined,
Aliases: []string{"u"},
},
},
{
&cli.BoolFlag{
Name: "diff",
Usage: "include only differing roles",
Destination: &conf.Diff,
Aliases: []string{"D"},
},
},
}},
},
Flags: []cli.Flag{
&cli.StringFlag{
Name: "separator",
Usage: "CSV field separator",
Destination: &conf.Separator,
Aliases: []string{"s"},
Value: ",",
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
csvfile := cmd.Args().Get(0)
if csvfile == "" {
return errors.New("no CSV file specified")
}
return es.RoleDiff(conf, cmd.Args().Get(0), cmd.Args().Get(1))
},
}
}

View File

@@ -22,6 +22,7 @@ import (
"os" "os"
"codeberg.org/scip/esctl/pkg/cfg" "codeberg.org/scip/esctl/pkg/cfg"
"codeberg.org/scip/esctl/pkg/es"
"codeberg.org/scip/esctl/pkg/log" "codeberg.org/scip/esctl/pkg/log"
"github.com/urfave/cli/v3" "github.com/urfave/cli/v3"
@@ -40,9 +41,9 @@ func Main() int {
conf := cfg.NewConfig() conf := cfg.NewConfig()
cmd := &cli.Command{ cmd := &cli.Command{
Name: "esctl", Name: "esctl",
Usage: "manage elasticsearch from cli", Usage: "manage elasticsearch from cli",
Version: cfg.Version, //Version: cfg.Version,
EnableShellCompletion: true, EnableShellCompletion: true,
Flags: []cli.Flag{ Flags: []cli.Flag{
@@ -54,6 +55,12 @@ func Main() int {
Sources: cli.EnvVars("ES_DEBUG"), Sources: cli.EnvVars("ES_DEBUG"),
Destination: &conf.Debug, Destination: &conf.Debug,
}, },
&cli.BoolFlag{
Name: "debug-http",
Value: false,
Usage: "enable HTTP debugging",
Destination: &conf.DebugHTTP,
},
&cli.StringFlag{ &cli.StringFlag{
Name: "config", Name: "config",
Aliases: []string{"c"}, Aliases: []string{"c"},
@@ -69,18 +76,39 @@ func Main() int {
Usage: "cluster alias to work with", Usage: "cluster alias to work with",
Destination: &conf.CurrentCluster, Destination: &conf.CurrentCluster,
}, },
&cli.StringFlag{
Name: "output",
Aliases: []string{"o"},
Value: "",
Usage: "output mode (tsv, markdown, json, yaml) default: tsv",
Destination: &conf.Output,
},
}, },
Commands: []*cli.Command{ Commands: []*cli.Command{
Status(conf),
Search(conf), Search(conf),
Cluster(conf),
Ccr(conf),
Index(conf), Index(conf),
Shard(conf),
Snapshot(conf), Snapshot(conf),
Node(conf),
Doc(conf),
Repl(conf),
Version(conf),
Debug(conf),
Roles(conf),
HelpJsonPath(conf),
}, },
Before: func(ctx context.Context, cmd *cli.Command) (context.Context, error) { Before: func(ctx context.Context, cmd *cli.Command) (context.Context, error) {
if err := conf.Init(); err != nil { if err := conf.Init(); err != nil {
Finish(err) if len(os.Args) > 1 {
return nil, err
} else {
fmt.Println(cmd.UsageText)
return nil, nil
}
} }
log.Init(conf) log.Init(conf)
@@ -91,3 +119,76 @@ func Main() int {
return Finish(cmd.Run(context.Background(), os.Args)) return Finish(cmd.Run(context.Background(), os.Args))
} }
func HelpJsonPath(conf *cfg.Config) *cli.Command {
msg := `jsonPath usage:
name.last >> "Anderson"
age >> 37
children >> ["Sara","Alex","Jack"]
children.# >> 3
children.1 >> "Alex"
child*.2 >> "Jack"
c?ildren.0 >> "Sara"
fav\.movie >> "Deer Hunter"
friends.#.first >> ["Dale","Roger","Jane"]
friends.1.last >> "Craig"
You can also query an array for the first match by using #(...), or
find all matches with #(...)#. Queries support the ==, !=, <, <=, >,
>= comparison operators and the simple pattern matching % (like) and
!% (not like) operators. Eg:
friends.#(last=="Murphy").first >> "Dale"
friends.#(last=="Murphy")#.first >> ["Dale","Jane"]
friends.#(age>45)#.last >> ["Craig","Murphy"]
friends.#(first%"D*").last >> "Murphy"
friends.#(first!%"D*").last >> "Craig"
friends.#(nets.#(=="fb"))#.first >> ["Dale","Roger"]
Documentation: https://github.com/tidwall/gjson/blob/master/SYNTAX.md`
return &cli.Command{
Name: "help-jsonpath",
Usage: "show jsonpath help",
Action: func(ctx context.Context, cmd *cli.Command) error {
_, err := fmt.Println(msg)
return err
},
}
}
func Version(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "version",
Usage: "show esctl version information",
Action: func(ctx context.Context, cmd *cli.Command) error {
_, err := fmt.Printf("esctl version: %s\n build: %s\n branch: %s\n commit: %s\n go version: %s\n API Version: %s\n",
cfg.Version, cfg.BUILD, cfg.BRANCH, cfg.COMMIT, cfg.GOVERSION, cfg.APIVERSION)
return err
},
}
}
func Debug(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "debug",
Usage: "developer only",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "index",
Usage: "index to search within",
Sources: cli.EnvVars("ES_INDEX"),
Destination: &conf.Index,
Aliases: []string{"i"},
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
return es.Debug(conf)
},
}
}

View File

@@ -25,12 +25,33 @@ import (
"github.com/urfave/cli/v3" "github.com/urfave/cli/v3"
) )
const SearchUsage = `<sep> might be one of:
=: Must match
!=: Must not match
You can omit a field spec and thereby search across all fields.
By default all queries contribute to matches (logical AND), use
-O to apply a logical OR operator.
You can also search multiple fields by separating them with comma, eg:
user,group=root
Use filters to further restrict results, they must match literally.
For datetime range format refer to:
https://www.elastic.co/docs/reference/elasticsearch/rest-apis/common-options#date-math
For timestamp formats refer to:
https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/mapping-date-format
`
func Search(conf *cfg.Config) *cli.Command { func Search(conf *cfg.Config) *cli.Command {
return &cli.Command{ return &cli.Command{
Name: "search", Name: "search",
Aliases: []string{"/"}, Aliases: []string{"/"},
Usage: "search within an index", Usage: "search within an index",
UsageText: "search [options] <query>", UsageText: "search [options] [<[field<sep>]pattern> ...]\n" + SearchUsage,
Flags: []cli.Flag{ Flags: []cli.Flag{
&cli.StringFlag{ &cli.StringFlag{
@@ -42,32 +63,89 @@ func Search(conf *cfg.Config) *cli.Command {
}, },
&cli.IntFlag{ &cli.IntFlag{
Name: "from", Name: "from",
Usage: "show results FROM (default 0)", Usage: "show results starting at <from>",
Destination: &conf.From, Destination: &conf.From,
Value: 0, Value: 0,
Aliases: []string{"f"}, Aliases: []string{"f"},
}, },
&cli.IntFlag{ &cli.IntFlag{
Name: "to", Name: "len",
Usage: "show results to (default 10)", Usage: "number of results to show (-1: all[max:10k], caution: might be slow)",
Destination: &conf.To, Destination: &conf.To,
Value: 10, Value: 20,
Aliases: []string{"t"}, Aliases: []string{"l"},
}, },
&cli.StringSliceFlag{ &cli.StringSliceFlag{
Name: "filter", Name: "filter",
Usage: "additional filters. format: key=value", Usage: "additional boolean filters. format: key=value",
Destination: &conf.Filter, Destination: &conf.Filter,
Aliases: []string{"F"}, Aliases: []string{"F"},
}, },
&cli.StringFlag{
Name: "jsonpath",
Usage: "jsonPath filter (e.g. source.message)",
Destination: &conf.Path,
Aliases: []string{"p"},
},
&cli.StringFlag{
Name: "timerange",
Usage: "field:<date> to <date> (e.g. @timestamp:2026-05-05 to 2026-05-15)",
Destination: &conf.Range,
Aliases: []string{"r"},
},
&cli.StringFlag{
Name: "timestamp-format",
Usage: "a valid ES builtin timestamp or custom format",
Destination: &conf.TimestampFormat,
Value: "strict_date_hour_minute",
},
&cli.StringFlag{
Name: "sort-by",
Usage: "sort by a field",
Destination: &conf.SortBy,
Value: "@timestamp",
Aliases: []string{"k"},
},
&cli.BoolFlag{
Name: "ascending",
Usage: "sort in ascending order (default: descending)",
Destination: &conf.Ascending,
Aliases: []string{"a"},
},
&cli.BoolFlag{
Name: "tail",
Usage: "follow search live, like tail -f",
Destination: &conf.Tail,
Aliases: []string{"T"},
},
&cli.BoolFlag{
Name: "or",
Usage: "logical operator (default: and)",
Destination: &conf.Or,
Aliases: []string{"O"},
},
&cli.BoolFlag{
Name: "validate",
Usage: "validate search query",
Destination: &conf.Validate,
Aliases: []string{"v"},
},
&cli.BoolFlag{
Name: "explain",
Usage: "explain search query",
Destination: &conf.Explain,
Aliases: []string{"e"},
},
}, },
Action: func(ctx context.Context, cmd *cli.Command) error { Action: func(ctx context.Context, cmd *cli.Command) error {
if err := es.Search(conf, cmd.Args().Get(0)); err != nil { args := cmd.Args()
return err
if conf.To == -1 {
conf.To = 10000
} }
return nil return es.Search(conf, args.Slice())
}, },
} }
} }

96
cmd/shards.go Normal file
View File

@@ -0,0 +1,96 @@
/*
Copyright © 2026 Thomas von Dein
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package cmd
import (
"context"
"errors"
"codeberg.org/scip/esctl/pkg/cfg"
"codeberg.org/scip/esctl/pkg/es"
"github.com/urfave/cli/v3"
)
func Shard(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "shard",
Aliases: []string{"s"},
Usage: "manage shards",
Commands: []*cli.Command{
ShardList(conf),
ShardShow(conf),
},
}
}
func ShardList(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "list",
Aliases: []string{"ls"},
Usage: "list shards",
Flags: []cli.Flag{
&cli.IntFlag{
Name: "max",
Usage: "max number of items to process",
Destination: &conf.MaxItems,
Aliases: []string{"m"},
},
&cli.BoolFlag{
Name: "reds",
Usage: "include only failed shards",
Destination: &conf.Failed,
Aliases: []string{"r"},
},
&cli.BoolFlag{
Name: "primaries",
Usage: "show only primary shards",
Destination: &conf.Primary,
Aliases: []string{"p"},
},
&cli.BoolFlag{
Name: "verbose",
Usage: "show node name and ip as well",
Destination: &conf.Verbose,
Aliases: []string{"v"},
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
return es.ShardList(conf)
},
}
}
func ShardShow(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "show",
Aliases: []string{"sh"},
Usage: "show details about a shard",
Action: func(ctx context.Context, cmd *cli.Command) error {
index := cmd.Args().Get(0)
if index == "" {
return errors.New("no index specified")
}
return es.ShardShow(conf, index)
},
}
}

View File

@@ -54,11 +54,7 @@ func SnapshotList(conf *cfg.Config) *cli.Command {
}, },
Action: func(ctx context.Context, cmd *cli.Command) error { Action: func(ctx context.Context, cmd *cli.Command) error {
if err := es.SnapshotList(conf); err != nil { return es.SnapshotList(conf)
return err
}
return nil
}, },
} }
} }
@@ -71,11 +67,7 @@ func SnapshotShow(conf *cfg.Config) *cli.Command {
UsageText: "show [options] <snapshot>", UsageText: "show [options] <snapshot>",
Action: func(ctx context.Context, cmd *cli.Command) error { Action: func(ctx context.Context, cmd *cli.Command) error {
if err := es.SnapshotShow(conf, cmd.Args().Get(0)); err != nil { return es.SnapshotShow(conf, cmd.Args().Get(0))
return err
}
return nil
}, },
} }
} }

31
commandtree.sh Executable file
View File

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

32
go.mod
View File

@@ -17,29 +17,39 @@ module codeberg.org/scip/esctl
go 1.25.0 go 1.25.0
require ( require (
github.com/alecthomas/repr v0.5.2 // indirect github.com/alecthomas/repr v0.5.2
github.com/chzyer/readline v1.5.1
github.com/dustin/go-humanize v1.0.1
github.com/elastic/elastic-transport-go/v8 v8.11.0
github.com/elastic/go-elasticsearch/v9 v9.3.4
github.com/fatih/color v1.19.0
github.com/mattn/go-isatty v0.0.22
github.com/olekukonko/tablewriter v1.1.4
github.com/tlinden/yadu v0.1.3
github.com/urfave/cli/v3 v3.9.1-0.20260524212652-be8b79d0c8de
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/clipperhouse/displaywidth v0.10.0 // indirect github.com/clipperhouse/displaywidth v0.10.0 // indirect
github.com/clipperhouse/uax29/v2 v2.6.0 // indirect github.com/clipperhouse/uax29/v2 v2.6.0 // indirect
github.com/elastic/elastic-transport-go/v8 v8.11.0 // indirect
github.com/elastic/go-elasticsearch/v9 v9.3.2 // indirect
github.com/fatih/color v1.19.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect
github.com/goccy/go-json v0.10.6 // indirect
github.com/lmittmann/tint v1.1.3 // indirect github.com/lmittmann/tint v1.1.3 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.21 // indirect github.com/mattn/go-runewidth v0.0.23 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect
github.com/olekukonko/errors v1.2.0 // indirect github.com/olekukonko/errors v1.2.0 // indirect
github.com/olekukonko/ll v0.1.6 // indirect github.com/olekukonko/ll v0.1.8 // indirect
github.com/olekukonko/tablewriter v1.1.4 // indirect github.com/tidwall/gjson v1.19.0 // indirect
github.com/tlinden/yadu v0.1.3 // indirect github.com/tidwall/match v1.1.1 // indirect
github.com/urfave/cli/v3 v3.8.0 // indirect github.com/tidwall/pretty v1.2.0 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/otel v1.35.0 // indirect go.opentelemetry.io/otel v1.35.0 // indirect
go.opentelemetry.io/otel/metric v1.35.0 // indirect go.opentelemetry.io/otel/metric v1.35.0 // indirect
go.opentelemetry.io/otel/trace v1.35.0 // indirect go.opentelemetry.io/otel/trace v1.35.0 // indirect
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
golang.org/x/sys v0.42.0 // indirect golang.org/x/sys v0.42.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
) )

29
go.sum
View File

@@ -2,16 +2,24 @@ github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 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/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ=
github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI=
github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk=
github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8=
github.com/clipperhouse/displaywidth v0.10.0 h1:GhBG8WuerxjFQQYeuZAeVTuyxuX+UraiZGD4HJQ3Y8g= github.com/clipperhouse/displaywidth v0.10.0 h1:GhBG8WuerxjFQQYeuZAeVTuyxuX+UraiZGD4HJQ3Y8g=
github.com/clipperhouse/displaywidth v0.10.0/go.mod h1:XqJajYsaiEwkxOj4bowCTMcT1SgvHo9flfF3jQasdbs= github.com/clipperhouse/displaywidth v0.10.0/go.mod h1:XqJajYsaiEwkxOj4bowCTMcT1SgvHo9flfF3jQasdbs=
github.com/clipperhouse/uax29/v2 v2.6.0 h1:z0cDbUV+aPASdFb2/ndFnS9ts/WNXgTNNGFoKXuhpos= github.com/clipperhouse/uax29/v2 v2.6.0 h1:z0cDbUV+aPASdFb2/ndFnS9ts/WNXgTNNGFoKXuhpos=
github.com/clipperhouse/uax29/v2 v2.6.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/clipperhouse/uax29/v2 v2.6.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/elastic/elastic-transport-go/v8 v8.9.0 h1:KeT/2P54F0xS0S8Y3Pf+tFDg4HmBgReQMB+BMz8dDAs= github.com/elastic/elastic-transport-go/v8 v8.9.0 h1:KeT/2P54F0xS0S8Y3Pf+tFDg4HmBgReQMB+BMz8dDAs=
github.com/elastic/elastic-transport-go/v8 v8.9.0/go.mod h1:ssMTvNS2hwf7CaiGsRRsx4gQHFZ/jS/DkLcISxekWzc= github.com/elastic/elastic-transport-go/v8 v8.9.0/go.mod h1:ssMTvNS2hwf7CaiGsRRsx4gQHFZ/jS/DkLcISxekWzc=
github.com/elastic/elastic-transport-go/v8 v8.11.0 h1:taYmqC2M6+fZt/+W+ENYh/W5L9+KrlJGOSbEJs8egWc= github.com/elastic/elastic-transport-go/v8 v8.11.0 h1:taYmqC2M6+fZt/+W+ENYh/W5L9+KrlJGOSbEJs8egWc=
github.com/elastic/elastic-transport-go/v8 v8.11.0/go.mod h1:DZQ0szCNywc9F+C9l/Kkd4n69SvJVj0I3yK1Of7s3l8= github.com/elastic/elastic-transport-go/v8 v8.11.0/go.mod h1:DZQ0szCNywc9F+C9l/Kkd4n69SvJVj0I3yK1Of7s3l8=
github.com/elastic/go-elasticsearch/v9 v9.3.2 h1:nvtvfN/Gsp/rzUPz/9yILwDAsYJ3s5L0VmhR16zPKCA= github.com/elastic/go-elasticsearch/v9 v9.3.2 h1:nvtvfN/Gsp/rzUPz/9yILwDAsYJ3s5L0VmhR16zPKCA=
github.com/elastic/go-elasticsearch/v9 v9.3.2/go.mod h1:ubKUMJCJbX5V/gW5MIn2NQZyaEZ61ubXwJmD5UMNrM8= github.com/elastic/go-elasticsearch/v9 v9.3.2/go.mod h1:ubKUMJCJbX5V/gW5MIn2NQZyaEZ61ubXwJmD5UMNrM8=
github.com/elastic/go-elasticsearch/v9 v9.3.4 h1:vnqXl6jnlA+ZwlfRaKF9BR9woZC1KSB17gtyvgf8zVU=
github.com/elastic/go-elasticsearch/v9 v9.3.4/go.mod h1:ubKUMJCJbX5V/gW5MIn2NQZyaEZ61ubXwJmD5UMNrM8=
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
@@ -23,6 +31,8 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/lmittmann/tint v1.1.3 h1:Hv4EaHWXQr+GTFnOU4VKf8UvAtZgn0VuKT+G0wFlO3I= github.com/lmittmann/tint v1.1.3 h1:Hv4EaHWXQr+GTFnOU4VKf8UvAtZgn0VuKT+G0wFlO3I=
github.com/lmittmann/tint v1.1.3/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE= github.com/lmittmann/tint v1.1.3/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
@@ -32,20 +42,36 @@ github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stg
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs= github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
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/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 h1:zrbMGy9YXpIeTnGj4EljqMiZsIcE09mmF8XsD5AYOJc= github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 h1:zrbMGy9YXpIeTnGj4EljqMiZsIcE09mmF8XsD5AYOJc=
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6/go.mod h1:rEKTHC9roVVicUIfZK7DYrdIoM0EOr8mK1Hj5s3JjH0= github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6/go.mod h1:rEKTHC9roVVicUIfZK7DYrdIoM0EOr8mK1Hj5s3JjH0=
github.com/olekukonko/errors v1.2.0 h1:10Zcn4GeV59t/EGqJc8fUjtFT/FuUh5bTMzZ1XwmCRo= github.com/olekukonko/errors v1.2.0 h1:10Zcn4GeV59t/EGqJc8fUjtFT/FuUh5bTMzZ1XwmCRo=
github.com/olekukonko/errors v1.2.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y= github.com/olekukonko/errors v1.2.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y=
github.com/olekukonko/ll v0.1.6 h1:lGVTHO+Qc4Qm+fce/2h2m5y9LvqaW+DCN7xW9hsU3uA= github.com/olekukonko/ll v0.1.6 h1:lGVTHO+Qc4Qm+fce/2h2m5y9LvqaW+DCN7xW9hsU3uA=
github.com/olekukonko/ll v0.1.6/go.mod h1:NVUmjBb/aCtUpjKk75BhWrOlARz3dqsM+OtszpY4o88= github.com/olekukonko/ll v0.1.6/go.mod h1:NVUmjBb/aCtUpjKk75BhWrOlARz3dqsM+OtszpY4o88=
github.com/olekukonko/ll v0.1.8 h1:ysHCJRGHYKzmBSdz9w5AySztx7lG8SQY+naTGYUbsz8=
github.com/olekukonko/ll v0.1.8/go.mod h1:RPRC6UcscfFZgjo1nulkfMH5IM0QAYim0LfnMvUuozw=
github.com/olekukonko/tablewriter v1.1.4 h1:ORUMI3dXbMnRlRggJX3+q7OzQFDdvgbN9nVWj1drm6I= github.com/olekukonko/tablewriter v1.1.4 h1:ORUMI3dXbMnRlRggJX3+q7OzQFDdvgbN9nVWj1drm6I=
github.com/olekukonko/tablewriter v1.1.4/go.mod h1:+kedxuyTtgoZLwif3P1Em4hARJs+mVnzKxmsCL/C5RY= github.com/olekukonko/tablewriter v1.1.4/go.mod h1:+kedxuyTtgoZLwif3P1Em4hARJs+mVnzKxmsCL/C5RY=
github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU=
github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tlinden/yadu v0.1.3 h1:5cRCUmj+l5yvlM2irtpFBIJwVV2DPEgYSaWvF19FtcY= github.com/tlinden/yadu v0.1.3 h1:5cRCUmj+l5yvlM2irtpFBIJwVV2DPEgYSaWvF19FtcY=
github.com/tlinden/yadu v0.1.3/go.mod h1:l3bRmHKL9zGAR6pnBHY2HRPxBecf7L74BoBgOOpTcUA= github.com/tlinden/yadu v0.1.3/go.mod h1:l3bRmHKL9zGAR6pnBHY2HRPxBecf7L74BoBgOOpTcUA=
github.com/urfave/cli/v3 v3.8.0 h1:XqKPrm0q4P0q5JpoclYoCAv0/MIvH/jZ2umzuf8pNTI= github.com/urfave/cli/v3 v3.8.0 h1:XqKPrm0q4P0q5JpoclYoCAv0/MIvH/jZ2umzuf8pNTI=
github.com/urfave/cli/v3 v3.8.0/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso= github.com/urfave/cli/v3 v3.8.0/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso=
github.com/urfave/cli/v3 v3.9.0 h1:AV9lIiPv3ukYnxunaCUsHnEozptYmDN2F0+yWqLMn/c=
github.com/urfave/cli/v3 v3.9.0/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso=
github.com/urfave/cli/v3 v3.9.1-0.20260524212652-be8b79d0c8de h1:ESKPiS7inVoBnv4FmgGNZdjWBI/wmvaragoyD3D9nM4=
github.com/urfave/cli/v3 v3.9.1-0.20260524212652-be8b79d0c8de/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ=
@@ -54,6 +80,9 @@ go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/
go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE= go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE=
go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs=
go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=

View File

@@ -17,9 +17,13 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
package cfg package cfg
import ( import (
"bytes"
"context"
"crypto/tls" "crypto/tls"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"log/slog"
"net/http" "net/http"
"os" "os"
@@ -30,7 +34,12 @@ import (
) )
const ( const (
Version string = `v0.0.2` Version string = `v0.0.18`
)
var (
// initialized during build, see Makefile:buildlocal
APIVERSION, GOVERSION, BUILD, COMMIT, BRANCH string
) )
type Cluster struct { type Cluster struct {
@@ -39,15 +48,41 @@ type Cluster struct {
} }
type Config struct { type Config struct {
ConfigFile string // -c ConfigFile string // -c
CurrentCluster string // -C CurrentCluster string // -C
Debug bool // -d Debug bool // -d
Clusters map[string]*Cluster Output string // -o <mode>
DefaultCluster *Cluster Clusters map[string]*Cluster
From, To, MaxItems int DefaultCluster *Cluster
Index string Index string // index: -i
Filter []string Failed, Partials bool // index: flags
Failed, Partials bool Shards, Replicas int // index create+allocation: -s -r
Wait bool // index create: -w
Primary bool // index allocation: -p
Searchable bool // index fields: -s
Aggretable bool // index fields: -a
From, To, MaxItems int // search: flags
Filter []string // search: -F
Path string // search+doc sh: -p
Subhelp bool // search+doc sh: -H
Tail bool // search: -f [tail]
Or bool // search: -O
Range string // search: -r
TimestampFormat string // search: --timestamp-format
Explain bool // search: -e
Validate bool // search: --validate
SortBy string // sort: -k
Ascending bool // sort: -a
Exclude string // cluster compare: -e (regexp)
All, Verbose bool // cluster status: -a -v
Persistent, Transient, Default bool // -p -t -D cluster settings set
Force bool // ccr follower renew: -f
HaveJQ bool // determined at runtime by ourselfes
DebugHTTP bool // root: --debug-http
Separator string // role diff: -s
NotDeployed bool // role diff: -n
Undefined bool // role diff: -u
Diff bool // role diff: -D
} }
func NewConfig() *Config { func NewConfig() *Config {
@@ -55,16 +90,26 @@ func NewConfig() *Config {
} }
func (conf *Config) Init() error { func (conf *Config) Init() error {
if conf.ConfigFile != "" { DefaultConfig := os.Getenv("HOME") + "/.config/esctl/config.yaml"
switch {
case fileExists(DefaultConfig):
conf.ConfigFile = DefaultConfig
fallthrough
case conf.ConfigFile != "":
if err := conf.LoadConfig(); err != nil { if err := conf.LoadConfig(); err != nil {
return err return err
} }
} else { default:
if err := conf.LoadEnv(); err != nil { if err := conf.LoadEnv(); err != nil {
return err return err
} }
} }
if err := conf.SetupES(); err != nil {
return err
}
if conf.CurrentCluster != "" { if conf.CurrentCluster != "" {
current, exists := conf.Clusters[conf.CurrentCluster] current, exists := conf.Clusters[conf.CurrentCluster]
if !exists { if !exists {
@@ -72,17 +117,50 @@ func (conf *Config) Init() error {
} else { } else {
conf.DefaultCluster = current 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 conf.Debug { conf.HaveJQ = isJQinstalled()
repr.Println(conf)
}
conf.SetupES() conf.PrintDebug()
return nil return nil
} }
func (conf *Config) PrintDebug() {
if !conf.Debug {
return
}
clone := *conf
for name := range clone.Clusters {
clone.Clusters[name] = nil
}
clone.DefaultCluster = nil
fmt.Println("config:")
repr.Println(clone)
}
func (conf *Config) LoadEnv() error { func (conf *Config) LoadEnv() error {
cluster := Cluster{ cluster := Cluster{
Uri: os.Getenv("ES_URI"), Uri: os.Getenv("ES_URI"),
@@ -140,18 +218,26 @@ func (conf *Config) LoadConfig() error {
return nil 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 { func (conf *Config) SetupES() error {
for _, cluster := range conf.Clusters { for _, cluster := range conf.Clusters {
es, err := elasticsearch.NewTyped( es, err := elasticsearch.NewTyped(
elasticsearch.WithAddresses(cluster.Uri), elasticsearch.WithAddresses(cluster.Uri),
elasticsearch.WithBasicAuth(cluster.User, cluster.Pass), elasticsearch.WithBasicAuth(cluster.User, cluster.Pass),
elasticsearch.WithTransportOptions( elasticsearch.WithTransportOptions(conf.getTransport()),
elastictransport.WithTransport(
&http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
},
),
),
) )
if err != nil { if err != nil {
@@ -163,3 +249,47 @@ func (conf *Config) SetupES() error {
return nil 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)
if err != nil {
// return false on any error
return false
}
return !info.IsDir()
}

38
pkg/cfg/jq.go Normal file
View File

@@ -0,0 +1,38 @@
/*
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 (
"context"
"os/exec"
"strings"
)
func isJQinstalled() bool {
cmd := exec.CommandContext(context.Background(), "jq", "-h")
out, err := cmd.Output()
if err != nil {
return false
}
if strings.Contains(string(out), "Usage") {
return true
}
return false
}

135
pkg/es/ccr.go Normal file
View File

@@ -0,0 +1,135 @@
/*
Copyright © 2026 Thomas von Dein
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package es
import (
"context"
"errors"
"fmt"
"log/slog"
"codeberg.org/scip/esctl/pkg/cfg"
"codeberg.org/scip/esctl/pkg/printer"
"github.com/elastic/go-elasticsearch/v9/typedapi/types"
)
const (
shard_pause = `cluster.routing.allocation.enable`
)
func CcrShardPause(conf *cfg.Config) error {
return ClusterSettingsSetSingle(conf, shard_pause, "none")
}
func CcrShardResume(conf *cfg.Config) error {
return ClusterSettingsSetSingle(conf, shard_pause, "all")
}
func CcrStatus(conf *cfg.Config, leader, follower string) error {
if !checkClusterIsLeader(conf, leader) {
if !checkClusterIsLeader(conf, follower) {
return errors.New("leader/follower attribution is invalid, both clusters are followers")
}
// reverse attribution
f := follower
follower = leader
leader = f
slog.Debug("leader/follower attribution is invalid, reversing", "leader", leader, "follower", follower)
}
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())
if err != nil {
return fmt.Errorf("failed to get indicies on %s: %s", alias, esErrorString(err))
}
indices[alias] = make(map[string]*types.IndicesRecord, len(res))
for _, index := range res {
indices[alias][*index.Index] = &index
}
}
if !checkClusterStatus(conf, leader, follower) {
return errors.New("one of the two clusters is in a failed state")
}
findIlmErrors(conf, leader, follower)
if findIndicesOnlyOnLeader(conf, indices, leader, follower) &&
findOrphanedIndices(conf, indices, leader, follower) &&
findFailedFollowerIndices(conf, indices, follower) {
fmt.Println("everything's hunky-dory.")
}
return nil
}
func CcrRemoteInfo(conf *cfg.Config, index string) error {
res, err := conf.DefaultCluster.ES.Cluster.RemoteInfo().
Header("content-type", "application/json").
Header("accept", "application/json").
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to retrieve follower info: %s", esErrorString(err))
}
slog.Debug("ccr remote info", "info", res)
remote := ""
var info *types.ClusterRemoteProxyInfo
for name, data := range res {
remote = name
info = data.(*types.ClusterRemoteProxyInfo)
break
}
if remote == "" {
return fmt.Errorf("cluster doesn't follow any other: %s", err)
}
mode := "follower"
if checkClusterIsLeader(conf, conf.CurrentCluster) {
mode = "leader"
}
table := printer.NewTable(conf, 2, 5)
table.Addheaders("ccr remote property", "value")
table.Entries = [][]string{
{"Remote Cluster", remote},
{"CCR Mode", mode},
{"Connected", fmt.Sprintf("%t", info.Connected)},
{"Num Proxy Sockets Connected", fmt.Sprintf("%d", info.NumProxySocketsConnected)},
{"Proxy Address", info.ProxyAddress},
}
if err := table.Print(); err != nil {
return err
}
return nil
}

199
pkg/es/ccr_follower.go Normal file
View File

@@ -0,0 +1,199 @@
/*
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 getRemoteName(conf *cfg.Config) (string, error) {
res, err := conf.DefaultCluster.ES.Cluster.RemoteInfo().
Header("content-type", "application/json").
Header("accept", "application/json").
Do(context.Background())
if err != nil {
return "", fmt.Errorf("failed to retrieve follower info: %s", esErrorString(err))
}
remote := ""
for name := range res {
remote = name
break
}
if remote == "" {
return "", fmt.Errorf("cluster doesn't have a follower")
}
return remote, nil
}
func wrapError(call func(*cfg.Config, string) error, conf *cfg.Config, index string) error {
err := call(conf, index)
if conf.Force {
fmt.Printf("caught error: %s, continuing anyway", err)
} else {
return err
}
return nil
}
func CcrFollowerRenew(conf *cfg.Config, index string) error {
if err := wrapError(IndexClose, conf, index); err != nil {
return err
}
fmt.Printf("closed %s", index)
if err := wrapError(CcrFollowerPause, conf, index); err != nil {
return err
}
fmt.Printf("paused %s", index)
if err := wrapError(CcrFollowerUnfollow, conf, index); err != nil {
return err
}
fmt.Printf("unfollowed %s", index)
if err := wrapError(IndexDelete, conf, index); err != nil {
return err
}
fmt.Printf("deleted %s", index)
if err := CcrFollowerAdd(conf, index); err != nil {
return err
}
fmt.Printf("added follower %s", index)
return nil
}
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())
if err != nil {
return fmt.Errorf("failed to resume ccr following: %s", esErrorString(err))
}
return nil
}
func CcrFollowerPause(conf *cfg.Config, index string) error {
create := conf.DefaultCluster.ES.Ccr.PauseFollow(index).
Header("content-type", "application/json").
Header("accept", "application/json")
_, err := create.Do(context.Background())
if err != nil {
return fmt.Errorf("failed to pause ccr following: %s", esErrorString(err))
}
return nil
}
func CcrFollowerUnfollow(conf *cfg.Config, index string) error {
create := conf.DefaultCluster.ES.Ccr.ForgetFollower(index).
Header("content-type", "application/json").
Header("accept", "application/json")
_, err := create.Do(context.Background())
if err != nil {
return fmt.Errorf("failed to unfollow index: %s", esErrorString(err))
}
return nil
}
func CcrFollowerAdd(conf *cfg.Config, index string) error {
remote, err := getRemoteName(conf)
if err != nil {
return err
}
create := conf.DefaultCluster.ES.Ccr.Follow(index).
LeaderIndex(index).
RemoteCluster(remote).
Header("content-type", "application/json").
Header("accept", "application/json")
if conf.Wait {
create.WaitForActiveShards("all")
}
_, err = create.Do(context.Background())
if err != nil {
return fmt.Errorf("failed to create follower index: %s", esErrorString(err))
}
return nil
}
func CcrFollowerShow(conf *cfg.Config, index string) error {
res, err := conf.DefaultCluster.ES.Ccr.FollowStats(index).
Header("content-type", "application/json").
Header("accept", "application/json").
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to retrieve follower index info: %s", esErrorString(err))
}
slog.Debug("ES result", "follower stats", res.Indices)
if len(res.Indices) == 0 {
return fmt.Errorf("cluster did not return any follower stats for index %s", index)
}
if len(res.Indices[0].Shards) == 0 {
return fmt.Errorf("cluster did not return any shard stats on follower index %s", index)
}
follower := res.Indices[0].Shards[0]
table := printer.NewTable(conf, 2, 9)
table.Addheaders("ccr follower property", "value")
table.Entries = [][]string{
{"name", index},
{"remote_cluster", follower.RemoteCluster},
{"leader_checkpoint", fmt.Sprintf("%d", follower.LeaderGlobalCheckpoint)},
{"follower_checkpoint", fmt.Sprintf("%d", follower.FollowerGlobalCheckpoint)},
{"bytes_read", fmt.Sprintf("%d", follower.BytesRead)},
{"failed_read_requests", fmt.Sprintf("%d", follower.FailedReadRequests)},
{"failed_write_requests", fmt.Sprintf("%d", follower.FailedWriteRequests)},
{"successful_read_requests", fmt.Sprintf("%d", follower.SuccessfulReadRequests)},
{"successful_write_requests", fmt.Sprintf("%d", follower.SuccessfulWriteRequests)},
}
if err := table.Print(); err != nil {
return err
}
return nil
}

233
pkg/es/cluster.go Normal file
View File

@@ -0,0 +1,233 @@
/*
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"
"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/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
)
type ClusterIndices map[string]map[string]*types.IndicesRecord
type apiResponse struct {
error error
info *info.Response
health *health.Response
ccr *stats.Response
stats *clusterstats.Response
which int
}
func ClusterList(conf *cfg.Config) error {
table := printer.NewTable(conf, 3, len(conf.Clusters))
table.Addheaders("cluster", "uri", "default")
idx := 0
names := make([]string, len(conf.Clusters))
for name := range conf.Clusters {
names[idx] = name
idx++
}
slices.Sort(names)
for idx, name := range names {
current := name == "default" || name == conf.CurrentCluster
cluster := conf.Clusters[name]
_, err := cluster.ES.Cluster.Health().
Header("content-type", "application/json").
Header("accept", "application/json").
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
}
// 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 := 3
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
}
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")
if conf.Verbose {
go getClusterData(es, wg, responses, "stats")
}
wg.Wait()
var clusterhealth *health.Response
var info *info.Response
var ccrstats *stats.Response
var clusterstats *clusterstats.Response
for i := 0; i < gocount; i++ {
r := <-responses
if r.error != nil {
return r.error
}
switch r.which {
case ResponseHealth:
clusterhealth = r.health
case ResponseCcr:
ccrstats = r.ccr
case ResponseInfo:
info = r.info
case ResponseStats:
clusterstats = r.stats
}
}
slog.Debug("ES result", "cluster health", clusterhealth)
ccrfollowing := ""
if len(ccrstats.AutoFollowStats.AutoFollowedClusters) > 0 {
// is following another cluster
ccrfollowing = fmt.Sprintf("%s (%d/%d)",
ccrstats.AutoFollowStats.AutoFollowedClusters[0].ClusterName,
ccrstats.AutoFollowStats.NumberOfSuccessfulFollowIndices,
ccrstats.AutoFollowStats.NumberOfFailedFollowIndices,
)
}
table := printer.NewTable(conf, 2, 7)
table.Addheaders(cluster, "status")
table.Entries = [][]string{
{"Cluster Name", clusterhealth.ClusterName},
{"ES Status", printer.Colorize(conf, clusterhealth.Status.Name, clusterhealth.Status.Name)},
{"ES Version", info.Version.Int},
{"Active Shards", fmt.Sprintf("%d", clusterhealth.ActiveShards)},
{"Active Primary Shards", fmt.Sprintf("%d", clusterhealth.ActivePrimaryShards)},
{"Nodes", fmt.Sprintf("%d", clusterhealth.NumberOfNodes)},
{"AutoFollow (success/failed indices)", ccrfollowing},
}
if conf.Verbose {
table = gatherClusterStats(conf, clusterstats, table)
}
if err := table.Print(); err != nil {
return err
}
}
return nil
}
func gatherClusterStats(conf *cfg.Config, clusterstats *clusterstats.Response, table *printer.Table) *printer.Table {
var querycount int64
var vmversion string
for _, count := range clusterstats.Indices.Search.Queries {
querycount += count
}
if len(clusterstats.Nodes.Jvm.Versions) > 0 {
vmversion = strings.Join([]string{
clusterstats.Nodes.Jvm.Versions[0].VmName,
clusterstats.Nodes.Jvm.Versions[0].VmVersion}, " ")
}
isleader := checkClusterIsLeader(conf, conf.CurrentCluster)
table.Entries = append(table.Entries, [][]string{
{"Indicies", fmt.Sprintf("%d", clusterstats.Indices.Count)},
{"Is Leader", fmt.Sprintf("%t", isleader)},
{"Docs", fmt.Sprintf("%d", clusterstats.Indices.Docs.Count)},
{"Total Size", humanize.Bytes(uint64(clusterstats.Indices.Docs.TotalSizeInBytes))},
{"Total Queries", fmt.Sprintf("%d", querycount)},
{"Shards Primaries", fmt.Sprintf("%d", clusterstats.Indices.Shards.Primaries)},
{"Shards Total", fmt.Sprintf("%d", clusterstats.Indices.Shards.Total)},
{"Storage", fmt.Sprintf(
"%s/%s",
humanize.Bytes(uint64(clusterstats.Indices.Store.SizeInBytes)),
humanize.Bytes(uint64(*clusterstats.Indices.Store.TotalDataSetSizeInBytes)),
)},
{"JVM Heap", fmt.Sprintf(
"%s/%s",
humanize.Bytes(uint64(clusterstats.Nodes.Jvm.Mem.HeapUsedInBytes)),
humanize.Bytes(uint64(clusterstats.Nodes.Jvm.Mem.HeapMaxInBytes)),
)},
{"JVM Threads", fmt.Sprintf("%d", clusterstats.Nodes.Jvm.Threads)},
{"JVM Version", vmversion},
{"CPUs", fmt.Sprintf("%d", clusterstats.Nodes.Os.AllocatedProcessors)},
{"CPU Usage", fmt.Sprintf("%d%%", clusterstats.Nodes.Process.Cpu.Percent)},
{"Open FDs", fmt.Sprintf("%d", clusterstats.Nodes.Process.OpenFileDescriptors.Avg)},
}...)
return table
}

161
pkg/es/cluster_settings.go Normal file
View File

@@ -0,0 +1,161 @@
/*
Copyright © 2026 Thomas von Dein
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package es
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"codeberg.org/scip/esctl/pkg/cfg"
"codeberg.org/scip/esctl/pkg/printer"
"github.com/urfave/cli/v3"
)
// recursively traverse the raw settings hash and build a flat map
// consisting of the translated path and its value.
//
// e.g.
// logger:
//
// org:
// elasticsearch:
// transport:
// OutboundHandler: "ERROR"
//
// gets:
//
// logger.org.elasticsearch.transport.OutboundHandler: "ERROR"
func getJsonPath(raw map[string]any, topic string) map[string]string {
paths := map[string]string{}
for name, data := range raw {
path := topic + "." + name
switch value := data.(type) {
case string:
paths[path] = value
case map[string]any:
paths = getJsonPath(value, path)
}
}
return paths
}
func ClusterSettingsList(conf *cfg.Config) error {
res, err := conf.DefaultCluster.ES.Cluster.GetSettings().
Header("content-type", "application/json").
Header("accept", "application/json").
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to get cluster settings: %s", esErrorString(err))
}
table := printer.NewTable(conf, 2, 0)
table.Addheaders("setting", "value")
entries := [][]string{}
settingshash := res.Persistent // == map[string]json.RawMessage
switch {
case conf.Transient:
settingshash = res.Transient
case conf.Default:
settingshash = res.Defaults
}
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(data, topic)
slog.Debug("settings", topic, paths)
for setting, value := range paths {
entries = append(entries, []string{setting, fmt.Sprintf("%v", value)})
}
}
table.Entries = entries
table.Sort()
if err := table.Print(); err != nil {
return err
}
return nil
}
func ClusterSettingsSet(conf *cfg.Config, args cli.Args) error {
put := conf.Clusters[conf.CurrentCluster].ES.Cluster.PutSettings()
for _, arg := range args.Slice() {
setting, value := splitArg(arg)
switch {
case conf.Transient:
message, err := json.Marshal(value)
if err != nil {
return fmt.Errorf("failed to marshall transient value <%v> to valid JSON: %s", value, err)
}
put.AddTransient(setting, message)
case conf.Persistent:
fallthrough
default:
message, err := json.Marshal(value)
if err != nil {
return fmt.Errorf("failed to marshall persistent value <%v> to valid JSON: %s", value, err)
}
put.AddPersistent(setting, message)
}
}
_, err := put.
Header("content-type", "application/json").
Header("accept", "application/json").
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to set settings: %s", esErrorString(err))
}
return nil
}
func ClusterSettingsSetSingle(conf *cfg.Config, setting, value string) error {
message, err := json.Marshal(value)
if err != nil {
return fmt.Errorf("failed to marshall persistent value <%v> to valid JSON: %s", value, err)
}
_, err = conf.Clusters[conf.CurrentCluster].ES.Cluster.PutSettings().
AddPersistent(setting, message).
Header("content-type", "application/json").
Header("accept", "application/json").
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to set %s: %s", setting, esErrorString(err))
}
return nil
}

399
pkg/es/cluster_util.go Normal file
View File

@@ -0,0 +1,399 @@
/*
Copyright © 2026 Thomas von Dein
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package es
import (
"context"
"errors"
"fmt"
"regexp"
"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"
)
const (
DefaultExclude = `(part|monitoring|.internal|metrics-endpoint)`
)
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").
Do(context.Background())
if err != nil {
fmt.Printf("failed to get ccr stats from %s: %s", leader, esErrorString(err))
return false
}
if len(stats.AutoFollowStats.AutoFollowedClusters) == 0 {
// is not following anyone
return true
}
if stats.AutoFollowStats.AutoFollowedClusters[0].ClusterName != "" {
fmt.Println("leader/follower attribution is invalid, reverse cluster attribution and retry")
return false
}
return true
}
// checks if both clusters are green
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").
Do(context.Background())
if err != nil {
fmt.Printf("failed to get health from %s: %s", cluster, esErrorString(err))
return false
}
status[cluster] = st
}
table := printer.NewTable(conf, 3, 6)
table.Addheaders("setting", "leader:"+leader, "follower:"+follower)
table.Entries = [][]string{
{"Cluster Name", status[leader].ClusterName, status[follower].ClusterName},
{"Cluster Status",
printer.Colorize(conf, status[leader].Status.Name, status[leader].Status.Name),
printer.Colorize(conf, status[follower].Status.Name, status[follower].Status.Name),
},
{"Active Shards",
fmt.Sprintf("%d", status[leader].ActiveShards),
fmt.Sprintf("%d", status[follower].ActiveShards),
},
{"Active Primary Shards",
fmt.Sprintf("%d", status[leader].ActivePrimaryShards),
fmt.Sprintf("%d", status[follower].ActivePrimaryShards),
},
{"Indicies",
fmt.Sprintf("%d", len(status[leader].Indices)),
fmt.Sprintf("%d", len(status[follower].Indices)),
},
{"Nodes",
fmt.Sprintf("%d", status[leader].NumberOfNodes),
fmt.Sprintf("%d", status[follower].NumberOfNodes),
},
}
if err := table.Print(); err != nil {
fmt.Println(err)
return false
}
fmt.Println()
if status[leader].Status.Name == "green" && status[follower].Status.Name == "green" {
return true
}
return false
}
// finds indices on both clusters which have ilm errors
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").
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))
return false
}
failed[cluster] = map[string]string{}
for name, ilmstate := range ilm.Indices {
count := ilmstate.(*types.LifecycleExplainManaged).FailedStepRetryCount
if count != nil && *count > 0 {
failed[cluster][name] = fmt.Sprintf("%d", *count)
}
}
}
if len(failed[leader]) == 0 && len(failed[follower]) == 0 {
return true
}
for idx, cluster := range []string{leader, follower} {
which := "leader"
if idx > 0 {
which = "follower"
}
if len(failed[cluster]) > 0 {
idx := 0
table := printer.NewTable(conf, 2, len(failed[cluster]))
table.Addheaders("ilm errors on "+which, "errors")
for name, count := range failed[cluster] {
table.Entries[idx] = []string{name, count}
idx++
}
table.Sort()
if err := table.Print(); err != nil {
fmt.Println(err)
return false
}
}
}
return false
}
// find unsynchronized indicies only present on leader
func findIndicesOnlyOnLeader(conf *cfg.Config, indices ClusterIndices, leader, follower string) bool {
exclude := regexp.MustCompile(DefaultExclude)
if conf.Exclude != "" {
exclude = regexp.MustCompile(conf.Exclude)
}
indexOnlyOnLeader := map[string]*types.IndicesRecord{}
for name, index := range indices[leader] {
if exclude.MatchString(name) {
continue
}
_, 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").
Do(context.Background())
if err != nil {
continue // ignore it then
}
_, defined := res[name]
if !defined {
// json response map didn't contain the index
continue
}
isWritable := false
for _, alias := range res[name].Aliases {
if *alias.IsWriteIndex {
isWritable = true
break
}
}
if isWritable {
// ignore index if associated alias index is writing
continue
}
indexOnlyOnLeader[name] = index
}
}
if len(indexOnlyOnLeader) == 0 {
return true
}
idx := 0
table := printer.NewTable(conf, 3, len(indexOnlyOnLeader))
table.Addheaders("index only on leader", "size", "docscount")
for name, index := range indexOnlyOnLeader {
name := printer.Colorize(conf, "red", name)
table.Entries[idx] = []string{name, *index.DatasetSize, *index.DocsCount}
idx++
}
table.Sort()
if err := table.Print(); err != nil {
fmt.Println(err)
return false
}
return false
}
// find indices only present on follower
func findOrphanedIndices(conf *cfg.Config, indices ClusterIndices, leader, follower string) bool {
orphaned := map[string]*types.IndicesRecord{}
for name, index := range indices[follower] {
_, 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").
Do(context.Background())
if err != nil {
continue // ignore it then
}
_, defined := res[name]
if !defined {
// json response map didn't contain the index
continue
}
orphaned[name] = index
}
}
if len(orphaned) == 0 {
return true
}
idx := 0
table := printer.NewTable(conf, 3, len(orphaned))
table.Addheaders("orphaned index on follower", "size", "docscount")
for name, index := range orphaned {
name := printer.Colorize(conf, "red", name)
table.Entries[idx] = []string{name, *index.DatasetSize, *index.DocsCount}
idx++
}
table.Sort()
if err := table.Print(); err != nil {
fmt.Println(err)
return false
}
return false
}
// find red indices on follower
func findFailedFollowerIndices(conf *cfg.Config, indices ClusterIndices, follower string) bool {
red := map[string]*types.IndicesRecord{}
for name, index := range indices[follower] {
if *index.Health == "red" {
red[name] = index
}
}
if len(red) == 0 {
return true
}
idx := 0
table := printer.NewTable(conf, 3, len(red))
table.Addheaders("red index on follower", "size", "docscount")
for name, index := range red {
name := printer.Colorize(conf, "red", name)
table.Entries[idx] = []string{name, *index.DatasetSize, *index.DocsCount}
idx++
}
table.Sort()
if err := table.Print(); err != nil {
fmt.Println(err)
return false
}
return false
}
func splitArg(arg string) (string, string) {
parts := strings.Split(arg, ":")
switch len(parts) {
case 0:
fallthrough
case 1:
return arg, ""
default:
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
}
if arerr != nil {
ar.error = fmt.Errorf("failed to get cluster health: %s", arerr)
}
reschan <- ar
}

132
pkg/es/doc.go Normal file
View File

@@ -0,0 +1,132 @@
/*
Copyright © 2026 Thomas von Dein
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package es
import (
"context"
"encoding/json"
"errors"
"fmt"
"math/rand/v2"
"strings"
"codeberg.org/scip/esctl/pkg/cfg"
"github.com/elastic/go-elasticsearch/v9/typedapi/core/deletebyquery"
"github.com/elastic/go-elasticsearch/v9/typedapi/esdsl"
"github.com/tidwall/gjson"
)
// create index with:
//
// esctl index create foo [id:keyword user:text age:integer]
//
// then add a doc:
//
// esctl doc add foo2 '{"id":"d8d8d","user":"scip"}'
func DocAdd(conf *cfg.Config, jsondoc string) error {
data := map[string]any{}
err := json.Unmarshal([]byte(jsondoc), &data)
if err != nil {
return fmt.Errorf("supplied document was not valid JSON: %s", err)
}
now := fmt.Sprintf("%d", rand.Int64())
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))
}
fmt.Println(res.Id_)
return nil
}
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").
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to retrieve doc in index %s: %s", conf.Index, esErrorString(err))
}
if !res.Found {
return errors.New("no document with that id found")
}
docjson := fmt.Sprintf(`{"id":%s, "index":"%s", "source":%s}`,
res.Id_,
res.Index_,
res.Source_)
if conf.Path != "" {
value := gjson.Get(docjson, conf.Path)
fmt.Println(value.String())
} else {
fmt.Println(docjson)
}
fmt.Println()
return nil
}
func DocDelete(conf *cfg.Config, queries []string) error {
if len(queries) == 1 && strings.Contains(queries[0], "id=") {
id, _ := strings.CutPrefix(queries[0], "id=")
_, err := conf.DefaultCluster.ES.Delete(conf.Index, id).
Header("content-type", "application/json").
Header("accept", "application/json").
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to delete doc in index %s: %s", conf.Index, esErrorString(err))
}
return nil
}
req := &deletebyquery.Request{}
if len(queries) == 0 && conf.All {
req.Query = esdsl.NewMatchAllQuery().QueryCaster()
} else {
queryCaster, err := prepareQuery(conf, queries)
if err != nil {
return err
}
req.Query = queryCaster
}
_, err := conf.DefaultCluster.ES.DeleteByQuery(conf.Index).
Request(req).
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))
}
return nil
}

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

@@ -0,0 +1,45 @@
/*
Copyright © 2026 Thomas von Dein
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package es
import (
"fmt"
"github.com/elastic/go-elasticsearch/v9/typedapi/types"
)
func esErrorString(err error) string {
msg := err.Error()
switch e := err.(type) {
case *types.ElasticsearchError:
causes := ""
for _, cause := range e.ErrorCause.RootCause {
causes += fmt.Sprintf("%s\n", *cause.Reason)
}
if e.ErrorCause.Reason != nil {
msg = *e.ErrorCause.Reason + ": " + causes
} else {
msg = fmt.Sprintf("http status %d: ", e.Status)
}
}
return msg
}

View File

@@ -20,13 +20,70 @@ import (
"context" "context"
"fmt" "fmt"
"log/slog" "log/slog"
"regexp"
"slices"
"strconv"
"strings"
"time"
"codeberg.org/scip/esctl/pkg/cfg" "codeberg.org/scip/esctl/pkg/cfg"
"codeberg.org/scip/esctl/pkg/printer"
"github.com/elastic/go-elasticsearch/v9/typedapi/cat/indices"
"github.com/elastic/go-elasticsearch/v9/typedapi/esdsl"
"github.com/elastic/go-elasticsearch/v9/typedapi/types/enums/healthstatus" "github.com/elastic/go-elasticsearch/v9/typedapi/types/enums/healthstatus"
) )
// 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").
Do(context.Background())
if err != nil {
return nil, fmt.Errorf("failed to get indicies: %s", esErrorString(err))
}
indices := make([]string, len(res))
for idx, index := range res {
indices[idx] = *index.Index
}
return indices, nil
}
func filterIndices(conf *cfg.Config, list indices.Response) indices.Response {
// apply partials filter first
selectedlist := indices.Response{}
for _, index := range list {
if !conf.Partials && strings.HasPrefix(*index.Index, "partial-") {
continue
}
selectedlist = append(selectedlist, index)
}
if len(conf.Filter) == 0 {
return selectedlist
}
// we support just one filter here, for now
filter := *regexp.MustCompile(conf.Filter[0])
newlist := indices.Response{}
for _, index := range selectedlist {
if filter.MatchString(*index.Index) {
newlist = append(newlist, index)
}
}
return newlist
}
func IndexList(conf *cfg.Config) error { func IndexList(conf *cfg.Config) error {
cat := conf.DefaultCluster.ES.Cat.Indices() 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")
if conf.Failed { if conf.Failed {
cat = cat.Health(healthstatus.Red) cat = cat.Health(healthstatus.Red)
@@ -34,12 +91,14 @@ func IndexList(conf *cfg.Config) error {
res, err := cat.Do(context.Background()) res, err := cat.Do(context.Background())
if err != nil { if err != nil {
return fmt.Errorf("Error getting indicies: %s", err) return fmt.Errorf("failed to get indicies: %s", esErrorString(err))
} }
slog.Debug("ES result", "indicies", res) slog.Debug("ES result", "indicies", res)
size := len(res) list := filterIndices(conf, res)
size := len(list)
if conf.MaxItems > 0 { if conf.MaxItems > 0 {
if size > conf.MaxItems { if size > conf.MaxItems {
@@ -47,13 +106,13 @@ func IndexList(conf *cfg.Config) error {
} }
} }
table := NewTable(3, size) table := printer.NewTable(conf, 3, size)
table.Addheaders("name", "size", "docscount") table.Addheaders("name", "size", "docscount")
for idx, index := range res { for idx, index := range list {
name := Colorize(*index.Health, *index.Index) name := printer.Colorize(conf, *index.Health, *index.Index)
table.entries[idx] = []string{name, *index.DatasetSize, *index.DocsCount} table.Entries[idx] = []string{name, *index.DatasetSize, *index.DocsCount}
if idx == size-1 { if idx == size-1 {
break break
@@ -61,20 +120,240 @@ func IndexList(conf *cfg.Config) error {
} }
table.Sort() table.Sort()
table.PrintMarkdown() if err := table.Print(); err != nil {
return err
}
return nil return nil
} }
func IndexShow(conf *cfg.Config, index string) error { func IndexShow(conf *cfg.Config, indexpattern string) error {
res, err := conf.DefaultCluster.ES.Indices.Get(index).Do(context.Background()) 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").
Do(context.Background())
if err != nil { if err != nil {
return fmt.Errorf("failed to get index: %s", err) return fmt.Errorf("failed to get index: %s", esErrorString(err))
}
for name, index := range res {
fields := make([]string, len(index.Mappings.Properties))
idx := 0
for field := range index.Mappings.Properties {
fields[idx] = field
idx++
}
table := printer.NewTable(conf, 2, 5)
table.Addheaders("index property", "value")
ts, err := strconv.ParseInt(index.Settings.Index.CreationDate.(string), 10, 64)
if err != nil {
ts = 0
}
created := time.Unix(ts/1000, 0)
table.Entries = [][]string{
{"name", name},
{"replicas", *index.Settings.Index.NumberOfReplicas},
{"shards", *index.Settings.Index.NumberOfShards},
{"created", created.Format("2006-01-02 15:04:05")},
{"uuid", *index.Settings.Index.Uuid},
{"fields", strings.Join(fields, ",")},
}
if err := table.Print(); err != nil {
return err
}
fmt.Println()
}
return nil
}
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")
if conf.Wait {
create.WaitForActiveShards("all")
}
if conf.Shards > 0 {
settings = settings.NumberOfShards(strconv.Itoa(conf.Shards))
}
if conf.Replicas > 0 {
settings = settings.NumberOfReplicas(strconv.Itoa(conf.Replicas))
}
if len(mappings) > 0 {
maps := esdsl.NewTypeMapping()
for _, mapping := range mappings {
parts := strings.Split(mapping, ":")
if len(parts) != 2 {
return fmt.Errorf("invalid mapping %s, expect <name:type> (type: integer, text, date, keyword)", mapping)
}
switch parts[1] {
case "text":
maps.AddProperty(parts[0], esdsl.NewTextProperty())
case "integer":
maps.AddProperty(parts[0], esdsl.NewIntegerNumberProperty())
case "date":
maps.AddProperty(parts[0], esdsl.NewDateProperty())
case "keyword":
maps.AddProperty(parts[0], esdsl.NewKeywordProperty())
}
}
create.Mappings(maps)
}
_, err := create.Settings(settings).
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to create index: %s", esErrorString(err))
}
return nil
}
func IndexDelete(conf *cfg.Config, index string) error {
_, err := conf.DefaultCluster.ES.Indices.Delete(index).
Header("content-type", "application/json").
Header("accept", "application/json").
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to delete index: %s", esErrorString(err))
}
return nil
}
func IndexClose(conf *cfg.Config, index string) error {
create := conf.DefaultCluster.ES.Indices.Close(index).
Header("content-type", "application/json").
Header("accept", "application/json")
_, err := create.Do(context.Background())
if err != nil {
return fmt.Errorf("failed to close index: %s", esErrorString(err))
}
return nil
}
func IndexAllocation(conf *cfg.Config, index string) error {
res, err := conf.DefaultCluster.ES.Cluster.AllocationExplain().
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))
} }
slog.Debug("ES result", "index", res) slog.Debug("ES result", "index", res)
// FIXME: add table printer like SnapshotShow currentNode := res.CurrentNode
table := printer.NewTable(conf, 2, 10)
table.Addheaders("index allocation setting", "value")
roles := make([]string, len(currentNode.Roles))
for idx, role := range currentNode.Roles {
roles[idx] = role.Name
}
table.Entries = [][]string{
{"Index", index},
{"Current node", currentNode.Name},
{"Current k8s node", currentNode.Attributes["k8s_node_name"]},
{"Current node address", currentNode.TransportAddress},
{"Current node id", currentNode.Id},
{"Current node weight", fmt.Sprintf("%d", currentNode.WeightRanking)},
{"Current node roles", strings.Join(roles, ",")},
{"Can rebalance cluster", res.CanRebalanceCluster.Name},
{"Can rebalance to another node", res.CanRebalanceToOtherNode.Name},
{"Can remain on current node", res.CanRemainOnCurrentNode.Name},
}
if err := table.Print(); err != nil {
return err
}
return nil
}
func IndexModify(conf *cfg.Config, index string) error {
settings := esdsl.NewIndexSettings().NumberOfReplicas(strconv.Itoa(conf.Replicas))
_, err := conf.DefaultCluster.ES.Indices.PutSettings().
Indices(index).
Index(settings).
Header("content-type", "application/json").
Header("accept", "application/json").
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to modify index settings: %s", esErrorString(err))
}
return nil
}
func IndexFields(conf *cfg.Config, index string) error {
res, err := conf.DefaultCluster.ES.FieldCaps().
Index(index).
Fields("*").
Header("content-type", "application/json").
Header("accept", "application/json").
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to retrieve field capabilties: %s", esErrorString(err))
}
table := printer.NewTable(conf, 5, 0)
table.Addheaders("field", "type", "searchable", "aggretable", "metadata")
idx := 0
for name, field := range res.Fields {
for fieldtype, caps := range field {
// fields only have 1 type, so this one is it
switch {
case conf.Searchable && !caps.Searchable:
continue
case conf.Aggretable && !caps.Aggregatable:
continue
case len(conf.Filter) > 0 && !slices.Contains(conf.Filter, fieldtype):
continue
}
table.Entries = append(table.Entries, []string{name, fieldtype,
fmt.Sprintf("%t", caps.Searchable),
fmt.Sprintf("%t", caps.Aggregatable),
fmt.Sprintf("%t", *caps.MetadataField)})
break
}
idx++
}
table.Sort()
if err := table.Print(); err != nil {
return err
}
return nil return nil
} }

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

@@ -0,0 +1,115 @@
/*
Copyright © 2026 Thomas von Dein
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package es
import (
"context"
"fmt"
"log/slog"
"regexp"
"strings"
"codeberg.org/scip/esctl/pkg/cfg"
"codeberg.org/scip/esctl/pkg/printer"
)
// FIXME: add filter support, see IndexCreate mapping
func IndexAliasCreate(conf *cfg.Config, index, alias string) error {
res, err := conf.DefaultCluster.ES.Indices.PutAlias(index, alias).
Header("content-type", "application/json").
Header("accept", "application/json").
Do(context.Background())
slog.Debug("create alias", "result", res)
if err != nil {
return fmt.Errorf("failed to create index alias: %s", esErrorString(err))
}
return nil
}
func IndexAliasList(conf *cfg.Config) error {
filter := regexp.Regexp{}
if len(conf.Filter) > 0 {
// we support just one filter here, for now
filter = *regexp.MustCompile(conf.Filter[0])
}
res, err := conf.DefaultCluster.ES.Indices.GetAlias().
Index("_all").
Header("content-type", "application/json").
Header("accept", "application/json").
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to list index aliases: %s", esErrorString(err))
}
slog.Debug("aliases list", "result", res)
aliaslist := map[string][]string{} // index => []aliases
for index, aliases := range res {
if len(conf.Filter) > 0 {
if !filter.MatchString(index) {
continue
}
}
for alias := range aliases.Aliases {
aliaslist[index] = append(aliaslist[index], alias)
}
}
table := printer.NewTable(conf, 2, len(aliaslist))
table.Addheaders("index", "alias")
idx := 0
for index, aliases := range aliaslist {
table.Entries[idx] = []string{
index,
strings.Join(aliases, ","),
}
idx++
}
table.Sort()
if err := table.Print(); err != nil {
return err
}
return nil
}
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").
Do(context.Background())
slog.Debug("delete alias", "result", res)
if err != nil {
return fmt.Errorf("failed to delete index alias: %s", esErrorString(err))
}
return nil
}

View File

@@ -19,32 +19,40 @@ package es
import ( import (
"context" "context"
"fmt" "fmt"
"log"
"log/slog" "log/slog"
"codeberg.org/scip/esctl/pkg/cfg" "codeberg.org/scip/esctl/pkg/cfg"
"codeberg.org/scip/esctl/pkg/printer"
) )
func Health(conf *cfg.Config) error { func NodeList(conf *cfg.Config) error {
res, err := conf.DefaultCluster.ES.Cluster.Health().Do(context.Background()) // get nodes
nodes, err := conf.DefaultCluster.ES.Cat.Nodes().Do(context.Background())
if err != nil { if err != nil {
log.Fatalf("Error getting health: %s", err) return fmt.Errorf("failed to get nodes: %s", esErrorString(err))
} }
slog.Debug("ES result", "cluster health", res) slog.Debug("ES result", "nodes", nodes)
table := NewTable(2, 5) table := printer.NewTable(conf, 7, len(nodes))
table.Addheaders("name", "ip", "load1m", "load5m", "load15m", "ram %", "heap %")
table.headers = []string{bold("SETTING"), bold("STATUS")} for idx, node := range nodes {
table.Entries[idx] = []string{
table.entries = [][]string{ *node.Name,
{"Cluster Name", Colorize(*&res.Status.Name, res.ClusterName)}, *node.Ip,
{"Active Shards", fmt.Sprintf("%d", res.ActiveShards)}, *node.Load1M,
{"Active Primary Shards", fmt.Sprintf("%d", res.ActivePrimaryShards)}, *node.Load5M,
{"Indicies", fmt.Sprintf("%d", len(res.Indices))}, *node.Load15M,
{"Nodes", fmt.Sprintf("%d", res.NumberOfNodes)}, node.RamPercent.(string),
node.HeapPercent.(string),
}
}
table.Sort()
if err := table.Print(); err != nil {
return err
} }
table.PrintMarkdown()
return nil return nil
} }

224
pkg/es/repl.go Normal file
View File

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

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

@@ -0,0 +1,245 @@
/*
Copyright © 2026 Thomas von Dein
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package es
import (
"context"
"fmt"
"log/slog"
"strings"
"codeberg.org/scip/esctl/pkg/cfg"
"codeberg.org/scip/esctl/pkg/printer"
"github.com/elastic/go-elasticsearch/v9/typedapi/types"
)
func RoleNames(conf *cfg.Config) ([]string, error) {
res, err := conf.DefaultCluster.ES.Security.GetRole().
Do(context.Background())
if err != nil {
return nil, fmt.Errorf("failed to get roles: %s", esErrorString(err))
}
roles := make([]string, len(res))
idx := 0
for name := range res {
roles[idx] = name
idx++
}
return roles, nil
}
func RoleList(conf *cfg.Config) error {
res, err := conf.DefaultCluster.ES.Security.GetRole().
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to get roles: %s", esErrorString(err))
}
slog.Debug("ES result", "roles", res)
table := printer.NewTable(conf, 3, len(res))
table.Addheaders("role", "index roles", "cluster roles")
idx := 0
for name, role := range res {
table.Entries[idx] = []string{
name,
fmt.Sprintf("%d", len(role.Cluster)),
fmt.Sprintf("%d", len(role.Indices)),
}
idx++
}
table.Sort()
return table.Print()
}
func RoleShow(conf *cfg.Config, rolename string) error {
res, err := conf.DefaultCluster.ES.Security.GetRole().
Name(rolename).
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to get role: %s", esErrorString(err))
}
slog.Debug("ES result", "role", res)
role, exists := res[rolename]
if !exists {
return fmt.Errorf("role %s does not exist", rolename)
}
if len(role.Indices) > 0 {
if err := roleIndices(conf, role); err != nil {
return err
}
fmt.Println()
}
if len(role.RemoteIndices) > 0 {
if err := roleRemoteIndices(conf, role); err != nil {
return err
}
fmt.Println()
}
if len(role.Cluster) > 0 {
if err := roleClusters(conf, role); err != nil {
return err
}
fmt.Println()
}
if len(role.RemoteCluster) > 0 {
if err := roleRemoteClusters(conf, role); err != nil {
return err
}
fmt.Println()
}
if len(role.Applications) > 0 {
if err := roleApplications(conf, role); err != nil {
return err
}
}
return nil
}
func roleRemoteClusters(conf *cfg.Config, role types.Role) error {
if len(role.RemoteCluster) == 0 {
return nil
}
table := printer.NewTable(conf, 2, len(role.RemoteCluster))
table.Addheaders("remote cluster", "privilege")
idx := 0
for _, priv := range role.RemoteCluster {
perms := []string{}
for _, perm := range priv.Privileges {
perms = append(perms, perm.Name)
}
table.Entries[idx] = []string{
strings.Join(priv.Clusters, ","),
strings.Join(perms, ","),
}
idx++
}
table.Sort()
return table.Print()
}
func roleClusters(conf *cfg.Config, role types.Role) error {
if len(role.Cluster) == 0 {
return nil
}
table := printer.NewTable(conf, 1, len(role.Cluster))
table.Addheaders("cluster rights")
idx := 0
for _, cluster := range role.Cluster {
table.Entries[idx] = []string{cluster.Name}
idx++
}
table.Sort()
return table.Print()
}
func roleRemoteIndices(conf *cfg.Config, role types.Role) error {
if len(role.RemoteIndices) == 0 {
return nil
}
table := printer.NewTable(conf, 3, len(role.RemoteIndices))
table.Addheaders("remote index names", "index permissions", "allow restricted")
idx := 0
for _, priv := range role.RemoteIndices {
perms := []string{}
for _, perm := range priv.Privileges {
perms = append(perms, perm.Name)
}
table.Entries[idx] = []string{
strings.Join(priv.Names, ", "),
strings.Join(perms, ", "),
fmt.Sprintf("%t", *priv.AllowRestrictedIndices),
}
idx++
}
table.Sort()
return table.Print()
}
func roleIndices(conf *cfg.Config, role types.Role) error {
if len(role.Indices) == 0 {
return nil
}
table := printer.NewTable(conf, 3, len(role.Indices))
table.Addheaders("index names", "index permissions", "allow restricted")
idx := 0
for _, priv := range role.Indices {
perms := []string{}
for _, perm := range priv.Privileges {
perms = append(perms, perm.Name)
}
table.Entries[idx] = []string{
strings.Join(priv.Names, ", "),
strings.Join(perms, ", "),
fmt.Sprintf("%t", *priv.AllowRestrictedIndices),
}
idx++
}
table.Sort()
return table.Print()
}
func roleApplications(conf *cfg.Config, role types.Role) error {
if len(role.Applications) == 0 {
return nil
}
table := printer.NewTable(conf, 3, len(role.Applications))
table.Addheaders("application", "privileges", "resources")
idx := 0
for _, priv := range role.Applications {
table.Entries[idx] = []string{
priv.Application,
strings.Join(priv.Privileges, ", "),
strings.Join(priv.Resources, ", "),
}
idx++
}
table.Sort()
return table.Print()
}

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

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

View File

@@ -18,14 +18,25 @@ package es
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"log"
"log/slog" "log/slog"
"strings" "time"
"codeberg.org/scip/esctl/pkg/cfg" "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/core/search"
"github.com/elastic/go-elasticsearch/v9/typedapi/esdsl" "github.com/elastic/go-elasticsearch/v9/typedapi/esdsl"
"github.com/elastic/go-elasticsearch/v9/typedapi/indices/validatequery"
"github.com/elastic/go-elasticsearch/v9/typedapi/types" "github.com/elastic/go-elasticsearch/v9/typedapi/types"
"github.com/elastic/go-elasticsearch/v9/typedapi/types/enums/sortorder"
"github.com/tidwall/gjson"
)
const (
MAXPAGE = 5000
) )
/* /*
@@ -34,42 +45,220 @@ Execute an ES search.
q is the actual search query given as arg to the 'search' cmd q is the actual search query given as arg to the 'search' cmd
additional filters can be given as -F key=value additional filters can be given as -F key=value
*/ */
func Search(conf *cfg.Config, q string) error { func Search(conf *cfg.Config, queries []string) error {
query := esdsl.NewBoolQuery(). if conf.Validate {
Must(esdsl.NewMatchQuery("message", q)) return validateSearch(conf, queries)
}
filters := make([]types.QueryVariant, len(conf.Filter)) searchEs := conf.DefaultCluster.ES.Search().Index(conf.Index)
for idx, filter := range conf.Filter { queryCaster, err := prepareQuery(conf, queries)
parts := strings.Split(filter, "=") if err != nil {
if len(parts) != 2 { return err
return fmt.Errorf("invalid filter spec: %s, expecting key=value", filter) }
req := &search.Request{Query: queryCaster}
searchEs.Request(req)
searchEs = addSort(conf, searchEs)
switch {
case conf.Tail:
return searchTail(conf, searchEs)
case conf.Explain:
return explainSearch(conf, searchEs)
default:
if conf.To > MAXPAGE {
return searchPit(conf, req)
} else {
return searchOnce(conf, searchEs)
} }
filters[idx] = esdsl.NewTermQuery(parts[0], esdsl.NewFieldValue().String(parts[1]))
} }
}
if len(filters) > 0 { func explainSearch(conf *cfg.Config, search *search.Search) error {
query.Filter(filters...) res, err := search.
} Explain(true).
Size(1). // one's enough for explain
res, err := conf.DefaultCluster.ES.Search().
Index(conf.Index).
Request(&search.Request{
Query: query.QueryCaster(),
From: &conf.From,
Size: &conf.To,
}).
Do(context.Background()) Do(context.Background())
if err != nil { if err != nil {
return fmt.Errorf("Error running search (esdsl): %s", err) return fmt.Errorf("failed to call explain search (esdsl): %s", esErrorString(err))
} }
slog.Debug("ES result", "search", res) if conf.Debug {
raw, err := json.Marshal(res)
if err != nil {
return fmt.Errorf("failed to marshal explain result: %s", err)
}
for _, hit := range res.Hits.Hits { value := gjson.Get(string(raw), "hits.hits.0._explanation")
fmt.Printf("%s\n", hit.Source_) fmt.Println(value.String())
}
if len(res.Hits.Hits) > 0 {
ex := res.Hits.Hits[0].Explanation_
fmt.Println(ex.Description)
fmt.Println(ex.Value)
// recurse into explanation details (it's a tree)
for _, ex := range ex.Details {
explain(&ex, " ")
}
} }
return nil return nil
} }
func explain(res *types.ExplanationDetail, indent string) {
fmt.Println(indent + "- " + res.Description)
for _, ex := range res.Details {
fmt.Println(indent + " - " + ex.Description)
fmt.Println(indent + fmt.Sprintf(" score: %f", res.Value))
explain(&ex, indent+" ")
}
}
func validateSearch(conf *cfg.Config, queries []string) error {
validate := conf.DefaultCluster.ES.Indices.ValidateQuery()
queryCaster, err := prepareQuery(conf, queries)
if err != nil {
return err
}
req := &validatequery.Request{Query: queryCaster}
validate.Request(req)
res, err := validate.
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to validate search (esdsl): %s", esErrorString(err))
}
slog.Debug("ES result", "search", res)
if res.Valid {
fmt.Println(printer.Colorize(conf, "green", "valid"))
} else {
fmt.Println(printer.Colorize(conf, "red", "invalid"))
}
return nil
}
func Debug(conf *cfg.Config) error {
res, err := conf.DefaultCluster.ES.Search().
Index(conf.Index).
Size(0).
Aggregations(map[string]types.Aggregations{
"min_ts": *esdsl.NewMinAggregation().Field("@timestamp").AggregationsCaster(),
"max_ts": *esdsl.NewMaxAggregation().Field("@timestamp").AggregationsCaster(),
}).
Do(context.Background())
if err != nil {
return err
}
repr.Println(res)
return nil
}
func searchOnce(conf *cfg.Config, search *search.Search) error {
res, err := search.
From(conf.From).
Size(conf.To).
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to run search (esdsl): %s", esErrorString(err))
}
slog.Debug("ES result", "search", res)
printer.PrintDocs(conf, res.Hits.Hits)
return nil
}
// https://www.elastic.co/docs/reference/elasticsearch/clients/go/using-the-api/searching#_pit_search_after
func searchPit(conf *cfg.Config, req *search.Request) error {
ctx := context.Background()
pit, err := conf.DefaultCluster.ES.OpenPointInTime(conf.Index).KeepAlive("1m").Do(ctx)
if err != nil {
return fmt.Errorf("failed to open point-in-time request for search: %s", err)
}
defer func() {
_, err := conf.DefaultCluster.ES.ClosePointInTime().Id(pit.Id).Do(ctx)
if err != nil {
log.Fatalf("failed to close PIT: %s", err)
}
}()
search := conf.DefaultCluster.ES.Search().
Request(req).
Pit(esdsl.NewPointInTimeReference().
Id(pit.Id).
KeepAlive(esdsl.NewDuration().String("1m"))).
Sort(esdsl.NewSortOptions().
AddSortOption("_shard_doc", esdsl.NewFieldSort(sortorder.Asc))).
Size(conf.To)
search = addSort(conf, search)
for {
res, err := search.Do(ctx)
if err != nil {
return fmt.Errorf("failed to run search (esdsl pit): %s", esErrorString(err))
}
if len(res.Hits.Hits) == 0 {
break
}
printer.PrintDocs(conf, res.Hits.Hits)
last := res.Hits.Hits[len(res.Hits.Hits)-1]
search = search.SearchAfterValues(last.Sort)
if res.PitId != nil {
search = search.Pit(esdsl.NewPointInTimeReference().
Id(*res.PitId).
KeepAlive(esdsl.NewDuration().String("1m")))
}
}
return nil
}
func searchTail(conf *cfg.Config, search *search.Search) error {
docs := map[string]int{}
fmt.Println("enter ctrl-c to abort...")
for {
res, err := search.Do(context.Background())
if err != nil {
return fmt.Errorf("failed to run search (esdsl): %s", esErrorString(err))
}
slog.Debug("ES result", "search", res)
for _, hit := range res.Hits.Hits {
_, exists := docs[*hit.Id_]
if exists {
continue
}
printer.PrintDoc(conf, hit)
fmt.Println()
docs[*hit.Id_] = 1
}
time.Sleep(100 * time.Millisecond)
}
}

280
pkg/es/search_filter.go Normal file
View File

@@ -0,0 +1,280 @@
/*
Copyright © 2026 Thomas von Dein
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package es
import (
"context"
"errors"
"fmt"
"log/slog"
"strings"
"codeberg.org/scip/esctl/pkg/cfg"
"github.com/elastic/go-elasticsearch/v9/typedapi/core/search"
"github.com/elastic/go-elasticsearch/v9/typedapi/esdsl"
"github.com/elastic/go-elasticsearch/v9/typedapi/types"
"github.com/elastic/go-elasticsearch/v9/typedapi/types/enums/operator"
"github.com/elastic/go-elasticsearch/v9/typedapi/types/enums/sortorder"
)
const (
Fmust = iota
Fmustnot
Fshould
)
// hold filter configuration, can be used multiple times through a search
type filter struct {
term, filter string // message=foo
mterm []string // multi_match
criteria int // must(=), mustnot(!=), should(?)
multi bool // message,title=foo => filter:foo, multi: []string{"message","title"}
}
// Build a new filter object. We use this to build our elastic query
// out of it. We support differnt types of queries:
//
// - nop filter: no query at all, just return the first N documents.
//
// - simple ones like: "authenticated", which match across all fields
//
// - simple ones combined like "authenticated monitoring", if -O was set,
// apply an OR logic
//
// - specific field queries: "message=authenticated" (can be combined too like above)
//
// - and specific field queries with negation: "message!=authenticated"
//
// All queries support additional filters using -F field=value, which
// must match literally, and range filters using -r "@timestamp:2026-05-28T10:00:00 to now"
func NewFilter(query string) (*filter, error) {
var separator string
var criteria int // we use the constants on top for this
switch {
case strings.Contains(query, "!="):
criteria = Fmustnot
separator = "!="
default:
criteria = Fmust
separator = "="
}
part := strings.Split(query, separator)
if len(part) != 2 {
return nil, fmt.Errorf("search queries must be in the form field<sep>pattern where <sep> must be one of: = or !=")
}
flt := &filter{term: part[0], filter: part[1], criteria: criteria}
if strings.Contains(part[0], ",") {
// a MultiMatchQuery, match across multiple fields at once
multi := strings.Split(part[0], ",")
flt.multi = true
flt.mterm = multi
}
return flt, nil
}
// Build a complex query set for queries containing field[!]=pattern
func mkMatchQueries(queries []string, op operator.Operator) ([]types.QueryVariant, []types.QueryVariant, error) {
matchqueries := []types.QueryVariant{}
matchNotqueries := []types.QueryVariant{}
for _, q := range queries {
filter, err := NewFilter(q)
if err != nil {
return nil, nil, err
}
// by default we match on a single field
var match types.QueryVariant = esdsl.NewMatchQuery(filter.term, filter.filter)
if filter.multi {
// ok, match across multiple given fields
match = esdsl.NewMultiMatchQuery(filter.filter).Fields(filter.mterm...).Operator(op)
}
if filter.criteria == Fmustnot {
matchNotqueries = append(matchNotqueries, match)
} else {
matchqueries = append(matchqueries, match)
}
}
return matchqueries, matchNotqueries, nil
}
// Prepare user search query and turn it into a proper search request
func prepareQuery(conf *cfg.Config, queries []string) (*types.Query, error) {
// logical operator for simple and multimatch queries
op := operator.Operator{Name: "AND"}
if conf.Or {
op = operator.Operator{Name: "OR"}
}
query := esdsl.NewBoolQuery()
wholeQuery := strings.Join(queries, " ")
switch {
case len(queries) == 0:
// nothing provided via ARGs, so match any docs
query.Must(esdsl.NewMatchAllQuery())
case !strings.ContainsAny(wholeQuery, "!="):
// simple query w/o any field[!]=pattern style
query.Must(esdsl.NewSimpleQueryStringQuery(wholeQuery).DefaultOperator(op))
default:
// a complex query
matchqueries, matchNotqueries, err := mkMatchQueries(queries, op)
if err != nil {
return nil, err
}
// apply boolean logic
if conf.Or {
query.Should(matchqueries...)
} else {
// by default we use AND
query.Must(matchqueries...)
}
if matchNotqueries != nil {
query.MustNot(matchNotqueries...)
}
}
slog.Debug("complex query", "query", query)
// there might be boolean or range filters like -Ffield=value as well
filters, err := addFilters(conf)
if err != nil {
return nil, err
}
if filters != nil {
query.Filter(filters...)
}
return query.QueryCaster(), nil
}
// Build a slice of filters, to be fed into query.Filter() including static ones
// like -Ffield=value and ranges like -r "@timestamp:2026-05-28T10:00:00 to now"
func addFilters(conf *cfg.Config) ([]types.QueryVariant, error) {
count := len(conf.Filter)
if conf.Range != "" {
count++
}
if count == 0 {
return nil, nil
}
filters := make([]types.QueryVariant, count)
// static filters
for idx, filter := range conf.Filter {
parts := strings.Split(filter, "=")
if len(parts) != 2 {
return nil, fmt.Errorf("invalid filter spec: %s, expecting key=value", filter)
}
filters[idx] = esdsl.NewTermQuery(parts[0], esdsl.NewFieldValue().String(parts[1]))
}
// range filters. format: @timestamp:2026-05-05 to 2026-05-15
// see: https://www.elastic.co/docs/reference/elasticsearch/rest-apis/common-options#date-math
if conf.Range != "" {
parts := strings.SplitN(conf.Range, ":", 2)
if len(parts) != 2 {
return nil, errors.New("invalid range format, expected field:range")
}
field := parts[0]
parts = strings.Split(parts[1], " to ")
if len(parts) != 2 {
return nil, errors.New("invalid date range format, expected '<start> to <end>'")
}
from := parts[0]
to := parts[1]
slog.Debug("time range filter",
"from", from,
"to", to,
"format", conf.TimestampFormat,
"count", count,
)
rng := esdsl.NewDateRangeQuery(field).
Gte(from).
Lte(to)
if strings.Contains(parts[1], ":") {
// specific format not needed as long as there are no times specified
rng.Format(conf.TimestampFormat)
}
filters[count-1] = rng
}
return filters, nil
}
// check if the conf.SortBy field (default: @timestamp) is searchable
// by using the field capabilities API.
func addSort(conf *cfg.Config, search *search.Search) *search.Search {
res, err := conf.DefaultCluster.ES.FieldCaps().
Index(conf.Index).
Fields(conf.SortBy).
Do(context.Background())
if err != nil {
// whatever it was, do not add Sort()
slog.Debug("get field capabilities", "field", conf.SortBy, "error", esErrorString(err))
return search
}
field, exists := res.Fields[conf.SortBy]
if exists {
// good, the field exists
for _, cap := range field {
if cap.Searchable {
// ok, ES would be willing to sort by this field
order := esdsl.NewFieldSort(sortorder.Desc)
if conf.Ascending {
order = esdsl.NewFieldSort(sortorder.Asc)
}
search = search.Sort(
esdsl.NewSortOptions().AddSortOption(conf.SortBy, order),
)
break
}
}
}
return search
}

151
pkg/es/shard.go Normal file
View File

@@ -0,0 +1,151 @@
/*
Copyright © 2026 Thomas von Dein
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package es
import (
"context"
"fmt"
"log/slog"
"codeberg.org/scip/esctl/pkg/cfg"
"codeberg.org/scip/esctl/pkg/printer"
"github.com/elastic/go-elasticsearch/v9/typedapi/cat/shards"
)
func colorzizeShard(conf *cfg.Config, state, name string) string {
color := "red" // in case of RELOCATING and UNASSIGNED
switch state {
case "STARTED":
color = "green"
case "INITIALIZING":
color = "yellow"
}
return printer.Colorize(conf, color, name)
}
func resolvePrirep(state string) string {
switch state {
case "p":
return "primary"
}
return "replica"
}
func filterShards(conf *cfg.Config, shardlist shards.Response) shards.Response {
filtered := shards.Response{}
size := len(shardlist)
if conf.MaxItems > 0 {
if size > conf.MaxItems {
size = conf.MaxItems
}
}
for idx, shard := range shardlist {
if conf.Failed {
if *shard.State == "STARTED" {
continue
}
}
if conf.Primary {
if *shard.Prirep != "p" {
continue
}
}
if idx == size-1 {
break
}
filtered = append(filtered, shard)
}
return filtered
}
func ShardList(conf *cfg.Config) error {
res, err := conf.DefaultCluster.ES.Cat.Shards().
Header("content-type", "application/json").
Header("accept", "application/json").
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to get shards: %s", esErrorString(err))
}
shardlist := filterShards(conf, res)
slog.Debug("ES result", "shards", res)
return printShards(conf, shardlist)
}
func printShards(conf *cfg.Config, shardlist shards.Response) error {
headers := []string{"index", "shard", "is primary", "store", "dataset", "docs"}
if conf.Verbose {
headers = append(headers, "node", "ip")
}
table := printer.NewTable(conf, len(headers), len(shardlist))
table.Addheaders(headers...)
for idx, shard := range shardlist {
name := colorzizeShard(conf, *shard.State, *shard.Index)
table.Entries[idx] = []string{
name,
*shard.Shard,
resolvePrirep(*shard.Prirep),
*shard.Store,
*shard.Dataset,
*shard.Docs,
}
if conf.Verbose {
table.Entries[idx] = append(table.Entries[idx],
*shard.Node,
*shard.Ip,
)
}
}
table.Sort()
if err := table.Print(); err != nil {
return err
}
return nil
}
func ShardShow(conf *cfg.Config, index string) error {
res, err := conf.DefaultCluster.ES.Cat.Shards().Index(index).
Header("content-type", "application/json").
Header("accept", "application/json").
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to get shards: %s", esErrorString(err))
}
slog.Debug("ES result", "shards", res)
conf.Verbose = true
return printShards(conf, res)
}

View File

@@ -25,6 +25,7 @@ import (
"strings" "strings"
"codeberg.org/scip/esctl/pkg/cfg" "codeberg.org/scip/esctl/pkg/cfg"
"codeberg.org/scip/esctl/pkg/printer"
) )
var ( var (
@@ -44,7 +45,7 @@ func SnapshotList(conf *cfg.Config) error {
// get partial indicies // get partial indicies
ires, err := conf.DefaultCluster.ES.Cat.Indices().Do(context.Background()) ires, err := conf.DefaultCluster.ES.Cat.Indices().Do(context.Background())
if err != nil { if err != nil {
return fmt.Errorf("Error getting indicies: %s", err) return fmt.Errorf("failed to get indicies: %s", esErrorString(err))
} }
indicies := map[string]int{} indicies := map[string]int{}
@@ -57,7 +58,7 @@ func SnapshotList(conf *cfg.Config) error {
// get snapshots // get snapshots
sres, err := conf.DefaultCluster.ES.Cat.Snapshots().Do(context.Background()) sres, err := conf.DefaultCluster.ES.Cat.Snapshots().Do(context.Background())
if err != nil { if err != nil {
return fmt.Errorf("Error getting snapshots: %s", err) return fmt.Errorf("failed to get snapshots: %s", esErrorString(err))
} }
slog.Debug("ES result", "indicies", sres) slog.Debug("ES result", "indicies", sres)
@@ -83,11 +84,11 @@ func SnapshotList(conf *cfg.Config) error {
} }
} }
table := NewTable(5, len(snapshots)) table := printer.NewTable(conf, 5, len(snapshots))
table.Addheaders("name", "index", "start", "orphaned", "status") table.Addheaders("name", "index", "start", "orphaned", "status")
for idx, snap := range snapshots { for idx, snap := range snapshots {
table.entries[idx] = []string{ table.Entries[idx] = []string{
snap.Name, snap.Name,
snap.Forindex, snap.Forindex,
snap.Start, snap.Start,
@@ -97,7 +98,9 @@ func SnapshotList(conf *cfg.Config) error {
} }
table.Sort() table.Sort()
table.PrintMarkdown() if err := table.Print(); err != nil {
return err
}
return nil return nil
} }
@@ -105,7 +108,7 @@ func SnapshotList(conf *cfg.Config) error {
func SnapshotShow(conf *cfg.Config, snapshot string) error { func SnapshotShow(conf *cfg.Config, snapshot string) error {
res, err := conf.DefaultCluster.ES.Snapshot.Get("*", snapshot).Do(context.Background()) res, err := conf.DefaultCluster.ES.Snapshot.Get("*", snapshot).Do(context.Background())
if err != nil { if err != nil {
return fmt.Errorf("failed to get snapshot: %s", err) return fmt.Errorf("failed to get snapshot: %s", esErrorString(err))
} }
slog.Debug("ES result", "snapshot", res) slog.Debug("ES result", "snapshot", res)
@@ -114,12 +117,12 @@ func SnapshotShow(conf *cfg.Config, snapshot string) error {
return errors.New("no snapshot retrieved") return errors.New("no snapshot retrieved")
} }
table := NewTable(2, 17) table := printer.NewTable(conf, 2, 17)
table.Addheaders("field", "value") table.Addheaders("snapshot property", "value")
snap := res.Snapshots[0] snap := res.Snapshots[0]
table.entries = [][]string{ table.Entries = [][]string{
{"snapshot", snapshot}, {"snapshot", snapshot},
{"uuid", snap.Uuid}, {"uuid", snap.Uuid},
{"repository", *snap.Repository}, {"repository", *snap.Repository},
@@ -135,7 +138,9 @@ func SnapshotShow(conf *cfg.Config, snapshot string) error {
{"shards-successful", fmt.Sprintf("%d", snap.Shards.Successful)}, {"shards-successful", fmt.Sprintf("%d", snap.Shards.Successful)},
} }
table.PrintMarkdown() if err := table.Print(); err != nil {
return err
}
return nil return nil
} }

View File

@@ -1,114 +0,0 @@
/*
Copyright © 2026 Thomas von Dein
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package es
import (
"fmt"
"sort"
"strings"
"github.com/olekukonko/tablewriter"
"github.com/olekukonko/tablewriter/renderer"
"github.com/olekukonko/tablewriter/tw"
)
type Table struct {
headers []string
entries [][]string
}
func NewTable(columns, rows int) *Table {
table := Table{}
table.headers = make([]string, columns)
table.entries = make([][]string, rows)
return &table
}
func (data *Table) PrintMarkdown() error {
tableString := &strings.Builder{}
table := tablewriter.NewTable(tableString,
tablewriter.WithRenderer(
renderer.NewBlueprint(
tw.Rendition{
Borders: tw.Border{
Left: tw.On,
Right: tw.On,
Top: tw.Off,
Bottom: tw.Off,
},
Settings: tw.Settings{
Separators: tw.Separators{
ShowHeader: tw.On,
ShowFooter: tw.Off,
BetweenRows: tw.Off,
BetweenColumns: 0,
},
},
Symbols: tw.NewSymbols(tw.StyleMarkdown),
})),
tablewriter.WithConfig(
tablewriter.Config{
Header: tw.CellConfig{
Formatting: tw.CellFormatting{
Alignment: tw.AlignLeft,
AutoFormat: tw.Off,
},
},
Row: tw.CellConfig{
Formatting: tw.CellFormatting{
Alignment: tw.AlignLeft,
},
},
},
),
)
table.Header(data.headers)
if err := table.Bulk(data.entries); err != nil {
return fmt.Errorf("Failed to add data to table renderer: %s", err)
}
if err := table.Render(); err != nil {
fmt.Errorf("Failed to render table: %s", err)
}
fmt.Println(tableString.String())
return nil
}
func (data *Table) Sort() {
// sanity checks
if len(data.entries) == 0 {
return
}
sort.Slice(data.entries, func(i, j int) bool {
return data.entries[i][0] < data.entries[j][0]
})
}
func (data *Table) Addheaders(headers ...string) {
for idx, header := range headers {
data.headers[idx] = bold(strings.ToUpper(header))
}
}

View File

@@ -15,18 +15,27 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>. along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
package es package printer
import "github.com/fatih/color" import (
"codeberg.org/scip/esctl/pkg/cfg"
"github.com/fatih/color"
)
var ( var (
green = color.New(color.BgGreen, color.FgHiWhite).SprintFunc() green = color.New(color.BgGreen, color.FgHiWhite).SprintFunc()
yellow = color.New(color.BgYellow, color.FgHiWhite).SprintFunc()
orange = color.BgRGB(255, 128, 0).AddRGB(0, 0, 0).SprintFunc() orange = color.BgRGB(255, 128, 0).AddRGB(0, 0, 0).SprintFunc()
red = color.New(color.BgRed, color.FgHiWhite).SprintFunc() red = color.New(color.BgRed, color.FgHiWhite).SprintFunc()
bold = color.New(color.Bold).SprintFunc() bold = color.New(color.Bold).SprintFunc()
) )
func Colorize(col, what string) string { func Colorize(conf *cfg.Config, col, what string) string {
switch conf.Output {
case "json", "yaml":
return what
}
switch col { switch col {
case "green": case "green":
what = green(what) what = green(what)
@@ -34,6 +43,8 @@ func Colorize(col, what string) string {
what = orange(what) what = orange(what)
case "red": case "red":
what = red(what) what = red(what)
case "yellow":
what = yellow(what)
} }
return what return what

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

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

306
pkg/printer/table.go Normal file
View File

@@ -0,0 +1,306 @@
/*
Copyright © 2026 Thomas von Dein
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package printer
import (
"encoding/json"
"fmt"
"regexp"
"sort"
"strconv"
"strings"
"codeberg.org/scip/esctl/pkg/cfg"
"github.com/olekukonko/tablewriter"
"github.com/olekukonko/tablewriter/renderer"
"github.com/olekukonko/tablewriter/tw"
"gopkg.in/yaml.v3"
)
type Table struct {
Mode string // table, markdown, json
Headers []string
Entries [][]string
lenHeaders []int
}
func NewTable(conf *cfg.Config, columns, rows int) *Table {
table := Table{Mode: conf.Output}
table.Headers = make([]string, columns)
table.Entries = make([][]string, rows)
table.lenHeaders = make([]int, columns)
return &table
}
func (data *Table) Print() error {
switch data.Mode {
case "markdown", "md":
return data.PrintMarkdown()
case "json":
return data.PrintJSON()
case "yaml":
return data.PrintYAML()
default:
return data.PrintTSV()
}
}
var (
ansiCtrlSeq = regexp.MustCompile(`\033.[0-9;]+m`)
)
// needed for json and yaml output
func (data *Table) toMap() []map[string]string {
raw := make([]map[string]string, len(data.Entries))
for idx, entries := range data.Entries {
raw[idx] = make(map[string]string, len(data.Headers))
for eidx, entry := range entries {
raw[idx][data.Headers[eidx]] = entry
}
}
return raw
}
func (data *Table) PrintYAML() error {
raw := data.toMap()
body, err := yaml.Marshal(raw)
if err != nil {
return fmt.Errorf("failed to produce YAML output: %s", err)
}
fmt.Println(string(body))
return nil
}
func (data *Table) PrintJSON() error {
raw := data.toMap()
body, err := json.MarshalIndent(raw, "", " ")
if err != nil {
return fmt.Errorf("failed to produce JSON output: %s", err)
}
fmt.Println(string(body))
return nil
}
// Well. tablewriter should be able to print in TSV mode, but it
// doesn't work because of the various ansic color escapes we're
// using. Therefore we do it the hard way and use our own TSV printer.
func (data *Table) PrintTSV() error {
// determine lenght's
for idx, head := range data.Headers {
data.lenHeaders[idx] = visibleLen(head)
}
for _, entries := range data.Entries {
for idx, entry := range entries {
length := visibleLen(entry)
if data.lenHeaders[idx] < length {
data.lenHeaders[idx] = length
}
}
}
// output
for idx, header := range data.Headers {
fmt.Print(header, strings.Repeat(" ", data.lenHeaders[idx]-visibleLen(header)))
if idx < len(data.Headers)-1 {
fmt.Print(" ")
}
}
fmt.Println()
for _, entries := range data.Entries {
for idx, entry := range entries {
length := visibleLen(entry)
if isInt(entry) {
// align right
fmt.Print(strings.Repeat(" ", data.lenHeaders[idx]-length), entry)
} else {
fmt.Print(entry, strings.Repeat(" ", data.lenHeaders[idx]-length))
}
if idx < len(data.Headers)-1 {
fmt.Print(" ")
}
}
fmt.Println()
}
return nil
}
func (data *Table) PrintTabularFail() error {
OFS := " "
tableString := &strings.Builder{}
styleTSV := tw.NewSymbolCustom("space").WithColumn("\t")
table := tablewriter.NewTable(tableString,
tablewriter.WithRenderer(
renderer.NewBlueprint(tw.Rendition{
Borders: tw.BorderNone,
Symbols: styleTSV,
Settings: tw.Settings{
Separators: tw.SeparatorsNone,
Lines: tw.LinesNone,
},
})),
tablewriter.WithConfig(tablewriter.Config{
Header: tw.CellConfig{
Formatting: tw.CellFormatting{
AutoFormat: tw.Off,
},
Padding: tw.CellPadding{Global: tw.Padding{Left: "", Right: OFS}},
},
Row: tw.CellConfig{
Formatting: tw.CellFormatting{
AutoWrap: tw.WrapNone,
Alignment: tw.AlignLeft,
},
Padding: tw.CellPadding{Global: tw.Padding{Right: OFS}},
},
Debug: true,
}),
)
table.Header(data.Headers)
if err := table.Bulk(data.Entries); err != nil {
return fmt.Errorf("failed to add data to table renderer: %s", err)
}
if err := table.Render(); err != nil {
return fmt.Errorf("failed to render table: %s", err)
}
// we need to trim our output here, because tablewriter appends
// excess whitespace to our rows.
cleanedString := &strings.Builder{}
for _, row := range strings.Split(strings.TrimSpace(tableString.String()), "\n") {
cleanedString.WriteString(strings.TrimSpace(row))
cleanedString.WriteString("\n")
}
fmt.Println(cleanedString.String())
return nil
}
func (data *Table) PrintMarkdown() error {
tableString := &strings.Builder{}
table := tablewriter.NewTable(tableString,
tablewriter.WithRenderer(
renderer.NewBlueprint(
tw.Rendition{
Borders: tw.Border{
Left: tw.On,
Right: tw.On,
Top: tw.Off,
Bottom: tw.Off,
},
Settings: tw.Settings{
Separators: tw.Separators{
ShowHeader: tw.On,
ShowFooter: tw.Off,
BetweenRows: tw.Off,
BetweenColumns: 0,
},
},
Symbols: tw.NewSymbols(tw.StyleMarkdown),
})),
tablewriter.WithConfig(
tablewriter.Config{
Header: tw.CellConfig{
Formatting: tw.CellFormatting{
Alignment: tw.AlignLeft,
AutoFormat: tw.Off,
},
},
Row: tw.CellConfig{
Formatting: tw.CellFormatting{
Alignment: tw.AlignLeft,
},
},
},
),
)
table.Header(data.Headers)
if err := table.Bulk(data.Entries); err != nil {
return fmt.Errorf("failed to add data to table renderer: %s", err)
}
if err := table.Render(); err != nil {
return fmt.Errorf("failed to render table: %s", err)
}
fmt.Println(tableString.String())
return nil
}
func (data *Table) Sort() {
// sanity checks
if len(data.Entries) == 0 {
return
}
sort.Slice(data.Entries, func(i, j int) bool {
return data.Entries[i][0] < data.Entries[j][0]
})
}
func (data *Table) Addheaders(headers ...string) {
for idx, header := range headers {
switch data.Mode {
case "json", "yaml":
data.Headers[idx] = strings.ReplaceAll(strings.ToLower(header), " ", "_")
default:
data.Headers[idx] = bold(strings.ReplaceAll(strings.ToUpper(header), " ", "-"))
}
}
}
// return the length of a string but only visible chars, w/o ansi color escapes
func visibleLen(word string) int {
return len(ansiCtrlSeq.ReplaceAllLiteralString(word, ""))
}
func isInt(num string) bool {
if _, err := strconv.Atoi(num); err == nil {
return true
}
return false
}