mirror of
https://codeberg.org/scip/esctl.git
synced 2026-08-24 16:14:17 +02:00
Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15e0f9dc90 | ||
|
|
dd619ab815 | ||
| 327bc84bd8 | |||
| a785eb642f | |||
| a209debe09 | |||
| d1d97fed1b | |||
| 4194a004c0 | |||
| ceab7698e2 | |||
| d22352da12 | |||
| 3bd26fde7a | |||
|
|
38ed9de0b4 | ||
|
|
5e82a3706b | ||
| 0122009774 | |||
| 8109481e23 | |||
|
|
af75bfa192 | ||
| a71be5001a | |||
|
|
fcfeaf66a9 | ||
|
|
a169d23b90 | ||
|
|
f89a09d1d2 | ||
|
|
01853c3299 | ||
| d6a96ee61f | |||
|
|
f27f9157fb | ||
|
|
3cbc67567b |
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!
|
||||||
32
README.md
32
README.md
@@ -1,3 +1,5 @@
|
|||||||
|
[](https://ci.codeberg.org/repos/16999)
|
||||||
|
|
||||||
# esctl
|
# esctl
|
||||||
|
|
||||||
Elasticsearch CLI
|
Elasticsearch CLI
|
||||||
@@ -12,15 +14,20 @@ USAGE:
|
|||||||
esctl [global options] [command [command options]]
|
esctl [global options] [command [command options]]
|
||||||
|
|
||||||
VERSION:
|
VERSION:
|
||||||
v0.0.1
|
v0.0.4
|
||||||
|
|
||||||
COMMANDS:
|
COMMANDS:
|
||||||
health show ES health
|
|
||||||
search, / search within an index
|
search, / search within an index
|
||||||
|
index, i manage indicies
|
||||||
|
snapshot, snap manage snapshots
|
||||||
|
cluster, c manage cluster[s]
|
||||||
|
node, snap manage nodes
|
||||||
help, h Shows a list of commands or help for one command
|
help, h Shows a list of commands or help for one command
|
||||||
|
|
||||||
GLOBAL OPTIONS:
|
GLOBAL OPTIONS:
|
||||||
--debug, -d enable debugging [$ES_DEBUG]
|
--debug, -d enable debugging [$ES_DEBUG]
|
||||||
|
--config string, -c string config file [$ES_CONFIG]
|
||||||
|
--cluster string, -C string cluster alias to work with
|
||||||
--help, -h show help
|
--help, -h show help
|
||||||
--version, -v print the version
|
--version, -v print the version
|
||||||
```
|
```
|
||||||
@@ -31,6 +38,27 @@ Configure `esctl` with environment variables:
|
|||||||
- `ES_USER`: username
|
- `ES_USER`: username
|
||||||
- `ES_PASS`: password
|
- `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
|
## Introduction
|
||||||
|
|
||||||
FIXME
|
FIXME
|
||||||
|
|||||||
5
TODO.md
5
TODO.md
@@ -1,2 +1,7 @@
|
|||||||
- [Go client docs](https://www.elastic.co/docs/reference/elasticsearch/clients/go/typed-api)
|
- [Go client docs](https://www.elastic.co/docs/reference/elasticsearch/clients/go/typed-api)
|
||||||
- [ES API docs](https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-indices-get)
|
- [ES API docs](https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-indices-get)
|
||||||
|
|
||||||
|
- Fix index names custom completion
|
||||||
|
- index show: add more details, see screenshots
|
||||||
|
- add shard explain, aka:
|
||||||
|
get /_cluster/allocation/explain {"index":"yourindex", "primary": true, "shard":0}
|
||||||
|
|||||||
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
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
58
cmd/doc.go
Normal file
58
cmd/doc.go
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
/*
|
||||||
|
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),
|
||||||
|
//Delete(conf),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func DocAdd(conf *cfg.Config) *cli.Command {
|
||||||
|
return &cli.Command{
|
||||||
|
Name: "add",
|
||||||
|
Aliases: []string{"+"},
|
||||||
|
Usage: "add JSON document index",
|
||||||
|
UsageText: "add [options] <index> '<json-doc>'",
|
||||||
|
|
||||||
|
Action: func(ctx context.Context, cmd *cli.Command) error {
|
||||||
|
args := cmd.Args()
|
||||||
|
|
||||||
|
if args.Len() != 2 {
|
||||||
|
return errors.New("missing arguments: <index> <json-doc>")
|
||||||
|
}
|
||||||
|
|
||||||
|
return es.DocAdd(conf, cmd.Args().Get(0), cmd.Args().Get(1))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
94
cmd/index.go
94
cmd/index.go
@@ -18,6 +18,7 @@ package cmd
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
"codeberg.org/scip/esctl/pkg/cfg"
|
"codeberg.org/scip/esctl/pkg/cfg"
|
||||||
"codeberg.org/scip/esctl/pkg/es"
|
"codeberg.org/scip/esctl/pkg/es"
|
||||||
@@ -34,6 +35,9 @@ func Index(conf *cfg.Config) *cli.Command {
|
|||||||
Commands: []*cli.Command{
|
Commands: []*cli.Command{
|
||||||
IndexList(conf),
|
IndexList(conf),
|
||||||
IndexShow(conf),
|
IndexShow(conf),
|
||||||
|
IndexCreate(conf),
|
||||||
|
IndexDelete(conf),
|
||||||
|
IndexClose(conf),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -66,11 +70,7 @@ func IndexList(conf *cfg.Config) *cli.Command {
|
|||||||
},
|
},
|
||||||
|
|
||||||
Action: func(ctx context.Context, cmd *cli.Command) error {
|
Action: func(ctx context.Context, cmd *cli.Command) error {
|
||||||
if err := es.IndexList(conf); err != nil {
|
return es.IndexList(conf)
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -82,11 +82,89 @@ func IndexShow(conf *cfg.Config) *cli.Command {
|
|||||||
Usage: "show details about an index",
|
Usage: "show details about an index",
|
||||||
|
|
||||||
Action: func(ctx context.Context, cmd *cli.Command) error {
|
Action: func(ctx context.Context, cmd *cli.Command) error {
|
||||||
if err := es.IndexShow(conf, cmd.Args().Get(0)); err != nil {
|
return es.IndexShow(conf, cmd.Args().Get(0))
|
||||||
return err
|
|
||||||
|
},
|
||||||
|
|
||||||
|
// 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
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
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",
|
||||||
|
|
||||||
|
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 {
|
||||||
|
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 {
|
||||||
|
return es.IndexClose(conf, cmd.Args().Get(0))
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
You should have received a copy of the GNU General Public License
|
||||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -26,17 +25,14 @@ import (
|
|||||||
"github.com/urfave/cli/v3"
|
"github.com/urfave/cli/v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
func Status(conf *cfg.Config) *cli.Command {
|
func Repl(conf *cfg.Config) *cli.Command {
|
||||||
return &cli.Command{
|
return &cli.Command{
|
||||||
Name: "status",
|
Name: "repl",
|
||||||
Usage: "show ES status",
|
Aliases: []string{"shell"},
|
||||||
Aliases: []string{"s"},
|
Usage: "interactive API repl",
|
||||||
Action: func(ctx context.Context, cmd *cli.Command) error {
|
|
||||||
if err := es.Health(conf); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
Action: func(ctx context.Context, cmd *cli.Command) error {
|
||||||
|
return es.Repl(conf)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
13
cmd/root.go
13
cmd/root.go
@@ -72,15 +72,24 @@ func Main() int {
|
|||||||
},
|
},
|
||||||
|
|
||||||
Commands: []*cli.Command{
|
Commands: []*cli.Command{
|
||||||
Status(conf),
|
|
||||||
Search(conf),
|
Search(conf),
|
||||||
Index(conf),
|
Index(conf),
|
||||||
Snapshot(conf),
|
Snapshot(conf),
|
||||||
|
Cluster(conf),
|
||||||
|
Ccr(conf),
|
||||||
|
Node(conf),
|
||||||
|
Doc(conf),
|
||||||
|
Repl(conf),
|
||||||
},
|
},
|
||||||
|
|
||||||
Before: func(ctx context.Context, cmd *cli.Command) (context.Context, error) {
|
Before: func(ctx context.Context, cmd *cli.Command) (context.Context, error) {
|
||||||
if err := conf.Init(); err != nil {
|
if err := conf.Init(); err != nil {
|
||||||
Finish(err)
|
if len(os.Args) > 1 {
|
||||||
|
return nil, err
|
||||||
|
} else {
|
||||||
|
fmt.Println(cmd.UsageText)
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Init(conf)
|
log.Init(conf)
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ package cmd
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
"codeberg.org/scip/esctl/pkg/cfg"
|
"codeberg.org/scip/esctl/pkg/cfg"
|
||||||
"codeberg.org/scip/esctl/pkg/es"
|
"codeberg.org/scip/esctl/pkg/es"
|
||||||
@@ -30,7 +31,7 @@ func Search(conf *cfg.Config) *cli.Command {
|
|||||||
Name: "search",
|
Name: "search",
|
||||||
Aliases: []string{"/"},
|
Aliases: []string{"/"},
|
||||||
Usage: "search within an index",
|
Usage: "search within an index",
|
||||||
UsageText: "search [options] <query>",
|
UsageText: "search [options] <field=pattern> ...",
|
||||||
|
|
||||||
Flags: []cli.Flag{
|
Flags: []cli.Flag{
|
||||||
&cli.StringFlag{
|
&cli.StringFlag{
|
||||||
@@ -63,11 +64,12 @@ func Search(conf *cfg.Config) *cli.Command {
|
|||||||
},
|
},
|
||||||
|
|
||||||
Action: func(ctx context.Context, cmd *cli.Command) error {
|
Action: func(ctx context.Context, cmd *cli.Command) error {
|
||||||
if err := es.Search(conf, cmd.Args().Get(0)); err != nil {
|
args := cmd.Args()
|
||||||
return err
|
if args.Len() == 0 {
|
||||||
|
return errors.New("at least one query must be specified (format: field=pattern)")
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return es.Search(conf, args.Slice())
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,11 +54,7 @@ func SnapshotList(conf *cfg.Config) *cli.Command {
|
|||||||
},
|
},
|
||||||
|
|
||||||
Action: func(ctx context.Context, cmd *cli.Command) error {
|
Action: func(ctx context.Context, cmd *cli.Command) error {
|
||||||
if err := es.SnapshotList(conf); err != nil {
|
return es.SnapshotList(conf)
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -71,11 +67,7 @@ func SnapshotShow(conf *cfg.Config) *cli.Command {
|
|||||||
UsageText: "show [options] <snapshot>",
|
UsageText: "show [options] <snapshot>",
|
||||||
|
|
||||||
Action: func(ctx context.Context, cmd *cli.Command) error {
|
Action: func(ctx context.Context, cmd *cli.Command) error {
|
||||||
if err := es.SnapshotShow(conf, cmd.Args().Get(0)); err != nil {
|
return es.SnapshotShow(conf, cmd.Args().Get(0))
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
3
go.mod
3
go.mod
@@ -19,8 +19,10 @@ go 1.25.0
|
|||||||
require (
|
require (
|
||||||
github.com/alecthomas/repr v0.5.2 // indirect
|
github.com/alecthomas/repr v0.5.2 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
|
github.com/chzyer/readline v1.5.1 // indirect
|
||||||
github.com/clipperhouse/displaywidth v0.10.0 // indirect
|
github.com/clipperhouse/displaywidth v0.10.0 // indirect
|
||||||
github.com/clipperhouse/uax29/v2 v2.6.0 // indirect
|
github.com/clipperhouse/uax29/v2 v2.6.0 // indirect
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/elastic/elastic-transport-go/v8 v8.11.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/elastic/go-elasticsearch/v9 v9.3.2 // indirect
|
||||||
github.com/fatih/color v1.19.0 // indirect
|
github.com/fatih/color v1.19.0 // indirect
|
||||||
@@ -40,6 +42,7 @@ require (
|
|||||||
go.opentelemetry.io/otel v1.35.0 // indirect
|
go.opentelemetry.io/otel v1.35.0 // indirect
|
||||||
go.opentelemetry.io/otel/metric v1.35.0 // indirect
|
go.opentelemetry.io/otel/metric v1.35.0 // indirect
|
||||||
go.opentelemetry.io/otel/trace v1.35.0 // indirect
|
go.opentelemetry.io/otel/trace v1.35.0 // indirect
|
||||||
|
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
|
||||||
golang.org/x/sys v0.42.0 // indirect
|
golang.org/x/sys v0.42.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
9
go.sum
9
go.sum
@@ -2,10 +2,16 @@ github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs
|
|||||||
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
|
github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ=
|
||||||
|
github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI=
|
||||||
|
github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk=
|
||||||
|
github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8=
|
||||||
github.com/clipperhouse/displaywidth v0.10.0 h1:GhBG8WuerxjFQQYeuZAeVTuyxuX+UraiZGD4HJQ3Y8g=
|
github.com/clipperhouse/displaywidth v0.10.0 h1:GhBG8WuerxjFQQYeuZAeVTuyxuX+UraiZGD4HJQ3Y8g=
|
||||||
github.com/clipperhouse/displaywidth v0.10.0/go.mod h1:XqJajYsaiEwkxOj4bowCTMcT1SgvHo9flfF3jQasdbs=
|
github.com/clipperhouse/displaywidth v0.10.0/go.mod h1:XqJajYsaiEwkxOj4bowCTMcT1SgvHo9flfF3jQasdbs=
|
||||||
github.com/clipperhouse/uax29/v2 v2.6.0 h1:z0cDbUV+aPASdFb2/ndFnS9ts/WNXgTNNGFoKXuhpos=
|
github.com/clipperhouse/uax29/v2 v2.6.0 h1:z0cDbUV+aPASdFb2/ndFnS9ts/WNXgTNNGFoKXuhpos=
|
||||||
github.com/clipperhouse/uax29/v2 v2.6.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
github.com/clipperhouse/uax29/v2 v2.6.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/elastic/elastic-transport-go/v8 v8.9.0 h1:KeT/2P54F0xS0S8Y3Pf+tFDg4HmBgReQMB+BMz8dDAs=
|
github.com/elastic/elastic-transport-go/v8 v8.9.0 h1:KeT/2P54F0xS0S8Y3Pf+tFDg4HmBgReQMB+BMz8dDAs=
|
||||||
github.com/elastic/elastic-transport-go/v8 v8.9.0/go.mod h1:ssMTvNS2hwf7CaiGsRRsx4gQHFZ/jS/DkLcISxekWzc=
|
github.com/elastic/elastic-transport-go/v8 v8.9.0/go.mod h1:ssMTvNS2hwf7CaiGsRRsx4gQHFZ/jS/DkLcISxekWzc=
|
||||||
github.com/elastic/elastic-transport-go/v8 v8.11.0 h1:taYmqC2M6+fZt/+W+ENYh/W5L9+KrlJGOSbEJs8egWc=
|
github.com/elastic/elastic-transport-go/v8 v8.11.0 h1:taYmqC2M6+fZt/+W+ENYh/W5L9+KrlJGOSbEJs8egWc=
|
||||||
@@ -54,6 +60,9 @@ go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/
|
|||||||
go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE=
|
go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE=
|
||||||
go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs=
|
go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs=
|
||||||
go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc=
|
go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc=
|
||||||
|
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
|
||||||
|
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
|
||||||
|
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
||||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|||||||
package cfg
|
package cfg
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -30,7 +31,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
Version string = `v0.0.2`
|
Version string = `v0.0.9`
|
||||||
)
|
)
|
||||||
|
|
||||||
type Cluster struct {
|
type Cluster struct {
|
||||||
@@ -44,10 +45,16 @@ type Config struct {
|
|||||||
Debug bool // -d
|
Debug bool // -d
|
||||||
Clusters map[string]*Cluster
|
Clusters map[string]*Cluster
|
||||||
DefaultCluster *Cluster
|
DefaultCluster *Cluster
|
||||||
From, To, MaxItems int
|
Index string // index: -i
|
||||||
Index string
|
Failed, Partials bool // index: flags
|
||||||
Filter []string
|
Shards, Replicas int // index create: -s -r
|
||||||
Failed, Partials bool
|
Wait bool // index create: -w
|
||||||
|
From, To, MaxItems int // search: flags
|
||||||
|
Filter []string // search: -F
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewConfig() *Config {
|
func NewConfig() *Config {
|
||||||
@@ -55,16 +62,26 @@ func NewConfig() *Config {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (conf *Config) Init() error {
|
func (conf *Config) Init() error {
|
||||||
if conf.ConfigFile != "" {
|
DefaultConfig := os.Getenv("HOME") + "/.config/esctl/config.yaml"
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case fileExists(DefaultConfig):
|
||||||
|
conf.ConfigFile = DefaultConfig
|
||||||
|
fallthrough
|
||||||
|
case conf.ConfigFile != "":
|
||||||
if err := conf.LoadConfig(); err != nil {
|
if err := conf.LoadConfig(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
} else {
|
default:
|
||||||
if err := conf.LoadEnv(); err != nil {
|
if err := conf.LoadEnv(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := conf.SetupES(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
if conf.CurrentCluster != "" {
|
if conf.CurrentCluster != "" {
|
||||||
current, exists := conf.Clusters[conf.CurrentCluster]
|
current, exists := conf.Clusters[conf.CurrentCluster]
|
||||||
if !exists {
|
if !exists {
|
||||||
@@ -72,17 +89,48 @@ func (conf *Config) Init() error {
|
|||||||
} else {
|
} else {
|
||||||
conf.DefaultCluster = current
|
conf.DefaultCluster = current
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
if len(conf.Clusters) == 1 {
|
||||||
|
for name, cluster := range conf.Clusters {
|
||||||
|
conf.DefaultCluster = cluster
|
||||||
|
conf.CurrentCluster = name
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for name, cluster := range conf.Clusters {
|
||||||
|
_, err := cluster.ES.Cluster.Health().
|
||||||
|
Header("content-type", "application/json").
|
||||||
|
Header("accept", "application/json").
|
||||||
|
Do(context.Background())
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
conf.DefaultCluster = cluster
|
||||||
|
conf.CurrentCluster = name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if conf.Debug {
|
conf.PrintDebug()
|
||||||
repr.Println(conf)
|
|
||||||
}
|
|
||||||
|
|
||||||
conf.SetupES()
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (conf *Config) PrintDebug() {
|
||||||
|
if !conf.Debug {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
clone := *conf
|
||||||
|
|
||||||
|
for name := range clone.Clusters {
|
||||||
|
clone.Clusters[name] = nil
|
||||||
|
}
|
||||||
|
clone.DefaultCluster = nil
|
||||||
|
|
||||||
|
fmt.Println("config:")
|
||||||
|
repr.Println(clone)
|
||||||
|
}
|
||||||
|
|
||||||
func (conf *Config) LoadEnv() error {
|
func (conf *Config) LoadEnv() error {
|
||||||
cluster := Cluster{
|
cluster := Cluster{
|
||||||
Uri: os.Getenv("ES_URI"),
|
Uri: os.Getenv("ES_URI"),
|
||||||
@@ -163,3 +211,14 @@ func (conf *Config) SetupES() error {
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func fileExists(filename string) bool {
|
||||||
|
info, err := os.Stat(filename)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
// return false on any error
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return !info.IsDir()
|
||||||
|
}
|
||||||
|
|||||||
134
pkg/es/ccr.go
Normal file
134
pkg/es/ccr.go
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
/*
|
||||||
|
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"
|
||||||
|
"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, 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", 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 := NewTable(2, 5)
|
||||||
|
table.Addheaders("field", "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.PrintMarkdown(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
198
pkg/es/ccr_follower.go
Normal file
198
pkg/es/ccr_follower.go
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
/*
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
|
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", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
remote := ""
|
||||||
|
for name := range res {
|
||||||
|
remote = name
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if remote == "" {
|
||||||
|
return "", fmt.Errorf("cluster doesn't have a follower: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
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", 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", 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", 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", 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", 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 := NewTable(2, 9)
|
||||||
|
table.Addheaders("field", "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.PrintMarkdown(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
231
pkg/es/cluster.go
Normal file
231
pkg/es/cluster.go
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
/*
|
||||||
|
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"
|
||||||
|
"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 := NewTable(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 = Colorize("green", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
table.entries[idx] = []string{name, cluster.Uri, fmt.Sprintf("%t", current)}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := table.PrintMarkdown(); 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 := NewTable(2, 5)
|
||||||
|
table.Addheaders(cluster, "status")
|
||||||
|
|
||||||
|
table.entries = [][]string{
|
||||||
|
{"Cluster Name", Colorize(clusterhealth.Status.Name, clusterhealth.ClusterName)},
|
||||||
|
{"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.PrintMarkdown(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func gatherClusterStats(conf *cfg.Config, clusterstats *clusterstats.Response, table *Table) *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
|
||||||
|
}
|
||||||
160
pkg/es/cluster_settings.go
Normal file
160
pkg/es/cluster_settings.go
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
/*
|
||||||
|
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"
|
||||||
|
"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", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
table := NewTable(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.PrintMarkdown(); 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", 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, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
395
pkg/es/cluster_util.go
Normal file
395
pkg/es/cluster_util.go
Normal file
@@ -0,0 +1,395 @@
|
|||||||
|
/*
|
||||||
|
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"
|
||||||
|
"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, 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, err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
status[cluster] = st
|
||||||
|
}
|
||||||
|
|
||||||
|
table := NewTable(3, 5)
|
||||||
|
|
||||||
|
table.Addheaders("setting", "leader:"+leader, "follower:"+follower)
|
||||||
|
|
||||||
|
table.entries = [][]string{
|
||||||
|
{"Cluster Name",
|
||||||
|
Colorize(status[leader].Status.Name, status[leader].ClusterName),
|
||||||
|
Colorize(status[follower].Status.Name, status[follower].ClusterName),
|
||||||
|
},
|
||||||
|
{"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.PrintMarkdown(); err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
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, 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 := NewTable(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.PrintMarkdown(); 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 := NewTable(3, len(indexOnlyOnLeader))
|
||||||
|
table.Addheaders("index only on leader", "size", "docscount")
|
||||||
|
|
||||||
|
for name, index := range indexOnlyOnLeader {
|
||||||
|
name := Colorize("red", name)
|
||||||
|
|
||||||
|
table.entries[idx] = []string{name, *index.DatasetSize, *index.DocsCount}
|
||||||
|
idx++
|
||||||
|
}
|
||||||
|
|
||||||
|
table.Sort()
|
||||||
|
if err := table.PrintMarkdown(); 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 := NewTable(3, len(orphaned))
|
||||||
|
table.Addheaders("orphaned index on follower", "size", "docscount")
|
||||||
|
|
||||||
|
for name, index := range orphaned {
|
||||||
|
name := Colorize("red", name)
|
||||||
|
|
||||||
|
table.entries[idx] = []string{name, *index.DatasetSize, *index.DocsCount}
|
||||||
|
idx++
|
||||||
|
}
|
||||||
|
|
||||||
|
table.Sort()
|
||||||
|
if err := table.PrintMarkdown(); 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 := NewTable(3, len(red))
|
||||||
|
table.Addheaders("red index on follower", "size", "docscount")
|
||||||
|
|
||||||
|
for name, index := range red {
|
||||||
|
name := Colorize("red", name)
|
||||||
|
|
||||||
|
table.entries[idx] = []string{name, *index.DatasetSize, *index.DocsCount}
|
||||||
|
idx++
|
||||||
|
}
|
||||||
|
|
||||||
|
table.Sort()
|
||||||
|
if err := table.PrintMarkdown(); 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
|
||||||
|
}
|
||||||
55
pkg/es/doc.go
Normal file
55
pkg/es/doc.go
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
/*
|
||||||
|
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"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"codeberg.org/scip/esctl/pkg/cfg"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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, index, 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", time.Now().Unix())
|
||||||
|
|
||||||
|
_, err = conf.DefaultCluster.ES.Create(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", index, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
148
pkg/es/index.go
148
pkg/es/index.go
@@ -20,13 +20,39 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"codeberg.org/scip/esctl/pkg/cfg"
|
"codeberg.org/scip/esctl/pkg/cfg"
|
||||||
|
"github.com/elastic/go-elasticsearch/v9/typedapi/esdsl"
|
||||||
"github.com/elastic/go-elasticsearch/v9/typedapi/types/enums/healthstatus"
|
"github.com/elastic/go-elasticsearch/v9/typedapi/types/enums/healthstatus"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// used for completion
|
||||||
|
func IndexNames(conf *cfg.Config) ([]string, error) {
|
||||||
|
res, err := conf.DefaultCluster.ES.Cat.Indices().
|
||||||
|
Header("content-type", "application/json").
|
||||||
|
Header("accept", "application/json").
|
||||||
|
Do(context.Background())
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get indicies: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
indices := make([]string, len(res))
|
||||||
|
for idx, index := range res {
|
||||||
|
indices[idx] = *index.Index
|
||||||
|
}
|
||||||
|
|
||||||
|
return indices, nil
|
||||||
|
}
|
||||||
|
|
||||||
func IndexList(conf *cfg.Config) error {
|
func IndexList(conf *cfg.Config) error {
|
||||||
cat := conf.DefaultCluster.ES.Cat.Indices()
|
cat := conf.DefaultCluster.ES.Cat.Indices().
|
||||||
|
// we need to add custom request headers, required for older ES instances
|
||||||
|
Header("content-type", "application/json").
|
||||||
|
Header("accept", "application/json")
|
||||||
|
|
||||||
if conf.Failed {
|
if conf.Failed {
|
||||||
cat = cat.Health(healthstatus.Red)
|
cat = cat.Health(healthstatus.Red)
|
||||||
@@ -34,7 +60,7 @@ func IndexList(conf *cfg.Config) error {
|
|||||||
|
|
||||||
res, err := cat.Do(context.Background())
|
res, err := cat.Do(context.Background())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("Error getting indicies: %s", err)
|
return fmt.Errorf("failed to get indicies: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
slog.Debug("ES result", "indicies", res)
|
slog.Debug("ES result", "indicies", res)
|
||||||
@@ -61,20 +87,132 @@ func IndexList(conf *cfg.Config) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
table.Sort()
|
table.Sort()
|
||||||
table.PrintMarkdown()
|
if err := table.PrintMarkdown(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func IndexShow(conf *cfg.Config, index string) error {
|
func IndexShow(conf *cfg.Config, index string) error {
|
||||||
res, err := conf.DefaultCluster.ES.Indices.Get(index).Do(context.Background())
|
if index == "" {
|
||||||
|
return fmt.Errorf("no index specified")
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to get index: %s", err)
|
return fmt.Errorf("failed to get index: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
slog.Debug("ES result", "index", res)
|
slog.Debug("ES result", "index", res)
|
||||||
|
|
||||||
// FIXME: add table printer like SnapshotShow
|
table := NewTable(2, 5)
|
||||||
|
table.Addheaders("field", "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},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := table.PrintMarkdown(); 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", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func IndexDelete(conf *cfg.Config, index string) error {
|
||||||
|
if index == "" {
|
||||||
|
return fmt.Errorf("no index specified")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, 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", 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", err)
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,32 +19,39 @@ package es
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
|
||||||
"codeberg.org/scip/esctl/pkg/cfg"
|
"codeberg.org/scip/esctl/pkg/cfg"
|
||||||
)
|
)
|
||||||
|
|
||||||
func Health(conf *cfg.Config) error {
|
func NodeList(conf *cfg.Config) error {
|
||||||
res, err := conf.DefaultCluster.ES.Cluster.Health().Do(context.Background())
|
// get nodes
|
||||||
|
nodes, err := conf.DefaultCluster.ES.Cat.Nodes().Do(context.Background())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Error getting health: %s", err)
|
return fmt.Errorf("failed to get nodes: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
slog.Debug("ES result", "cluster health", res)
|
slog.Debug("ES result", "nodes", nodes)
|
||||||
|
|
||||||
table := NewTable(2, 5)
|
table := NewTable(7, len(nodes))
|
||||||
|
table.Addheaders("name", "ip", "load1m", "load5m", "load15m", "ram %", "heap %")
|
||||||
|
|
||||||
table.headers = []string{bold("SETTING"), bold("STATUS")}
|
for idx, node := range nodes {
|
||||||
|
table.entries[idx] = []string{
|
||||||
table.entries = [][]string{
|
*node.Name,
|
||||||
{"Cluster Name", Colorize(*&res.Status.Name, res.ClusterName)},
|
*node.Ip,
|
||||||
{"Active Shards", fmt.Sprintf("%d", res.ActiveShards)},
|
*node.Load1M,
|
||||||
{"Active Primary Shards", fmt.Sprintf("%d", res.ActivePrimaryShards)},
|
*node.Load5M,
|
||||||
{"Indicies", fmt.Sprintf("%d", len(res.Indices))},
|
*node.Load15M,
|
||||||
{"Nodes", fmt.Sprintf("%d", res.NumberOfNodes)},
|
node.RamPercent.(string),
|
||||||
|
node.HeapPercent.(string),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
table.Sort()
|
||||||
|
if err := table.PrintMarkdown(); err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
table.PrintMarkdown()
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
153
pkg/es/repl.go
Normal file
153
pkg/es/repl.go
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
/*
|
||||||
|
Copyright © 2026 Thomas von Dein
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package es
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/tls"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"codeberg.org/scip/esctl/pkg/cfg"
|
||||||
|
"github.com/chzyer/readline"
|
||||||
|
)
|
||||||
|
|
||||||
|
func encodeAuth(username, password string) string {
|
||||||
|
return base64.StdEncoding.EncodeToString([]byte(username + ":" + password))
|
||||||
|
}
|
||||||
|
|
||||||
|
func CallAPI(conf *cfg.Config, input []string) error {
|
||||||
|
var data string
|
||||||
|
|
||||||
|
verb := strings.ToUpper(input[0])
|
||||||
|
path := input[1]
|
||||||
|
|
||||||
|
if len(input) == 3 {
|
||||||
|
data = input[2]
|
||||||
|
}
|
||||||
|
|
||||||
|
// we're using port-forwards anyway
|
||||||
|
tr := &http.Transport{
|
||||||
|
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &http.Client{Transport: tr}
|
||||||
|
|
||||||
|
req, err := http.NewRequest(verb, conf.DefaultCluster.Uri+path, bytes.NewBuffer([]byte(data)))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Add("Content-Type", "application/json")
|
||||||
|
req.Header.Add("accept", "application/json")
|
||||||
|
req.Header.Add("Authorization", "Basic "+encodeAuth(conf.DefaultCluster.User, conf.DefaultCluster.Pass))
|
||||||
|
|
||||||
|
// actually execute the request
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read and print response
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read response body: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var pretty bytes.Buffer
|
||||||
|
error := json.Indent(&pretty, body, "", "\t")
|
||||||
|
if error != nil {
|
||||||
|
return fmt.Errorf("json parse error: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println(pretty.String())
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func Repl(conf *cfg.Config) error {
|
||||||
|
verbs := []string{"post", "get", "put", "delete"}
|
||||||
|
|
||||||
|
fmt.Println("Input format: verb path [data]")
|
||||||
|
fmt.Println("example: post /yourindex/_ccr/pause_follow")
|
||||||
|
|
||||||
|
reader, err := readline.NewEx(&readline.Config{
|
||||||
|
Prompt: "> ",
|
||||||
|
HistoryFile: os.Getenv("HOME") + "/.config/esctl/history",
|
||||||
|
HistoryLimit: 500,
|
||||||
|
InterruptPrompt: "^C",
|
||||||
|
EOFPrompt: "exit",
|
||||||
|
HistorySearchFold: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to initialize readline lib: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
text, err := reader.Readline()
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
text = strings.TrimSpace(text)
|
||||||
|
|
||||||
|
if text == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.SplitN(strings.TrimSpace(text), " ", 3)
|
||||||
|
if len(parts) < 2 {
|
||||||
|
fmt.Println("error: you need to input a verb, uri [and post data]")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if !slices.Contains(verbs, strings.ToLower(parts[0])) {
|
||||||
|
fmt.Println("error: verb must be one of " + strings.Join(verbs, ","))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.HasPrefix(parts[1], "/") {
|
||||||
|
fmt.Println("error: url path must start with /")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(parts) == 3 {
|
||||||
|
data := map[string]any{}
|
||||||
|
err := json.Unmarshal([]byte(parts[2]), &data)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("error: input data is not proper JSON: %s", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = CallAPI(conf, parts)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("failed to call API: %s\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reader.SetPrompt("> ")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -34,9 +34,17 @@ Execute an ES search.
|
|||||||
q is the actual search query given as arg to the 'search' cmd
|
q is the actual search query given as arg to the 'search' cmd
|
||||||
additional filters can be given as -F key=value
|
additional filters can be given as -F key=value
|
||||||
*/
|
*/
|
||||||
func Search(conf *cfg.Config, q string) error {
|
func Search(conf *cfg.Config, queries []string) error {
|
||||||
query := esdsl.NewBoolQuery().
|
query := esdsl.NewBoolQuery()
|
||||||
Must(esdsl.NewMatchQuery("message", q))
|
|
||||||
|
for _, q := range queries {
|
||||||
|
parts := strings.Split(q, "=")
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return fmt.Errorf("search queries must be in the form field=pattern")
|
||||||
|
}
|
||||||
|
|
||||||
|
query.Must(esdsl.NewMatchQuery(parts[0], parts[1]))
|
||||||
|
}
|
||||||
|
|
||||||
filters := make([]types.QueryVariant, len(conf.Filter))
|
filters := make([]types.QueryVariant, len(conf.Filter))
|
||||||
|
|
||||||
@@ -62,7 +70,7 @@ func Search(conf *cfg.Config, q string) error {
|
|||||||
}).
|
}).
|
||||||
Do(context.Background())
|
Do(context.Background())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("Error running search (esdsl): %s", err)
|
return fmt.Errorf("failed to run search (esdsl): %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
slog.Debug("ES result", "search", res)
|
slog.Debug("ES result", "search", res)
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ func SnapshotList(conf *cfg.Config) error {
|
|||||||
// get partial indicies
|
// get partial indicies
|
||||||
ires, err := conf.DefaultCluster.ES.Cat.Indices().Do(context.Background())
|
ires, err := conf.DefaultCluster.ES.Cat.Indices().Do(context.Background())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("Error getting indicies: %s", err)
|
return fmt.Errorf("failed to get indicies: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
indicies := map[string]int{}
|
indicies := map[string]int{}
|
||||||
@@ -57,7 +57,7 @@ func SnapshotList(conf *cfg.Config) error {
|
|||||||
// get snapshots
|
// get snapshots
|
||||||
sres, err := conf.DefaultCluster.ES.Cat.Snapshots().Do(context.Background())
|
sres, err := conf.DefaultCluster.ES.Cat.Snapshots().Do(context.Background())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("Error getting snapshots: %s", err)
|
return fmt.Errorf("failed to get snapshots: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
slog.Debug("ES result", "indicies", sres)
|
slog.Debug("ES result", "indicies", sres)
|
||||||
@@ -97,7 +97,9 @@ func SnapshotList(conf *cfg.Config) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
table.Sort()
|
table.Sort()
|
||||||
table.PrintMarkdown()
|
if err := table.PrintMarkdown(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -135,7 +137,9 @@ func SnapshotShow(conf *cfg.Config, snapshot string) error {
|
|||||||
{"shards-successful", fmt.Sprintf("%d", snap.Shards.Successful)},
|
{"shards-successful", fmt.Sprintf("%d", snap.Shards.Successful)},
|
||||||
}
|
}
|
||||||
|
|
||||||
table.PrintMarkdown()
|
if err := table.PrintMarkdown(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,11 +84,11 @@ func (data *Table) PrintMarkdown() error {
|
|||||||
table.Header(data.headers)
|
table.Header(data.headers)
|
||||||
|
|
||||||
if err := table.Bulk(data.entries); err != nil {
|
if err := table.Bulk(data.entries); err != nil {
|
||||||
return fmt.Errorf("Failed to add data to table renderer: %s", err)
|
return fmt.Errorf("failed to add data to table renderer: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := table.Render(); err != nil {
|
if err := table.Render(); err != nil {
|
||||||
fmt.Errorf("Failed to render table: %s", err)
|
return fmt.Errorf("failed to render table: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println(tableString.String())
|
fmt.Println(tableString.String())
|
||||||
|
|||||||
Reference in New Issue
Block a user