mirror of
https://codeberg.org/scip/esctl.git
synced 2026-08-24 09:54:18 +02:00
Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0696095bb0 | |||
|
|
504db9835d | ||
| f1877c6903 | |||
|
|
2b2094b155 | ||
| 1c72b5ec9a | |||
| aeded16b89 | |||
| 57bbe680a7 | |||
|
|
130d0b879d | ||
|
|
f7302ea506 | ||
| b2da6f0f29 | |||
| c92f10fb5d | |||
| 501deec538 | |||
|
|
98a577f68a | ||
|
|
89e2498aad | ||
|
|
0870ceb3df | ||
| 32de4863af | |||
|
|
58d94a4f56 | ||
| 565d92b491 | |||
|
|
40e0612bef | ||
|
|
db50072a7b | ||
|
|
b71819f9e8 | ||
|
|
15e0f9dc90 | ||
|
|
dd619ab815 | ||
| 327bc84bd8 | |||
| a785eb642f | |||
| a209debe09 | |||
| d1d97fed1b | |||
| 4194a004c0 | |||
| ceab7698e2 | |||
| d22352da12 | |||
| 3bd26fde7a | |||
|
|
38ed9de0b4 | ||
|
|
5e82a3706b | ||
| 0122009774 | |||
| 8109481e23 | |||
|
|
af75bfa192 | ||
| a71be5001a | |||
|
|
fcfeaf66a9 | ||
|
|
a169d23b90 | ||
|
|
f89a09d1d2 | ||
|
|
01853c3299 | ||
| d6a96ee61f | |||
|
|
f27f9157fb | ||
|
|
3cbc67567b |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -26,3 +26,7 @@ go.work.sum
|
||||
.env
|
||||
|
||||
esctl
|
||||
|
||||
*.sh
|
||||
*.json
|
||||
*.log
|
||||
|
||||
42
.goreleaser.yaml
Normal file
42
.goreleaser.yaml
Normal 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
26
.woodpecker/build.yaml
Normal 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
15
.woodpecker/release.yaml
Normal 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
18
CODE_OF_CONDUCT.md
Normal 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
91
CONTRIBUTING.md
Normal 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!
|
||||
15
Makefile
15
Makefile
@@ -23,11 +23,24 @@ UID = root
|
||||
GID = 0
|
||||
HAVE_POD := $(shell pod2text -h 2>/dev/null)
|
||||
|
||||
BRANCH = $(shell git branch --show-current)
|
||||
COMMIT = $(shell git rev-parse --short=8 HEAD)
|
||||
BUILD = $(shell date +%Y.%m.%d.%H%M%S)
|
||||
GOVERSION = $(shell grep -E "^go" go.mod | cut -d' ' -f2)
|
||||
APIVERSION = $(shell grep github.com/elastic/go-elasticsearch go.mod | cut -d' ' -f2)
|
||||
|
||||
|
||||
all: 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)
|
||||
|
||||
install: buildlocal
|
||||
|
||||
96
README.md
96
README.md
@@ -1,28 +1,67 @@
|
||||
[](https://ci.codeberg.org/repos/16999)
|
||||
|
||||
# esctl
|
||||
|
||||
Elasticsearch CLI
|
||||
|
||||
## Usage
|
||||
|
||||
Command tree:
|
||||
|
||||
```console
|
||||
NAME:
|
||||
esctl - manage elasticsearch from cli
|
||||
|
||||
USAGE:
|
||||
esctl [global options] [command [command options]]
|
||||
|
||||
VERSION:
|
||||
v0.0.1
|
||||
|
||||
COMMANDS:
|
||||
health show ES health
|
||||
search, / search within an index
|
||||
help, h Shows a list of commands or help for one command
|
||||
|
||||
GLOBAL OPTIONS:
|
||||
--debug, -d enable debugging [$ES_DEBUG]
|
||||
--help, -h show help
|
||||
--version, -v print the version
|
||||
ccr
|
||||
follower
|
||||
add
|
||||
delete
|
||||
pause
|
||||
renew
|
||||
resume
|
||||
show
|
||||
unfollow
|
||||
info
|
||||
pause
|
||||
resume
|
||||
status
|
||||
cluster
|
||||
list
|
||||
settings
|
||||
list
|
||||
set
|
||||
status
|
||||
debug
|
||||
doc
|
||||
add
|
||||
delete
|
||||
show
|
||||
help
|
||||
index
|
||||
alias
|
||||
create
|
||||
delete
|
||||
list
|
||||
allocation
|
||||
close
|
||||
create
|
||||
delete
|
||||
fields
|
||||
list
|
||||
modify
|
||||
show
|
||||
node
|
||||
list
|
||||
show
|
||||
repl
|
||||
role
|
||||
list
|
||||
show
|
||||
search
|
||||
shard
|
||||
list
|
||||
show
|
||||
snapshot
|
||||
list
|
||||
show
|
||||
version
|
||||
```
|
||||
|
||||
Configure `esctl` with environment variables:
|
||||
@@ -31,6 +70,27 @@ Configure `esctl` with environment variables:
|
||||
- `ES_USER`: username
|
||||
- `ES_PASS`: password
|
||||
|
||||
Or create a config file such as this:
|
||||
|
||||
```yaml
|
||||
clusters:
|
||||
default:
|
||||
uri: https://es.foo.bar:9200/
|
||||
user: elastic
|
||||
pass: 123456
|
||||
other:
|
||||
uri: https://myes.foo:9200/
|
||||
user: elastic
|
||||
pass: asdasdasd
|
||||
```
|
||||
|
||||
and specify it with `-c configfile`. You may also put clusters into a
|
||||
default config file in `~/.config/esctl/config.yaml`. In this case you
|
||||
can omit `-c ...`.
|
||||
|
||||
If you want to work on a specific cluster, specify its name with the
|
||||
global `-C` option.
|
||||
|
||||
## Introduction
|
||||
|
||||
FIXME
|
||||
|
||||
31
TODO.md
31
TODO.md
@@ -1,2 +1,33 @@
|
||||
- [Go client docs](https://www.elastic.co/docs/reference/elasticsearch/clients/go/typed-api)
|
||||
- [ES API docs](https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-indices-get)
|
||||
|
||||
- Fix index names custom completion
|
||||
https://github.com/urfave/cli/issues/2332
|
||||
https://github.com/urfave/cli/issues/2333
|
||||
|
||||
- index show: add more details, see screenshots
|
||||
|
||||
- add shard explain, aka:
|
||||
get /_cluster/allocation/explain {"index":"yourindex", "primary": true, "shard":0}
|
||||
|
||||
|
||||
- add validate:
|
||||
|
||||
> GET /mock/_validate/query?rewrite=true {"from":0,"query":{"bool":{"must":[{"match_all":{}}]}},"size":20,"sort":[{"name":{"order":"desc"}}]}
|
||||
{
|
||||
"valid": false
|
||||
}
|
||||
|
||||
- add explain to search (maybe option -e)
|
||||
|
||||
> GET /mock/_explain/1780043878 {"query":{"bool":{"must":[{"match_all":{}}]}}}
|
||||
{
|
||||
"_index": "mock",
|
||||
"_id": "1780043878",
|
||||
"matched": true,
|
||||
"explanation": {
|
||||
"value": 1.0,
|
||||
"description": "*:*",
|
||||
"details": []
|
||||
}
|
||||
}
|
||||
|
||||
116
cmd/ccr.go
Normal file
116
cmd/ccr.go
Normal file
@@ -0,0 +1,116 @@
|
||||
/*
|
||||
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)",
|
||||
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "exclude",
|
||||
Usage: "regexp of indicies to exclude",
|
||||
Destination: &conf.Exclude,
|
||||
Aliases: []string{"e"},
|
||||
},
|
||||
},
|
||||
|
||||
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>]",
|
||||
|
||||
Action: func(ctx context.Context, cmd *cli.Command) error {
|
||||
return es.CcrRemoteInfo(conf, cmd.Args().Get(0))
|
||||
},
|
||||
}
|
||||
}
|
||||
193
cmd/ccr_follower.go
Normal file
193
cmd/ccr_follower.go
Normal file
@@ -0,0 +1,193 @@
|
||||
/*
|
||||
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"},
|
||||
},
|
||||
},
|
||||
|
||||
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>",
|
||||
|
||||
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>",
|
||||
|
||||
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>",
|
||||
|
||||
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"},
|
||||
},
|
||||
},
|
||||
|
||||
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>",
|
||||
|
||||
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>",
|
||||
|
||||
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
79
cmd/cluster.go
Normal 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
119
cmd/cluster_settings.go
Normal 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
|
||||
},
|
||||
}
|
||||
}
|
||||
177
cmd/doc.go
Normal file
177
cmd/doc.go
Normal file
@@ -0,0 +1,177 @@
|
||||
/*
|
||||
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"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "help-jsonpath",
|
||||
Usage: "show jsonPath help",
|
||||
Destination: &conf.Subhelp,
|
||||
Aliases: []string{"H"},
|
||||
},
|
||||
},
|
||||
|
||||
Action: func(ctx context.Context, cmd *cli.Command) error {
|
||||
if conf.Subhelp {
|
||||
return showJsonPathHelp()
|
||||
}
|
||||
|
||||
args := cmd.Args()
|
||||
|
||||
if args.Len() != 1 {
|
||||
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 {
|
||||
if conf.Subhelp {
|
||||
return showJsonPathHelp()
|
||||
}
|
||||
|
||||
args := cmd.Args()
|
||||
|
||||
if args.Len() == 0 && !conf.All {
|
||||
return errors.New("missing arguments: <query>")
|
||||
}
|
||||
|
||||
return es.DocDelete(conf, cmd.Args().Slice())
|
||||
},
|
||||
}
|
||||
}
|
||||
192
cmd/index.go
192
cmd/index.go
@@ -18,6 +18,8 @@ package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"codeberg.org/scip/esctl/pkg/cfg"
|
||||
"codeberg.org/scip/esctl/pkg/es"
|
||||
@@ -34,6 +36,13 @@ func Index(conf *cfg.Config) *cli.Command {
|
||||
Commands: []*cli.Command{
|
||||
IndexList(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,
|
||||
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 {
|
||||
if err := es.IndexList(conf); err != nil {
|
||||
return err
|
||||
return es.IndexList(conf)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
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,148 @@ func IndexShow(conf *cfg.Config) *cli.Command {
|
||||
Usage: "show details about an index",
|
||||
|
||||
Action: func(ctx context.Context, cmd *cli.Command) error {
|
||||
if err := es.IndexShow(conf, cmd.Args().Get(0)); err != nil {
|
||||
return err
|
||||
index := cmd.Args().Get(0)
|
||||
if index == "" {
|
||||
return errors.New("no index specified")
|
||||
}
|
||||
|
||||
return nil
|
||||
return es.IndexShow(conf, cmd.Args().Get(0))
|
||||
},
|
||||
|
||||
// FIXME: doesn't work at all
|
||||
// FIXME: also it would ONLY work if the user uses env vars, -C would not be
|
||||
// there when the completion output is being generated
|
||||
ShellComplete: func(ctx context.Context, cmd *cli.Command) {
|
||||
if cmd.NArg() > 0 {
|
||||
return
|
||||
}
|
||||
|
||||
indices, err := es.IndexNames(conf)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
for _, index := range indices {
|
||||
fmt.Println(index)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
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",
|
||||
|
||||
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",
|
||||
|
||||
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>",
|
||||
|
||||
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>",
|
||||
|
||||
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)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
104
cmd/index_alias.go
Normal file
104
cmd/index_alias.go
Normal file
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
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),
|
||||
//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>",
|
||||
|
||||
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>",
|
||||
|
||||
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)
|
||||
},
|
||||
}
|
||||
}
|
||||
65
cmd/node.go
Normal file
65
cmd/node.go
Normal file
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
Copyright © 2026 Thomas von Dein
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package 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 {
|
||||
// return es.NodeShow(conf, cmd.Args().Get(0))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,6 @@ 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 (
|
||||
@@ -26,17 +25,14 @@ import (
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
func Status(conf *cfg.Config) *cli.Command {
|
||||
func Repl(conf *cfg.Config) *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "status",
|
||||
Usage: "show ES status",
|
||||
Aliases: []string{"s"},
|
||||
Action: func(ctx context.Context, cmd *cli.Command) error {
|
||||
if err := es.Health(conf); err != nil {
|
||||
return err
|
||||
}
|
||||
Name: "repl",
|
||||
Aliases: []string{"shell"},
|
||||
Usage: "interactive API repl",
|
||||
|
||||
return nil
|
||||
Action: func(ctx context.Context, cmd *cli.Command) error {
|
||||
return es.Repl(conf)
|
||||
},
|
||||
}
|
||||
}
|
||||
78
cmd/roles.go
Normal file
78
cmd/roles.go
Normal file
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
Copyright © 2026 Thomas von Dein
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package 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),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
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>",
|
||||
|
||||
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))
|
||||
},
|
||||
}
|
||||
}
|
||||
71
cmd/root.go
71
cmd/root.go
@@ -22,6 +22,7 @@ import (
|
||||
"os"
|
||||
|
||||
"codeberg.org/scip/esctl/pkg/cfg"
|
||||
"codeberg.org/scip/esctl/pkg/es"
|
||||
"codeberg.org/scip/esctl/pkg/log"
|
||||
|
||||
"github.com/urfave/cli/v3"
|
||||
@@ -40,9 +41,9 @@ func Main() int {
|
||||
conf := cfg.NewConfig()
|
||||
|
||||
cmd := &cli.Command{
|
||||
Name: "esctl",
|
||||
Usage: "manage elasticsearch from cli",
|
||||
Version: cfg.Version,
|
||||
Name: "esctl",
|
||||
Usage: "manage elasticsearch from cli",
|
||||
//Version: cfg.Version,
|
||||
EnableShellCompletion: true,
|
||||
|
||||
Flags: []cli.Flag{
|
||||
@@ -54,6 +55,12 @@ func Main() int {
|
||||
Sources: cli.EnvVars("ES_DEBUG"),
|
||||
Destination: &conf.Debug,
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "debug-http",
|
||||
Value: false,
|
||||
Usage: "enable HTTP debugging",
|
||||
Destination: &conf.DebugHTTP,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "config",
|
||||
Aliases: []string{"c"},
|
||||
@@ -69,18 +76,38 @@ func Main() int {
|
||||
Usage: "cluster alias to work with",
|
||||
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{
|
||||
Status(conf),
|
||||
Search(conf),
|
||||
Cluster(conf),
|
||||
Ccr(conf),
|
||||
Index(conf),
|
||||
Shard(conf),
|
||||
Snapshot(conf),
|
||||
Node(conf),
|
||||
Doc(conf),
|
||||
Repl(conf),
|
||||
Version(conf),
|
||||
Debug(conf),
|
||||
Roles(conf),
|
||||
},
|
||||
|
||||
Before: func(ctx context.Context, cmd *cli.Command) (context.Context, error) {
|
||||
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)
|
||||
@@ -91,3 +118,37 @@ func Main() int {
|
||||
|
||||
return Finish(cmd.Run(context.Background(), os.Args))
|
||||
}
|
||||
|
||||
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)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
128
cmd/search.go
128
cmd/search.go
@@ -18,6 +18,7 @@ package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"codeberg.org/scip/esctl/pkg/cfg"
|
||||
"codeberg.org/scip/esctl/pkg/es"
|
||||
@@ -25,12 +26,33 @@ import (
|
||||
"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 {
|
||||
return &cli.Command{
|
||||
Name: "search",
|
||||
Aliases: []string{"/"},
|
||||
Usage: "search within an index",
|
||||
UsageText: "search [options] <query>",
|
||||
UsageText: "search [options] [<[field<sep>]pattern> ...]\n" + SearchUsage,
|
||||
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
@@ -42,32 +64,118 @@ func Search(conf *cfg.Config) *cli.Command {
|
||||
},
|
||||
&cli.IntFlag{
|
||||
Name: "from",
|
||||
Usage: "show results FROM (default 0)",
|
||||
Usage: "show results starting at <from>",
|
||||
Destination: &conf.From,
|
||||
Value: 0,
|
||||
Aliases: []string{"f"},
|
||||
},
|
||||
&cli.IntFlag{
|
||||
Name: "to",
|
||||
Usage: "show results to (default 10)",
|
||||
Name: "len",
|
||||
Usage: "number of results to show (-1: all[max:10k], caution: might be slow)",
|
||||
Destination: &conf.To,
|
||||
Value: 10,
|
||||
Aliases: []string{"t"},
|
||||
Value: 20,
|
||||
Aliases: []string{"l"},
|
||||
},
|
||||
&cli.StringSliceFlag{
|
||||
Name: "filter",
|
||||
Usage: "additional filters. format: key=value",
|
||||
Usage: "additional boolean filters. format: key=value",
|
||||
Destination: &conf.Filter,
|
||||
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: "help-jsonpath",
|
||||
Usage: "show jsonPath help",
|
||||
Destination: &conf.Subhelp,
|
||||
Aliases: []string{"H"},
|
||||
},
|
||||
&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"},
|
||||
},
|
||||
},
|
||||
|
||||
Action: func(ctx context.Context, cmd *cli.Command) error {
|
||||
if err := es.Search(conf, cmd.Args().Get(0)); err != nil {
|
||||
return err
|
||||
if conf.Subhelp {
|
||||
return showJsonPathHelp()
|
||||
}
|
||||
|
||||
return nil
|
||||
args := cmd.Args()
|
||||
|
||||
if conf.To == -1 {
|
||||
conf.To = 10000
|
||||
}
|
||||
|
||||
return es.Search(conf, args.Slice())
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func showJsonPathHelp() error {
|
||||
_, err := fmt.Println(`jsonPath usage:
|
||||
|
||||
name.last >> "Anderson"
|
||||
age >> 37
|
||||
children >> ["Sara","Alex","Jack"]
|
||||
children.# >> 3
|
||||
children.1 >> "Alex"
|
||||
child*.2 >> "Jack"
|
||||
c?ildren.0 >> "Sara"
|
||||
fav\.movie >> "Deer Hunter"
|
||||
friends.#.first >> ["Dale","Roger","Jane"]
|
||||
friends.1.last >> "Craig"
|
||||
|
||||
You can also query an array for the first match by using #(...), or
|
||||
find all matches with #(...)#. Queries support the ==, !=, <, <=, >,
|
||||
>= comparison operators and the simple pattern matching % (like) and
|
||||
!% (not like) operators. Eg:
|
||||
|
||||
friends.#(last=="Murphy").first >> "Dale"
|
||||
friends.#(last=="Murphy")#.first >> ["Dale","Jane"]
|
||||
friends.#(age>45)#.last >> ["Craig","Murphy"]
|
||||
friends.#(first%"D*").last >> "Murphy"
|
||||
friends.#(first!%"D*").last >> "Craig"
|
||||
friends.#(nets.#(=="fb"))#.first >> ["Dale","Roger"]
|
||||
|
||||
Documentation: https://github.com/tidwall/gjson/blob/master/SYNTAX.md`)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
96
cmd/shards.go
Normal file
96
cmd/shards.go
Normal 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)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -54,11 +54,7 @@ func SnapshotList(conf *cfg.Config) *cli.Command {
|
||||
},
|
||||
|
||||
Action: func(ctx context.Context, cmd *cli.Command) error {
|
||||
if err := es.SnapshotList(conf); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
return es.SnapshotList(conf)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -71,11 +67,7 @@ func SnapshotShow(conf *cfg.Config) *cli.Command {
|
||||
UsageText: "show [options] <snapshot>",
|
||||
|
||||
Action: func(ctx context.Context, cmd *cli.Command) error {
|
||||
if err := es.SnapshotShow(conf, cmd.Args().Get(0)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
return es.SnapshotShow(conf, cmd.Args().Get(0))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
31
commandtree.sh
Executable file
31
commandtree.sh
Executable 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
32
go.mod
@@ -17,29 +17,39 @@ module codeberg.org/scip/esctl
|
||||
go 1.25.0
|
||||
|
||||
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/clipperhouse/displaywidth v0.10.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/stdr v1.2.2 // indirect
|
||||
github.com/goccy/go-json v0.10.6 // indirect
|
||||
github.com/lmittmann/tint v1.1.3 // 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.19 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.23 // indirect
|
||||
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect
|
||||
github.com/olekukonko/errors v1.2.0 // indirect
|
||||
github.com/olekukonko/ll v0.1.6 // indirect
|
||||
github.com/olekukonko/tablewriter v1.1.4 // indirect
|
||||
github.com/tlinden/yadu v0.1.3 // indirect
|
||||
github.com/urfave/cli/v3 v3.8.0 // indirect
|
||||
github.com/olekukonko/ll v0.1.8 // indirect
|
||||
github.com/tidwall/gjson v1.19.0 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.0 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||
go.opentelemetry.io/otel v1.35.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.35.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.35.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
29
go.sum
29
go.sum
@@ -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/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
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/go.mod h1:XqJajYsaiEwkxOj4bowCTMcT1SgvHo9flfF3jQasdbs=
|
||||
github.com/clipperhouse/uax29/v2 v2.6.0 h1:z0cDbUV+aPASdFb2/ndFnS9ts/WNXgTNNGFoKXuhpos=
|
||||
github.com/clipperhouse/uax29/v2 v2.6.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/elastic/elastic-transport-go/v8 v8.9.0 h1:KeT/2P54F0xS0S8Y3Pf+tFDg4HmBgReQMB+BMz8dDAs=
|
||||
github.com/elastic/elastic-transport-go/v8 v8.9.0/go.mod h1:ssMTvNS2hwf7CaiGsRRsx4gQHFZ/jS/DkLcISxekWzc=
|
||||
github.com/elastic/elastic-transport-go/v8 v8.11.0 h1:taYmqC2M6+fZt/+W+ENYh/W5L9+KrlJGOSbEJs8egWc=
|
||||
github.com/elastic/elastic-transport-go/v8 v8.11.0/go.mod h1:DZQ0szCNywc9F+C9l/Kkd4n69SvJVj0I3yK1Of7s3l8=
|
||||
github.com/elastic/go-elasticsearch/v9 v9.3.2 h1:nvtvfN/Gsp/rzUPz/9yILwDAsYJ3s5L0VmhR16zPKCA=
|
||||
github.com/elastic/go-elasticsearch/v9 v9.3.2/go.mod h1:ubKUMJCJbX5V/gW5MIn2NQZyaEZ61ubXwJmD5UMNrM8=
|
||||
github.com/elastic/go-elasticsearch/v9 v9.3.4 h1:vnqXl6jnlA+ZwlfRaKF9BR9woZC1KSB17gtyvgf8zVU=
|
||||
github.com/elastic/go-elasticsearch/v9 v9.3.4/go.mod h1:ubKUMJCJbX5V/gW5MIn2NQZyaEZ61ubXwJmD5UMNrM8=
|
||||
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
|
||||
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
|
||||
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||
@@ -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/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
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/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
|
||||
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.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.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
|
||||
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/mattn/go-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/go.mod h1:rEKTHC9roVVicUIfZK7DYrdIoM0EOr8mK1Hj5s3JjH0=
|
||||
github.com/olekukonko/errors v1.2.0 h1:10Zcn4GeV59t/EGqJc8fUjtFT/FuUh5bTMzZ1XwmCRo=
|
||||
github.com/olekukonko/errors v1.2.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y=
|
||||
github.com/olekukonko/ll v0.1.6 h1:lGVTHO+Qc4Qm+fce/2h2m5y9LvqaW+DCN7xW9hsU3uA=
|
||||
github.com/olekukonko/ll v0.1.6/go.mod h1:NVUmjBb/aCtUpjKk75BhWrOlARz3dqsM+OtszpY4o88=
|
||||
github.com/olekukonko/ll v0.1.8 h1:ysHCJRGHYKzmBSdz9w5AySztx7lG8SQY+naTGYUbsz8=
|
||||
github.com/olekukonko/ll v0.1.8/go.mod h1:RPRC6UcscfFZgjo1nulkfMH5IM0QAYim0LfnMvUuozw=
|
||||
github.com/olekukonko/tablewriter v1.1.4 h1:ORUMI3dXbMnRlRggJX3+q7OzQFDdvgbN9nVWj1drm6I=
|
||||
github.com/olekukonko/tablewriter v1.1.4/go.mod h1:+kedxuyTtgoZLwif3P1Em4hARJs+mVnzKxmsCL/C5RY=
|
||||
github.com/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/go.mod h1:l3bRmHKL9zGAR6pnBHY2HRPxBecf7L74BoBgOOpTcUA=
|
||||
github.com/urfave/cli/v3 v3.8.0 h1:XqKPrm0q4P0q5JpoclYoCAv0/MIvH/jZ2umzuf8pNTI=
|
||||
github.com/urfave/cli/v3 v3.8.0/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso=
|
||||
github.com/urfave/cli/v3 v3.9.0 h1:AV9lIiPv3ukYnxunaCUsHnEozptYmDN2F0+yWqLMn/c=
|
||||
github.com/urfave/cli/v3 v3.9.0/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso=
|
||||
github.com/urfave/cli/v3 v3.9.1-0.20260524212652-be8b79d0c8de h1:ESKPiS7inVoBnv4FmgGNZdjWBI/wmvaragoyD3D9nM4=
|
||||
github.com/urfave/cli/v3 v3.9.1-0.20260524212652-be8b79d0c8de/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ=
|
||||
@@ -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/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs=
|
||||
go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc=
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
|
||||
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
|
||||
@@ -17,9 +17,13 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
package cfg
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
@@ -30,7 +34,12 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
Version string = `v0.0.2`
|
||||
Version string = `v0.0.15`
|
||||
)
|
||||
|
||||
var (
|
||||
// initialized during build, see Makefile:buildlocal
|
||||
APIVERSION, GOVERSION, BUILD, COMMIT, BRANCH string
|
||||
)
|
||||
|
||||
type Cluster struct {
|
||||
@@ -39,15 +48,34 @@ type Cluster struct {
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
ConfigFile string // -c
|
||||
CurrentCluster string // -C
|
||||
Debug bool // -d
|
||||
Clusters map[string]*Cluster
|
||||
DefaultCluster *Cluster
|
||||
From, To, MaxItems int
|
||||
Index string
|
||||
Filter []string
|
||||
Failed, Partials bool
|
||||
ConfigFile string // -c
|
||||
CurrentCluster string // -C
|
||||
Debug bool // -d
|
||||
Output string // -o <mode>
|
||||
Clusters map[string]*Cluster
|
||||
DefaultCluster *Cluster
|
||||
Index string // index: -i
|
||||
Failed, Partials bool // index: flags
|
||||
Shards, Replicas int // index create+allocation: -s -r
|
||||
Wait bool // index create: -w
|
||||
Primary bool // index allocation: -p
|
||||
From, To, MaxItems int // search: flags
|
||||
Filter []string // search: -F
|
||||
Path string // search+doc sh: -p
|
||||
Subhelp bool // search+doc sh: -H
|
||||
Tail bool // search: -f [tail]
|
||||
Or bool // search: -O
|
||||
Range string // search: -r
|
||||
TimestampFormat string // search: --timestamp-format
|
||||
Explain bool // search: -e
|
||||
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
|
||||
}
|
||||
|
||||
func NewConfig() *Config {
|
||||
@@ -55,16 +83,26 @@ func NewConfig() *Config {
|
||||
}
|
||||
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
default:
|
||||
if err := conf.LoadEnv(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := conf.SetupES(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if conf.CurrentCluster != "" {
|
||||
current, exists := conf.Clusters[conf.CurrentCluster]
|
||||
if !exists {
|
||||
@@ -72,17 +110,50 @@ func (conf *Config) Init() error {
|
||||
} else {
|
||||
conf.DefaultCluster = current
|
||||
}
|
||||
} else {
|
||||
if len(conf.Clusters) == 1 {
|
||||
for name, cluster := range conf.Clusters {
|
||||
conf.DefaultCluster = cluster
|
||||
conf.CurrentCluster = name
|
||||
}
|
||||
} else {
|
||||
for name, cluster := range conf.Clusters {
|
||||
_, err := cluster.ES.Cluster.Health().
|
||||
Header("content-type", "application/json").
|
||||
Header("accept", "application/json").
|
||||
Do(context.Background())
|
||||
|
||||
if err == nil {
|
||||
conf.DefaultCluster = cluster
|
||||
conf.CurrentCluster = name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if conf.Debug {
|
||||
repr.Println(conf)
|
||||
}
|
||||
conf.HaveJQ = isJQinstalled()
|
||||
|
||||
conf.SetupES()
|
||||
conf.PrintDebug()
|
||||
|
||||
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 {
|
||||
cluster := Cluster{
|
||||
Uri: os.Getenv("ES_URI"),
|
||||
@@ -140,18 +211,26 @@ func (conf *Config) LoadConfig() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (conf *Config) getTransport() elastictransport.Option {
|
||||
transport := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
}
|
||||
|
||||
if conf.DebugHTTP {
|
||||
return elastictransport.WithTransport(
|
||||
&DebugTransport{Transport: transport},
|
||||
)
|
||||
}
|
||||
|
||||
return elastictransport.WithTransport(transport)
|
||||
}
|
||||
|
||||
func (conf *Config) SetupES() error {
|
||||
for _, cluster := range conf.Clusters {
|
||||
es, err := elasticsearch.NewTyped(
|
||||
elasticsearch.WithAddresses(cluster.Uri),
|
||||
elasticsearch.WithBasicAuth(cluster.User, cluster.Pass),
|
||||
elasticsearch.WithTransportOptions(
|
||||
elastictransport.WithTransport(
|
||||
&http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
},
|
||||
),
|
||||
),
|
||||
elasticsearch.WithTransportOptions(conf.getTransport()),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
@@ -163,3 +242,47 @@ func (conf *Config) SetupES() error {
|
||||
|
||||
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
38
pkg/cfg/jq.go
Normal 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
135
pkg/es/ccr.go
Normal 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
199
pkg/es/ccr_follower.go
Normal 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
233
pkg/es/cluster.go
Normal 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
161
pkg/es/cluster_settings.go
Normal 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
399
pkg/es/cluster_util.go
Normal 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
132
pkg/es/doc.go
Normal 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
|
||||
}
|
||||
40
pkg/es/errors.go
Normal file
40
pkg/es/errors.go
Normal file
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
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)
|
||||
}
|
||||
|
||||
msg = *e.ErrorCause.Reason + ": " + causes
|
||||
}
|
||||
|
||||
return msg
|
||||
}
|
||||
280
pkg/es/index.go
280
pkg/es/index.go
@@ -20,13 +20,60 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// 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 {
|
||||
if len(conf.Filter) == 0 {
|
||||
return list
|
||||
}
|
||||
|
||||
// we support just one filter here, for now
|
||||
filter := *regexp.MustCompile(conf.Filter[0])
|
||||
newlist := indices.Response{}
|
||||
|
||||
for _, index := range list {
|
||||
if filter.MatchString(*index.Index) {
|
||||
newlist = append(newlist, index)
|
||||
}
|
||||
}
|
||||
|
||||
return newlist
|
||||
}
|
||||
|
||||
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 {
|
||||
cat = cat.Health(healthstatus.Red)
|
||||
@@ -34,12 +81,14 @@ func IndexList(conf *cfg.Config) error {
|
||||
|
||||
res, err := cat.Do(context.Background())
|
||||
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)
|
||||
|
||||
size := len(res)
|
||||
list := filterIndices(conf, res)
|
||||
|
||||
size := len(list)
|
||||
|
||||
if conf.MaxItems > 0 {
|
||||
if size > conf.MaxItems {
|
||||
@@ -47,13 +96,13 @@ func IndexList(conf *cfg.Config) error {
|
||||
}
|
||||
}
|
||||
|
||||
table := NewTable(3, size)
|
||||
table := printer.NewTable(conf, 3, size)
|
||||
table.Addheaders("name", "size", "docscount")
|
||||
|
||||
for idx, index := range res {
|
||||
name := Colorize(*index.Health, *index.Index)
|
||||
for idx, index := range list {
|
||||
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 {
|
||||
break
|
||||
@@ -61,20 +110,229 @@ func IndexList(conf *cfg.Config) error {
|
||||
}
|
||||
|
||||
table.Sort()
|
||||
table.PrintMarkdown()
|
||||
if err := table.Print(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func IndexShow(conf *cfg.Config, index string) error {
|
||||
res, err := conf.DefaultCluster.ES.Indices.Get(index).Do(context.Background())
|
||||
res, err := conf.DefaultCluster.ES.Indices.Get(index).
|
||||
// we need to add custom request headers, required for older ES instances
|
||||
Header("content-type", "application/json").
|
||||
Header("accept", "application/json").
|
||||
Do(context.Background())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get index: %s", err)
|
||||
return fmt.Errorf("failed to get index: %s", esErrorString(err))
|
||||
}
|
||||
|
||||
slog.Debug("ES result", "index", res)
|
||||
|
||||
// FIXME: add table printer like SnapshotShow
|
||||
fields := make([]string, len(res[index].Mappings.Properties))
|
||||
idx := 0
|
||||
for field := range res[index].Mappings.Properties {
|
||||
fields[idx] = field
|
||||
idx++
|
||||
}
|
||||
|
||||
table := printer.NewTable(conf, 2, 5)
|
||||
table.Addheaders("index property", "value")
|
||||
|
||||
ts, err := strconv.ParseInt(res[index].Settings.Index.CreationDate.(string), 10, 64)
|
||||
if err != nil {
|
||||
ts = 0
|
||||
}
|
||||
|
||||
created := time.Unix(ts/1000, 0)
|
||||
|
||||
table.Entries = [][]string{
|
||||
{"name", index},
|
||||
{"replicas", *res[index].Settings.Index.NumberOfReplicas},
|
||||
{"shards", *res[index].Settings.Index.NumberOfShards},
|
||||
{"created", created.Format("2006-01-02 15:04:05")},
|
||||
{"uuid", *res[index].Settings.Index.Uuid},
|
||||
{"fields", strings.Join(fields, ",")},
|
||||
}
|
||||
|
||||
if err := table.Print(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
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, len(res.Fields))
|
||||
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
|
||||
table.Entries[idx] = []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
|
||||
}
|
||||
|
||||
115
pkg/es/index_alias.go
Normal file
115
pkg/es/index_alias.go
Normal 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
|
||||
}
|
||||
@@ -19,32 +19,40 @@ package es
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
|
||||
"codeberg.org/scip/esctl/pkg/cfg"
|
||||
"codeberg.org/scip/esctl/pkg/printer"
|
||||
)
|
||||
|
||||
func Health(conf *cfg.Config) error {
|
||||
res, err := conf.DefaultCluster.ES.Cluster.Health().Do(context.Background())
|
||||
func NodeList(conf *cfg.Config) error {
|
||||
// get nodes
|
||||
nodes, err := conf.DefaultCluster.ES.Cat.Nodes().Do(context.Background())
|
||||
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")}
|
||||
|
||||
table.entries = [][]string{
|
||||
{"Cluster Name", Colorize(*&res.Status.Name, res.ClusterName)},
|
||||
{"Active Shards", fmt.Sprintf("%d", res.ActiveShards)},
|
||||
{"Active Primary Shards", fmt.Sprintf("%d", res.ActivePrimaryShards)},
|
||||
{"Indicies", fmt.Sprintf("%d", len(res.Indices))},
|
||||
{"Nodes", fmt.Sprintf("%d", res.NumberOfNodes)},
|
||||
for idx, node := range nodes {
|
||||
table.Entries[idx] = []string{
|
||||
*node.Name,
|
||||
*node.Ip,
|
||||
*node.Load1M,
|
||||
*node.Load5M,
|
||||
*node.Load15M,
|
||||
node.RamPercent.(string),
|
||||
node.HeapPercent.(string),
|
||||
}
|
||||
}
|
||||
|
||||
table.Sort()
|
||||
if err := table.Print(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
table.PrintMarkdown()
|
||||
return nil
|
||||
}
|
||||
224
pkg/es/repl.go
Normal file
224
pkg/es/repl.go
Normal 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
|
||||
}
|
||||
232
pkg/es/role.go
Normal file
232
pkg/es/role.go
Normal file
@@ -0,0 +1,232 @@
|
||||
/*
|
||||
Copyright © 2026 Thomas von Dein
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package 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 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()
|
||||
if err := table.Print(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
154
pkg/es/search.go
154
pkg/es/search.go
@@ -19,13 +19,21 @@ package es
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"codeberg.org/scip/esctl/pkg/cfg"
|
||||
"codeberg.org/scip/esctl/pkg/printer"
|
||||
"github.com/alecthomas/repr"
|
||||
"github.com/elastic/go-elasticsearch/v9/typedapi/core/search"
|
||||
"github.com/elastic/go-elasticsearch/v9/typedapi/esdsl"
|
||||
"github.com/elastic/go-elasticsearch/v9/typedapi/types"
|
||||
"github.com/elastic/go-elasticsearch/v9/typedapi/types/enums/sortorder"
|
||||
)
|
||||
|
||||
const (
|
||||
MAXPAGE = 5000
|
||||
)
|
||||
|
||||
/*
|
||||
@@ -34,42 +42,146 @@ Execute an ES search.
|
||||
q is the actual search query given as arg to the 'search' cmd
|
||||
additional filters can be given as -F key=value
|
||||
*/
|
||||
func Search(conf *cfg.Config, q string) error {
|
||||
query := esdsl.NewBoolQuery().
|
||||
Must(esdsl.NewMatchQuery("message", q))
|
||||
func Search(conf *cfg.Config, queries []string) error {
|
||||
searchEs := conf.DefaultCluster.ES.Search().
|
||||
Index(conf.Index)
|
||||
|
||||
filters := make([]types.QueryVariant, len(conf.Filter))
|
||||
queryCaster, err := prepareQuery(conf, queries)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for idx, filter := range conf.Filter {
|
||||
parts := strings.Split(filter, "=")
|
||||
if len(parts) != 2 {
|
||||
return fmt.Errorf("invalid filter spec: %s, expecting key=value", filter)
|
||||
req := &search.Request{Query: queryCaster}
|
||||
|
||||
searchEs.Request(req)
|
||||
|
||||
searchEs = addSort(conf, searchEs)
|
||||
|
||||
switch conf.Tail {
|
||||
case true:
|
||||
return searchTail(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 {
|
||||
query.Filter(filters...)
|
||||
}
|
||||
}
|
||||
|
||||
func Debug(conf *cfg.Config) error {
|
||||
res, err := conf.DefaultCluster.ES.Search().
|
||||
Index(conf.Index).
|
||||
Request(&search.Request{
|
||||
Query: query.QueryCaster(),
|
||||
From: &conf.From,
|
||||
Size: &conf.To,
|
||||
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 fmt.Errorf("Error running search (esdsl): %s", err)
|
||||
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)
|
||||
|
||||
for _, hit := range res.Hits.Hits {
|
||||
fmt.Printf("%s\n", hit.Source_)
|
||||
printer.PrintDoc(conf, hit)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
for _, hit := range res.Hits.Hits {
|
||||
printer.PrintDoc(conf, hit)
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
docs[*hit.Id_] = 1
|
||||
}
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
280
pkg/es/search_filter.go
Normal file
280
pkg/es/search_filter.go
Normal 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
151
pkg/es/shard.go
Normal 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)
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"codeberg.org/scip/esctl/pkg/cfg"
|
||||
"codeberg.org/scip/esctl/pkg/printer"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -44,7 +45,7 @@ func SnapshotList(conf *cfg.Config) error {
|
||||
// get partial indicies
|
||||
ires, err := conf.DefaultCluster.ES.Cat.Indices().Do(context.Background())
|
||||
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{}
|
||||
@@ -57,7 +58,7 @@ func SnapshotList(conf *cfg.Config) error {
|
||||
// get snapshots
|
||||
sres, err := conf.DefaultCluster.ES.Cat.Snapshots().Do(context.Background())
|
||||
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)
|
||||
@@ -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")
|
||||
|
||||
for idx, snap := range snapshots {
|
||||
table.entries[idx] = []string{
|
||||
table.Entries[idx] = []string{
|
||||
snap.Name,
|
||||
snap.Forindex,
|
||||
snap.Start,
|
||||
@@ -97,7 +98,9 @@ func SnapshotList(conf *cfg.Config) error {
|
||||
}
|
||||
|
||||
table.Sort()
|
||||
table.PrintMarkdown()
|
||||
if err := table.Print(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -105,7 +108,7 @@ func SnapshotList(conf *cfg.Config) error {
|
||||
func SnapshotShow(conf *cfg.Config, snapshot string) error {
|
||||
res, err := conf.DefaultCluster.ES.Snapshot.Get("*", snapshot).Do(context.Background())
|
||||
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)
|
||||
@@ -114,12 +117,12 @@ func SnapshotShow(conf *cfg.Config, snapshot string) error {
|
||||
return errors.New("no snapshot retrieved")
|
||||
}
|
||||
|
||||
table := NewTable(2, 17)
|
||||
table.Addheaders("field", "value")
|
||||
table := printer.NewTable(conf, 2, 17)
|
||||
table.Addheaders("snapshot property", "value")
|
||||
|
||||
snap := res.Snapshots[0]
|
||||
|
||||
table.entries = [][]string{
|
||||
table.Entries = [][]string{
|
||||
{"snapshot", snapshot},
|
||||
{"uuid", snap.Uuid},
|
||||
{"repository", *snap.Repository},
|
||||
@@ -135,7 +138,9 @@ func SnapshotShow(conf *cfg.Config, snapshot string) error {
|
||||
{"shards-successful", fmt.Sprintf("%d", snap.Shards.Successful)},
|
||||
}
|
||||
|
||||
table.PrintMarkdown()
|
||||
if err := table.Print(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
114
pkg/es/table.go
114
pkg/es/table.go
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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/>.
|
||||
*/
|
||||
|
||||
package es
|
||||
package printer
|
||||
|
||||
import "github.com/fatih/color"
|
||||
import (
|
||||
"codeberg.org/scip/esctl/pkg/cfg"
|
||||
"github.com/fatih/color"
|
||||
)
|
||||
|
||||
var (
|
||||
green = color.New(color.BgGreen, color.FgHiWhite).SprintFunc()
|
||||
yellow = color.New(color.BgYellow, color.FgHiWhite).SprintFunc()
|
||||
orange = color.BgRGB(255, 128, 0).AddRGB(0, 0, 0).SprintFunc()
|
||||
red = color.New(color.BgRed, color.FgHiWhite).SprintFunc()
|
||||
bold = color.New(color.Bold).SprintFunc()
|
||||
)
|
||||
|
||||
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 {
|
||||
case "green":
|
||||
what = green(what)
|
||||
@@ -34,6 +43,8 @@ func Colorize(col, what string) string {
|
||||
what = orange(what)
|
||||
case "red":
|
||||
what = red(what)
|
||||
case "yellow":
|
||||
what = yellow(what)
|
||||
}
|
||||
|
||||
return what
|
||||
45
pkg/printer/doc.go
Normal file
45
pkg/printer/doc.go
Normal 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 printer
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"codeberg.org/scip/esctl/pkg/cfg"
|
||||
"github.com/elastic/go-elasticsearch/v9/typedapi/types"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
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.Println(docjson)
|
||||
}
|
||||
}
|
||||
306
pkg/printer/table.go
Normal file
306
pkg/printer/table.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user