Compare commits

..

27 Commits

Author SHA1 Message Date
734dea4dff fix go version 2026-07-08 09:30:33 +02:00
5d7050018f fix #79: remove compatibility header 2026-07-08 09:28:28 +02:00
1d45a3cd4c set go version for releaser 2026-07-08 00:00:37 +02:00
0104e1bf58 fix go releaser go version 2026-07-07 23:53:47 +02:00
2dc716d1c9 fix version newline 2026-07-07 23:49:57 +02:00
T. von Dein
0d63e7c4d2 satisfy full linter (#77) 2026-07-07 23:46:43 +02:00
T. von Dein
b8e0ee074c internal/gofix and fix error wrapping (#76) 2026-07-07 10:41:34 +02:00
T. von Dein
f10366dbde mv index allocate to shard allocate, add explanations, fixes #71 (#75) 2026-07-07 08:39:32 +02:00
T. von Dein
c35ffa5e4b internal/rework-table (#74) 2026-07-07 07:29:03 +02:00
92865c0fe2 cosmetics 2026-07-06 14:29:36 +02:00
5475b9986d typo 2026-07-06 14:28:31 +02:00
T. von Dein
82c017144a various fixes (#70)
- dont show ccr status if license insufficient
- add automated tests
- fix config loading
- fix search output
- fix `license show` panics
- fix `ilm show` completion
- print ask passwd prompt to stderr
2026-07-06 14:22:59 +02:00
T. von Dein
a07f38e945 add 'license show', add printer.NewTableEmpty() along .WithHeaders() (#69) 2026-07-03 13:52:36 +02:00
T. von Dein
c197e138ef fix repl output: didn't print json smaller than term height, add debug-http to api repl as well (#68) 2026-07-03 13:30:37 +02:00
T. von Dein
fb6f2389fe add 'cluster settings set' settings completion (#67) 2026-07-03 12:52:13 +02:00
T. von Dein
c9fb572935 get rid of getJsonPath, use flat_settings query flag instead (#66) 2026-07-03 10:25:57 +02:00
T. von Dein
1fc2004474 add external pager support via ES_JSON_PAGER (#65) 2026-07-03 10:07:11 +02:00
T. von Dein
7b576c976c add option -f to usage and filter support (#64) 2026-07-03 08:54:19 +02:00
T. von Dein
b51ee109f2 tune cluster ls: parallized and use tcp connect instead of https (#63) 2026-07-03 08:30:35 +02:00
T. von Dein
b573d63c54 enhance cluster status, add word wrap to wide table rows (#62) 2026-07-02 12:16:59 +02:00
T. von Dein
e3b96b3e3b add node show + node clients (#61) 2026-07-01 13:13:16 +02:00
T. von Dein
6436bcfb22 ask for pass if missing or use env pass, add token support (#60) 2026-07-01 10:58:39 +02:00
T. von Dein
f5c8a23589 add cluster reroute commands, fix usage width bug (#59)
fixes #36
2026-06-29 13:08:05 +02:00
T. von Dein
f9eded5f92 enhance ilm show, add ilm update, rename modify to update everywhere (#58) 2026-06-29 11:48:29 +02:00
T. von Dein
b7a87051c0 add 'ilm show -t' to show a better visualization of a ilm policy (#57) 2026-06-29 10:11:50 +02:00
T. von Dein
81987b75f2 turn --show-command-tree into sub command, hide hidden commands (#56) 2026-06-29 08:38:03 +02:00
34bf4fbed9 add ilm forecast example 2026-06-26 11:41:55 +02:00
77 changed files with 4171 additions and 1392 deletions

3
.gitignore vendored
View File

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

110
.golangci.yml Normal file
View File

@@ -0,0 +1,110 @@
version: "2"
linters:
enable:
- errcheck
- govet
- ineffassign
- staticcheck
- unused
- arangolint
- asasalint
- asciicheck
- bidichk
- bodyclose
- canonicalheader
- clickhouselint
- containedctx
#- contextcheck
- copyloopvar
#- cyclop
- decorder
- dogsled
- dupword
- durationcheck
- embeddedstructfieldcheck
- errchkjson
- errname
#- errorlint
- exhaustive
- exptostd
- fatcontext
- funcorder
#- funlen
- ginkgolinter
- gocheckcompilerdirectives
- gochecknoinits
- gochecksumtype
#- gocognit
- gocritic
#- gocyclo
#- godoclint
- goheader
- gomoddirectives
- gomodguard_v2
- goprintffuncname
#- gosec
- gosmopolitan
- grouper
- iface
- importas
- inamedparam
- interfacebloat
- intrange
- iotamixing
- lll
- loggercheck
- makezero
- misspell
- modernize
- nakedret
#- nestif
- nilerr
- nilnesserr
- nlreturn
- nonamedreturns
- nosprintfhostport
- paralleltest
- perfsprint
- prealloc
#- predeclared
- promlinter
- protogetter
- reassign
- recvcheck
#- revive
- rowserrcheck
- sloglint
- spancheck
- sqlclosecheck
- tagalign
- testableexamples
- testifylint
- testpackage
- thelper
- tparallel
- unconvert
- unparam
- unqueryvet
- usestdlibvars
- usetesting
- varnamelen
- wastedassign
- whitespace
- wsl_v5
- zerologlint
settings:
varnamelen:
ignore-names:
- err
- wg
- mu
- ts
- to
- es
- op
- fd
- id
- fn

View File

@@ -16,6 +16,7 @@ builds:
goos: goos:
- linux - linux
- darwin - darwin
tool: "go1.26.4"
changelog: changelog:
sort: asc sort: asc

View File

@@ -2,7 +2,7 @@ matrix:
platform: platform:
- linux/amd64 - linux/amd64
goversion: goversion:
- 1.25.8 - 1.26.4
labels: labels:
platform: ${platform} platform: ${platform}
@@ -21,6 +21,6 @@ steps:
event: [push,manual] event: [push,manual]
image: golang:${goversion} image: golang:${goversion}
commands: commands:
- curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s -- -b $(go env GOPATH)/bin v2.5.0 - curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s -- -b $(go env GOPATH)/bin v2.12.2
- golangci-lint --version - golangci-lint --version
- golangci-lint run ./... - golangci-lint run ./...

View File

@@ -62,17 +62,16 @@ install: buildlocal
clean: clean:
rm -rf $(tool) coverage.out testdata t/out pkg/es/openspec.go rm -rf $(tool) coverage.out testdata t/out pkg/es/openspec.go
test: clean test: clean buildlocal
mkdir -p t/out make -C t test
go test ./... $(ARGS)
testlint: test lint testlint: test lint
lint: lint:
golangci-lint run golangci-lint run --enable-only errcheck,govet,ineffassign,staticcheck,unused
lint-full: lint-full:
golangci-lint run --enable-all --exclude-use-default --disable exhaustivestruct,exhaustruct,depguard,interfacer,deadcode,golint,structcheck,scopelint,varcheck,ifshort,maligned,nosnakecase,godot,funlen,gofumpt,cyclop,noctx,gochecknoglobals,paralleltest golangci-lint run --show-stats=false
testfuzzy: clean testfuzzy: clean
go test -fuzz ./... $(ARGS) go test -fuzz ./... $(ARGS)

View File

@@ -23,6 +23,8 @@ Features:
- Shell completion support (bash, zsh and fish). Put this into your - Shell completion support (bash, zsh and fish). Put this into your
rc: `source <(esctl completion bash)`. rc: `source <(esctl completion bash)`.
- Cluster settings can be viewed and modified. - Cluster settings can be viewed and modified.
- Comprehensive cluster status.
- Cluster reroute support.
- Search: you can search indices using full text or by fields, select - Search: you can search indices using full text or by fields, select
logical condition (OR, AND), use PIT, limit datetime (ES date math logical condition (OR, AND), use PIT, limit datetime (ES date math
can be used), etc. It is however not yet possible to create can be used), etc. It is however not yet possible to create
@@ -47,7 +49,9 @@ Features:
elasticsearch API. You can run API calls on the current selected elasticsearch API. You can run API calls on the current selected
cluster w/o the hassle to specify the whole url, credentials etc. It cluster w/o the hassle to specify the whole url, credentials etc. It
has line editing and history support. If `jq` is installed output has line editing and history support. If `jq` is installed output
JSON will be syntax highlighted. JSON will be syntax highlighted. A simple internal pager will be
used if output exceeds the terminal height. You can tweak this using
the `$ES_JSON_PAGER` environment variable (I'd recommend [fx](https://fx.wtf/)).
- Doc support. You can put, delete and show docs for an index. Very - Doc support. You can put, delete and show docs for an index. Very
handy if you want to play with it. Just create a new index: handy if you want to play with it. Just create a new index:
`esctl index create foo` and then insert docs into it for search `esctl index create foo` and then insert docs into it for search
@@ -113,6 +117,7 @@ Configure `esctl` with environment variables:
- `ES_URI`: elasticsearch uri - `ES_URI`: elasticsearch uri
- `ES_USER`: username - `ES_USER`: username
- `ES_PASS`: password - `ES_PASS`: password
- `ES_TOKEN`: API token, instead of user+password
Or create a config file such as this: Or create a config file such as this:
@@ -121,11 +126,10 @@ clusters:
foobar: foobar:
uri: https://es.foo.bar:9200/ uri: https://es.foo.bar:9200/
user: elastic user: elastic
pass: 123456 pass: ******
other: other:
uri: https://myes.foo:9200/ uri: https://myes.foo:9200/
user: elastic token: ******
pass: asdasdasd
``` ```
and specify it with `-c configfile`. You may also put clusters into a and specify it with `-c configfile`. You may also put clusters into a
@@ -433,6 +437,40 @@ $ esctl search -i hyperdrive -l 2 | jq
} }
``` ```
There are also some uniq features which are not directly available via
the ES API or GUI. Here's one example: say you have a number of
indices with ILM policies each. At some day there were a surge in
incoming data in some of them and you want to know, when the excess
data will be rolled over to cold storage:
```console
$ esctl ilm forecast sh -f warm -w 3d
1.1 TB bytes of data in warm phase will be rolled within 72h0m0s to the next phase
```
You may also look at a detailed ilm forecast list:
```console
$ esctl ilm forecast ls -f warm -w 3d
INDEX CURRENT-SIZE CURRENT-AGE VIRTUAL-AGE MIN-AGE MIN-SIZE CURRENT-PHASE NEXT-PHASE
foobar-n1-p01-elastic-000126 12 GB 2d:8h:12m 3d:5h:16m 7d 25 GB hot warm
foobar-n1-p01-misc-000070 16 GB 5d:16h:31m 4d:10h:31m 7d 25 GB hot warm
foobar-n1-q01-elastic-000122 23 GB 5d:0h:11m 6d:10h:33m 7d 25 GB hot warm
foobar-n1-q01-misc-000072 18 GB 6d:15h:21m 4d:21h:36m 7d 25 GB hot warm
delaware-f1-p01-kafka-000023 1.3 MB 15h:12m 0s 7d 25 GB hot warm
delaware-f1-p01-misc-000024 17 GB 6d:16h:1m 4d:16h:33m 7d 25 GB hot warm
delaware-f1-q01-kafka-000023 1.5 MB 15h:12m 0s 7d 25 GB hot warm
delaware-f1-q01-misc-000024 16 GB 6d:15h:31m 4d:13h:52m 7d 25 GB hot warm
delaware-n1-p01-elastic-000417 16 GB 12h:42m 4d:10h:31m 7d 25 GB hot warm
```
So you can see, which index will be rolled over when. Note the
**VIRTUAL-AGE** field however: it is calculated from the current
storage usage of the index in relation to rollover max shard size. So
you can see, when an index will be rolled over either because it aged
out or because its storage exceeded the limit.
---
Please note, that `esctl` is still in its early stages and things are Please note, that `esctl` is still in its early stages and things are
changing heavily every now and then. New commands are being added changing heavily every now and then. New commands are being added
constantly as well. constantly as well.
@@ -464,6 +502,12 @@ cluster - manage cluster[s]
settings - cluster settings management settings - cluster settings management
list - show cluster settings list - show cluster settings
set - set|update cluster settings set - set|update cluster settings
reroute - manually change the allocation of individual shards in the cluster.
move - move shard to another node
allocate-replica - allocate-replica replica to another node
cancel - cancel a reroute operation
allocate-empty-primary - allocate an empty primary shard to a node
allocate-stale-primary - allocate a stale primary shard to a node
datastream - manage data streams datastream - manage data streams
list - list indicies list - list indicies
show - show details about an data stream show - show details about an data stream
@@ -479,18 +523,19 @@ ilm - manage index lifecycle
status - get the current index lifecycle management status status - get the current index lifecycle management status
list - list index lifecycle policies list - list index lifecycle policies
show - show details about an index lifecycle policy show - show details about an index lifecycle policy
create - create a index lifecycle policy create - create a new lifecycle policy
update - update an lifecycle policy
forecast - calculate index phase movements forecast - calculate index phase movements
list - list index rollover config list - list index rollover config
show - show rollover forecast over all indices show - show rollover forecast over all indices
explain - explain ilm condition of an index
index - manage indicies index - manage indicies
list - list indicies list - list indicies
show - show details about an index show - show details about an index
create - create a new index create - create a new index
modify - modify anindex update - update an index
delete - delete an index delete - delete an index
close - close an index close - close an index
allocation - explain index allocation
fields - show info about field capabilities fields - show info about field capabilities
ilm - show ilm status ilm - show ilm status
alias - manage index aliases alias - manage index aliases
@@ -502,11 +547,14 @@ index - manage indicies
list - list index templates list - list index templates
show - show details about an index template show - show details about an index template
create - create a new index template create - create a new index template
modify - modify a new index template update - update a new index template
delete - delete an index template delete - delete an index template
license - manage cluster license
show - show details about the cluster license
node - manage nodes node - manage nodes
list - list nodes list - list nodes
show - show details about a node show - show details about a node
clients - show node http clients
role - manage roles role - manage roles
list - list roles list - list roles
show - show details about a role show - show details about a role
@@ -515,6 +563,7 @@ search - search within an index
shard - manage shards shard - manage shards
list - list shards list - list shards
show - show details about a shard show - show details about a shard
allocation - explain shard allocation
snapshot - manage snapshots snapshot - manage snapshots
list - list snapshots list - list snapshots
show - show details about a snapshot show - show details about a snapshot
@@ -524,11 +573,7 @@ task - manage tasks
version - show esctl version information version - show esctl version information
debug - developer only debug - developer only
help-jsonpath - show jsonpath help help-jsonpath - show jsonpath help
completion - Output shell completion script for bash, zsh, fish, or Powershell help-usage - show overview of all available commands
pwsh - Output pwsh completion script
bash - Output bash completion script
zsh - Output zsh completion script
fish - Output fish completion script
``` ```
# Development # Development

View File

@@ -3,10 +3,6 @@
- index show: add more details, see screenshots - index show: add more details, see screenshots
- add shard explain, aka:
get /_cluster/allocation/explain {"index":"yourindex", "primary": true, "shard":0}
- add datastream support: - add datastream support:
https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-indices-get-data-stream https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-indices-get-data-stream

View File

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

View File

@@ -37,6 +37,7 @@ func Cluster(conf *cfg.Config) *cli.Command {
ClusterSwitch(conf), ClusterSwitch(conf),
ClusterList(conf), ClusterList(conf),
ClusterSettings(conf), ClusterSettings(conf),
ClusterReroute(conf),
}, },
} }
} }
@@ -60,12 +61,6 @@ func ClusterStatus(conf *cfg.Config) *cli.Command {
Aliases: []string{"s"}, Aliases: []string{"s"},
Flags: []cli.Flag{ Flags: []cli.Flag{
&cli.BoolFlag{
Name: "all",
Usage: "show status of all clusters",
Destination: &conf.All,
Aliases: []string{"a"},
},
&cli.BoolFlag{ &cli.BoolFlag{
Name: "verbose", Name: "verbose",
Usage: "include verbose statistics", Usage: "include verbose statistics",

224
cmd/cluster_reroute.go Normal file
View File

@@ -0,0 +1,224 @@
/*
Copyright © 2026 Thomas von Dein
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package cmd
import (
"context"
"errors"
"codeberg.org/scip/esctl/pkg/cfg"
"codeberg.org/scip/esctl/pkg/es"
"github.com/urfave/cli/v3"
)
func ClusterReroute(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "reroute",
Usage: "manually change the allocation of individual shards in the cluster.",
UsageText: "reroute <cluster-name>",
Aliases: []string{"ctx"},
Commands: []*cli.Command{
ClusterRerouteMove(conf),
ClusterRerouteAllocateReplica(conf),
ClusterRerouteCancel(conf),
ClusterRerouteAllocatePrimary(conf, false),
ClusterRerouteAllocatePrimary(conf, true),
},
}
}
func ClusterRerouteMove(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "move",
Usage: "move shard to another node",
UsageText: "move [options] <index>",
ShellComplete: func(ctx context.Context, cmd *cli.Command) {
complete(cmd, Cindex)
},
Flags: []cli.Flag{
&cli.IntFlag{
Name: "shard",
Usage: "shard to move",
Destination: &conf.Shards,
Aliases: []string{"s"},
Required: true,
},
&cli.StringFlag{
Name: "from-node",
Usage: "current node",
Destination: &conf.FromNode,
Aliases: []string{"f"},
Required: true,
},
&cli.StringFlag{
Name: "to-node",
Usage: "node to move to",
Destination: &conf.ToNode,
Aliases: []string{"t"},
Required: true,
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
index := cmd.Args().Get(0)
if index == "" {
return errors.New("no index specified")
}
return es.ClusterRerouteMove(conf, index)
},
}
}
func ClusterRerouteAllocateReplica(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "allocate-replica",
Usage: "allocate-replica replica to another node",
UsageText: "allocate-replica [options] <index>",
ShellComplete: func(ctx context.Context, cmd *cli.Command) {
complete(cmd, Cindex)
},
Flags: []cli.Flag{
&cli.IntFlag{
Name: "shard",
Usage: "shard to move",
Destination: &conf.Shards,
Aliases: []string{"s"},
Required: true,
},
&cli.StringFlag{
Name: "to-node",
Usage: "node to move to",
Destination: &conf.ToNode,
Aliases: []string{"t"},
Required: true,
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
index := cmd.Args().Get(0)
if index == "" {
return errors.New("no index specified")
}
return es.ClusterRerouteAllocateReplica(conf, index)
},
}
}
func ClusterRerouteCancel(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "cancel",
Usage: "cancel a reroute operation",
UsageText: "cancel [options] <index>",
ShellComplete: func(ctx context.Context, cmd *cli.Command) {
complete(cmd, Cindex)
},
Flags: []cli.Flag{
&cli.IntFlag{
Name: "shard",
Usage: "shard to move",
Destination: &conf.Shards,
Aliases: []string{"s"},
Required: true,
},
&cli.StringFlag{
Name: "to-node",
Usage: "node to move to",
Destination: &conf.ToNode,
Aliases: []string{"t"},
Required: true,
},
&cli.BoolFlag{
Name: "allow-primary",
Usage: "allow primary shard to cancel",
Destination: &conf.AllowPrimary,
Aliases: []string{"a"},
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
index := cmd.Args().Get(0)
if index == "" {
return errors.New("no index specified")
}
return es.ClusterRerouteCancel(conf, index)
},
}
}
func ClusterRerouteAllocatePrimary(conf *cfg.Config, stale bool) *cli.Command {
name := "allocate-empty-primary"
usage := "allocate an empty primary shard to a node"
if stale {
name = "allocate-stale-primary"
usage = "allocate a stale primary shard to a node"
}
return &cli.Command{
Name: name,
Usage: usage,
UsageText: name + " [options] <index>",
ShellComplete: func(ctx context.Context, cmd *cli.Command) {
complete(cmd, Cindex)
},
Flags: []cli.Flag{
&cli.IntFlag{
Name: "shard",
Usage: "shard to move",
Destination: &conf.Shards,
Aliases: []string{"s"},
Required: true,
},
&cli.StringFlag{
Name: "to-node",
Usage: "node to move to",
Destination: &conf.ToNode,
Aliases: []string{"t"},
Required: true,
},
&cli.BoolFlag{
Name: "accept-data-loss",
Usage: "",
Destination: &conf.AcceptDataLoss,
Aliases: []string{"a"},
Required: true,
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
index := cmd.Args().Get(0)
if index == "" {
return errors.New("no index specified")
}
return es.ClusterRerouteAllocatePrimary(conf, index, stale)
},
}
}

View File

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

View File

@@ -32,6 +32,8 @@ const (
Ccluster Ccluster
Capi Capi
Cilm Cilm
Cnode
Cclustersettings
) )
func complete(cmd *cli.Command, what int) { func complete(cmd *cli.Command, what int) {
@@ -46,8 +48,10 @@ func complete(cmd *cli.Command, what int) {
return return
} }
var list []string var (
var err error list []string
err error
)
switch what { switch what {
case Cindex: case Cindex:
@@ -64,6 +68,10 @@ func complete(cmd *cli.Command, what int) {
list = es.ApiPathNames() list = es.ApiPathNames()
case Cilm: case Cilm:
list, err = es.IlmNames(conf) list, err = es.IlmNames(conf)
case Cnode:
list, err = es.NodeNames(conf)
case Cclustersettings:
list, err = es.ClusterSettingsNames(conf)
} }
if err != nil { if err != nil {

View File

@@ -36,7 +36,8 @@ func Ilm(conf *cfg.Config) *cli.Command {
IlmStatus(conf), IlmStatus(conf),
IlmList(conf), IlmList(conf),
IlmShow(conf), IlmShow(conf),
IlmCreate(conf), IlmCreate(conf, false),
IlmCreate(conf, true),
IlmForecast(conf), IlmForecast(conf),
IlmExplain(conf), IlmExplain(conf),
}, },
@@ -103,6 +104,15 @@ func IlmShow(conf *cfg.Config) *cli.Command {
Usage: "show details about an index lifecycle policy", Usage: "show details about an index lifecycle policy",
UsageText: "show <policy>", UsageText: "show <policy>",
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "tree",
Usage: "display policy as a tree",
Destination: &conf.Ilm.Tree,
Aliases: []string{"t"},
},
},
Action: func(ctx context.Context, cmd *cli.Command) error { Action: func(ctx context.Context, cmd *cli.Command) error {
policy := cmd.Args().Get(0) policy := cmd.Args().Get(0)
if policy == "" { if policy == "" {
@@ -118,12 +128,22 @@ func IlmShow(conf *cfg.Config) *cli.Command {
} }
} }
func IlmCreate(conf *cfg.Config) *cli.Command { func IlmCreate(conf *cfg.Config, modify bool) *cli.Command {
name := "create"
alias := "+"
usage := "create a new lifecycle policy"
if modify {
name = "update"
usage = "update an lifecycle policy"
alias = "upd"
}
return &cli.Command{ return &cli.Command{
Name: "create", Name: name,
Aliases: []string{"+"}, Aliases: []string{alias},
Usage: "create a index lifecycle policy", Usage: usage,
UsageText: "create [options] <policy>", UsageText: name + " [options] <policy>",
Flags: []cli.Flag{ Flags: []cli.Flag{
&cli.StringFlag{ &cli.StringFlag{

View File

@@ -71,7 +71,7 @@ func IlmForecastList(conf *cfg.Config) *cli.Command {
}, },
&cli.StringFlag{ &cli.StringFlag{
Name: "within", Name: "within",
Usage: "<duration> withing which to forecast", Usage: "<duration> within which to forecast",
Destination: &conf.Ilm.Within, Destination: &conf.Ilm.Within,
Aliases: []string{"w"}, Aliases: []string{"w"},
}, },
@@ -121,7 +121,7 @@ func IlmForecastShow(conf *cfg.Config) *cli.Command {
}, },
&cli.StringFlag{ &cli.StringFlag{
Name: "within", Name: "within",
Usage: "duration withing which to forecast", Usage: "duration within which to forecast",
Destination: &conf.Ilm.Within, Destination: &conf.Ilm.Within,
Aliases: []string{"w"}, Aliases: []string{"w"},
}, },

View File

@@ -19,7 +19,6 @@ package cmd
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"codeberg.org/scip/esctl/pkg/cfg" "codeberg.org/scip/esctl/pkg/cfg"
"codeberg.org/scip/esctl/pkg/es" "codeberg.org/scip/esctl/pkg/es"
@@ -40,7 +39,6 @@ func Index(conf *cfg.Config) *cli.Command {
IndexCreate(conf, true), IndexCreate(conf, true),
IndexDelete(conf), IndexDelete(conf),
IndexClose(conf), IndexClose(conf),
IndexAllocation(conf),
IndexFields(conf), IndexFields(conf),
IndexIlm(conf), IndexIlm(conf),
@@ -96,38 +94,6 @@ func IndexList(conf *cfg.Config) *cli.Command {
} }
} }
func IndexAllocation(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "allocation",
Aliases: []string{"a"},
Usage: "explain index allocation",
Flags: []cli.Flag{
&cli.IntFlag{
Name: "shard",
Usage: "shard number to explain for",
Destination: &conf.Shards,
Aliases: []string{"s"},
},
&cli.BoolFlag{
Name: "primary",
Usage: "explain primary allocation (default true)",
Destination: &conf.Primary,
Aliases: []string{"p"},
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
index := cmd.Args().Get(0)
if index == "" {
return errors.New("no index specified")
}
return es.IndexAllocation(conf, cmd.Args().Get(0))
},
}
}
func IndexShow(conf *cfg.Config) *cli.Command { func IndexShow(conf *cfg.Config) *cli.Command {
return &cli.Command{ return &cli.Command{
Name: "show", Name: "show",
@@ -154,8 +120,8 @@ func IndexCreate(conf *cfg.Config, modify bool) *cli.Command {
usage := "create a new index" usage := "create a new index"
if modify { if modify {
name = "modify" name = "update"
usage = "modify anindex" usage = "update an index"
} }
return &cli.Command{ return &cli.Command{
@@ -196,7 +162,7 @@ Valid field mapping types: integer, text, date, keyword`,
Action: func(ctx context.Context, cmd *cli.Command) error { Action: func(ctx context.Context, cmd *cli.Command) error {
args := cmd.Args() args := cmd.Args()
if args.Len() == 0 { if args.Len() == 0 {
return fmt.Errorf("no index specified") return errors.New("no index specified")
} }
mappings := args.Slice()[1:] mappings := args.Slice()[1:]

View File

@@ -38,7 +38,8 @@ func IndexAlias(conf *cfg.Config) *cli.Command {
IndexAliasDelete(conf), IndexAliasDelete(conf),
IndexAliasRollover(conf), IndexAliasRollover(conf),
// FIXME: implement IndexAliasAdd // FIXME: implement IndexAliasAdd
//IndexAliasAdd(conf), // see https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-indices-update-aliases // see https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-indices-update-aliases
// IndexAliasAdd(conf),
}, },
} }
} }

View File

@@ -19,7 +19,6 @@ package cmd
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"slices" "slices"
"codeberg.org/scip/esctl/pkg/cfg" "codeberg.org/scip/esctl/pkg/cfg"
@@ -83,8 +82,8 @@ func IndexTemplateCreate(conf *cfg.Config, modify bool) *cli.Command {
required := true required := true
if modify { if modify {
name = "modify" name = "update"
alias = "mod" alias = "upd"
required = false required = false
} }
@@ -180,7 +179,7 @@ https://www.elastic.co/docs/reference/elasticsearch/index-settings
Action: func(ctx context.Context, cmd *cli.Command) error { Action: func(ctx context.Context, cmd *cli.Command) error {
args := cmd.Args() args := cmd.Args()
if args.Len() == 0 { if args.Len() == 0 {
return fmt.Errorf("no name specified") return errors.New("no name specified")
} }
if conf.Mode != "" { if conf.Mode != "" {

49
cmd/license.go Normal file
View File

@@ -0,0 +1,49 @@
/*
Copyright © 2026 Thomas von Dein
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package cmd
import (
"context"
"codeberg.org/scip/esctl/pkg/cfg"
"codeberg.org/scip/esctl/pkg/es"
"github.com/urfave/cli/v3"
)
func License(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "license",
Usage: "manage cluster license",
Commands: []*cli.Command{
LicenseShow(conf),
},
}
}
func LicenseShow(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "show",
Aliases: []string{"sh"},
Usage: "show details about the cluster license",
Action: func(ctx context.Context, cmd *cli.Command) error {
return es.LicenseShow(conf)
},
}
}

View File

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

View File

@@ -31,151 +31,16 @@ import (
"github.com/urfave/cli/v3" "github.com/urfave/cli/v3"
) )
func Finish(err error) int { const (
if err != nil { versionFmt = `esctl version: %s
fmt.Fprintln(os.Stderr, "Error: ", err.Error()) build: %s
return 1 branch: %s
} commit: %s
go version: %s
API Version: %s
`
return 0 jsonPathUsage = `jsonPath usage:
}
func Main() int {
conf := cfg.NewConfig()
tree := false
cmd := &cli.Command{
Name: "esctl",
Usage: "manage elasticsearch from cli",
EnableShellCompletion: true,
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "debug",
Aliases: []string{"d"},
Value: false,
Usage: "enable debugging",
Sources: cli.EnvVars("ES_DEBUG"),
Destination: &conf.Debug,
},
&cli.BoolFlag{
Name: "debug-http",
Value: false,
Usage: "enable HTTP debugging",
Destination: &conf.DebugHTTP,
},
&cli.BoolFlag{
Name: "show-command-tree",
Value: false,
Usage: "generate a command tree",
Destination: &tree,
Hidden: true,
},
&cli.BoolFlag{
Name: "align-ints",
Aliases: []string{"I"},
Value: false,
Usage: "right align integers in tabular output",
Destination: &conf.AlignInts,
},
&cli.StringFlag{
Name: "config",
Aliases: []string{"c"},
Value: "",
Usage: "config file",
Sources: cli.EnvVars("ES_CONFIG"),
Destination: &conf.ConfigFile,
},
&cli.StringFlag{
Name: "cluster",
Aliases: []string{"C"},
Value: "",
Usage: "cluster alias to work with",
Destination: &conf.CurrentCluster,
},
&cli.StringFlag{
Name: "output",
Aliases: []string{"o"},
Value: "",
Usage: "output mode (tsv, markdown, json, yaml) default: tsv",
Destination: &conf.Output,
},
&cli.StringFlag{
Name: "profile-file",
Usage: "golang profiler output file",
Destination: &conf.ProfileFile,
Hidden: true,
},
},
Commands: []*cli.Command{
Api(conf),
Ccr(conf),
Cluster(conf),
Datastream(conf),
Doc(conf),
Ilm(conf),
Index(conf),
Node(conf),
Roles(conf),
Search(conf),
Shard(conf),
Snapshot(conf),
Task(conf),
Version(conf),
Debug(conf),
HelpJsonPath(conf),
},
Before: func(ctx context.Context, cmd *cli.Command) (context.Context, error) {
if tree {
if err := Tree(cmd); err != nil {
return nil, err
}
os.Exit(0)
}
if err := conf.Init(); err != nil {
if len(os.Args) > 1 {
return nil, err
} else {
fmt.Println(cmd.UsageText)
return nil, nil
}
}
log.Init(conf)
if conf.ProfileFile != "" {
// enable cpu profiling. Do NOT use q to stop the game but
// close the window to get a profile
fd, err := os.Create(conf.ProfileFile)
if err != nil {
return nil, err
}
defer func() {
if err := fd.Close(); err != nil {
golog.Fatal(err)
}
}()
if err := pprof.StartCPUProfile(fd); err != nil {
golog.Fatal(err)
}
defer pprof.StopCPUProfile()
}
return nil, nil
},
}
return Finish(cmd.Run(context.Background(), os.Args))
}
func HelpJsonPath(conf *cfg.Config) *cli.Command {
msg := `jsonPath usage:
name.last >> "Anderson" name.last >> "Anderson"
age >> 37 age >> 37
@@ -215,13 +80,147 @@ To extract more than one field, use:
Documentation: https://github.com/tidwall/gjson/blob/master/SYNTAX.md` Documentation: https://github.com/tidwall/gjson/blob/master/SYNTAX.md`
)
func Finish(err error) int {
if err != nil {
fmt.Fprintln(os.Stderr, "Error: ", err.Error())
return 1
}
return 0
}
func Main() int {
conf := cfg.NewConfig()
cmd := &cli.Command{
Name: "esctl",
Usage: "manage elasticsearch from cli",
EnableShellCompletion: true,
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "debug",
Aliases: []string{"d"},
Value: false,
Usage: "enable debugging",
Sources: cli.EnvVars("ES_DEBUG"),
Destination: &conf.Debug,
},
&cli.BoolFlag{
Name: "debug-http",
Value: false,
Usage: "enable HTTP debugging",
Destination: &conf.DebugHTTP,
},
&cli.BoolFlag{
Name: "align-ints",
Aliases: []string{"I"},
Value: false,
Usage: "right align integers in tabular output",
Destination: &conf.AlignInts,
},
&cli.StringFlag{
Name: "config",
Aliases: []string{"c"},
Value: "",
Usage: "config file",
Sources: cli.EnvVars("ES_CONFIG"),
Destination: &conf.ConfigFile,
},
&cli.StringFlag{
Name: "cluster",
Aliases: []string{"C"},
Value: "",
Usage: "cluster alias to work with",
Destination: &conf.CurrentCluster,
},
&cli.StringFlag{
Name: "output",
Aliases: []string{"o"},
Value: "",
Usage: "output mode (tsv, json, yaml) default: tsv",
Destination: &conf.Output,
},
&cli.StringFlag{
Name: "profile-file",
Usage: "golang profiler output file",
Destination: &conf.ProfileFile,
Hidden: true,
},
},
Commands: []*cli.Command{
Api(conf),
Ccr(conf),
Cluster(conf),
Datastream(conf),
Doc(conf),
Ilm(conf),
Index(conf),
License(conf),
Node(conf),
Roles(conf),
Search(conf),
Shard(conf),
Snapshot(conf),
Task(conf),
Version(conf),
Debug(conf),
HelpJsonPath(conf),
HelpUsage(conf),
},
Before: func(ctx context.Context, cmd *cli.Command) (context.Context, error) {
if err := conf.Init(); err != nil {
if len(os.Args) > 1 {
return nil, err
} else {
fmt.Println(cmd.UsageText)
return nil, nil
}
}
log.Init(conf)
if conf.ProfileFile != "" {
// enable cpu profiling. Do NOT use q to stop the game but
// close the window to get a profile
fd, err := os.Create(conf.ProfileFile)
if err != nil {
return nil, err
}
defer func() {
if err := fd.Close(); err != nil {
golog.Fatal(err)
}
}()
if err := pprof.StartCPUProfile(fd); err != nil {
golog.Fatal(err)
}
defer pprof.StopCPUProfile()
}
return nil, nil
},
}
return Finish(cmd.Run(context.Background(), os.Args))
}
func HelpJsonPath(conf *cfg.Config) *cli.Command {
return &cli.Command{ return &cli.Command{
Name: "help-jsonpath", Name: "help-jsonpath",
Usage: "show jsonpath help", Usage: "show jsonpath help",
Action: func(ctx context.Context, cmd *cli.Command) error { Action: func(ctx context.Context, cmd *cli.Command) error {
_, err := fmt.Println(msg) _, err := fmt.Println(jsonPathUsage)
return err return err
}, },
} }
@@ -233,8 +232,9 @@ func Version(conf *cfg.Config) *cli.Command {
Usage: "show esctl version information", Usage: "show esctl version information",
Action: func(ctx context.Context, cmd *cli.Command) error { Action: func(ctx context.Context, cmd *cli.Command) error {
_, err := fmt.Printf("esctl version: %s\n build: %s\n branch: %s\n commit: %s\n go version: %s\n API Version: %s\n", _, err := fmt.Printf(versionFmt,
cfg.Version, cfg.BUILD, cfg.BRANCH, cfg.COMMIT, cfg.GOVERSION, cfg.APIVERSION) cfg.Version, cfg.BUILD, cfg.BRANCH, cfg.COMMIT, cfg.GOVERSION, cfg.APIVERSION)
return err return err
}, },
} }
@@ -261,22 +261,108 @@ func Debug(conf *cfg.Config) *cli.Command {
} }
} }
func Tree(cmd *cli.Command) error { func HelpUsage(conf *cfg.Config) *cli.Command {
max := 20 return &cli.Command{
Name: "help-usage",
Usage: "show overview of all available commands",
UsageText: "help-usage [<filter>]",
Aliases: []string{"usage"},
CustomHelpTemplate: addReference(`<filter> implies -f`),
return cmd.Walk(func(cmd *cli.Command) error { Flags: []cli.Flag{
&cli.BoolFlag{
Name: "hidden",
Usage: "include hidden commands",
Destination: &conf.Hidden,
Aliases: []string{"H"},
},
&cli.BoolFlag{
Name: "full-commands",
Usage: "show full commands",
Destination: &conf.Force,
Aliases: []string{"f"},
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
maxCommandWidth := 0
filter := cmd.Args().Get(0)
if filter != "" {
conf.Force = true
}
// first pass, determine max command width
if err := walkVisible(conf, cmd.Root(), func(cmd *cli.Command) error {
path := cmd.Path() path := cmd.Path()
command := path[len(path)-1] size := len(path[len(path)-1])
if len(path) == 1 || command == "help" { if conf.Force {
path := strings.Join(cmd.Path(), " ")
size = len(path)
}
if size > maxCommandWidth {
maxCommandWidth = size
}
return nil
}); err != nil {
return err
}
maxCommandWidth += 4 // account for indent width
// second pass, build tree
return walkVisible(conf, cmd.Root(), func(cmd *cli.Command) error {
path := cmd.Path()
if filter != "" {
if !strings.Contains(strings.Join(path, " "), filter) {
return nil
}
}
command := path[len(path)-1]
if conf.Force {
command = strings.Join(cmd.Path(), " ")
}
if len(path) == 1 || strings.HasSuffix(command, "help") {
return nil return nil
} }
indent := strings.Repeat(" ", len(path[1:])-1) indent := strings.Repeat(" ", len(path[1:])-1)
space := strings.Repeat(" ", max-(len(command)+len(indent))) space := strings.Repeat(" ", maxCommandWidth-(len(command)+len(indent)))
fmt.Printf("%s%s %s - %s\n", indent, command, space, cmd.Usage) fmt.Printf("%s%s %s - %s\n", indent, command, space, cmd.Usage)
return nil return nil
}) })
},
}
}
// copy of cmd.Walk() with the exception to skip hidden commands and its siblings
// see: https://github.com/urfave/cli/issues/2372
func walkVisible(conf *cfg.Config, cmd *cli.Command, fn func(*cli.Command) error) error {
if fn == nil {
return nil
}
if !conf.Hidden && cmd.Hidden {
return nil
}
if err := fn(cmd); err != nil {
return err
}
for _, sub := range cmd.Commands {
if err := walkVisible(conf, sub, fn); err != nil {
return err
}
}
return nil
} }

View File

@@ -35,6 +35,7 @@ func Shard(conf *cfg.Config) *cli.Command {
Commands: []*cli.Command{ Commands: []*cli.Command{
ShardList(conf), ShardList(conf),
ShardShow(conf), ShardShow(conf),
ShardAllocation(conf),
}, },
} }
} }
@@ -94,3 +95,41 @@ func ShardShow(conf *cfg.Config) *cli.Command {
}, },
} }
} }
func ShardAllocation(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "allocation",
Aliases: []string{"a"},
Usage: "explain shard allocation",
Flags: []cli.Flag{
&cli.IntFlag{
Name: "shard",
Usage: "shard number to explain for",
Destination: &conf.Shards,
Aliases: []string{"s"},
},
&cli.BoolFlag{
Name: "primary",
Usage: "explain primary allocation (default true)",
Destination: &conf.Primary,
Aliases: []string{"p"},
},
&cli.StringFlag{
Name: "node",
Usage: "explain a shard only if it is currently located on the specified node name or node ID",
Destination: &conf.FromNode,
Aliases: []string{"n"},
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
index := cmd.Args().Get(0)
if index == "" {
return errors.New("no index specified")
}
return es.ShardAllocation(conf, cmd.Args().Get(0))
},
}
}

View File

@@ -26,9 +26,10 @@ import (
func addReference(ref string) string { func addReference(ref string) string {
indentedRef := []string{} indentedRef := []string{}
for _, line := range strings.Split(ref, "\n") { for line := range strings.SplitSeq(ref, "\n") {
indentedRef = append(indentedRef, " "+line) indentedRef = append(indentedRef, " "+line)
} }
return fmt.Sprintf("%s\nREFERENCE:\n%s\n", return fmt.Sprintf("%s\nREFERENCE:\n%s\n",
cli.SubcommandHelpTemplate, cli.SubcommandHelpTemplate,
strings.Join(indentedRef, "\n")) strings.Join(indentedRef, "\n"))

16
go.mod
View File

@@ -14,11 +14,13 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>. // along with this program. If not, see <http://www.gnu.org/licenses/>.
module codeberg.org/scip/esctl module codeberg.org/scip/esctl
go 1.25.8 go 1.26
require ( require (
github.com/MichaelMure/go-term-markdown v0.1.4 github.com/MichaelMure/go-term-markdown v0.1.4
github.com/alecthomas/repr v0.5.2 github.com/alecthomas/repr v0.5.2
github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0 github.com/charmbracelet/lipgloss v1.1.0
github.com/chzyer/readline v1.5.1 github.com/chzyer/readline v1.5.1
github.com/dustin/go-humanize v1.0.1 github.com/dustin/go-humanize v1.0.1
@@ -29,10 +31,11 @@ require (
github.com/go-openapi/spec v0.22.5 github.com/go-openapi/spec v0.22.5
github.com/go-openapi/swag/loading v0.26.1 github.com/go-openapi/swag/loading v0.26.1
github.com/mattn/go-isatty v0.0.22 github.com/mattn/go-isatty v0.0.22
github.com/olekukonko/tablewriter v1.1.4 github.com/seeruk/go-wordwrap v0.0.0-20191208221741-14ec4aac9550
github.com/tidwall/gjson v1.19.0 github.com/tidwall/gjson v1.19.0
github.com/tlinden/yadu v0.1.3 github.com/tlinden/yadu v0.1.3
github.com/urfave/cli/v3 v3.10.1-0.20260623012112-f980ca84bf65 github.com/urfave/cli/v3 v3.10.1-0.20260623012112-f980ca84bf65
golang.org/x/term v0.44.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )
@@ -40,9 +43,6 @@ require (
github.com/MichaelMure/go-term-text v0.3.1 // indirect github.com/MichaelMure/go-term-text v0.3.1 // indirect
github.com/alecthomas/chroma v0.7.1 // indirect github.com/alecthomas/chroma v0.7.1 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charmbracelet/bubbles v1.0.0 // indirect
github.com/charmbracelet/bubbletea v1.3.10 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect github.com/charmbracelet/colorprofile v0.4.1 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
@@ -69,7 +69,6 @@ require (
github.com/go-openapi/swag/typeutils v0.26.1 // indirect github.com/go-openapi/swag/typeutils v0.26.1 // indirect
github.com/go-openapi/swag/yamlutils v0.26.1 // indirect github.com/go-openapi/swag/yamlutils v0.26.1 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/goccy/go-json v0.10.6 // indirect
github.com/gomarkdown/markdown v0.0.0-20191123064959-2c17d62f5098 // indirect github.com/gomarkdown/markdown v0.0.0-20191123064959-2c17d62f5098 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/kyokomi/emoji/v2 v2.2.8 // indirect github.com/kyokomi/emoji/v2 v2.2.8 // indirect
@@ -81,9 +80,6 @@ require (
github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect github.com/muesli/termenv v0.16.0 // indirect
github.com/oklog/ulid/v2 v2.1.1 // indirect github.com/oklog/ulid/v2 v2.1.1 // indirect
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect
github.com/olekukonko/errors v1.2.0 // indirect
github.com/olekukonko/ll v0.1.8 // indirect
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect github.com/tidwall/pretty v1.2.0 // indirect
@@ -93,10 +89,8 @@ require (
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
go.yaml.in/yaml/v3 v3.0.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect
golang.org/x/image v0.0.0-20191206065243-da761ea9ff43 // indirect golang.org/x/image v0.0.0-20191206065243-da761ea9ff43 // indirect
golang.org/x/net v0.55.0 // indirect golang.org/x/net v0.55.0 // indirect
golang.org/x/sys v0.46.0 // indirect golang.org/x/sys v0.46.0 // indirect
golang.org/x/term v0.44.0 // indirect
golang.org/x/text v0.37.0 // indirect golang.org/x/text v0.37.0 // indirect
) )

26
go.sum
View File

@@ -14,28 +14,18 @@ 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/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM= github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM=
@@ -114,8 +104,6 @@ github.com/go-openapi/testify/v2 v2.5.1 h1:TMdhCaw8fUNraVSf3Omoob1dO/AzBfhtFAPW0
github.com/go-openapi/testify/v2 v2.5.1/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= github.com/go-openapi/testify/v2 v2.5.1/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/gomarkdown/markdown v0.0.0-20191123064959-2c17d62f5098 h1:Qxs3bNRWe8GTcKMxYOSXm0jx6j0de8XUtb/fsP3GZ0I= github.com/gomarkdown/markdown v0.0.0-20191123064959-2c17d62f5098 h1:Qxs3bNRWe8GTcKMxYOSXm0jx6j0de8XUtb/fsP3GZ0I=
github.com/gomarkdown/markdown v0.0.0-20191123064959-2c17d62f5098/go.mod h1:aii0r/K0ZnHv7G0KF7xy1v0A7s2Ljrb5byB7MO5p6TU= github.com/gomarkdown/markdown v0.0.0-20191123064959-2c17d62f5098/go.mod h1:aii0r/K0ZnHv7G0KF7xy1v0A7s2Ljrb5byB7MO5p6TU=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
@@ -154,14 +142,6 @@ github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s= github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s=
github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 h1:zrbMGy9YXpIeTnGj4EljqMiZsIcE09mmF8XsD5AYOJc=
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6/go.mod h1:rEKTHC9roVVicUIfZK7DYrdIoM0EOr8mK1Hj5s3JjH0=
github.com/olekukonko/errors v1.2.0 h1:10Zcn4GeV59t/EGqJc8fUjtFT/FuUh5bTMzZ1XwmCRo=
github.com/olekukonko/errors v1.2.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y=
github.com/olekukonko/ll v0.1.8 h1:ysHCJRGHYKzmBSdz9w5AySztx7lG8SQY+naTGYUbsz8=
github.com/olekukonko/ll v0.1.8/go.mod h1:RPRC6UcscfFZgjo1nulkfMH5IM0QAYim0LfnMvUuozw=
github.com/olekukonko/tablewriter v1.1.4 h1:ORUMI3dXbMnRlRggJX3+q7OzQFDdvgbN9nVWj1drm6I=
github.com/olekukonko/tablewriter v1.1.4/go.mod h1:+kedxuyTtgoZLwif3P1Em4hARJs+mVnzKxmsCL/C5RY=
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -171,6 +151,8 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/seeruk/go-wordwrap v0.0.0-20191208221741-14ec4aac9550 h1:C3CfUXH/qmWuQFRqnPm3Sx8PFxa+pqACjhV5CaNO8pw=
github.com/seeruk/go-wordwrap v0.0.0-20191208221741-14ec4aac9550/go.mod h1:Sl541M2Em6rRG3V9WObycR7MYFZiERVkd/TJg0Gt0U4=
github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ= github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ=
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@@ -187,8 +169,6 @@ github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tlinden/yadu v0.1.3 h1:5cRCUmj+l5yvlM2irtpFBIJwVV2DPEgYSaWvF19FtcY= github.com/tlinden/yadu v0.1.3 h1:5cRCUmj+l5yvlM2irtpFBIJwVV2DPEgYSaWvF19FtcY=
github.com/tlinden/yadu v0.1.3/go.mod h1:l3bRmHKL9zGAR6pnBHY2HRPxBecf7L74BoBgOOpTcUA= github.com/tlinden/yadu v0.1.3/go.mod h1:l3bRmHKL9zGAR6pnBHY2HRPxBecf7L74BoBgOOpTcUA=
github.com/urfave/cli/v3 v3.9.1-0.20260524212652-be8b79d0c8de h1:ESKPiS7inVoBnv4FmgGNZdjWBI/wmvaragoyD3D9nM4=
github.com/urfave/cli/v3 v3.9.1-0.20260524212652-be8b79d0c8de/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso=
github.com/urfave/cli/v3 v3.10.1-0.20260623012112-f980ca84bf65 h1:NXitXXO9DupDLEFj8hIyYtsubRxSFTp5JXqohElT0nU= github.com/urfave/cli/v3 v3.10.1-0.20260623012112-f980ca84bf65 h1:NXitXXO9DupDLEFj8hIyYtsubRxSFTp5JXqohElT0nU=
github.com/urfave/cli/v3 v3.10.1-0.20260623012112-f980ca84bf65/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso= github.com/urfave/cli/v3 v3.10.1-0.20260623012112-f980ca84bf65/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
@@ -221,8 +201,6 @@ golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5h
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=

View File

@@ -17,26 +17,32 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
package cfg package cfg
import ( import (
"crypto/tls"
"errors" "errors"
"fmt" "fmt"
"net"
"net/http" "net/http"
"os" "os"
"strings"
"syscall"
"time"
"github.com/elastic/elastic-transport-go/v8/elastictransport" "github.com/elastic/elastic-transport-go/v8/elastictransport"
"github.com/elastic/go-elasticsearch/v9" "github.com/elastic/go-elasticsearch/v9"
"golang.org/x/term"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
// used in general config struct // used in general config struct
type Cluster struct { type Cluster struct {
Uri, User, Pass string Name, Uri, User, Pass, Token string
client *elasticsearch.TypedClient client *elasticsearch.TypedClient
Default bool Default, DebugHTTP bool
} }
// used just for writing back to the config file // used just for writing back to the config file
type ClusterConfig struct { type ClusterConfig struct {
Uri, User, Pass string Uri, User, Pass, Token string
Default bool Default bool
} }
@@ -45,17 +51,66 @@ type WriteConfig struct {
Clusters map[string]*ClusterConfig Clusters map[string]*ClusterConfig
} }
func (cluster *Cluster) SetClient(client *elasticsearch.TypedClient) {
cluster.client = client
}
// return the go-elasticsearch client object but before doing that,
// check if we need to tune auth
func (cluster *Cluster) ES() *elasticsearch.TypedClient { func (cluster *Cluster) ES() *elasticsearch.TypedClient {
if cluster.client == nil { if cluster.client == nil {
fmt.Println("no current cluster, use 'esctl cluster switch <name>' to set one") fmt.Println("no current cluster, use 'esctl cluster switch <name>' to set one")
os.Exit(1) os.Exit(1)
} }
if err := cluster.CheckAuth(); err != nil {
fmt.Printf("Error: %s", err)
os.Exit(1)
}
return cluster.client return cluster.client
} }
func (cluster *Cluster) SetClient(client *elasticsearch.TypedClient) { // add authentication to es client, if not yet done
cluster.client = client func (cluster *Cluster) CheckAuth() error {
if cluster.Pass == "" && cluster.User != "" && cluster.Token == "" && cluster.Default {
// no token - user is set, but no password.
// check if the env var is set
pass := os.Getenv("ES_PASS")
if pass != "" {
cluster.Pass = pass
} else {
// k, try interactively
fmt.Fprintf(os.Stderr, "Enter password for elasticsearch user %s@%s: ", cluster.User, cluster.Name)
pass, err := term.ReadPassword(syscall.Stdin)
if err != nil {
return err
}
passwd := strings.TrimSpace(string(pass))
if passwd == "" {
return errors.New("password empty")
}
cluster.Pass = string(pass)
fmt.Println()
}
opts := cluster.getDefaultOptions()
opts = append(opts, elasticsearch.WithBasicAuth(cluster.User, cluster.Pass))
es, err := elasticsearch.NewTyped(opts...)
if err != nil {
return fmt.Errorf("failed to setup elasticsearch connection: %w", err)
}
cluster.SetClient(es)
}
return nil
} }
// set Default=true for the given cluster in the config (if exists) // set Default=true for the given cluster in the config (if exists)
@@ -72,6 +127,7 @@ func (conf *Config) SwitchCluster(name string) error {
Uri: cluster.Uri, Uri: cluster.Uri,
User: cluster.User, User: cluster.User,
Pass: cluster.Pass, Pass: cluster.Pass,
Token: cluster.Token,
Default: false, Default: false,
} }
@@ -97,24 +153,77 @@ func (conf *Config) SwitchCluster(name string) error {
return nil return nil
} }
func (conf *Config) SetupES() error { // We do NOT use go-elasticsearch to check for cluster reachability,
// because at this stage, auth may not have been configured. So
// instead we just connect to the cluster using plan net/tcp
func (cluster *Cluster) IsReachable() (bool, error) {
timeout := 500 * time.Millisecond
url := strings.TrimPrefix(strings.TrimPrefix(cluster.Uri, "https://"), "http://")
host := strings.Split(url, "/")
if !strings.Contains(host[0], ":") {
host[0] += ":443"
}
conn, err := net.DialTimeout("tcp", host[0], timeout)
if err != nil {
return false, err
}
return true, conn.Close()
}
func (cluster *Cluster) getDefaultOptions() []elasticsearch.Option {
// These headers are not needed with ES 9, but with ES 8, we set // These headers are not needed with ES 9, but with ES 8, we set
// them here so every API call uses it. The only exception being // them here so every API call uses it. The only exception being
// the api repl, which does it on its own. // the api repl, which does it on its own.
headers := http.Header{} headers := http.Header{}
headers.Add("content-type", "application/json") headers.Add("Content-Type", "application/json")
headers.Add("Accept", "application/json") headers.Add("Accept", "application/json")
for _, cluster := range conf.Clusters { return []elasticsearch.Option{
es, err := elasticsearch.NewTyped(
elasticsearch.WithAddresses(cluster.Uri), elasticsearch.WithAddresses(cluster.Uri),
elasticsearch.WithBasicAuth(cluster.User, cluster.Pass),
elasticsearch.WithTransportOptions( elasticsearch.WithTransportOptions(
conf.getTransport(), cluster.getTransport(),
elastictransport.WithHeader(headers), elastictransport.WithHeader(headers),
), ),
) elasticsearch.WithCompatibilityMode(),
}
}
func (cluster *Cluster) getTransport() elastictransport.Option {
transport := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
if cluster.DebugHTTP {
return elastictransport.WithTransport(
&DebugTransport{Transport: transport},
)
}
return elastictransport.WithTransport(
&CompatibilityTransport{Transport: transport},
)
}
func (conf *Config) SetupES() error {
for name, cluster := range conf.Clusters {
cluster.Name = name
cluster.DebugHTTP = conf.DebugHTTP
opts := cluster.getDefaultOptions()
switch {
case cluster.Pass != "" && cluster.User != "":
opts = append(opts, elasticsearch.WithBasicAuth(cluster.User, cluster.Pass))
case cluster.Token != "":
opts = append(opts, elasticsearch.WithAPIKey(cluster.Token))
}
es, err := elasticsearch.NewTyped(opts...)
if err != nil { if err != nil {
return fmt.Errorf("failed to setup elasticsearch connection: %w", err) return fmt.Errorf("failed to setup elasticsearch connection: %w", err)
} }

View File

@@ -28,7 +28,7 @@ import (
) )
const ( const (
Version string = `v0.0.22` Version string = `v0.0.26`
) )
var ( var (
@@ -103,6 +103,11 @@ type Config struct {
Tag string // api ls: -t Tag string // api ls: -t
Ilm Ilm // ilm create Ilm Ilm // ilm create
FromNode, ToNode string // cluster reroute move: -f + -t
AllowPrimary, AcceptDataLoss bool // cluster reroute cancel: -p,-a
Pager string // api repl: -p || PAGER
} }
func NewConfig() *Config { func NewConfig() *Config {
@@ -116,10 +121,11 @@ func getDefaultPath() string {
func (conf *Config) Init() error { func (conf *Config) Init() error {
DefaultConfig := getDefaultPath() DefaultConfig := getDefaultPath()
switch { if conf.ConfigFile == "" && fileExists(DefaultConfig) {
case fileExists(DefaultConfig):
conf.ConfigFile = DefaultConfig conf.ConfigFile = DefaultConfig
fallthrough }
switch {
case conf.ConfigFile != "": case conf.ConfigFile != "":
if err := conf.LoadConfig(); err != nil { if err := conf.LoadConfig(); err != nil {
return err return err
@@ -134,6 +140,107 @@ func (conf *Config) Init() error {
return err return err
} }
if err := conf.determineDefaultCluster(); err != nil {
return err
}
conf.HaveJQ = isJQinstalled()
conf.PrintDebug()
return nil
}
// we are using reflect to clone a config obj w/o the ES stuff for
// shorter repr.Println() output (the ES structure is just too large)
func (conf *Config) Clone() Config {
clone := Config{}
ref := reflect.ValueOf(*conf)
typeOfS := ref.Type()
for idx := range ref.NumField() {
field := typeOfS.Field(idx).Name
if field == "Clusters" || field == "DefaultCluster" || !ref.Field(idx).CanInterface() {
continue
}
reflect.ValueOf(&clone).Elem().FieldByName(field).Set(reflect.ValueOf(ref.Field(idx).Interface()))
}
return clone
}
func (conf *Config) PrintDebug() {
if !conf.Debug {
return
}
clone := conf.Clone()
fmt.Println("config:")
repr.Println(clone)
}
func (conf *Config) LoadEnv() error {
cluster := Cluster{
Uri: os.Getenv("ES_URI"),
User: os.Getenv("ES_USER"),
Pass: os.Getenv("ES_PASS"),
Token: os.Getenv("ES_TOKEN"),
}
switch {
case cluster.Uri == "":
return errors.New("ES_URI unset")
case cluster.User == "" || cluster.Token == "":
return errors.New("ES_USER and ES_TOKEN unset")
}
conf.Clusters["default"] = &cluster
conf.DefaultCluster = &cluster
return nil
}
func (conf *Config) LoadConfig() error {
if conf.ConfigFile == "" {
return nil
}
data, err := os.ReadFile(conf.ConfigFile)
if err != nil {
return fmt.Errorf("failed to read config file: %w", err)
}
newconf := &Config{}
err = yaml.Unmarshal(data, newconf)
if err != nil {
return fmt.Errorf("failed to unmarshal config file: %w", err)
}
if len(newconf.Clusters) > 0 {
conf.Clusters = newconf.Clusters
for _, cluster := range conf.Clusters {
if cluster.Default {
conf.DefaultCluster = cluster
break
}
}
if conf.DefaultCluster == nil {
conf.DefaultCluster = &Cluster{}
}
}
return nil
}
func (conf *Config) determineDefaultCluster() error {
if conf.CurrentCluster != "" { if conf.CurrentCluster != "" {
// -C specified, set current cluster explicitly, no matter what the config says // -C specified, set current cluster explicitly, no matter what the config says
current, exists := conf.Clusters[conf.CurrentCluster] current, exists := conf.Clusters[conf.CurrentCluster]
@@ -169,105 +276,11 @@ func (conf *Config) Init() error {
} }
} }
conf.HaveJQ = isJQinstalled()
conf.PrintDebug()
return nil
}
// we are using reflect to clone a config obj w/o the ES stuff for
// shorter repr.Println() output (the ES structure is just too large)
func (conf *Config) Clone() Config {
clone := Config{}
ref := reflect.ValueOf(*conf)
typeOfS := ref.Type()
for i := 0; i < ref.NumField(); i++ {
field := typeOfS.Field(i).Name
if field == "Clusters" || field == "DefaultCluster" || !ref.Field(i).CanInterface() {
continue
}
reflect.ValueOf(&clone).Elem().FieldByName(field).Set(reflect.ValueOf(ref.Field(i).Interface()))
}
return clone
}
func (conf *Config) PrintDebug() {
if !conf.Debug {
return
}
clone := conf.Clone()
fmt.Println("config:")
repr.Println(clone)
}
func (conf *Config) LoadEnv() error {
cluster := Cluster{
Uri: os.Getenv("ES_URI"),
User: os.Getenv("ES_USER"),
Pass: os.Getenv("ES_PASS"),
}
switch {
case cluster.Uri == "":
return errors.New("ES_URI unset")
case cluster.User == "":
return errors.New("ES_USER unset")
case cluster.Pass == "":
return errors.New("ES_PASS unset")
}
conf.Clusters["default"] = &cluster
conf.DefaultCluster = &cluster
return nil
}
func (conf *Config) LoadConfig() error {
if conf.ConfigFile == "" {
return nil
}
data, err := os.ReadFile(conf.ConfigFile)
if err != nil {
return fmt.Errorf("failed to read config file: %w", err)
}
newconf := &Config{}
err = yaml.Unmarshal(data, newconf)
if err != nil {
return fmt.Errorf("failed to unmarshal config file: %w", err)
}
if len(newconf.Clusters) > 0 {
conf.Clusters = newconf.Clusters
for _, cluster := range conf.Clusters {
if cluster.Default {
conf.DefaultCluster = cluster
break
}
}
if conf.DefaultCluster == nil {
conf.DefaultCluster = &Cluster{}
}
}
return nil return nil
} }
func fileExists(filename string) bool { func fileExists(filename string) bool {
info, err := os.Stat(filename) info, err := os.Stat(filename)
if err != nil { if err != nil {
// return false on any error // return false on any error
return false return false

View File

@@ -42,6 +42,7 @@ type Ilm struct {
DeleteSearchableSnapshots bool DeleteSearchableSnapshots bool
MinAge, FromPhase, Within string // forecast MinAge, FromPhase, Within string // forecast
Tree bool
} }
func (cfg *Ilm) HaveHot() bool { func (cfg *Ilm) HaveHot() bool {

View File

@@ -25,6 +25,7 @@ import (
func isJQinstalled() bool { func isJQinstalled() bool {
cmd := exec.CommandContext(context.Background(), "jq", "-h") cmd := exec.CommandContext(context.Background(), "jq", "-h")
out, err := cmd.Output() out, err := cmd.Output()
if err != nil { if err != nil {
return false return false

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

@@ -0,0 +1,49 @@
/*
Copyright © 2026 Thomas von Dein
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package cfg
import (
"os"
"golang.org/x/term"
)
const (
DefaultMargin = 4
)
func GetTermWidth() int {
if term.IsTerminal(int(os.Stdout.Fd())) {
width, _, err := term.GetSize(int(os.Stdout.Fd()))
if err == nil {
return width - DefaultMargin
}
}
return 80
}
func GetTermHeight() int {
if term.IsTerminal(int(os.Stdout.Fd())) {
_, height, err := term.GetSize(int(os.Stdout.Fd()))
if err == nil {
return height
}
}
return 25
}

View File

@@ -18,13 +18,10 @@ package cfg
import ( import (
"bytes" "bytes"
"crypto/tls"
"encoding/json" "encoding/json"
"fmt" "fmt"
"log/slog" "log/slog"
"net/http" "net/http"
"github.com/elastic/elastic-transport-go/v8/elastictransport"
) )
// used to print uri, path and body of a request made by the go-client // used to print uri, path and body of a request made by the go-client
@@ -46,15 +43,21 @@ func (t *DebugTransport) RoundTrip(req *http.Request) (*http.Response, error) {
} }
var pretty bytes.Buffer var pretty bytes.Buffer
err = json.Indent(&pretty, buf.Bytes(), "", "\t") err = json.Indent(&pretty, buf.Bytes(), "", "\t")
if err != nil { if err != nil {
return nil, fmt.Errorf("json parse error: %s", err) return nil, fmt.Errorf("json parse error: %w", err)
} }
content = pretty.String() content = pretty.String()
contentline = buf.String() contentline = buf.String()
} }
if req.Header.Get("Accept") != "" {
req.Header.Del("Accept")
req.Header.Add("Accept", "application/json")
}
slog.Info("req", "host", req.URL.Host, "uri", req.URL.Path, slog.Info("req", "host", req.URL.Host, "uri", req.URL.Path,
"body", content, "bodyline", contentline, "body", content, "bodyline", contentline,
"headers", req.Header, "headers", req.Header,
@@ -63,16 +66,20 @@ func (t *DebugTransport) RoundTrip(req *http.Request) (*http.Response, error) {
return t.Transport.RoundTrip(req) return t.Transport.RoundTrip(req)
} }
func (conf *Config) getTransport() elastictransport.Option { // Fixes https://codeberg.org/scip/esctl/issues/79:
transport := &http.Transport{ // the API client has this Accept header hardcoded everywhere:
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, // req.Header.Set("Accept", "application/vnd.elasticsearch+json;compatible-with=9")
// While this works pretty well with ES9, it doesn't with ES8. So, we replace
// this header with a new one without the compatibility part.
type CompatibilityTransport struct {
Transport http.RoundTripper
} }
if conf.DebugHTTP { func (t *CompatibilityTransport) RoundTrip(req *http.Request) (*http.Response, error) {
return elastictransport.WithTransport( if req.Header.Get("Accept") != "" {
&DebugTransport{Transport: transport}, req.Header.Del("Accept")
) req.Header.Add("Accept", "application/json")
} }
return elastictransport.WithTransport(transport) return t.Transport.RoundTrip(req)
} }

View File

@@ -26,6 +26,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"log"
"log/slog" "log/slog"
"net/http" "net/http"
"os" "os"
@@ -41,11 +42,9 @@ import (
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
"github.com/chzyer/readline" "github.com/chzyer/readline"
"github.com/go-openapi/spec" "github.com/go-openapi/spec"
"golang.org/x/term"
) )
const ( const (
DefaultMargin = 4
intro = `Input format: verb path [data]" intro = `Input format: verb path [data]"
Example: Example:
@@ -94,9 +93,8 @@ func ApiRepl(conf *cfg.Config) error {
EOFPrompt: "exit", EOFPrompt: "exit",
HistorySearchFold: true, HistorySearchFold: true,
}) })
if err != nil { if err != nil {
return fmt.Errorf("failed to initialize readline lib: %s", err) return fmt.Errorf("failed to initialize readline lib: %w", err)
} }
for { for {
@@ -114,11 +112,13 @@ func ApiRepl(conf *cfg.Config) error {
parts := strings.SplitN(strings.TrimSpace(text), " ", 3) parts := strings.SplitN(strings.TrimSpace(text), " ", 3)
if len(parts) < 2 { if len(parts) < 2 {
fmt.Println("error: you need to input a verb, uri [and post data]") fmt.Println("error: you need to input a verb, uri [and post data]")
continue continue
} }
if !slices.Contains(verbs, strings.ToLower(parts[0])) { if !slices.Contains(verbs, strings.ToLower(parts[0])) {
fmt.Println("error: verb must be one of " + strings.Join(verbs, ",")) fmt.Println("error: verb must be one of " + strings.Join(verbs, ","))
continue continue
} }
@@ -143,14 +143,54 @@ func ApiRepl(conf *cfg.Config) error {
fmt.Printf("failed to call API: %s\n", esErrorString(err)) fmt.Printf("failed to call API: %s\n", esErrorString(err))
} }
if err := prettyfiJson(conf, raw); err != nil { pageJsonOutput(conf, raw)
fmt.Println(err)
}
} }
//nolint:nilerr
return nil return nil
} }
func pageJsonOutput(conf *cfg.Config, raw []byte) {
tmpconf := &cfg.Config{HaveJQ: conf.HaveJQ}
if conf.Pager != "" {
tmpconf.HaveJQ = false
}
output, err := prettyfiJson(tmpconf, raw)
if err != nil {
fmt.Println(err)
}
lines := len(strings.Split(output, "\n"))
height := cfg.GetTermHeight()
if lines > height {
if conf.Pager != "" {
cmd := strings.Split(conf.Pager, " ")
pager := exec.Command(cmd[0], cmd[1:]...)
var buf bytes.Buffer
buf.WriteString(output)
pager.Stdout = os.Stdout
pager.Stdin = &buf
pager.Stderr = os.Stderr
err := pager.Run()
if err != nil {
fmt.Printf("failed to execute pager '%s': %s", conf.Pager, err)
}
} else {
printer.Pager("json output", output)
}
return
}
fmt.Println(output)
}
func encodeAuth(username, password string) string { func encodeAuth(username, password string) string {
return base64.StdEncoding.EncodeToString([]byte(username + ":" + password)) return base64.StdEncoding.EncodeToString([]byte(username + ":" + password))
} }
@@ -159,11 +199,17 @@ func CallAPI(conf *cfg.Config, verb, path, data string) ([]byte, error) {
verb = strings.ToUpper(verb) verb = strings.ToUpper(verb)
// we're using port-forwards anyway // we're using port-forwards anyway
tr := &http.Transport{ noVerifyTransport := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
} }
client := &http.Client{Transport: tr} client := &http.Client{Transport: noVerifyTransport}
if conf.DebugHTTP {
client = &http.Client{
Transport: &cfg.DebugTransport{
Transport: noVerifyTransport}}
}
req, err := http.NewRequest(verb, conf.DefaultCluster.Uri+path, bytes.NewBuffer([]byte(data))) req, err := http.NewRequest(verb, conf.DefaultCluster.Uri+path, bytes.NewBuffer([]byte(data)))
if err != nil { if err != nil {
@@ -171,60 +217,73 @@ func CallAPI(conf *cfg.Config, verb, path, data string) ([]byte, error) {
} }
req.Header.Add("Content-Type", "application/json") req.Header.Add("Content-Type", "application/json")
req.Header.Add("accept", "application/json") req.Header.Add("Accept", "application/json")
// make sure we have got all we need
if err := conf.DefaultCluster.CheckAuth(); err != nil {
return nil, err
}
if conf.DefaultCluster.Token != "" {
req.Header.Add("Authorization", "APIKey "+conf.DefaultCluster.Token)
} else {
req.Header.Add("Authorization", "Basic "+encodeAuth(conf.DefaultCluster.User, conf.DefaultCluster.Pass)) req.Header.Add("Authorization", "Basic "+encodeAuth(conf.DefaultCluster.User, conf.DefaultCluster.Pass))
}
// actually execute the request // actually execute the request
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer func() {
if err := resp.Body.Close(); err != nil {
log.Fatal(err)
}
}()
// Read and print response // Read and print response
body, err := io.ReadAll(resp.Body) body, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to read response body: %s", err) return nil, fmt.Errorf("failed to read response body: %w", err)
} }
return body, nil return body, nil
} }
func prettyfiJson(conf *cfg.Config, raw []byte) error { func prettyfiJson(conf *cfg.Config, raw []byte) (string, error) {
if conf.HaveJQ { if conf.HaveJQ {
cmd := exec.CommandContext(context.Background(), "jq", "-C") cmd := exec.CommandContext(context.Background(), "jq", "-C")
cmd.Stdin = bytes.NewReader(raw) cmd.Stdin = bytes.NewReader(raw)
var out bytes.Buffer var out bytes.Buffer
cmd.Stdout = &out cmd.Stdout = &out
err := cmd.Run() err := cmd.Run()
if err != nil { if err != nil {
return err return "", err
} }
fmt.Println(out.String()) return out.String(), nil
return nil
} }
var pretty bytes.Buffer var pretty bytes.Buffer
err := json.Indent(&pretty, raw, "", "\t") err := json.Indent(&pretty, raw, "", "\t")
if err != nil { if err != nil {
return fmt.Errorf("json parse error: %s", err) return "", fmt.Errorf("json parse error: %w", err)
} }
fmt.Println(pretty.String()) return pretty.String(), nil
return nil
} }
// interactively read arbitrary JSON data from STDIN, which is // interactively read arbitrary JSON data from STDIN, which is
// virtually a repl inside the primary repl // virtually a repl inside the primary repl
func readJSON(input string) (string, error) { func readJSON(input string) (string, error) {
data := "" data := strings.Builder{}
if input != "" { if input != "" {
data = input data.WriteString(input)
} else { } else {
scanner := bufio.NewScanner(os.Stdin) scanner := bufio.NewScanner(os.Stdin)
for scanner.Scan() { for scanner.Scan() {
@@ -234,23 +293,23 @@ func readJSON(input string) (string, error) {
break break
} }
data += line data.WriteString(line)
} }
} }
if data == "" { if data.Len() == 0 {
return data, nil return "", nil
} }
// validate // validate
check := map[string]any{} check := map[string]any{}
err := json.Unmarshal([]byte(data), &check)
if err != nil {
return "", fmt.Errorf("error: input data is not proper JSON: %s", err)
err := json.Unmarshal([]byte(data.String()), &check)
if err != nil {
return "", fmt.Errorf("error: input data is not proper JSON: %w", err)
} }
return data, nil return data.String(), nil
} }
func ApiList(conf *cfg.Config, pattern string) error { func ApiList(conf *cfg.Config, pattern string) error {
@@ -289,6 +348,7 @@ func ApiList(conf *cfg.Config, pattern string) error {
} }
table.Sort() table.Sort()
return table.Print() return table.Print()
} }
@@ -296,10 +356,13 @@ func ApiList(conf *cfg.Config, pattern string) error {
func ApiPathNames() []string { func ApiPathNames() []string {
assets.LoadAssetOpenApi() assets.LoadAssetOpenApi()
paths := []string{} paths := make([]string, len(assets.OpenAPI.Spec().Paths.Paths))
idx := 0
for path := range assets.OpenAPI.Spec().Paths.Paths { for path := range assets.OpenAPI.Spec().Paths.Paths {
paths = append(paths, path) paths[idx] = path
idx++
} }
return paths return paths
@@ -307,6 +370,7 @@ func ApiPathNames() []string {
func ApiShow(conf *cfg.Config, showpath, verb string) error { func ApiShow(conf *cfg.Config, showpath, verb string) error {
assets.LoadAssetOpenApi() assets.LoadAssetOpenApi()
out := printer.Builder{} out := printer.Builder{}
op, err := matchOperation(showpath, verb) op, err := matchOperation(showpath, verb)
@@ -318,23 +382,25 @@ func ApiShow(conf *cfg.Config, showpath, verb string) error {
cleanMarkup := regexp.MustCompile(`<[^<>]+>`) cleanMarkup := regexp.MustCompile(`<[^<>]+>`)
width := getTermWidth() width := cfg.GetTermWidth()
params := getApiParameters(op, showpath, width) params := getApiParameters(op, showpath, width)
sample := getApiExample(op) sample := getApiExample(op)
description := markdown.Render(cleanMarkup.ReplaceAllString(op.Op.Description, ""), width, DefaultMargin) description := markdown.Render(cleanMarkup.ReplaceAllString(op.Op.Description, ""), width, cfg.DefaultMargin)
var bold = lipgloss.NewStyle(). var (
bold = lipgloss.NewStyle().
Bold(true) Bold(true)
var paragraph = lipgloss.NewStyle(). paragraph = lipgloss.NewStyle().
MarginBottom(1). MarginBottom(1).
MarginLeft(DefaultMargin) MarginLeft(cfg.DefaultMargin)
var boldparagraph = lipgloss.NewStyle(). boldparagraph = lipgloss.NewStyle().
MarginBottom(1). MarginBottom(1).
MarginLeft(DefaultMargin). MarginLeft(cfg.DefaultMargin).
Bold(true) Bold(true)
var indentparagraph = lipgloss.NewStyle(). indentparagraph = lipgloss.NewStyle().
MarginBottom(1). MarginBottom(1).
MarginLeft(2) MarginLeft(2)
)
out.WriteStringLine(bold.Render("ID: " + op.Op.ID)) out.WriteStringLine(bold.Render("ID: " + op.Op.ID))
out.WriteStringLine(paragraph.Render(fmt.Sprintf("%s %s", op.Verb, showpath))) out.WriteStringLine(paragraph.Render(fmt.Sprintf("%s %s", op.Verb, showpath)))
@@ -350,10 +416,11 @@ func ApiShow(conf *cfg.Config, showpath, verb string) error {
if len(params.Path) > 0 { if len(params.Path) > 0 {
out.WriteStringLine(bold.Render("Path Parameters")) out.WriteStringLine(bold.Render("Path Parameters"))
for _, param := range params.Path { for _, param := range params.Path {
out.WriteStringLine(boldparagraph.Render(param.Param)) out.WriteStringLine(boldparagraph.Render(param.Param))
if param.Description != "" { if param.Description != "" {
//out.WriteStringLine(indentparagraph.Render(param.Description))
out.WriteStringLine(param.Description) out.WriteStringLine(param.Description)
} }
} }
@@ -361,8 +428,10 @@ func ApiShow(conf *cfg.Config, showpath, verb string) error {
if len(params.Query) > 0 { if len(params.Query) > 0 {
out.WriteStringLine(bold.Render("Query Parameters")) out.WriteStringLine(bold.Render("Query Parameters"))
for _, param := range params.Query { for _, param := range params.Query {
out.WriteStringLine(boldparagraph.Render(param.Param)) out.WriteStringLine(boldparagraph.Render(param.Param))
if param.Description != "" { if param.Description != "" {
out.WriteStringLine(indentparagraph.Render(param.Description)) out.WriteStringLine(indentparagraph.Render(param.Description))
} }
@@ -413,7 +482,7 @@ func getApiParameters(op *Op, path string, width int) Params {
} }
} else { } else {
par := Param{ par := Param{
Description: string(markdown.Render(param.Description, width, DefaultMargin)), Description: string(markdown.Render(param.Description, width, cfg.DefaultMargin)),
Param: param.Name, Param: param.Name,
} }
@@ -444,10 +513,8 @@ func getApiExample(op *Op) string {
if hassource { if hassource {
sample = strings.TrimSpace(source.(string)) sample = strings.TrimSpace(source.(string))
} }
} }
} }
} }
} }
@@ -460,11 +527,13 @@ func getApiExample(op *Op) string {
func matchOperation(showpath, verb string) (*Op, error) { func matchOperation(showpath, verb string) (*Op, error) {
ops := []*Op{} ops := []*Op{}
op := &Op{} op := &Op{}
var found bool var found bool
for path, item := range assets.OpenAPI.Spec().Paths.Paths { for path, item := range assets.OpenAPI.Spec().Paths.Paths {
if path == showpath { if path == showpath {
ops = findOperation(item.PathItemProps) ops = findOperation(item.PathItemProps)
break break
} }
} }
@@ -481,6 +550,7 @@ func matchOperation(showpath, verb string) (*Op, error) {
if strings.ToLower(item.Verb) == verb { if strings.ToLower(item.Verb) == verb {
op = item op = item
found = true found = true
break break
} }
} }
@@ -517,14 +587,3 @@ func findOperation(item spec.PathItemProps) []*Op {
return ops return ops
} }
func getTermWidth() int {
if term.IsTerminal(int(os.Stdout.Fd())) {
width, _, err := term.GetSize(int(os.Stdout.Fd()))
if err == nil {
return width - DefaultMargin
}
}
return 80
}

View File

@@ -46,9 +46,8 @@ func CcrStatus(conf *cfg.Config, leader, follower string) error {
} }
// reverse attribution // reverse attribution
f := follower follower, leader = leader, follower
follower = leader
leader = f
slog.Debug("leader/follower attribution is invalid, reversing", "leader", leader, "follower", follower) slog.Debug("leader/follower attribution is invalid, reversing", "leader", leader, "follower", follower)
} }
@@ -58,7 +57,7 @@ func CcrStatus(conf *cfg.Config, leader, follower string) error {
res, err := conf.Clusters[alias].ES().Cat.Indices(). res, err := conf.Clusters[alias].ES().Cat.Indices().
Do(context.Background()) Do(context.Background())
if err != nil { if err != nil {
return fmt.Errorf("failed to get indicies on %s: %s", alias, esErrorString(err)) return fmt.Errorf("failed to get indicies on %s: %w", alias, esErrorString(err))
} }
indices[alias] = make(map[string]*types.IndicesRecord, len(res)) indices[alias] = make(map[string]*types.IndicesRecord, len(res))
@@ -87,22 +86,24 @@ func CcrRemoteInfo(conf *cfg.Config, index string) error {
res, err := conf.DefaultCluster.ES().Cluster.RemoteInfo(). res, err := conf.DefaultCluster.ES().Cluster.RemoteInfo().
Do(context.Background()) Do(context.Background())
if err != nil { if err != nil {
return fmt.Errorf("failed to retrieve follower info: %s", esErrorString(err)) return fmt.Errorf("failed to retrieve follower info: %w", esErrorString(err))
} }
slog.Debug("ccr remote info", "info", res) slog.Debug("ccr remote info", "info", res)
remote := "" remote := ""
var info *types.ClusterRemoteProxyInfo var info *types.ClusterRemoteProxyInfo
for name, data := range res { for name, data := range res {
remote = name remote = name
info = data.(*types.ClusterRemoteProxyInfo) info = data.(*types.ClusterRemoteProxyInfo)
break break
} }
if remote == "" { if remote == "" {
return fmt.Errorf("cluster doesn't follow any other: %s", err) return errors.New("cluster doesn't follow any other")
} }
mode := "follower" mode := "follower"
@@ -113,11 +114,11 @@ func CcrRemoteInfo(conf *cfg.Config, index string) error {
table := printer.NewTable(conf, 2, 5) table := printer.NewTable(conf, 2, 5)
table.Addheaders("ccr remote property", "value") table.Addheaders("ccr remote property", "value")
table.Entries = [][]string{ table.Entries = [][]any{
{"Remote Cluster", remote}, {"Remote Cluster", remote},
{"CCR Mode", mode}, {"CCR Mode", mode},
{"Connected", fmt.Sprintf("%t", info.Connected)}, {"Connected", info.Connected},
{"Num Proxy Sockets Connected", fmt.Sprintf("%d", info.NumProxySocketsConnected)}, {"Num Proxy Sockets Connected", info.NumProxySocketsConnected},
{"Proxy Address", info.ProxyAddress}, {"Proxy Address", info.ProxyAddress},
} }

View File

@@ -18,6 +18,7 @@ package es
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"log/slog" "log/slog"
@@ -29,17 +30,18 @@ func getRemoteName(conf *cfg.Config) (string, error) {
res, err := conf.DefaultCluster.ES().Cluster.RemoteInfo(). res, err := conf.DefaultCluster.ES().Cluster.RemoteInfo().
Do(context.Background()) Do(context.Background())
if err != nil { if err != nil {
return "", fmt.Errorf("failed to retrieve follower info: %s", esErrorString(err)) return "", fmt.Errorf("failed to retrieve follower info: %w", esErrorString(err))
} }
remote := "" remote := ""
for name := range res { for name := range res {
remote = name remote = name
break break
} }
if remote == "" { if remote == "" {
return "", fmt.Errorf("cluster doesn't have a follower") return "", errors.New("cluster doesn't have a follower")
} }
return remote, nil return remote, nil
@@ -61,26 +63,31 @@ func CcrFollowerRenew(conf *cfg.Config, index string) error {
if err := wrapError(IndexClose, conf, index); err != nil { if err := wrapError(IndexClose, conf, index); err != nil {
return err return err
} }
fmt.Printf("closed %s", index) fmt.Printf("closed %s", index)
if err := wrapError(CcrFollowerPause, conf, index); err != nil { if err := wrapError(CcrFollowerPause, conf, index); err != nil {
return err return err
} }
fmt.Printf("paused %s", index) fmt.Printf("paused %s", index)
if err := wrapError(CcrFollowerUnfollow, conf, index); err != nil { if err := wrapError(CcrFollowerUnfollow, conf, index); err != nil {
return err return err
} }
fmt.Printf("unfollowed %s", index) fmt.Printf("unfollowed %s", index)
if err := wrapError(IndexDelete, conf, index); err != nil { if err := wrapError(IndexDelete, conf, index); err != nil {
return err return err
} }
fmt.Printf("deleted %s", index) fmt.Printf("deleted %s", index)
if err := CcrFollowerAdd(conf, index); err != nil { if err := CcrFollowerAdd(conf, index); err != nil {
return err return err
} }
fmt.Printf("added follower %s", index) fmt.Printf("added follower %s", index)
return nil return nil
@@ -89,9 +96,8 @@ func CcrFollowerRenew(conf *cfg.Config, index string) error {
func CcrFollowerResume(conf *cfg.Config, index string) error { func CcrFollowerResume(conf *cfg.Config, index string) error {
_, err := conf.DefaultCluster.ES().Ccr.ResumeFollow(index). _, err := conf.DefaultCluster.ES().Ccr.ResumeFollow(index).
Do(context.Background()) Do(context.Background())
if err != nil { if err != nil {
return fmt.Errorf("failed to resume ccr following: %s", esErrorString(err)) return fmt.Errorf("failed to resume ccr following: %w", esErrorString(err))
} }
return nil return nil
@@ -100,9 +106,8 @@ func CcrFollowerResume(conf *cfg.Config, index string) error {
func CcrFollowerPause(conf *cfg.Config, index string) error { func CcrFollowerPause(conf *cfg.Config, index string) error {
_, err := conf.DefaultCluster.ES().Ccr.PauseFollow(index). _, err := conf.DefaultCluster.ES().Ccr.PauseFollow(index).
Do(context.Background()) Do(context.Background())
if err != nil { if err != nil {
return fmt.Errorf("failed to pause ccr following: %s", esErrorString(err)) return fmt.Errorf("failed to pause ccr following: %w", esErrorString(err))
} }
return nil return nil
@@ -111,9 +116,8 @@ func CcrFollowerPause(conf *cfg.Config, index string) error {
func CcrFollowerUnfollow(conf *cfg.Config, index string) error { func CcrFollowerUnfollow(conf *cfg.Config, index string) error {
_, err := conf.DefaultCluster.ES().Ccr.ForgetFollower(index). _, err := conf.DefaultCluster.ES().Ccr.ForgetFollower(index).
Do(context.Background()) Do(context.Background())
if err != nil { if err != nil {
return fmt.Errorf("failed to unfollow index: %s", esErrorString(err)) return fmt.Errorf("failed to unfollow index: %w", esErrorString(err))
} }
return nil return nil
@@ -134,9 +138,8 @@ func CcrFollowerAdd(conf *cfg.Config, index string) error {
} }
_, err = create.Do(context.Background()) _, err = create.Do(context.Background())
if err != nil { if err != nil {
return fmt.Errorf("failed to create follower index: %s", esErrorString(err)) return fmt.Errorf("failed to create follower index: %w", esErrorString(err))
} }
return nil return nil
@@ -146,7 +149,7 @@ func CcrFollowerShow(conf *cfg.Config, index string) error {
res, err := conf.DefaultCluster.ES().Ccr.FollowStats(index). res, err := conf.DefaultCluster.ES().Ccr.FollowStats(index).
Do(context.Background()) Do(context.Background())
if err != nil { if err != nil {
return fmt.Errorf("failed to retrieve follower index info: %s", esErrorString(err)) return fmt.Errorf("failed to retrieve follower index info: %w", esErrorString(err))
} }
slog.Debug("ES result", "follower stats", res.Indices) slog.Debug("ES result", "follower stats", res.Indices)
@@ -164,16 +167,16 @@ func CcrFollowerShow(conf *cfg.Config, index string) error {
table := printer.NewTable(conf, 2, 9) table := printer.NewTable(conf, 2, 9)
table.Addheaders("ccr follower property", "value") table.Addheaders("ccr follower property", "value")
table.Entries = [][]string{ table.Entries = [][]any{
{"name", index}, {"name", index},
{"remote_cluster", follower.RemoteCluster}, {"remote_cluster", follower.RemoteCluster},
{"leader_checkpoint", fmt.Sprintf("%d", follower.LeaderGlobalCheckpoint)}, {"leader_checkpoint", follower.LeaderGlobalCheckpoint},
{"follower_checkpoint", fmt.Sprintf("%d", follower.FollowerGlobalCheckpoint)}, {"follower_checkpoint", follower.FollowerGlobalCheckpoint},
{"bytes_read", fmt.Sprintf("%d", follower.BytesRead)}, {"bytes_read", follower.BytesRead},
{"failed_read_requests", fmt.Sprintf("%d", follower.FailedReadRequests)}, {"failed_read_requests", follower.FailedReadRequests},
{"failed_write_requests", fmt.Sprintf("%d", follower.FailedWriteRequests)}, {"failed_write_requests", follower.FailedWriteRequests},
{"successful_read_requests", fmt.Sprintf("%d", follower.SuccessfulReadRequests)}, {"successful_read_requests", follower.SuccessfulReadRequests},
{"successful_write_requests", fmt.Sprintf("%d", follower.SuccessfulWriteRequests)}, {"successful_write_requests", follower.SuccessfulWriteRequests},
} }
if err := table.Print(); err != nil { if err := table.Print(); err != nil {

View File

@@ -0,0 +1,59 @@
/*
Copyright © 2026 Thomas von Dein
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package es
import (
"encoding/json"
"fmt"
"codeberg.org/scip/esctl/pkg/cfg"
)
type HealthReport struct {
Indicators map[string]HealthReportIndicator
}
type HealthReportIndicator struct {
Status, Symptom string
Diagnosis []HealthReportDiagnosis
}
type HealthReportDiagnosis struct {
Id, Cause, Action string
AffectedResources map[string][]string `json:"affected_resources"`
}
// we do not use the go-elasticsearch client API here but call the ES
// API directly, because the returned structure (a
// healthreport.Response) is not iterable, you'd have to explicitly
// call every indicator type and every cause etc which also have
// different types each. To check which is !green would result in a
// gigantic function.
func getHealthReport(conf *cfg.Config) (*HealthReport, error) {
raw, err := CallAPI(conf, "GET", "/_health_report", "")
if err != nil {
return nil, err
}
report := HealthReport{}
if err := json.Unmarshal(raw, &report); err != nil {
return nil, fmt.Errorf("failed to unmarshal healthreport response: %w", err)
}
return &report, nil
}

View File

@@ -17,7 +17,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
package es package es
import ( import (
"context" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
"strings" "strings"
@@ -26,142 +26,157 @@ import (
"codeberg.org/scip/esctl/pkg/cfg" "codeberg.org/scip/esctl/pkg/cfg"
"codeberg.org/scip/esctl/pkg/printer" "codeberg.org/scip/esctl/pkg/printer"
"github.com/dustin/go-humanize" "github.com/dustin/go-humanize"
"github.com/elastic/go-elasticsearch/v9/typedapi/cat/indices"
"github.com/elastic/go-elasticsearch/v9/typedapi/cat/tasks"
"github.com/elastic/go-elasticsearch/v9/typedapi/ccr/stats"
"github.com/elastic/go-elasticsearch/v9/typedapi/cluster/health"
clusterstats "github.com/elastic/go-elasticsearch/v9/typedapi/cluster/stats" 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" "github.com/elastic/go-elasticsearch/v9/typedapi/types"
) )
type ClusterIndices map[string]map[string]*types.IndicesRecord type ClusterIndices map[string]map[string]*types.IndicesRecord
func ClusterList(conf *cfg.Config) error { type clusterReachable struct {
table := printer.NewTable(conf, 4, len(conf.Clusters)) reachable bool
err error
}
table.Addheaders("cluster", "uri", "reachable", "current") func ClusterList(conf *cfg.Config) error {
var (
mu sync.Mutex
wg sync.WaitGroup
)
reachable := make(map[string]clusterReachable, len(conf.Clusters))
// check endpoints in parallel to speed things up
for name, cluster := range conf.Clusters {
wg.Go(func() {
online, err := cluster.IsReachable()
mu.Lock()
reachable[name] = clusterReachable{reachable: online, err: err}
mu.Unlock()
})
}
wg.Wait()
table := printer.NewTable(conf, 5, len(conf.Clusters))
table.Addheaders("cluster", "uri", "reachable", "current", "error")
idx := 0 idx := 0
for name, cluster := range conf.Clusters { for name, cluster := range conf.Clusters {
reachable := "no" reachableStr := "no"
current := "no" current := "no"
errmsg := ""
_, err := cluster.ES().Cluster.Health(). if reachable[name].reachable {
Do(context.Background()) reachableStr = printer.Colorize(conf, "green", "reachable")
if err == nil {
reachable = printer.Colorize(conf, "green", "reachable")
} }
if cluster.Default { if cluster.Default {
current = printer.Colorize(conf, "green", "yes") current = printer.Colorize(conf, "green", "yes")
if err != nil { if !reachable[name].reachable {
reachable = printer.Colorize(conf, "red", "no") reachableStr = printer.Colorize(conf, "red", "no")
errmsg = reachable[name].err.Error()
} }
} }
table.Entries[idx] = []string{name, cluster.Uri, reachable, current} table.Entries[idx] = []any{name, cluster.Uri, reachableStr, current, errmsg}
idx++ idx++
} }
table.Sort() table.Sort()
if err := table.Print(); err != nil { return table.Print()
return err
}
return nil
} }
// We're using goroutines here to parallelize API requests, since we // We're using goroutines here to parallelize API requests, since we
// have to do 3 of'em for each cluster. This speeds things up. // have to do 3 of'em for each cluster. This speeds things up.
func ClusterStatus(conf *cfg.Config) error { func getClusterStatus(conf *cfg.Config) (*apiResponse, error) {
clusters := []string{} gocount := 6
gocount := 5
if conf.Verbose { if conf.Verbose {
gocount++ 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() es := conf.DefaultCluster.ES()
if cluster != "default" {
es = conf.Clusters[cluster].ES()
}
responses := make(chan apiResponse, gocount) responses := make(chan apiResponse, gocount)
wg := &sync.WaitGroup{} wg := &sync.WaitGroup{}
wg.Add(gocount) wg.Add(gocount)
go getApiData(es, wg, responses, "health") go getApiData(conf, es, wg, responses, "health")
go getApiData(es, wg, responses, "info") go getApiData(conf, es, wg, responses, "healthreport")
go getApiData(es, wg, responses, "ccrstats") go getApiData(conf, es, wg, responses, "info")
go getApiData(es, wg, responses, "indices") go getApiData(conf, es, wg, responses, "ccr")
go getApiData(es, wg, responses, "tasks") go getApiData(conf, es, wg, responses, "indices")
go getApiData(conf, es, wg, responses, "tasks")
if conf.Verbose { if conf.Verbose {
go getApiData(es, wg, responses, "stats") go getApiData(conf, es, wg, responses, "stats")
} }
wg.Wait() wg.Wait()
var clusterhealth *health.Response all := apiResponse{}
var info *info.Response
var ccrstats *stats.Response
var clusterstats *clusterstats.Response
var indexstats *indices.Response
var taskstatus *tasks.Response
for i := 0; i < gocount; i++ { var err error
r := <-responses
if r.error != nil { for range gocount {
return r.error res := <-responses
}
switch r.which { err = errors.Join(err, res.error)
switch res.which {
case ResponseHealth: case ResponseHealth:
clusterhealth = r.health all.health = res.health
case ResponseCcr: case ResponseCcr:
ccrstats = r.ccr all.ccr = res.ccr
case ResponseInfo: case ResponseInfo:
info = r.info all.info = res.info
case ResponseStats: case ResponseStats:
clusterstats = r.stats all.stats = res.stats
case ResponseIndices: case ResponseIndices:
indexstats = r.indices all.indices = res.indices
case ResponseTasks: case ResponseTasks:
taskstatus = r.tasks all.tasks = res.tasks
case ResponseHealthReport:
all.healthreport = res.healthreport
} }
} }
slog.Debug("ES result", "cluster health", clusterhealth) return &all, err
}
isleader := len(ccrstats.AutoFollowStats.AutoFollowedClusters) == 0 func ClusterStatus(conf *cfg.Config) error {
res, err := getClusterStatus(conf)
if err != nil && !strings.Contains(err.Error(), "current license is non-compliant") {
return err
}
ccrfollowing := "" slog.Debug("ES result", "cluster health", res.health)
if len(ccrstats.AutoFollowStats.AutoFollowedClusters) > 0 {
var (
isleader bool
ccrfollowing string
)
if res.ccr != nil {
isleader = len(res.ccr.AutoFollowStats.AutoFollowedClusters) == 0
if len(res.ccr.AutoFollowStats.AutoFollowedClusters) > 0 {
// is following another cluster // is following another cluster
ccrfollowing = fmt.Sprintf("%s (%d/%d)", ccrfollowing = fmt.Sprintf("%s (%d/%d)",
ccrstats.AutoFollowStats.AutoFollowedClusters[0].ClusterName, res.ccr.AutoFollowStats.AutoFollowedClusters[0].ClusterName,
ccrstats.AutoFollowStats.NumberOfSuccessfulFollowIndices, res.ccr.AutoFollowStats.NumberOfSuccessfulFollowIndices,
ccrstats.AutoFollowStats.NumberOfFailedFollowIndices, res.ccr.AutoFollowStats.NumberOfFailedFollowIndices,
) )
} }
}
// look for red indices, if any // look for red indices, if any
redindices := 0 redindices := 0
for _, index := range *indexstats {
for _, index := range *res.indices {
if *index.Health == "red" { if *index.Health == "red" {
redindices++ redindices++
} }
@@ -169,52 +184,72 @@ func ClusterStatus(conf *cfg.Config) error {
// look for long running tasks // look for long running tasks
longtasks := 0 longtasks := 0
for _, task := range *taskstatus {
for _, task := range *res.tasks {
if strings.Contains(*task.RunningTime, "d") { if strings.Contains(*task.RunningTime, "d") {
longtasks++ longtasks++
} }
} }
table := printer.NewTable(conf, 2, 7) table := printer.NewTable(conf, 2, 7)
table.Addheaders(cluster, "status") table.Addheaders(conf.DefaultCluster.Name, "status")
table.Entries = [][]string{ table.Entries = [][]any{
{"Cluster Name", clusterhealth.ClusterName}, {"Cluster Name", res.health.ClusterName},
{"ES Status", printer.Colorize(conf, clusterhealth.Status.Name, clusterhealth.Status.Name)}, {"ES Status", printer.Colorize(conf, res.health.Status.Name, res.health.Status.Name)},
{"ES Version", info.Version.Int}, {"ES Version", res.info.Version.Int},
{"Is Leader", fmt.Sprintf("%t", isleader)}, {"Is Leader", isleader},
{"Active Shards", fmt.Sprintf("%d", clusterhealth.ActiveShards)}, {"Active Shards", res.health.ActiveShards},
{"Active Primary Shards", fmt.Sprintf("%d", clusterhealth.ActivePrimaryShards)}, {"Active Primary Shards", res.health.ActivePrimaryShards},
{"Unassigned Shards", fmt.Sprintf("%d", clusterhealth.UnassignedShards)}, {"Unassigned Shards", res.health.UnassignedShards},
{"Unassigned Primary Shards", fmt.Sprintf("%d", clusterhealth.UnassignedPrimaryShards)}, {"Unassigned Primary Shards", res.health.UnassignedPrimaryShards},
{"Pending Tasks", fmt.Sprintf("%d", clusterhealth.NumberOfPendingTasks)}, {"Pending Tasks", res.health.NumberOfPendingTasks},
{"Nodes", fmt.Sprintf("%d", clusterhealth.NumberOfNodes)}, {"Nodes", res.health.NumberOfNodes},
{"Red Indices", fmt.Sprintf("%d", redindices)}, {"Red Indices", redindices},
{"Long Running Tasks", fmt.Sprintf("%d", longtasks)}, {"Long Running Tasks", longtasks},
} }
if !isleader { if !isleader && res.ccr != nil {
table.Entries = append(table.Entries, [][]string{ table.Entries = append(table.Entries, [][]any{
{"AutoFollow (success/failed indices)", ccrfollowing}, {"AutoFollow (success/failed indices)", ccrfollowing},
{"Followed Indices", fmt.Sprintf("%d", len(ccrstats.FollowStats.Indices))}, {"Followed Indices", len(res.ccr.FollowStats.Indices)},
}...) }...)
} }
if conf.Verbose { if conf.Verbose {
table = gatherClusterStats(conf, clusterstats, table) table = gatherClusterStats(res.stats, table)
}
if res.health.Status.Name != "green" {
for name, indicator := range res.healthreport.Indicators {
if indicator.Status != "green" {
table.Entries = append(table.Entries, []any{
printer.Colorize(conf, indicator.Status, "Bad health "+name), indicator.Symptom,
})
for _, diag := range indicator.Diagnosis {
table.Entries = append(table.Entries, []any{" -> cause", diag.Cause})
for resource, items := range diag.AffectedResources {
table.Entries = append(table.Entries, []any{" -> affected " + resource, strings.Join(items, ",")})
}
}
}
}
} }
if err := table.Print(); err != nil { if err := table.Print(); err != nil {
return err return err
} }
}
return nil return nil
} }
func gatherClusterStats(conf *cfg.Config, clusterstats *clusterstats.Response, table *printer.Table) *printer.Table { func gatherClusterStats(clusterstats *clusterstats.Response, table *printer.Table) *printer.Table {
var querycount int64 var (
var vmversion string querycount int64
vmversion string
)
for _, count := range clusterstats.Indices.Search.Queries { for _, count := range clusterstats.Indices.Search.Queries {
querycount += count querycount += count
@@ -226,13 +261,13 @@ func gatherClusterStats(conf *cfg.Config, clusterstats *clusterstats.Response, t
clusterstats.Nodes.Jvm.Versions[0].VmVersion}, " ") clusterstats.Nodes.Jvm.Versions[0].VmVersion}, " ")
} }
table.Entries = append(table.Entries, [][]string{ table.Entries = append(table.Entries, [][]any{
{"Indicies", fmt.Sprintf("%d", clusterstats.Indices.Count)}, {"Indicies", clusterstats.Indices.Count},
{"Docs", fmt.Sprintf("%d", clusterstats.Indices.Docs.Count)}, {"Docs", clusterstats.Indices.Docs.Count},
{"Total Size", humanize.Bytes(uint64(clusterstats.Indices.Docs.TotalSizeInBytes))}, {"Total Size", printer.Bytes(clusterstats.Indices.Docs.TotalSizeInBytes)},
{"Total Queries", fmt.Sprintf("%d", querycount)}, {"Total Queries", "%d", querycount},
{"Shards Primaries", fmt.Sprintf("%d", clusterstats.Indices.Shards.Primaries)}, {"Shards Primaries", clusterstats.Indices.Shards.Primaries},
{"Shards Total", fmt.Sprintf("%d", clusterstats.Indices.Shards.Total)}, {"Shards Total", clusterstats.Indices.Shards.Total},
{"Storage", fmt.Sprintf( {"Storage", fmt.Sprintf(
"%s/%s", "%s/%s",
humanize.Bytes(uint64(clusterstats.Indices.Store.SizeInBytes)), humanize.Bytes(uint64(clusterstats.Indices.Store.SizeInBytes)),
@@ -243,11 +278,11 @@ func gatherClusterStats(conf *cfg.Config, clusterstats *clusterstats.Response, t
humanize.Bytes(uint64(clusterstats.Nodes.Jvm.Mem.HeapUsedInBytes)), humanize.Bytes(uint64(clusterstats.Nodes.Jvm.Mem.HeapUsedInBytes)),
humanize.Bytes(uint64(clusterstats.Nodes.Jvm.Mem.HeapMaxInBytes)), humanize.Bytes(uint64(clusterstats.Nodes.Jvm.Mem.HeapMaxInBytes)),
)}, )},
{"JVM Threads", fmt.Sprintf("%d", clusterstats.Nodes.Jvm.Threads)}, {"JVM Threads", clusterstats.Nodes.Jvm.Threads},
{"JVM Version", vmversion}, {"JVM Version", vmversion},
{"CPUs", fmt.Sprintf("%d", clusterstats.Nodes.Os.AllocatedProcessors)}, {"CPUs", clusterstats.Nodes.Os.AllocatedProcessors},
{"CPU Usage", fmt.Sprintf("%d%%", clusterstats.Nodes.Process.Cpu.Percent)}, {"CPU Usage", fmt.Sprintf("%d%%", clusterstats.Nodes.Process.Cpu.Percent)},
{"Open FDs", fmt.Sprintf("%d", clusterstats.Nodes.Process.OpenFileDescriptors.Avg)}, {"Open FDs", clusterstats.Nodes.Process.OpenFileDescriptors.Avg},
}...) }...)
return table return table

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

@@ -0,0 +1,126 @@
/*
Copyright © 2026 Thomas von Dein
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package es
import (
"context"
"fmt"
"codeberg.org/scip/esctl/pkg/cfg"
"github.com/elastic/go-elasticsearch/v9/typedapi/esdsl"
"github.com/elastic/go-elasticsearch/v9/typedapi/types"
)
func ClusterRerouteMove(conf *cfg.Config, index string) error {
move := conf.DefaultCluster.ES().Cluster.Reroute()
commands := esdsl.NewCommand()
moveCommand := &types.CommandMoveAction{
Shard: conf.Shards,
FromNode: conf.FromNode,
ToNode: conf.ToNode,
Index: index,
}
commands.CommandCaster().Move = moveCommand
move.Commands(commands)
_, err := move.Do(context.Background())
if err != nil {
return fmt.Errorf("failed to reroute move: %w", err)
}
return nil
}
func ClusterRerouteAllocateReplica(conf *cfg.Config, index string) error {
move := conf.DefaultCluster.ES().Cluster.Reroute()
commands := esdsl.NewCommand()
allocCommand := &types.CommandAllocateReplicaAction{
Shard: conf.Shards,
Node: conf.ToNode,
Index: index,
}
commands.CommandCaster().AllocateReplica = allocCommand
move.Commands(commands)
_, err := move.Do(context.Background())
if err != nil {
return fmt.Errorf("failed to allocate a replica shard: %w", err)
}
return nil
}
func ClusterRerouteCancel(conf *cfg.Config, index string) error {
move := conf.DefaultCluster.ES().Cluster.Reroute()
commands := esdsl.NewCommand()
cancelCommand := &types.CommandCancelAction{
Shard: conf.Shards,
Node: conf.ToNode,
Index: index,
AllowPrimary: &conf.AllowPrimary,
}
commands.CommandCaster().Cancel = cancelCommand
move.Commands(commands)
_, err := move.Do(context.Background())
if err != nil {
return fmt.Errorf("failed to cancel a reroute process: %w", err)
}
return nil
}
func ClusterRerouteAllocatePrimary(conf *cfg.Config, index string, stale bool) error {
move := conf.DefaultCluster.ES().Cluster.Reroute()
commands := esdsl.NewCommand()
allocCommand := &types.CommandAllocatePrimaryAction{
Shard: conf.Shards,
Node: conf.ToNode,
Index: index,
AcceptDataLoss: conf.AcceptDataLoss,
}
if stale {
commands.CommandCaster().AllocateStalePrimary = allocCommand
} else {
commands.CommandCaster().AllocateEmptyPrimary = allocCommand
}
move.Commands(commands)
_, err := move.Do(context.Background())
if err != nil {
which := "empty"
if stale {
which = "stale"
}
return fmt.Errorf("failed to allocate %s primary shard: %w", which, err)
}
return nil
}

View File

@@ -20,23 +20,28 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"log/slog"
"codeberg.org/scip/esctl/pkg/cfg" "codeberg.org/scip/esctl/pkg/cfg"
"codeberg.org/scip/esctl/pkg/printer" "codeberg.org/scip/esctl/pkg/printer"
"github.com/urfave/cli/v3" "github.com/urfave/cli/v3"
) )
func ClusterSettingsNames(conf *cfg.Config) ([]string, error) {
return validClusterSettings, nil
}
func ClusterSettingsList(conf *cfg.Config) error { func ClusterSettingsList(conf *cfg.Config) error {
res, err := conf.DefaultCluster.ES().Cluster.GetSettings(). res, err := conf.DefaultCluster.ES().Cluster.GetSettings().
FlatSettings(true).
Do(context.Background()) Do(context.Background())
if err != nil { if err != nil {
return fmt.Errorf("failed to get cluster settings: %s", esErrorString(err)) return fmt.Errorf("failed to get cluster settings: %w", esErrorString(err))
} }
table := printer.NewTable(conf, 2, 0) table := printer.NewTable(conf, 2, 0)
table.Addheaders("setting", "value") table.Addheaders("setting", "value")
entries := [][]string{}
entries := [][]any{}
settingshash := res.Persistent // == map[string]json.RawMessage settingshash := res.Persistent // == map[string]json.RawMessage
@@ -48,19 +53,7 @@ func ClusterSettingsList(conf *cfg.Config) error {
} }
for topic, val := range settingshash { for topic, val := range settingshash {
data := map[string]any{} entries = append(entries, []any{topic, string(val)})
err := json.Unmarshal(val, &data)
if err != nil {
return fmt.Errorf("failed to unmarshall setting for topic %s: %s", topic, err)
}
paths := getJsonPath(map[string]string{}, data, topic)
slog.Debug("settings", topic, paths)
for setting, value := range paths {
entries = append(entries, []string{setting, fmt.Sprintf("%v", value)})
}
} }
table.Entries = entries table.Entries = entries
@@ -83,23 +76,25 @@ func ClusterSettingsSet(conf *cfg.Config, args cli.Args) error {
case conf.Transient: case conf.Transient:
message, err := json.Marshal(value) message, err := json.Marshal(value)
if err != nil { if err != nil {
return fmt.Errorf("failed to marshall transient value <%v> to valid JSON: %s", value, err) return fmt.Errorf("failed to marshall transient value <%v> to valid JSON: %w", value, err)
} }
put.AddTransient(setting, message) put.AddTransient(setting, message)
case conf.Persistent: case conf.Persistent:
fallthrough fallthrough
default: default:
message, err := json.Marshal(value) message, err := json.Marshal(value)
if err != nil { if err != nil {
return fmt.Errorf("failed to marshall persistent value <%v> to valid JSON: %s", value, err) return fmt.Errorf("failed to marshall persistent value <%v> to valid JSON: %w", value, err)
} }
put.AddPersistent(setting, message) put.AddPersistent(setting, message)
} }
} }
_, err := put.Do(context.Background()) _, err := put.Do(context.Background())
if err != nil { if err != nil {
return fmt.Errorf("failed to set settings: %s", esErrorString(err)) return fmt.Errorf("failed to set settings: %w", esErrorString(err))
} }
return nil return nil
@@ -108,15 +103,14 @@ func ClusterSettingsSet(conf *cfg.Config, args cli.Args) error {
func ClusterSettingsSetSingle(conf *cfg.Config, setting, value string) error { func ClusterSettingsSetSingle(conf *cfg.Config, setting, value string) error {
message, err := json.Marshal(value) message, err := json.Marshal(value)
if err != nil { if err != nil {
return fmt.Errorf("failed to marshall persistent value <%v> to valid JSON: %s", value, err) return fmt.Errorf("failed to marshall persistent value <%v> to valid JSON: %w", value, err)
} }
_, err = conf.DefaultCluster.ES().Cluster.PutSettings(). _, err = conf.DefaultCluster.ES().Cluster.PutSettings().
AddPersistent(setting, message). AddPersistent(setting, message).
Do(context.Background()) Do(context.Background())
if err != nil { if err != nil {
return fmt.Errorf("failed to set %s: %s", setting, esErrorString(err)) return fmt.Errorf("failed to set %s: %w", setting, esErrorString(err))
} }
return nil return nil

View File

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

View File

@@ -20,7 +20,6 @@ import (
"context" "context"
"fmt" "fmt"
"regexp" "regexp"
"strconv"
"strings" "strings"
"codeberg.org/scip/esctl/pkg/cfg" "codeberg.org/scip/esctl/pkg/cfg"
@@ -38,6 +37,7 @@ func checkClusterIsLeader(conf *cfg.Config, leader string) bool {
Do(context.Background()) Do(context.Background())
if err != nil { if err != nil {
fmt.Printf("failed to get ccr stats from %s: %s", leader, esErrorString(err)) fmt.Printf("failed to get ccr stats from %s: %s", leader, esErrorString(err))
return false return false
} }
@@ -48,6 +48,7 @@ func checkClusterIsLeader(conf *cfg.Config, leader string) bool {
if stats.AutoFollowStats.AutoFollowedClusters[0].ClusterName != "" { if stats.AutoFollowStats.AutoFollowedClusters[0].ClusterName != "" {
fmt.Println("leader/follower attribution is invalid, reverse cluster attribution and retry") fmt.Println("leader/follower attribution is invalid, reverse cluster attribution and retry")
return false return false
} }
@@ -59,46 +60,48 @@ func checkClusterStatus(conf *cfg.Config, leader, follower string) bool {
status := map[string]*health.Response{} status := map[string]*health.Response{}
for _, cluster := range []string{leader, follower} { for _, cluster := range []string{leader, follower} {
st, err := conf.Clusters[leader].ES().Cluster.Health(). clusterHealth, err := conf.Clusters[leader].ES().Cluster.Health().
Do(context.Background()) Do(context.Background())
if err != nil { if err != nil {
fmt.Printf("failed to get health from %s: %s", cluster, esErrorString(err)) fmt.Printf("failed to get health from %s: %s", cluster, esErrorString(err))
return false return false
} }
status[cluster] = st status[cluster] = clusterHealth
} }
table := printer.NewTable(conf, 3, 6) table := printer.NewTable(conf, 3, 6)
table.Addheaders("setting", "leader:"+leader, "follower:"+follower) table.Addheaders("setting", "leader:"+leader, "follower:"+follower)
table.Entries = [][]string{ table.Entries = [][]any{
{"Cluster Name", status[leader].ClusterName, status[follower].ClusterName}, {"Cluster Name", status[leader].ClusterName, status[follower].ClusterName},
{"Cluster Status", {"Cluster Status",
printer.Colorize(conf, status[leader].Status.Name, status[leader].Status.Name), printer.Colorize(conf, status[leader].Status.Name, status[leader].Status.Name),
printer.Colorize(conf, status[follower].Status.Name, status[follower].Status.Name), printer.Colorize(conf, status[follower].Status.Name, status[follower].Status.Name),
}, },
{"Active Shards", {"Active Shards",
fmt.Sprintf("%d", status[leader].ActiveShards), status[leader].ActiveShards,
fmt.Sprintf("%d", status[follower].ActiveShards), status[follower].ActiveShards,
}, },
{"Active Primary Shards", {"Active Primary Shards",
fmt.Sprintf("%d", status[leader].ActivePrimaryShards), status[leader].ActivePrimaryShards,
fmt.Sprintf("%d", status[follower].ActivePrimaryShards), status[follower].ActivePrimaryShards,
}, },
{"Indicies", {"Indicies",
fmt.Sprintf("%d", len(status[leader].Indices)), len(status[leader].Indices),
fmt.Sprintf("%d", len(status[follower].Indices)), len(status[follower].Indices),
}, },
{"Nodes", {"Nodes",
fmt.Sprintf("%d", status[leader].NumberOfNodes), status[leader].NumberOfNodes,
fmt.Sprintf("%d", status[follower].NumberOfNodes), status[follower].NumberOfNodes,
}, },
} }
if err := table.Print(); err != nil { if err := table.Print(); err != nil {
fmt.Println(err) fmt.Println(err)
return false return false
} }
@@ -113,7 +116,7 @@ func checkClusterStatus(conf *cfg.Config, leader, follower string) bool {
// finds indices on both clusters which have ilm errors // finds indices on both clusters which have ilm errors
func findIlmErrors(conf *cfg.Config, leader, follower string) bool { func findIlmErrors(conf *cfg.Config, leader, follower string) bool {
failed := map[string]map[string]string{} failed := map[string]map[string]any{}
for _, cluster := range []string{leader, follower} { for _, cluster := range []string{leader, follower} {
ilm, err := conf.Clusters[cluster].ES().Ilm.ExplainLifecycle("_all"). ilm, err := conf.Clusters[cluster].ES().Ilm.ExplainLifecycle("_all").
@@ -121,16 +124,17 @@ func findIlmErrors(conf *cfg.Config, leader, follower string) bool {
Do(context.Background()) Do(context.Background())
if err != nil { if err != nil {
fmt.Printf("failed to get ilm status from %s: %s", cluster, esErrorString(err)) fmt.Printf("failed to get ilm status from %s: %s", cluster, esErrorString(err))
return false return false
} }
failed[cluster] = map[string]string{} failed[cluster] = map[string]any{}
for name, ilmstate := range ilm.Indices { for name, ilmstate := range ilm.Indices {
count := ilmstate.(*types.LifecycleExplainManaged).FailedStepRetryCount count := ilmstate.(*types.LifecycleExplainManaged).FailedStepRetryCount
if count != nil && *count > 0 { if count != nil && *count > 0 {
failed[cluster][name] = fmt.Sprintf("%d", *count) failed[cluster][name] = *count
} }
} }
} }
@@ -151,7 +155,7 @@ func findIlmErrors(conf *cfg.Config, leader, follower string) bool {
table.Addheaders("ilm errors on "+which, "errors") table.Addheaders("ilm errors on "+which, "errors")
for name, count := range failed[cluster] { for name, count := range failed[cluster] {
table.Entries[idx] = []string{name, count} table.Entries[idx] = []any{name, count}
idx++ idx++
} }
@@ -159,6 +163,7 @@ func findIlmErrors(conf *cfg.Config, leader, follower string) bool {
if err := table.Print(); err != nil { if err := table.Print(); err != nil {
fmt.Println(err) fmt.Println(err)
return false return false
} }
} }
@@ -175,6 +180,7 @@ func findIndicesOnlyOnLeader(conf *cfg.Config, indices ClusterIndices, leader, f
} }
indexOnlyOnLeader := map[string]*types.IndicesRecord{} indexOnlyOnLeader := map[string]*types.IndicesRecord{}
for name, index := range indices[leader] { for name, index := range indices[leader] {
if exclude.MatchString(name) { if exclude.MatchString(name) {
continue continue
@@ -196,9 +202,11 @@ func findIndicesOnlyOnLeader(conf *cfg.Config, indices ClusterIndices, leader, f
} }
isWritable := false isWritable := false
for _, alias := range res[name].Aliases { for _, alias := range res[name].Aliases {
if *alias.IsWriteIndex { if *alias.IsWriteIndex {
isWritable = true isWritable = true
break break
} }
} }
@@ -223,13 +231,15 @@ func findIndicesOnlyOnLeader(conf *cfg.Config, indices ClusterIndices, leader, f
for name, index := range indexOnlyOnLeader { for name, index := range indexOnlyOnLeader {
name := printer.Colorize(conf, "red", name) name := printer.Colorize(conf, "red", name)
table.Entries[idx] = []string{name, *index.DatasetSize, *index.DocsCount} table.Entries[idx] = []any{name, *index.DatasetSize, *index.DocsCount}
idx++ idx++
} }
table.Sort() table.Sort()
if err := table.Print(); err != nil { if err := table.Print(); err != nil {
fmt.Println(err) fmt.Println(err)
return false return false
} }
@@ -271,13 +281,15 @@ func findOrphanedIndices(conf *cfg.Config, indices ClusterIndices, leader, follo
for name, index := range orphaned { for name, index := range orphaned {
name := printer.Colorize(conf, "red", name) name := printer.Colorize(conf, "red", name)
table.Entries[idx] = []string{name, *index.DatasetSize, *index.DocsCount} table.Entries[idx] = []any{name, *index.DatasetSize, *index.DocsCount}
idx++ idx++
} }
table.Sort() table.Sort()
if err := table.Print(); err != nil { if err := table.Print(); err != nil {
fmt.Println(err) fmt.Println(err)
return false return false
} }
@@ -305,13 +317,15 @@ func findFailedFollowerIndices(conf *cfg.Config, indices ClusterIndices, followe
for name, index := range red { for name, index := range red {
name := printer.Colorize(conf, "red", name) name := printer.Colorize(conf, "red", name)
table.Entries[idx] = []string{name, *index.DatasetSize, *index.DocsCount} table.Entries[idx] = []any{name, *index.DatasetSize, *index.DocsCount}
idx++ idx++
} }
table.Sort() table.Sort()
if err := table.Print(); err != nil { if err := table.Print(); err != nil {
fmt.Println(err) fmt.Println(err)
return false return false
} }
@@ -330,44 +344,3 @@ func splitArg(arg string) (string, string) {
return parts[0], parts[1] return parts[0], parts[1]
} }
} }
// recursively traverse the raw settings hash and build a flat map
// consisting of the translated path and its value.
//
// e.g.
// logger:
//
// org:
// elasticsearch:
// transport:
// OutboundHandler: "ERROR"
//
// gets:
//
// logger.org.elasticsearch.transport.OutboundHandler: "ERROR"
func getJsonPath(paths map[string]string, raw map[string]any, topic string) map[string]string {
for name, data := range raw {
path := topic + "." + name
switch value := data.(type) {
case string:
paths[path] = value
case *string:
paths[path] = *value
case int:
paths[path] = strconv.Itoa(value)
case *int:
paths[path] = strconv.Itoa(*value)
case map[string]any:
paths = getJsonPath(paths, value, path)
case []any:
val := []string{}
for _, item := range value {
val = append(val, fmt.Sprintf("%v", item))
}
paths[path] = strings.Join(val, ",")
}
}
return paths
}

View File

@@ -25,7 +25,6 @@ import (
"codeberg.org/scip/esctl/pkg/cfg" "codeberg.org/scip/esctl/pkg/cfg"
"codeberg.org/scip/esctl/pkg/printer" "codeberg.org/scip/esctl/pkg/printer"
"github.com/dustin/go-humanize"
"github.com/elastic/go-elasticsearch/v9/typedapi/types" "github.com/elastic/go-elasticsearch/v9/typedapi/types"
) )
@@ -33,7 +32,7 @@ func DatastreamNames(conf *cfg.Config) ([]string, error) {
res, err := conf.DefaultCluster.ES().Indices.GetDataStream(). res, err := conf.DefaultCluster.ES().Indices.GetDataStream().
Do(context.Background()) Do(context.Background())
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get data streams: %s", esErrorString(err)) return nil, fmt.Errorf("failed to get data streams: %w", esErrorString(err))
} }
dss := make([]string, len(res.DataStreams)) dss := make([]string, len(res.DataStreams))
@@ -48,7 +47,7 @@ func DatastreamList(conf *cfg.Config) error {
res, err := conf.DefaultCluster.ES().Indices.GetDataStream(). res, err := conf.DefaultCluster.ES().Indices.GetDataStream().
Do(context.Background()) Do(context.Background())
if err != nil { if err != nil {
return fmt.Errorf("failed to get data streams: %s", esErrorString(err)) return fmt.Errorf("failed to get data streams: %w", esErrorString(err))
} }
slog.Debug("ES result", "data streams", res) slog.Debug("ES result", "data streams", res)
@@ -66,21 +65,22 @@ func DatastreamList(conf *cfg.Config) error {
table := printer.NewTable(conf, 7, size) table := printer.NewTable(conf, 7, size)
table.Addheaders("name", "ilm policy", "hidden", "system", "replicated", "generation", "timestamp field") table.Addheaders("name", "ilm policy", "hidden", "system", "replicated", "generation", "timestamp field")
for idx, ds := range list { for idx, datastream := range list {
name := printer.Colorize(conf, ds.Status.String(), ds.Name) name := printer.Colorize(conf, datastream.Status.String(), datastream.Name)
policy := "" policy := ""
if ds.IlmPolicy != nil { if datastream.IlmPolicy != nil {
policy = *ds.IlmPolicy policy = *datastream.IlmPolicy
} }
table.Entries[idx] = []string{ table.Entries[idx] = []any{
name, name,
policy, policy,
fmt.Sprintf("%t", ds.Hidden), datastream.Hidden,
fmt.Sprintf("%t", *ds.System), *datastream.System,
fmt.Sprintf("%t", *ds.Replicated), *datastream.Replicated,
fmt.Sprintf("%d", ds.Generation), datastream.Generation,
ds.TimestampField.Name, datastream.TimestampField.Name,
} }
if idx == size-1 { if idx == size-1 {
@@ -89,6 +89,7 @@ func DatastreamList(conf *cfg.Config) error {
} }
table.Sort() table.Sort()
if err := table.Print(); err != nil { if err := table.Print(); err != nil {
return err return err
} }
@@ -129,7 +130,7 @@ func DatastreamShow(conf *cfg.Config, dsname string) error {
Name(dsname). Name(dsname).
Do(context.Background()) Do(context.Background())
if err != nil { if err != nil {
return fmt.Errorf("failed to get data stream: %s", esErrorString(err)) return fmt.Errorf("failed to get data stream: %w", esErrorString(err))
} }
slog.Debug("ES result", "data stream", res) slog.Debug("ES result", "data stream", res)
@@ -142,61 +143,63 @@ func DatastreamShow(conf *cfg.Config, dsname string) error {
Name(dsname). Name(dsname).
Do(context.Background()) Do(context.Background())
if err != nil { if err != nil {
return fmt.Errorf("failed to get data stream stats: %s", esErrorString(err)) return fmt.Errorf("failed to get data stream stats: %w", esErrorString(err))
} }
table := printer.NewTable(conf, 11, 2) table := printer.NewTable(conf, 11, 2)
table.Addheaders("data stream property", "value") table.Addheaders("data stream property", "value")
ds := res.DataStreams[0] datastream := res.DataStreams[0]
name := printer.Colorize(conf, datastream.Status.String(), datastream.Name)
name := printer.Colorize(conf, ds.Status.String(), ds.Name)
policy := "" policy := ""
if ds.IlmPolicy != nil { if datastream.IlmPolicy != nil {
policy = *ds.IlmPolicy policy = *datastream.IlmPolicy
} }
table.Entries = [][]string{ table.Entries = [][]any{
{"name", name}, {"name", name},
{"ilm policy", policy}, {"ilm policy", policy},
{"hidden", fmt.Sprintf("%t", ds.Hidden)}, {"hidden", datastream.Hidden},
{"system", fmt.Sprintf("%t", *ds.System)}, {"system", *datastream.System},
{"replicated", fmt.Sprintf("%t", *ds.Replicated)}, {"replicated", *datastream.Replicated},
{"generation", fmt.Sprintf("%d", ds.Generation)}, {"generation", datastream.Generation},
{"timestamp field", ds.TimestampField.Name}, {"timestamp field", datastream.TimestampField.Name},
{"backing indices", fmt.Sprintf("%d", stats.BackingIndices)}, {"backing indices", stats.BackingIndices},
{"size", humanize.Bytes(uint64(stats.TotalStoreSizeBytes))}, {"size", printer.Bytes(stats.TotalStoreSizeBytes)},
{"shards-failed", fmt.Sprintf("%d", stats.Shards_.Failed)}, {"shards-failed", stats.Shards_.Failed},
{"shards-successful", fmt.Sprintf("%d", stats.Shards_.Successful)}, {"shards-successful", stats.Shards_.Successful},
} }
if err := table.Print(); err != nil { if err := table.Print(); err != nil {
return err return err
} }
table = printer.NewTable(conf, 5, len(ds.Indices)) table = printer.NewTable(conf, 5, len(datastream.Indices))
table.Addheaders("backing index name", "uuid", "prefer ilm", "ilm policy", "managed by") table.Addheaders("backing index name", "uuid", "prefer ilm", "ilm policy", "managed by")
for idx, index := range ds.Indices { for idx, index := range datastream.Indices {
policy := "" policy := ""
if ds.IlmPolicy != nil { if datastream.IlmPolicy != nil {
policy = *ds.IlmPolicy policy = *datastream.IlmPolicy
} }
table.Entries[idx] = []string{ table.Entries[idx] = []any{
index.IndexName, index.IndexName,
index.IndexUuid, index.IndexUuid,
fmt.Sprintf("%t", *index.PreferIlm), *index.PreferIlm,
policy, policy,
index.ManagedBy.Name, index.ManagedBy.Name,
} }
if idx < len(ds.Indices) { if idx < len(datastream.Indices) {
fmt.Println() fmt.Println()
} }
} }
table.Sort() table.Sort()
if err := table.Print(); err != nil { if err := table.Print(); err != nil {
return err return err
} }
@@ -207,9 +210,8 @@ func DatastreamShow(conf *cfg.Config, dsname string) error {
func DatastreamCreate(conf *cfg.Config, dsname string) error { func DatastreamCreate(conf *cfg.Config, dsname string) error {
_, err := conf.DefaultCluster.ES().Indices.CreateDataStream(dsname). _, err := conf.DefaultCluster.ES().Indices.CreateDataStream(dsname).
Do(context.Background()) Do(context.Background())
if err != nil { if err != nil {
return fmt.Errorf("failed to create datastream: %s", esErrorString(err)) return fmt.Errorf("failed to create datastream: %w", esErrorString(err))
} }
return nil return nil
@@ -218,9 +220,8 @@ func DatastreamCreate(conf *cfg.Config, dsname string) error {
func DatastreamDelete(conf *cfg.Config, dsname string) error { func DatastreamDelete(conf *cfg.Config, dsname string) error {
_, err := conf.DefaultCluster.ES().Indices.DeleteDataStream(dsname). _, err := conf.DefaultCluster.ES().Indices.DeleteDataStream(dsname).
Do(context.Background()) Do(context.Background())
if err != nil { if err != nil {
return fmt.Errorf("failed to delete datastream: %s", esErrorString(err)) return fmt.Errorf("failed to delete datastream: %w", esErrorString(err))
} }
return nil return nil
@@ -228,17 +229,16 @@ func DatastreamDelete(conf *cfg.Config, dsname string) error {
func DatastreamRollover(conf *cfg.Config, ds string) error { func DatastreamRollover(conf *cfg.Config, ds string) error {
res, err := RolloverAlias(conf, ds) res, err := RolloverAlias(conf, ds)
if err != nil { if err != nil {
return fmt.Errorf("failed to rollover data stream: %s", esErrorString(err)) return fmt.Errorf("failed to rollover data stream: %w", esErrorString(err))
} }
table := printer.NewTable(conf, 2, 5) table := printer.NewTable(conf, 2, 5)
table.Addheaders("rollover response", "value") table.Addheaders("rollover response", "value")
table.Entries = [][]string{ table.Entries = [][]any{
{"acknowledged", fmt.Sprintf("%t", res.Acknowledged)}, {"acknowledged", res.Acknowledged},
{"rolled over", fmt.Sprintf("%t", res.RolledOver)}, {"rolled over", res.RolledOver},
{"shards acknowledged", fmt.Sprintf("%t", res.ShardsAcknowledged)}, {"shards acknowledged", res.ShardsAcknowledged},
{"old index", res.OldIndex}, {"old index", res.OldIndex},
{"new index", res.NewIndex}, {"new index", res.NewIndex},
} }

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

@@ -0,0 +1,48 @@
/*
Copyright © 2026 Thomas von Dein
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package es
import (
"codeberg.org/scip/esctl/pkg/cfg"
"github.com/alecthomas/repr"
)
func Debug(conf *cfg.Config) error {
report, err := getHealthReport(conf)
if err != nil {
return err
}
repr.Println(report)
/*
res, err := conf.DefaultCluster.ES().Search().
Index(conf.Index).
Size(0).
Aggregations(map[string]types.Aggregations{
"min_ts": *esdsl.NewMinAggregation().Field("@timestamp").AggregationsCaster(),
"max_ts": *esdsl.NewMaxAggregation().Field("@timestamp").AggregationsCaster(),
}).
Do(context.Background())
if err != nil {
return err
}
repr.Println(res)
*/
return nil
}

View File

@@ -22,6 +22,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"math/rand/v2" "math/rand/v2"
"strconv"
"strings" "strings"
"codeberg.org/scip/esctl/pkg/cfg" "codeberg.org/scip/esctl/pkg/cfg"
@@ -42,16 +43,16 @@ func DocAdd(conf *cfg.Config, jsondoc string) error {
err := json.Unmarshal([]byte(jsondoc), &data) err := json.Unmarshal([]byte(jsondoc), &data)
if err != nil { if err != nil {
return fmt.Errorf("supplied document was not valid JSON: %s", err) return fmt.Errorf("supplied document was not valid JSON: %w", err)
} }
now := fmt.Sprintf("%d", rand.Int64()) now := strconv.FormatInt(rand.Int64(), 10)
res, err := conf.DefaultCluster.ES().Create(conf.Index, now). res, err := conf.DefaultCluster.ES().Create(conf.Index, now).
Document(data). Document(data).
Do(context.Background()) Do(context.Background())
if err != nil { if err != nil {
return fmt.Errorf("failed to create new doc in index %s: %s", conf.Index, esErrorString(err)) return fmt.Errorf("failed to create new doc in index %s: %w", conf.Index, esErrorString(err))
} }
fmt.Println(res.Id_) fmt.Println(res.Id_)
@@ -63,7 +64,7 @@ func DocShow(conf *cfg.Config, id string) error {
res, err := conf.DefaultCluster.ES().Get(conf.Index, id). res, err := conf.DefaultCluster.ES().Get(conf.Index, id).
Do(context.Background()) Do(context.Background())
if err != nil { if err != nil {
return fmt.Errorf("failed to retrieve doc in index %s: %s", conf.Index, esErrorString(err)) return fmt.Errorf("failed to retrieve doc in index %s: %w", conf.Index, esErrorString(err))
} }
if !res.Found { if !res.Found {
@@ -94,7 +95,7 @@ func DocDelete(conf *cfg.Config, queries []string) error {
_, err := conf.DefaultCluster.ES().Delete(conf.Index, id). _, err := conf.DefaultCluster.ES().Delete(conf.Index, id).
Do(context.Background()) Do(context.Background())
if err != nil { if err != nil {
return fmt.Errorf("failed to delete doc in index %s: %s", conf.Index, esErrorString(err)) return fmt.Errorf("failed to delete doc in index %s: %w", conf.Index, esErrorString(err))
} }
return nil return nil
@@ -117,7 +118,7 @@ func DocDelete(conf *cfg.Config, queries []string) error {
Request(req). Request(req).
Do(context.Background()) Do(context.Background())
if err != nil { if err != nil {
return fmt.Errorf("failed to delete docs in index %s: %s", conf.Index, esErrorString(err)) return fmt.Errorf("failed to delete docs in index %s: %w", conf.Index, esErrorString(err))
} }
return nil return nil

View File

@@ -45,7 +45,6 @@ Convert a duration into an integer. Valid time units are "s", "m", "h" and
via https://codeberg.org/scip/tablizer/src/branch/main/lib/sort.go#L113 via https://codeberg.org/scip/tablizer/src/branch/main/lib/sort.go#L113
*/ */
func parseDuration(duration string) time.Duration { func parseDuration(duration string) time.Duration {
seconds := 0 seconds := 0
for _, match := range matchDuration.FindAllStringSubmatch(duration, -1) { for _, match := range matchDuration.FindAllStringSubmatch(duration, -1) {
@@ -111,12 +110,14 @@ func formatDuration(val time.Duration) string {
if esd.minutes == 0 { if esd.minutes == 0 {
return fmt.Sprintf("%dh", esd.hours) return fmt.Sprintf("%dh", esd.hours)
} }
return fmt.Sprintf("%dh:%dm", esd.hours, int64(esd.minutes)) return fmt.Sprintf("%dh:%dm", esd.hours, int64(esd.minutes))
default: default:
if esd.minutes == 0 && hoursLeft == 0 { if esd.minutes == 0 && hoursLeft == 0 {
return fmt.Sprintf("%dd", int64(days)) return fmt.Sprintf("%dd", int64(days))
} }
return fmt.Sprintf("%dd:%dh:%dm", return fmt.Sprintf("%dd:%dh:%dm",
int64(days), int64(days),
int64(hoursLeft), int64(hoursLeft),

View File

@@ -17,29 +17,31 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
package es package es
import ( import (
"errors"
"fmt" "fmt"
"strings"
"github.com/elastic/go-elasticsearch/v9/typedapi/types" "github.com/elastic/go-elasticsearch/v9/typedapi/types"
) )
func esErrorString(err error) string { func esErrorString(err error) error {
msg := err.Error() msg := err.Error()
switch e := err.(type) { //nolint:gocritic
switch errVal := err.(type) {
case *types.ElasticsearchError: case *types.ElasticsearchError:
causes := "" var causes strings.Builder
for _, cause := range e.ErrorCause.RootCause { for _, cause := range errVal.ErrorCause.RootCause {
causes += fmt.Sprintf("%s\n", *cause.Reason) fmt.Fprintf(&causes, "%s\n", *cause.Reason)
} }
if e.ErrorCause.Reason != nil { if errVal.ErrorCause.Reason != nil {
msg = *e.ErrorCause.Reason + ": " + causes msg = *errVal.ErrorCause.Reason + ": " + causes.String()
} else { } else {
msg = fmt.Sprintf("http status %d: ", e.Status) msg = fmt.Sprintf("http status %d: ", errVal.Status)
}
} }
} return errors.New(msg)
return msg
} }

View File

@@ -37,7 +37,7 @@ func IlmRetry(conf *cfg.Config, index string) error {
_, err := conf.DefaultCluster.ES().Ilm.Retry(index). _, err := conf.DefaultCluster.ES().Ilm.Retry(index).
Do(context.Background()) Do(context.Background())
if err != nil { if err != nil {
return fmt.Errorf("failed to retry ilm: %s", esErrorString(err)) return fmt.Errorf("failed to retry ilm: %w", esErrorString(err))
} }
return nil return nil
@@ -47,7 +47,7 @@ func IlmStatus(conf *cfg.Config) error {
res, err := conf.DefaultCluster.ES().Ilm.GetStatus(). res, err := conf.DefaultCluster.ES().Ilm.GetStatus().
Do(context.Background()) Do(context.Background())
if err != nil { if err != nil {
return fmt.Errorf("failed to get ilm status: %s", esErrorString(err)) return fmt.Errorf("failed to get ilm status: %w", esErrorString(err))
} }
fmt.Println(res.OperationMode.Name) fmt.Println(res.OperationMode.Name)
@@ -59,7 +59,7 @@ func IlmNames(conf *cfg.Config) ([]string, error) {
res, err := conf.DefaultCluster.ES().Ilm.GetLifecycle(). res, err := conf.DefaultCluster.ES().Ilm.GetLifecycle().
Do(context.Background()) Do(context.Background())
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get ilm policies: %s", esErrorString(err)) return nil, fmt.Errorf("failed to get ilm policies: %w", esErrorString(err))
} }
names := make([]string, len(res)) names := make([]string, len(res))
@@ -67,6 +67,7 @@ func IlmNames(conf *cfg.Config) ([]string, error) {
for name := range res { for name := range res {
names[idx] = name names[idx] = name
idx++
} }
return names, nil return names, nil
@@ -81,7 +82,7 @@ func IlmList(conf *cfg.Config, pattern string) error {
res, err := ilm.Do(context.Background()) res, err := ilm.Do(context.Background())
if err != nil { if err != nil {
return fmt.Errorf("failed to get ilm policies: %s", esErrorString(err)) return fmt.Errorf("failed to get ilm policies: %w", esErrorString(err))
} }
if conf.Debug { if conf.Debug {
@@ -101,6 +102,7 @@ func IlmList(conf *cfg.Config, pattern string) error {
} }
table.Sort() table.Sort()
return table.Print() return table.Print()
} }
@@ -109,7 +111,7 @@ func IlmShow(conf *cfg.Config, policy string) error {
Policy(policy). Policy(policy).
Do(context.Background()) Do(context.Background())
if err != nil { if err != nil {
return fmt.Errorf("failed to get ilm status: %s", esErrorString(err)) return fmt.Errorf("failed to get ilm status: %w", esErrorString(err))
} }
if conf.Debug { if conf.Debug {
@@ -121,10 +123,14 @@ func IlmShow(conf *cfg.Config, policy string) error {
return errors.New("no ilm policy retrieved") return errors.New("no ilm policy retrieved")
} }
if conf.Ilm.Tree {
return IlmShowTree(conf, ilm.Policy)
}
table := printer.NewTable(conf, 2, 5) table := printer.NewTable(conf, 2, 5)
table.Addheaders("ilm policy setting", "value") table.Addheaders("ilm policy setting", "value")
table.Entries = [][]string{ table.Entries = [][]any{
{"policy", policy}, {"policy", policy},
{"hot phase", ilmPhaseString(ilm.Policy.Phases.Hot, false)}, {"hot phase", ilmPhaseString(ilm.Policy.Phases.Hot, false)},
{"warm phase", ilmPhaseString(ilm.Policy.Phases.Warm, false)}, {"warm phase", ilmPhaseString(ilm.Policy.Phases.Warm, false)},
@@ -135,6 +141,102 @@ func IlmShow(conf *cfg.Config, policy string) error {
return table.Print() return table.Print()
} }
// Visualize phases or a ILM policy, taking into account that the
// minAge for the current phases is set in the next phase
func IlmShowTree(conf *cfg.Config, ilm types.IlmPolicy) error {
indent := ""
table := printer.NewTable(conf, 4, 0)
table.Addheaders("phase", "min age", "min size", "snapshot repo")
for _, phase := range IlmPhaseOrder {
if phase == "hot" {
fmt.Printf("%s%s phase:\n%s rollover after %s\n",
indent, phase,
indent, formatDuration(parseDuration(ilm.Phases.Hot.Actions.Rollover.MaxAge.(string))),
)
if ilm.Phases.Hot.Actions.Rollover.MaxPrimaryShardSize != "" {
fmt.Printf("%s rollover when storage > %s\n",
indent, ilm.Phases.Hot.Actions.Rollover.MaxPrimaryShardSize)
}
if ilm.Phases.Hot.Actions.Rollover.MaxDocs != nil {
fmt.Printf("%s rollover docs > %d\n",
indent, *ilm.Phases.Hot.Actions.Rollover.MaxDocs)
}
indent += " "
continue
}
current := getIlmCurrentPhase(ilm, phase)
if current == nil {
continue
}
next := findNextPhase(ilm, phase)
fmt.Printf("%s%s phase:\n", indent, phase)
if next != nil {
fmt.Printf("%s rollover after %s\n",
indent, formatDuration(next.minage),
)
if current.Actions.SearchableSnapshot != nil {
fmt.Printf("%s roll to snapshot repo: %s\n",
indent, current.Actions.SearchableSnapshot.SnapshotRepository)
}
if current.Actions.Forcemerge != nil {
fmt.Printf("%s force merge segments: %d\n",
indent, current.Actions.Forcemerge.MaxNumSegments)
}
if current.Actions.Shrink != nil {
fmt.Printf("%s shrink shards: %d\n",
indent, *current.Actions.Shrink.NumberOfShards)
}
if current.Actions.SetPriority != nil {
fmt.Printf("%s priority: %d\n",
indent, *current.Actions.SetPriority.Priority)
}
if current.Actions.Delete != nil && current.Actions.Delete.DeleteSearchableSnapshot != nil {
fmt.Printf("%s delete searchable snapshots: %t\n",
indent, *current.Actions.Delete.DeleteSearchableSnapshot)
}
}
if phase == "delete" {
fmt.Printf("%s delete immediately\n", indent)
}
indent += " "
}
return nil
}
func getIlmCurrentPhase(policy types.IlmPolicy, currentPhase string) *types.Phase {
switch currentPhase {
case "hot":
return policy.Phases.Hot
case "warm":
return policy.Phases.Warm
case "cold":
return policy.Phases.Cold
case "frozen":
return policy.Phases.Frozen
default:
return policy.Phases.Delete
}
}
func ilmPhaseString(phase *types.Phase, short bool) string { func ilmPhaseString(phase *types.Phase, short bool) string {
if phase == nil { if phase == nil {
return "" return ""
@@ -160,7 +262,7 @@ func ilmPhaseString(phase *types.Phase, short bool) string {
} }
if phase.Actions.SearchableSnapshot != nil { if phase.Actions.SearchableSnapshot != nil {
out = append(out, fmt.Sprintf("searchable-snapshot:%s", phase.Actions.SearchableSnapshot.SnapshotRepository)) out = append(out, "searchable-snapshot:"+phase.Actions.SearchableSnapshot.SnapshotRepository)
} }
return strings.Join(out, ",") return strings.Join(out, ",")
@@ -170,7 +272,7 @@ func IlmExplain(conf *cfg.Config, index string) error {
res, err := conf.DefaultCluster.ES().Ilm.ExplainLifecycle(index). res, err := conf.DefaultCluster.ES().Ilm.ExplainLifecycle(index).
Do(context.Background()) Do(context.Background())
if err != nil { if err != nil {
return fmt.Errorf("failed to get ilm state: %s", esErrorString(err)) return fmt.Errorf("failed to get ilm state: %w", esErrorString(err))
} }
slog.Debug("ilm status", "ilm", res) slog.Debug("ilm status", "ilm", res)
@@ -186,22 +288,23 @@ func IlmExplain(conf *cfg.Config, index string) error {
table.Addheaders("ilm status field", "value") table.Addheaders("ilm status field", "value")
info := "" info := ""
err = json.Unmarshal(ilm.StepInfo["reason"], &info) err = json.Unmarshal(ilm.StepInfo["reason"], &info)
if err != nil { if err != nil {
return fmt.Errorf("failed to unmarshal step info: %w", err) return fmt.Errorf("failed to unmarshal step info: %w", err)
} }
table.Entries = [][]string{ table.Entries = [][]any{
{"index", ilm.Index}, {"index", ilm.Index},
{"ilm policy", *ilm.Policy}, {"ilm policy", *ilm.Policy},
{"action", *ilm.Action}, {"action", *ilm.Action},
{"age", fmt.Sprintf("%s", ilm.Age)}, {"age", ilm.Age},
{"managed", fmt.Sprintf("%t", ilm.Managed)}, {"managed", ilm.Managed},
{"phase", *ilm.Phase}, {"phase", *ilm.Phase},
{"phase execution", ilmPhaseString(ilm.PhaseExecution.PhaseDefinition, false)}, {"phase execution", ilmPhaseString(ilm.PhaseExecution.PhaseDefinition, false)},
{"step", *ilm.Step}, {"step", *ilm.Step},
{"failed step", *ilm.FailedStep}, {"failed step", *ilm.FailedStep},
{"failed step retry count", fmt.Sprintf("%d", ilm.FailedStepRetryCount)}, {"failed step retry count", ilm.FailedStepRetryCount},
} }
if err := table.Print(); err != nil { if err := table.Print(); err != nil {
@@ -220,6 +323,24 @@ func IlmExplain(conf *cfg.Config, index string) error {
} }
func IlmCreate(conf *cfg.Config, policyname string) error { func IlmCreate(conf *cfg.Config, policyname string) error {
var policy *types.IlmPolicy = nil
res, err := conf.DefaultCluster.ES().Ilm.GetLifecycle().
Policy(policyname).
Do(context.Background())
if err == nil {
if conf.Debug {
repr.Println(res)
}
ilm, exists := res[policyname]
if !exists {
return errors.New("no ilm policy retrieved")
}
policy = &ilm.Policy
}
ilm := conf.DefaultCluster.ES().Ilm.PutLifecycle(policyname) ilm := conf.DefaultCluster.ES().Ilm.PutLifecycle(policyname)
cfg := conf.Ilm cfg := conf.Ilm
@@ -227,10 +348,22 @@ func IlmCreate(conf *cfg.Config, policyname string) error {
if cfg.HaveHot() { if cfg.HaveHot() {
hot := types.Phase{} hot := types.Phase{}
var actions types.IlmActionsVariant = esdsl.NewIlmActions() var actions types.IlmActionsVariant = esdsl.NewIlmActions()
rollover := &types.RolloverAction{} rollover := &types.RolloverAction{}
haveroll := false haveroll := false
if policy != nil {
// update
actions = policy.Phases.Hot.Actions
if policy.Phases.Hot.Actions.Rollover != nil {
rollover = policy.Phases.Hot.Actions.Rollover
haveroll = true
}
}
if cfg.HotMinAge != "" { if cfg.HotMinAge != "" {
hot.MinAge = cfg.HotMinAge hot.MinAge = cfg.HotMinAge
} }
@@ -255,12 +388,21 @@ func IlmCreate(conf *cfg.Config, policyname string) error {
hot.Actions = actions.IlmActionsCaster() hot.Actions = actions.IlmActionsCaster()
phases.PhasesCaster().Hot = &hot phases.PhasesCaster().Hot = &hot
} else if policy != nil {
// update
phases.PhasesCaster().Hot = policy.Phases.Hot
} }
if cfg.HaveWarm() { if cfg.HaveWarm() {
warm := types.Phase{} warm := types.Phase{}
var actions types.IlmActionsVariant = esdsl.NewIlmActions() var actions types.IlmActionsVariant = esdsl.NewIlmActions()
if policy != nil {
// update
actions = policy.Phases.Warm.Actions
}
if cfg.WarmForceMerge != 0 { if cfg.WarmForceMerge != 0 {
actions.IlmActionsCaster().Forcemerge = &types.ForceMergeAction{MaxNumSegments: cfg.WarmForceMerge} actions.IlmActionsCaster().Forcemerge = &types.ForceMergeAction{MaxNumSegments: cfg.WarmForceMerge}
} }
@@ -279,12 +421,21 @@ func IlmCreate(conf *cfg.Config, policyname string) error {
warm.Actions = actions.IlmActionsCaster() warm.Actions = actions.IlmActionsCaster()
phases.PhasesCaster().Warm = &warm phases.PhasesCaster().Warm = &warm
} else if policy != nil && policy.Phases.Warm != nil {
// update
phases.PhasesCaster().Warm = policy.Phases.Warm
} }
if cfg.HaveCold() { if cfg.HaveCold() {
cold := types.Phase{} cold := types.Phase{}
var actions types.IlmActionsVariant = esdsl.NewIlmActions() var actions types.IlmActionsVariant = esdsl.NewIlmActions()
if policy != nil {
// update
actions = policy.Phases.Cold.Actions
}
if cfg.ColdForceMerge != 0 { if cfg.ColdForceMerge != 0 {
actions.IlmActionsCaster().Forcemerge = &types.ForceMergeAction{MaxNumSegments: cfg.ColdForceMerge} actions.IlmActionsCaster().Forcemerge = &types.ForceMergeAction{MaxNumSegments: cfg.ColdForceMerge}
} }
@@ -304,12 +455,21 @@ func IlmCreate(conf *cfg.Config, policyname string) error {
cold.Actions = actions.IlmActionsCaster() cold.Actions = actions.IlmActionsCaster()
phases.PhasesCaster().Cold = &cold phases.PhasesCaster().Cold = &cold
} else if policy != nil && policy.Phases.Cold != nil {
// update
phases.PhasesCaster().Cold = policy.Phases.Cold
} }
if cfg.HaveFrozen() { if cfg.HaveFrozen() {
froze := types.Phase{} froze := types.Phase{}
var actions types.IlmActionsVariant = esdsl.NewIlmActions() var actions types.IlmActionsVariant = esdsl.NewIlmActions()
if policy != nil {
// update
actions = policy.Phases.Frozen.Actions
}
if cfg.FrozenMinAge != "" { if cfg.FrozenMinAge != "" {
froze.MinAge = cfg.FrozenMinAge froze.MinAge = cfg.FrozenMinAge
} }
@@ -321,13 +481,22 @@ func IlmCreate(conf *cfg.Config, policyname string) error {
froze.Actions = actions.IlmActionsCaster() froze.Actions = actions.IlmActionsCaster()
phases.PhasesCaster().Frozen = &froze phases.PhasesCaster().Frozen = &froze
} else if policy != nil && policy.Phases.Frozen != nil {
// update
phases.PhasesCaster().Frozen = policy.Phases.Frozen
} }
if cfg.HaveDelete() { if cfg.HaveDelete() {
del := types.Phase{} del := types.Phase{}
delete := types.DeleteAction{} delete := types.DeleteAction{}
var actions types.IlmActionsVariant = esdsl.NewIlmActions() var actions types.IlmActionsVariant = esdsl.NewIlmActions()
if policy != nil {
// update
actions = policy.Phases.Delete.Actions
}
if cfg.DeleteMinAge != "" { if cfg.DeleteMinAge != "" {
del.MinAge = cfg.DeleteMinAge del.MinAge = cfg.DeleteMinAge
} }
@@ -339,18 +508,21 @@ func IlmCreate(conf *cfg.Config, policyname string) error {
actions.IlmActionsCaster().Delete = &delete actions.IlmActionsCaster().Delete = &delete
del.Actions = actions.IlmActionsCaster() del.Actions = actions.IlmActionsCaster()
phases.PhasesCaster().Delete = &del phases.PhasesCaster().Delete = &del
} else if policy != nil && policy.Phases.Delete != nil {
// update
phases.PhasesCaster().Delete = policy.Phases.Delete
} }
put := &putlifecycle.Request{} put := &putlifecycle.Request{}
policy := &types.IlmPolicy{} newpolicy := &types.IlmPolicy{}
policy.IlmPolicyCaster().Phases = *phases.PhasesCaster() newpolicy.IlmPolicyCaster().Phases = *phases.PhasesCaster()
put.Policy = policy put.Policy = newpolicy
ilm.Request(put) ilm.Request(put)
_, err := ilm.Do(context.Background()) _, err = ilm.Do(context.Background())
if err != nil { if err != nil {
return fmt.Errorf("failed create ilm policy: %s", esErrorString(err)) return fmt.Errorf("failed create ilm policy: %w", esErrorString(err))
} }
return nil return nil

View File

@@ -119,13 +119,13 @@ func IlmForecastList(conf *cfg.Config, filter string) error {
virtualAge := virtualAge(&phase) virtualAge := virtualAge(&phase)
row := []string{ row := []any{
phase.index, phase.index,
humanize.Bytes(uint64(phase.size)), printer.Bytes(phase.size),
formatDuration(phase.age), formatDuration(phase.age),
formatDuration(virtualAge), formatDuration(virtualAge),
formatDuration(phase.minage), formatDuration(phase.minage),
humanize.Bytes(uint64(phase.minsize)), printer.Bytes(phase.minsize),
phase.currentPhase, phase.currentPhase,
phase.nextPhase, phase.nextPhase,
} }
@@ -138,6 +138,7 @@ func IlmForecastList(conf *cfg.Config, filter string) error {
} }
table.Sort() table.Sort()
return table.Print() return table.Print()
} }
@@ -154,14 +155,11 @@ func IlmForecastShow(conf *cfg.Config) error {
} }
within := parseDuration(conf.Ilm.Within) within := parseDuration(conf.Ilm.Within)
var toBeFreed int64 = 0 var toBeFreed int64 = 0
for _, phase := range phaseData { for _, phase := range phaseData {
age := virtualAge(&phase) age := max(virtualAge(&phase), phase.age)
if age < phase.age {
age = phase.age
}
if age+within >= phase.minage { if age+within >= phase.minage {
toBeFreed += phase.size toBeFreed += phase.size
@@ -190,30 +188,32 @@ func getIlmPhaseData(conf *cfg.Config) ([]PhaseData, error) {
wg := &sync.WaitGroup{} wg := &sync.WaitGroup{}
wg.Add(3) wg.Add(3)
go getApiData(conf.DefaultCluster.ES(), wg, responses, "indicesbytes") go getApiData(conf, conf.DefaultCluster.ES(), wg, responses, "indicesbytes")
go getApiData(conf.DefaultCluster.ES(), wg, responses, "explain") go getApiData(conf, conf.DefaultCluster.ES(), wg, responses, "explain")
go getApiData(conf.DefaultCluster.ES(), wg, responses, "policies") go getApiData(conf, conf.DefaultCluster.ES(), wg, responses, "policies")
wg.Wait() wg.Wait()
var ilmdetails *explainlifecycle.Response var (
var indicesres *indices.Response ilmdetails *explainlifecycle.Response
var ilmpolicies getlifecycle.Response indicesres *indices.Response
ilmpolicies getlifecycle.Response
)
for i := 0; i < 3; i++ { for range 3 {
r := <-responses res := <-responses
if r.error != nil { if res.error != nil {
return nil, r.error return nil, res.error
} }
switch r.which { switch res.which {
case ResponseExplain: case ResponseExplain:
ilmdetails = r.explainlifecycle ilmdetails = res.explainlifecycle
case ResponseIndices: case ResponseIndices:
indicesres = r.indicesbytes indicesres = res.indicesbytes
case ResponseLifecycle: case ResponseLifecycle:
ilmpolicies = *r.lifecycle ilmpolicies = *res.lifecycle
} }
} }

View File

@@ -37,9 +37,8 @@ import (
func IndexNames(conf *cfg.Config) ([]string, error) { func IndexNames(conf *cfg.Config) ([]string, error) {
res, err := conf.DefaultCluster.ES().Cat.Indices(). res, err := conf.DefaultCluster.ES().Cat.Indices().
Do(context.Background()) Do(context.Background())
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get indicies: %s", esErrorString(err)) return nil, fmt.Errorf("failed to get indicies: %w", esErrorString(err))
} }
indices := make([]string, len(res)) indices := make([]string, len(res))
@@ -97,7 +96,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("failed to get indicies: %s", esErrorString(err)) return fmt.Errorf("failed to get indicies: %w", esErrorString(err))
} }
slog.Debug("ES result", "indicies", res) slog.Debug("ES result", "indicies", res)
@@ -118,7 +117,7 @@ func IndexList(conf *cfg.Config) error {
for idx, index := range list { for idx, index := range list {
name := printer.Colorize(conf, *index.Health, *index.Index) name := printer.Colorize(conf, *index.Health, *index.Index)
table.Entries[idx] = []string{name, *index.DatasetSize, *index.DocsCount} table.Entries[idx] = []any{name, *index.DatasetSize, *index.DocsCount}
if idx == size-1 { if idx == size-1 {
break break
@@ -126,6 +125,7 @@ func IndexList(conf *cfg.Config) error {
} }
table.Sort() table.Sort()
return table.Print() return table.Print()
} }
@@ -133,13 +133,14 @@ func IndexShow(conf *cfg.Config, indexpattern string) error {
res, err := conf.DefaultCluster.ES().Indices.Get(indexpattern). res, err := conf.DefaultCluster.ES().Indices.Get(indexpattern).
Do(context.Background()) Do(context.Background())
if err != nil { if err != nil {
return fmt.Errorf("failed to get index: %s", esErrorString(err)) return fmt.Errorf("failed to get index: %w", esErrorString(err))
} }
slog.Debug("index show", "index", res) slog.Debug("index show", "index", res)
for name, index := range res { for name, index := range res {
fields := make([]string, len(index.Mappings.Properties)) fields := make([]string, len(index.Mappings.Properties))
idx := 0 idx := 0
for field := range index.Mappings.Properties { for field := range index.Mappings.Properties {
fields[idx] = field fields[idx] = field
@@ -156,18 +157,18 @@ func IndexShow(conf *cfg.Config, indexpattern string) error {
created := time.Unix(ts/1000, 0) created := time.Unix(ts/1000, 0)
table.Entries = [][]string{ table.Entries = [][]any{
{"name", name}, {"name", name},
{"replicas", *index.Settings.Index.NumberOfReplicas}, {"replicas", *index.Settings.Index.NumberOfReplicas},
{"shards", *index.Settings.Index.NumberOfShards}, {"shards", *index.Settings.Index.NumberOfShards},
{"created", created.Format("2006-01-02 15:04:05")}, {"created", created},
{"uuid", *index.Settings.Index.Uuid}, {"uuid", *index.Settings.Index.Uuid},
{"version", *index.Settings.Index.Version.Created}, {"version", *index.Settings.Index.Version.Created},
{"fields", strings.Join(fields, ",")}, {"fields", fields},
} }
if index.Settings.Index.Lifecycle != nil { if index.Settings.Index.Lifecycle != nil {
table.Entries = append(table.Entries, [][]string{ table.Entries = append(table.Entries, [][]any{
{"ilm policy", *index.Settings.Index.Lifecycle.Name}, {"ilm policy", *index.Settings.Index.Lifecycle.Name},
{"ilm rollover alias", *index.Settings.Index.Lifecycle.RolloverAlias}, {"ilm rollover alias", *index.Settings.Index.Lifecycle.RolloverAlias},
}...) }...)
@@ -195,6 +196,7 @@ func IndexCreate(conf *cfg.Config, index string, mappings []string) error {
if conf.Shards > 0 { if conf.Shards > 0 {
settings.NumberOfShards(strconv.Itoa(conf.Shards)) settings.NumberOfShards(strconv.Itoa(conf.Shards))
} }
if conf.Replicas > 0 { if conf.Replicas > 0 {
settings.NumberOfReplicas(strconv.Itoa(conf.Replicas)) settings.NumberOfReplicas(strconv.Itoa(conf.Replicas))
} }
@@ -232,9 +234,8 @@ func IndexCreate(conf *cfg.Config, index string, mappings []string) error {
_, err := create.Settings(settings). _, err := create.Settings(settings).
Do(context.Background()) Do(context.Background())
if err != nil { if err != nil {
return fmt.Errorf("failed to create index: %s", esErrorString(err)) return fmt.Errorf("failed to create index: %w", esErrorString(err))
} }
return nil return nil
@@ -244,7 +245,7 @@ func IndexDelete(conf *cfg.Config, index string) error {
_, err := conf.DefaultCluster.ES().Indices.Delete(index). _, err := conf.DefaultCluster.ES().Indices.Delete(index).
Do(context.Background()) Do(context.Background())
if err != nil { if err != nil {
return fmt.Errorf("failed to delete index: %s", esErrorString(err)) return fmt.Errorf("failed to delete index: %w", esErrorString(err))
} }
return nil return nil
@@ -253,85 +254,27 @@ func IndexDelete(conf *cfg.Config, index string) error {
func IndexClose(conf *cfg.Config, index string) error { func IndexClose(conf *cfg.Config, index string) error {
_, err := conf.DefaultCluster.ES().Indices.Close(index). _, err := conf.DefaultCluster.ES().Indices.Close(index).
Do(context.Background()) Do(context.Background())
if err != nil { if err != nil {
return fmt.Errorf("failed to close index: %s", esErrorString(err)) return fmt.Errorf("failed to close index: %w", esErrorString(err))
} }
return nil return nil
} }
func IndexAllocation(conf *cfg.Config, index string) error {
res, err := conf.DefaultCluster.ES().Cluster.AllocationExplain().
Index(index).
Primary(conf.Primary).
Shard(conf.Shards).
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to get index allocation explain: %s", esErrorString(err))
}
slog.Debug("ES result", "index", res)
currentNode := res.CurrentNode
table := printer.NewTable(conf, 2, 10)
table.Addheaders("index allocation setting", "value")
roles := make([]string, len(currentNode.Roles))
for idx, role := range currentNode.Roles {
roles[idx] = role.Name
}
table.Entries = [][]string{
{"Index", index},
{"Current node", currentNode.Name},
{"Current k8s node", currentNode.Attributes["k8s_node_name"]},
{"Current node address", currentNode.TransportAddress},
{"Current node id", currentNode.Id},
{"Current node weight", fmt.Sprintf("%d", currentNode.WeightRanking)},
{"Current node roles", strings.Join(roles, ",")},
{"Can rebalance cluster", res.CanRebalanceCluster.Name},
{"Can rebalance to another node", res.CanRebalanceToOtherNode.Name},
{"Can remain on current node", res.CanRemainOnCurrentNode.Name},
}
if err := table.Print(); err != nil {
return err
}
return nil
}
/*
func IndexModify(conf *cfg.Config, index string) error {
settings := esdsl.NewIndexSettings().NumberOfReplicas(strconv.Itoa(conf.Replicas))
_, err := conf.DefaultCluster.ES().Indices.PutSettings().
Indices(index).
Index(settings).
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to modify index settings: %s", esErrorString(err))
}
return nil
}
*/
func IndexFields(conf *cfg.Config, index string) error { func IndexFields(conf *cfg.Config, index string) error {
res, err := conf.DefaultCluster.ES().FieldCaps(). res, err := conf.DefaultCluster.ES().FieldCaps().
Index(index). Index(index).
Fields("*"). Fields("*").
Do(context.Background()) Do(context.Background())
if err != nil { if err != nil {
return fmt.Errorf("failed to retrieve field capabilties: %s", esErrorString(err)) return fmt.Errorf("failed to retrieve field capabilties: %w", esErrorString(err))
} }
table := printer.NewTable(conf, 5, 0) table := printer.NewTable(conf, 5, 0)
table.Addheaders("field", "type", "searchable", "aggretable", "metadata") table.Addheaders("field", "type", "searchable", "aggretable", "metadata")
idx := 0 idx := 0
for name, field := range res.Fields { for name, field := range res.Fields {
for fieldtype, caps := range field { for fieldtype, caps := range field {
// fields only have 1 type, so this one is it // fields only have 1 type, so this one is it
@@ -344,10 +287,11 @@ func IndexFields(conf *cfg.Config, index string) error {
continue continue
} }
table.Entries = append(table.Entries, []string{name, fieldtype, table.AddRow(name, fieldtype,
fmt.Sprintf("%t", caps.Searchable), caps.Searchable,
fmt.Sprintf("%t", caps.Aggregatable), caps.Aggregatable,
fmt.Sprintf("%t", *caps.MetadataField)}) *caps.MetadataField)
break break
} }

View File

@@ -35,7 +35,7 @@ func IndexAliasCreate(conf *cfg.Config, index, alias string) error {
slog.Debug("create alias", "result", res) slog.Debug("create alias", "result", res)
if err != nil { if err != nil {
return fmt.Errorf("failed to create index alias: %s", esErrorString(err)) return fmt.Errorf("failed to create index alias: %w", esErrorString(err))
} }
return nil return nil
@@ -52,9 +52,8 @@ func IndexAliasList(conf *cfg.Config) error {
res, err := conf.DefaultCluster.ES().Indices.GetAlias(). res, err := conf.DefaultCluster.ES().Indices.GetAlias().
Index("_all"). Index("_all").
Do(context.Background()) Do(context.Background())
if err != nil { if err != nil {
return fmt.Errorf("failed to list index aliases: %s", esErrorString(err)) return fmt.Errorf("failed to list index aliases: %w", esErrorString(err))
} }
slog.Debug("aliases list", "result", res) slog.Debug("aliases list", "result", res)
@@ -78,7 +77,7 @@ func IndexAliasList(conf *cfg.Config) error {
idx := 0 idx := 0
for index, aliases := range aliaslist { for index, aliases := range aliaslist {
table.Entries[idx] = []string{ table.Entries[idx] = []any{
index, index,
strings.Join(aliases, ","), strings.Join(aliases, ","),
} }
@@ -102,7 +101,7 @@ func IndexAliasDelete(conf *cfg.Config, index, alias string) error {
slog.Debug("delete alias", "result", res) slog.Debug("delete alias", "result", res)
if err != nil { if err != nil {
return fmt.Errorf("failed to delete index alias: %s", esErrorString(err)) return fmt.Errorf("failed to delete index alias: %w", esErrorString(err))
} }
return nil return nil
@@ -110,17 +109,16 @@ func IndexAliasDelete(conf *cfg.Config, index, alias string) error {
func IndexAliasRollover(conf *cfg.Config, alias string) error { func IndexAliasRollover(conf *cfg.Config, alias string) error {
res, err := RolloverAlias(conf, alias) res, err := RolloverAlias(conf, alias)
if err != nil { if err != nil {
return fmt.Errorf("failed to rollover index alias: %s", esErrorString(err)) return fmt.Errorf("failed to rollover index alias: %w", esErrorString(err))
} }
table := printer.NewTable(conf, 2, 5) table := printer.NewTable(conf, 2, 5)
table.Addheaders("rollover response", "value") table.Addheaders("rollover response", "value")
table.Entries = [][]string{ table.Entries = [][]any{
{"acknowledged", fmt.Sprintf("%t", res.Acknowledged)}, {"acknowledged", res.Acknowledged},
{"rolled over", fmt.Sprintf("%t", res.RolledOver)}, {"rolled over", res.RolledOver},
{"shards acknowledged", fmt.Sprintf("%t", res.ShardsAcknowledged)}, {"shards acknowledged", res.ShardsAcknowledged},
{"old index", res.OldIndex}, {"old index", res.OldIndex},
{"new index", res.NewIndex}, {"new index", res.NewIndex},
} }

View File

@@ -22,6 +22,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
"maps"
"strconv" "strconv"
"strings" "strings"
@@ -35,9 +36,8 @@ import (
func IndexTemplateList(conf *cfg.Config) error { func IndexTemplateList(conf *cfg.Config) error {
res, err := conf.DefaultCluster.ES().Indices.GetIndexTemplate(). res, err := conf.DefaultCluster.ES().Indices.GetIndexTemplate().
Do(context.Background()) Do(context.Background())
if err != nil { if err != nil {
return fmt.Errorf("failed to get index templates: %s", esErrorString(err)) return fmt.Errorf("failed to get index templates: %w", esErrorString(err))
} }
slog.Debug("res", "index templates", res) slog.Debug("res", "index templates", res)
@@ -51,14 +51,11 @@ func IndexTemplateList(conf *cfg.Config) error {
return fmt.Errorf("failed to unmarshal meta json data: %w", err) return fmt.Errorf("failed to unmarshal meta json data: %w", err)
} }
table.Entries[idx] = []string{ table.Entries[idx] = []any{tpl.Name, desc, tpl.IndexTemplate.Priority}
tpl.Name,
string(desc),
fmt.Sprintf("%d", tpl.IndexTemplate.Priority),
}
} }
table.Sort() table.Sort()
return table.Print() return table.Print()
} }
@@ -66,9 +63,8 @@ func IndexTemplateShow(conf *cfg.Config, tplname string) error {
res, err := conf.DefaultCluster.ES().Indices.GetIndexTemplate(). res, err := conf.DefaultCluster.ES().Indices.GetIndexTemplate().
Name(tplname). Name(tplname).
Do(context.Background()) Do(context.Background())
if err != nil { if err != nil {
return fmt.Errorf("failed to get index template: %s", esErrorString(err)) return fmt.Errorf("failed to get index template: %w", esErrorString(err))
} }
slog.Debug("res", "index template", res) slog.Debug("res", "index template", res)
@@ -94,13 +90,13 @@ func IndexTemplateShow(conf *cfg.Config, tplname string) error {
aliases = append(aliases, alias) aliases = append(aliases, alias)
} }
table.Entries = [][]string{ table.Entries = [][]any{
{"name", tpl.Name}, {"name", tpl.Name},
{"description", string(desc)}, {"description", desc},
{"index patterns", strings.Join(tpl.IndexTemplate.IndexPatterns, ",")}, {"index patterns", tpl.IndexTemplate.IndexPatterns},
{"composed of", strings.Join(tpl.IndexTemplate.ComposedOf, ",")}, {"composed of", tpl.IndexTemplate.ComposedOf},
{"data stream enabled", fmt.Sprintf("%t", hasds)}, {"data stream enabled", hasds},
{"aliases", strings.Join(aliases, ",")}, {"aliases", aliases},
} }
if err := table.Print(); err != nil { if err := table.Print(); err != nil {
@@ -112,10 +108,11 @@ func IndexTemplateShow(conf *cfg.Config, tplname string) error {
err = getIndexTemplateSettings(conf, tplname, table) err = getIndexTemplateSettings(conf, tplname, table)
if err != nil { if err != nil {
return nil return err
} }
fmt.Println() fmt.Println()
if err := table.Print(); err != nil { if err := table.Print(); err != nil {
return err return err
} }
@@ -136,12 +133,11 @@ func IndexTemplateShow(conf *cfg.Config, tplname string) error {
typeval = val.Type typeval = val.Type
} }
table.Entries = append(table.Entries, []string{ table.AddRow(name, typeval)
name, typeval,
})
} }
fmt.Println() fmt.Println()
if err := table.Print(); err != nil { if err := table.Print(); err != nil {
return err return err
} }
@@ -229,9 +225,8 @@ func IndexTemplateCreate(conf *cfg.Config, name string, mappings []string) error
create.IndexPatterns(conf.Patterns...) create.IndexPatterns(conf.Patterns...)
_, err := create.Do(context.Background()) _, err := create.Do(context.Background())
if err != nil { if err != nil {
return fmt.Errorf("failed to create index template: %s", esErrorString(err)) return fmt.Errorf("failed to create index template: %w", esErrorString(err))
} }
return nil return nil
@@ -246,9 +241,8 @@ func IndexTemplateModify(conf *cfg.Config, name string, mappings []string) error
res, err := conf.DefaultCluster.ES().Indices.GetIndexTemplate(). res, err := conf.DefaultCluster.ES().Indices.GetIndexTemplate().
Name(name). Name(name).
Do(context.Background()) Do(context.Background())
if err != nil { if err != nil {
return fmt.Errorf("failed to get index template: %s", esErrorString(err)) return fmt.Errorf("failed to get index template: %w", esErrorString(err))
} }
slog.Debug("res", "index template", res) slog.Debug("res", "index template", res)
@@ -340,9 +334,8 @@ func IndexTemplateModify(conf *cfg.Config, name string, mappings []string) error
modify.IndexPatterns(patterns...) modify.IndexPatterns(patterns...)
_, err = modify.Do(context.Background()) _, err = modify.Do(context.Background())
if err != nil { if err != nil {
return fmt.Errorf("failed to modify index template: %s", esErrorString(err)) return fmt.Errorf("failed to modify index template: %w", esErrorString(err))
} }
if conf.Rollover { if conf.Rollover {
@@ -358,7 +351,7 @@ func IndexTemplateDelete(conf *cfg.Config, name string) error {
_, err := conf.DefaultCluster.ES().Indices.DeleteIndexTemplate(name). _, err := conf.DefaultCluster.ES().Indices.DeleteIndexTemplate(name).
Do(context.Background()) Do(context.Background())
if err != nil { if err != nil {
return fmt.Errorf("failed to delete index template: %s", esErrorString(err)) return fmt.Errorf("failed to delete index template: %w", esErrorString(err))
} }
return nil return nil
@@ -371,9 +364,8 @@ func rolloverAliasIndexTemplate(conf *cfg.Config, name string) error {
res, err := conf.DefaultCluster.ES().Indices.GetIndexTemplate(). res, err := conf.DefaultCluster.ES().Indices.GetIndexTemplate().
Name(name). Name(name).
Do(context.Background()) Do(context.Background())
if err != nil { if err != nil {
return fmt.Errorf("failed to get index template: %s", esErrorString(err)) return fmt.Errorf("failed to get index template: %w", esErrorString(err))
} }
slog.Debug("res", "index template", res) slog.Debug("res", "index template", res)
@@ -390,7 +382,7 @@ func rolloverAliasIndexTemplate(conf *cfg.Config, name string) error {
res, err := conf.DefaultCluster.ES().Indices.ResolveIndex(pattern). res, err := conf.DefaultCluster.ES().Indices.ResolveIndex(pattern).
Do(context.Background()) Do(context.Background())
if err != nil { if err != nil {
return fmt.Errorf("failed to resolve index pattern: %s", esErrorString(err)) return fmt.Errorf("failed to resolve index pattern: %w", esErrorString(err))
} }
for _, index := range res.Indices { for _, index := range res.Indices {
@@ -414,12 +406,7 @@ func rolloverAliasIndexTemplate(conf *cfg.Config, name string) error {
return err return err
} }
table.Entries = append(table.Entries, []string{ table.AddRow(alias, res.RolledOver, res.Acknowledged, res.NewIndex)
alias,
fmt.Sprintf("%t", res.RolledOver),
fmt.Sprintf("%t", res.Acknowledged),
res.NewIndex,
})
if err := table.Print(); err != nil { if err := table.Print(); err != nil {
return err return err
@@ -427,7 +414,6 @@ func rolloverAliasIndexTemplate(conf *cfg.Config, name string) error {
} }
return nil return nil
} }
func modMappings(mappings []string) (types.TypeMappingVariant, error) { func modMappings(mappings []string) (types.TypeMappingVariant, error) {
@@ -459,9 +445,7 @@ func modMappings(mappings []string) (types.TypeMappingVariant, error) {
func modMeta(conf *cfg.Config, meta types.Metadata) (map[string]json.RawMessage, error) { func modMeta(conf *cfg.Config, meta types.Metadata) (map[string]json.RawMessage, error) {
metadata := map[string]json.RawMessage{} metadata := map[string]json.RawMessage{}
for key, value := range meta { maps.Copy(metadata, meta)
metadata[key] = value
}
for _, meta := range conf.Meta { for _, meta := range conf.Meta {
parts := strings.Split(meta, ":") parts := strings.Split(meta, ":")

View File

@@ -28,7 +28,7 @@ type Tpl struct {
} }
func getIndexTemplateSettings(conf *cfg.Config, tplname string, table *printer.Table) error { func getIndexTemplateSettings(conf *cfg.Config, tplname string, table *printer.Table) error {
raw, err := CallAPI(conf, "GET", "/_index_template/"+tplname, "") raw, err := CallAPI(conf, "GET", "/_index_template/"+tplname+"?flat_settings", "")
if err != nil { if err != nil {
return err return err
} }
@@ -39,9 +39,12 @@ func getIndexTemplateSettings(conf *cfg.Config, tplname string, table *printer.T
} }
if conf.Debug { if conf.Debug {
if err := prettyfiJson(conf, raw); err != nil { output, err := prettyfiJson(conf, raw)
if err != nil {
return err return err
} }
fmt.Println(output)
} }
if len(data.IndexTemplates) == 0 { if len(data.IndexTemplates) == 0 {
@@ -50,11 +53,7 @@ func getIndexTemplateSettings(conf *cfg.Config, tplname string, table *printer.T
tpl := data.IndexTemplates[0].IndexTemplate.Template.Settings tpl := data.IndexTemplates[0].IndexTemplate.Template.Settings
for topic, val := range tpl { for topic, val := range tpl {
paths := getJsonPath(map[string]string{}, val.(map[string]any), topic) table.AddRow(topic, val)
for setting, value := range paths {
table.Entries = append(table.Entries, []string{setting, fmt.Sprintf("%v", value)})
}
} }
return nil return nil

72
pkg/es/license.go Normal file
View File

@@ -0,0 +1,72 @@
/*
Copyright © 2026 Thomas von Dein
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package es
import (
"context"
"fmt"
"log/slog"
"strconv"
"codeberg.org/scip/esctl/pkg/cfg"
"codeberg.org/scip/esctl/pkg/printer"
)
func LicenseShow(conf *cfg.Config) error {
res, err := conf.DefaultCluster.ES().License.Get().
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to get license: %w", esErrorString(err))
}
slog.Debug("license show", "license", res)
table := printer.NewTableEmpty(conf).WithHeaders("license setting", "value")
lic := res.License
var (
maxnodes = "infinite"
maxunits = "infinite"
expire = "never"
)
if lic.MaxNodes != nil {
maxnodes = strconv.FormatInt(*lic.MaxNodes, 10)
}
if lic.ExpiryDate != nil {
expire = lic.ExpiryDate.(string)
}
if lic.MaxResourceUnits != nil {
maxunits = strconv.Itoa(*lic.MaxResourceUnits)
}
table.Entries = [][]any{
{"UID", lic.Uid},
{"Issued to", lic.IssuedTo},
{"Expires", expire},
{"Issued", lic.IssueDate},
{"Max nodes", maxnodes},
{"Max resource units", maxunits},
{"Type", lic.Type.Name},
{"Status", lic.Status.Name},
}
return table.Print()
}

View File

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

View File

@@ -18,10 +18,10 @@ package es
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"sync" "sync"
"codeberg.org/scip/esctl/pkg/cfg"
"github.com/elastic/go-elasticsearch/v9" "github.com/elastic/go-elasticsearch/v9"
"github.com/elastic/go-elasticsearch/v9/typedapi/cat/indices" "github.com/elastic/go-elasticsearch/v9/typedapi/cat/indices"
"github.com/elastic/go-elasticsearch/v9/typedapi/cat/tasks" "github.com/elastic/go-elasticsearch/v9/typedapi/cat/tasks"
@@ -43,12 +43,14 @@ const (
ResponseTasks ResponseTasks
ResponseExplain ResponseExplain
ResponseLifecycle ResponseLifecycle
ResponseHealthReport
) )
type apiResponse struct { type apiResponse struct {
error error error error
info *info.Response info *info.Response
health *health.Response health *health.Response
healthreport *HealthReport
ccr *stats.Response ccr *stats.Response
stats *clusterstats.Response stats *clusterstats.Response
indices *indices.Response indices *indices.Response
@@ -59,52 +61,64 @@ type apiResponse struct {
which int which int
} }
func getApiData(es *elasticsearch.TypedClient, wg *sync.WaitGroup, func getApiData(
reschan chan apiResponse, which string) { conf *cfg.Config,
es *elasticsearch.TypedClient,
wg *sync.WaitGroup,
reschan chan apiResponse,
which string) {
defer wg.Done() defer wg.Done()
ar := apiResponse{} apiRes := apiResponse{}
arerr := errors.New("")
var arerr error
switch which { switch which {
case "health": case "health":
res, err := es.Cluster.Health(). res, err := es.Cluster.Health().
Do(context.Background()) Do(context.Background())
ar.health = res apiRes.health = res
ar.which = ResponseHealth apiRes.which = ResponseHealth
arerr = err
case "healthreport":
report, err := getHealthReport(conf)
apiRes.healthreport = report
apiRes.which = ResponseHealthReport
arerr = err arerr = err
case "info": case "info":
res, err := es.Info(). res, err := es.Info().
Do(context.Background()) Do(context.Background())
ar.info = res apiRes.info = res
ar.which = ResponseInfo apiRes.which = ResponseInfo
arerr = err arerr = err
case "ccrstats": case "ccr":
res, err := es.Ccr.Stats(). res, err := es.Ccr.Stats().
Do(context.Background()) Do(context.Background())
ar.ccr = res apiRes.ccr = res
ar.which = ResponseCcr apiRes.which = ResponseCcr
arerr = err arerr = err
case "stats": case "stats":
res, err := es.Cluster.Stats(). res, err := es.Cluster.Stats().
Do(context.Background()) Do(context.Background())
ar.stats = res apiRes.stats = res
ar.which = ResponseStats apiRes.which = ResponseStats
arerr = err arerr = err
case "indices": case "indices":
res, err := es.Cat.Indices(). res, err := es.Cat.Indices().
Do(context.Background()) Do(context.Background())
ar.indices = &res apiRes.indices = &res
ar.which = ResponseIndices apiRes.which = ResponseIndices
arerr = err arerr = err
case "indicesbytes": case "indicesbytes":
@@ -113,16 +127,16 @@ func getApiData(es *elasticsearch.TypedClient, wg *sync.WaitGroup,
Bytes(bytes.Bytes{Name: "b"}). Bytes(bytes.Bytes{Name: "b"}).
Do(context.Background()) Do(context.Background())
ar.indicesbytes = &res apiRes.indicesbytes = &res
ar.which = ResponseIndices apiRes.which = ResponseIndices
arerr = err arerr = err
case "tasks": case "tasks":
res, err := es.Cat.Tasks(). res, err := es.Cat.Tasks().
Do(context.Background()) Do(context.Background())
ar.tasks = &res apiRes.tasks = &res
ar.which = ResponseTasks apiRes.which = ResponseTasks
arerr = err arerr = err
case "explain": case "explain":
@@ -130,8 +144,8 @@ func getApiData(es *elasticsearch.TypedClient, wg *sync.WaitGroup,
ExplainLifecycle("_all"). ExplainLifecycle("_all").
Do(context.Background()) Do(context.Background())
ar.explainlifecycle = res apiRes.explainlifecycle = res
ar.which = ResponseExplain apiRes.which = ResponseExplain
arerr = err arerr = err
case "policies": case "policies":
@@ -139,14 +153,14 @@ func getApiData(es *elasticsearch.TypedClient, wg *sync.WaitGroup,
GetLifecycle(). GetLifecycle().
Do(context.Background()) Do(context.Background())
ar.lifecycle = &res apiRes.lifecycle = &res
ar.which = ResponseLifecycle apiRes.which = ResponseLifecycle
arerr = err arerr = err
} }
if arerr != nil { if arerr != nil {
ar.error = fmt.Errorf("failed to get data from API: %s", arerr) apiRes.error = fmt.Errorf("failed to get data from API: %w", arerr)
} }
reschan <- ar reschan <- apiRes
} }

View File

@@ -20,7 +20,6 @@ import (
"context" "context"
"fmt" "fmt"
"log/slog" "log/slog"
"strings"
"codeberg.org/scip/esctl/pkg/cfg" "codeberg.org/scip/esctl/pkg/cfg"
"codeberg.org/scip/esctl/pkg/printer" "codeberg.org/scip/esctl/pkg/printer"
@@ -31,10 +30,11 @@ func RoleNames(conf *cfg.Config) ([]string, error) {
res, err := conf.DefaultCluster.ES().Security.GetRole(). res, err := conf.DefaultCluster.ES().Security.GetRole().
Do(context.Background()) Do(context.Background())
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get roles: %s", esErrorString(err)) return nil, fmt.Errorf("failed to get roles: %w", esErrorString(err))
} }
roles := make([]string, len(res)) roles := make([]string, len(res))
idx := 0 idx := 0
for name := range res { for name := range res {
roles[idx] = name roles[idx] = name
@@ -48,7 +48,7 @@ func RoleList(conf *cfg.Config) error {
res, err := conf.DefaultCluster.ES().Security.GetRole(). res, err := conf.DefaultCluster.ES().Security.GetRole().
Do(context.Background()) Do(context.Background())
if err != nil { if err != nil {
return fmt.Errorf("failed to get roles: %s", esErrorString(err)) return fmt.Errorf("failed to get roles: %w", esErrorString(err))
} }
slog.Debug("ES result", "roles", res) slog.Debug("ES result", "roles", res)
@@ -58,15 +58,16 @@ func RoleList(conf *cfg.Config) error {
idx := 0 idx := 0
for name, role := range res { for name, role := range res {
table.Entries[idx] = []string{ table.Entries[idx] = []any{
name, name,
fmt.Sprintf("%d", len(role.Cluster)), role.Cluster,
fmt.Sprintf("%d", len(role.Indices)), role.Indices,
} }
idx++ idx++
} }
table.Sort() table.Sort()
return table.Print() return table.Print()
} }
@@ -75,7 +76,7 @@ func RoleShow(conf *cfg.Config, rolename string) error {
Name(rolename). Name(rolename).
Do(context.Background()) Do(context.Background())
if err != nil { if err != nil {
return fmt.Errorf("failed to get role: %s", esErrorString(err)) return fmt.Errorf("failed to get role: %w", esErrorString(err))
} }
slog.Debug("ES result", "role", res) slog.Debug("ES result", "role", res)
@@ -133,21 +134,22 @@ func roleRemoteClusters(conf *cfg.Config, role types.Role) error {
table := printer.NewTable(conf, 2, len(role.RemoteCluster)) table := printer.NewTable(conf, 2, len(role.RemoteCluster))
table.Addheaders("remote cluster", "privilege") table.Addheaders("remote cluster", "privilege")
idx := 0 idx := 0
for _, priv := range role.RemoteCluster { for _, priv := range role.RemoteCluster {
perms := []string{} perms := []string{}
for _, perm := range priv.Privileges { for _, perm := range priv.Privileges {
perms = append(perms, perm.Name) perms = append(perms, perm.Name)
} }
table.Entries[idx] = []string{ table.Entries[idx] = []any{priv.Clusters, perms}
strings.Join(priv.Clusters, ","),
strings.Join(perms, ","),
}
idx++ idx++
} }
table.Sort() table.Sort()
return table.Print() return table.Print()
} }
@@ -158,13 +160,15 @@ func roleClusters(conf *cfg.Config, role types.Role) error {
table := printer.NewTable(conf, 1, len(role.Cluster)) table := printer.NewTable(conf, 1, len(role.Cluster))
table.Addheaders("cluster rights") table.Addheaders("cluster rights")
idx := 0 idx := 0
for _, cluster := range role.Cluster { for _, cluster := range role.Cluster {
table.Entries[idx] = []string{cluster.Name} table.Entries[idx] = []any{cluster.Name}
idx++ idx++
} }
table.Sort() table.Sort()
return table.Print() return table.Print()
} }
@@ -175,23 +179,22 @@ func roleRemoteIndices(conf *cfg.Config, role types.Role) error {
table := printer.NewTable(conf, 3, len(role.RemoteIndices)) table := printer.NewTable(conf, 3, len(role.RemoteIndices))
table.Addheaders("remote index names", "index permissions", "allow restricted") table.Addheaders("remote index names", "index permissions", "allow restricted")
idx := 0 idx := 0
for _, priv := range role.RemoteIndices { for _, priv := range role.RemoteIndices {
perms := []string{} perms := []string{}
for _, perm := range priv.Privileges { for _, perm := range priv.Privileges {
perms = append(perms, perm.Name) perms = append(perms, perm.Name)
} }
table.Entries[idx] = []string{ table.Entries[idx] = []any{priv.Names, perms, *priv.AllowRestrictedIndices}
strings.Join(priv.Names, ", "),
strings.Join(perms, ", "),
fmt.Sprintf("%t", *priv.AllowRestrictedIndices),
}
idx++ idx++
} }
table.Sort() table.Sort()
return table.Print() return table.Print()
} }
@@ -202,23 +205,22 @@ func roleIndices(conf *cfg.Config, role types.Role) error {
table := printer.NewTable(conf, 3, len(role.Indices)) table := printer.NewTable(conf, 3, len(role.Indices))
table.Addheaders("index names", "index permissions", "allow restricted") table.Addheaders("index names", "index permissions", "allow restricted")
idx := 0 idx := 0
for _, priv := range role.Indices { for _, priv := range role.Indices {
perms := []string{} perms := []string{}
for _, perm := range priv.Privileges { for _, perm := range priv.Privileges {
perms = append(perms, perm.Name) perms = append(perms, perm.Name)
} }
table.Entries[idx] = []string{ table.Entries[idx] = []any{priv.Names, perms, *priv.AllowRestrictedIndices}
strings.Join(priv.Names, ", "),
strings.Join(perms, ", "),
fmt.Sprintf("%t", *priv.AllowRestrictedIndices),
}
idx++ idx++
} }
table.Sort() table.Sort()
return table.Print() return table.Print()
} }
@@ -229,17 +231,19 @@ func roleApplications(conf *cfg.Config, role types.Role) error {
table := printer.NewTable(conf, 3, len(role.Applications)) table := printer.NewTable(conf, 3, len(role.Applications))
table.Addheaders("application", "privileges", "resources") table.Addheaders("application", "privileges", "resources")
idx := 0 idx := 0
for _, priv := range role.Applications { for _, priv := range role.Applications {
table.Entries[idx] = []string{ table.Entries[idx] = []any{
priv.Application, priv.Application,
strings.Join(priv.Privileges, ", "), priv.Privileges,
strings.Join(priv.Resources, ", "), priv.Resources,
} }
idx++ idx++
} }
table.Sort() table.Sort()
return table.Print() return table.Print()
} }

View File

@@ -75,7 +75,7 @@ type Register struct {
func getCsvRecords(conf *cfg.Config, csvfile string) (map[string]Record, error) { func getCsvRecords(conf *cfg.Config, csvfile string) (map[string]Record, error) {
data, err := os.ReadFile(csvfile) data, err := os.ReadFile(csvfile)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to read CSV file: %s", err) return nil, fmt.Errorf("failed to read CSV file: %w", err)
} }
csvreader := csv.NewReader(bytes.NewReader(data)) csvreader := csv.NewReader(bytes.NewReader(data))
@@ -85,7 +85,7 @@ func getCsvRecords(conf *cfg.Config, csvfile string) (map[string]Record, error)
rows, err := csvreader.ReadAll() rows, err := csvreader.ReadAll()
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to parse CSV: %s", err) return nil, fmt.Errorf("failed to parse CSV: %w", err)
} }
records := make(map[string]Record, len(rows)-1) records := make(map[string]Record, len(rows)-1)
@@ -118,7 +118,7 @@ func getCsvRecords(conf *cfg.Config, csvfile string) (map[string]Record, error)
func getCsvRecord(conf *cfg.Config, csvfile, rolename string) (*Record, error) { func getCsvRecord(conf *cfg.Config, csvfile, rolename string) (*Record, error) {
fd, err := os.Open(csvfile) fd, err := os.Open(csvfile)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to open CSV file: %s", err) return nil, fmt.Errorf("failed to open CSV file: %w", err)
} }
defer func() { defer func() {
if err := fd.Close(); err != nil { if err := fd.Close(); err != nil {
@@ -179,6 +179,7 @@ func diffRoles(conf *cfg.Config, records map[string]Record, res getrole.Response
// iterate over records from CSV and register only those which are not deployed // iterate over records from CSV and register only those which are not deployed
for name := range records { for name := range records {
reg := Register{name: name, defined: true} reg := Register{name: name, defined: true}
_, deployed := deployed[name] _, deployed := deployed[name]
if !deployed { if !deployed {
rows = append(rows, reg) rows = append(rows, reg)
@@ -210,7 +211,7 @@ func RoleDiff(conf *cfg.Config, csvfile, role string) error {
res, err := conf.DefaultCluster.ES().Security.GetRole(). res, err := conf.DefaultCluster.ES().Security.GetRole().
Do(context.Background()) Do(context.Background())
if err != nil { if err != nil {
return fmt.Errorf("failed to get roles: %s", esErrorString(err)) return fmt.Errorf("failed to get roles: %w", esErrorString(err))
} }
rows := diffRoles(conf, records, res) rows := diffRoles(conf, records, res)
@@ -229,7 +230,7 @@ func RoleDiff(conf *cfg.Config, csvfile, role string) error {
defined = printer.Colorize(conf, "red", "undefined") defined = printer.Colorize(conf, "red", "undefined")
} }
table.Entries[idx] = []string{ table.Entries[idx] = []any{
row.name, row.name,
deployed, deployed,
defined, defined,
@@ -245,10 +246,11 @@ func getRoleMappingGroups(conf *cfg.Config, rolename string) ([]string, error) {
mappings, err := conf.DefaultCluster.ES().Security.GetRoleMapping(). mappings, err := conf.DefaultCluster.ES().Security.GetRoleMapping().
Do(context.Background()) Do(context.Background())
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get role mappings: %s", esErrorString(err)) return nil, fmt.Errorf("failed to get role mappings: %w", esErrorString(err))
} }
groups := []string{} groups := []string{}
for _, mapping := range mappings { for _, mapping := range mappings {
if slices.Contains(mapping.Roles, rolename) { if slices.Contains(mapping.Roles, rolename) {
for _, rule := range mapping.Rules.Any { for _, rule := range mapping.Rules.Any {
@@ -262,13 +264,13 @@ func getRoleMappingGroups(conf *cfg.Config, rolename string) ([]string, error) {
return groups, nil return groups, nil
} }
func compareSlices(name string, a, b []string) { func compareSlices(name string, left, right []string) {
slices.Sort(a) slices.Sort(left)
slices.Sort(b) slices.Sort(right)
if slices.Compare(a, b) != 0 { if slices.Compare(left, right) != 0 {
fmt.Printf("%s differs:\ndeployed: %s\n csv: %s\n", fmt.Printf("%s differs:\ndeployed: %s\n csv: %s\n",
name, strings.Join(a, ","), strings.Join(b, ",")) name, strings.Join(left, ","), strings.Join(right, ","))
} else { } else {
fmt.Printf("deployed %s matches csv definition\n", name) fmt.Printf("deployed %s matches csv definition\n", name)
} }
@@ -279,7 +281,7 @@ func RoleDiffSingle(conf *cfg.Config, csvfile, rolename string) error {
Name(rolename). Name(rolename).
Do(context.Background()) Do(context.Background())
if err != nil { if err != nil {
return fmt.Errorf("failed to get role: %s", esErrorString(err)) return fmt.Errorf("failed to get role: %w", esErrorString(err))
} }
record, err := getCsvRecord(conf, csvfile, rolename) record, err := getCsvRecord(conf, csvfile, rolename)
@@ -289,16 +291,19 @@ func RoleDiffSingle(conf *cfg.Config, csvfile, rolename string) error {
if !record.defined { if !record.defined {
fmt.Printf("role %s is not defined\n", rolename) fmt.Printf("role %s is not defined\n", rolename)
return nil return nil
} }
role, exists := res[rolename] role, exists := res[rolename]
if !exists { if !exists {
fmt.Printf("role %s is not deployed\n", rolename) fmt.Printf("role %s is not deployed\n", rolename)
return nil return nil
} else { } else {
fmt.Printf("role %s is deployed\n", rolename) fmt.Printf("role %s is deployed\n", rolename)
} }
slog.Debug("found role", "role", role) slog.Debug("found role", "role", role)
if conf.Debug { if conf.Debug {
@@ -310,6 +315,7 @@ func RoleDiffSingle(conf *cfg.Config, csvfile, rolename string) error {
if err != nil { if err != nil {
return err return err
} }
slog.Debug("group mappings", "groups", groups) slog.Debug("group mappings", "groups", groups)
// check cluster setting // check cluster setting
@@ -321,6 +327,7 @@ func RoleDiffSingle(conf *cfg.Config, csvfile, rolename string) error {
// check index names+privs // check index names+privs
indices := []string{} indices := []string{}
privs := []string{} privs := []string{}
for _, index := range role.Indices { for _, index := range role.Indices {
for _, name := range index.Names { for _, name := range index.Names {
indices = append(indices, strings.ReplaceAll(name, "**", "*")) indices = append(indices, strings.ReplaceAll(name, "**", "*"))
@@ -333,6 +340,7 @@ func RoleDiffSingle(conf *cfg.Config, csvfile, rolename string) error {
// check kibana application space // check kibana application space
spaces := []string{} spaces := []string{}
for _, app := range role.Applications { for _, app := range role.Applications {
for _, resource := range app.Resources { for _, resource := range app.Resources {
if strings.Contains(resource, "space:") { if strings.Contains(resource, "space:") {

View File

@@ -31,14 +31,17 @@ func RolloverConditions(conf *cfg.Config) types.RolloverConditionsVariant {
if conf.MaxAge != "" { if conf.MaxAge != "" {
cond.MaxAge(esdsl.NewDuration().String(conf.MaxAge)) cond.MaxAge(esdsl.NewDuration().String(conf.MaxAge))
} }
if conf.MaxDocs > 0 { if conf.MaxDocs > 0 {
cond.MaxDocs(int64(conf.MaxDocs)) cond.MaxDocs(int64(conf.MaxDocs))
} }
if conf.MaxShardSize > 0 { if conf.MaxShardSize > 0 {
bs := esdsl.NewByteSize() bs := esdsl.NewByteSize()
bs.Int64(int64(conf.MaxShardSize)) bs.Int64(int64(conf.MaxShardSize))
cond.MaxPrimaryShardSize(bs) cond.MaxPrimaryShardSize(bs)
} }
if conf.MaxShardDocs > 0 { if conf.MaxShardDocs > 0 {
cond.MaxPrimaryShardDocs(int64(conf.MaxShardDocs)) cond.MaxPrimaryShardDocs(int64(conf.MaxShardDocs))
} }

View File

@@ -26,7 +26,6 @@ import (
"codeberg.org/scip/esctl/pkg/cfg" "codeberg.org/scip/esctl/pkg/cfg"
"codeberg.org/scip/esctl/pkg/printer" "codeberg.org/scip/esctl/pkg/printer"
"github.com/alecthomas/repr"
"github.com/elastic/go-elasticsearch/v9/typedapi/core/search" "github.com/elastic/go-elasticsearch/v9/typedapi/core/search"
"github.com/elastic/go-elasticsearch/v9/typedapi/esdsl" "github.com/elastic/go-elasticsearch/v9/typedapi/esdsl"
"github.com/elastic/go-elasticsearch/v9/typedapi/indices/validatequery" "github.com/elastic/go-elasticsearch/v9/typedapi/indices/validatequery"
@@ -83,13 +82,13 @@ func explainSearch(conf *cfg.Config, search *search.Search) error {
Size(1). // one's enough for explain Size(1). // one's enough for explain
Do(context.Background()) Do(context.Background())
if err != nil { if err != nil {
return fmt.Errorf("failed to call explain search (esdsl): %s", esErrorString(err)) return fmt.Errorf("failed to call explain search (esdsl): %w", esErrorString(err))
} }
if conf.Debug { if conf.Debug {
raw, err := json.Marshal(res) raw, err := json.Marshal(res)
if err != nil { if err != nil {
return fmt.Errorf("failed to marshal explain result: %s", err) return fmt.Errorf("failed to marshal explain result: %w", err)
} }
value := gjson.Get(string(raw), "hits.hits.0._explanation") value := gjson.Get(string(raw), "hits.hits.0._explanation")
@@ -112,6 +111,7 @@ func explainSearch(conf *cfg.Config, search *search.Search) error {
func explain(res *types.ExplanationDetail, indent string) { func explain(res *types.ExplanationDetail, indent string) {
fmt.Println(indent + "- " + res.Description) fmt.Println(indent + "- " + res.Description)
for _, ex := range res.Details { for _, ex := range res.Details {
fmt.Println(indent + " - " + ex.Description) fmt.Println(indent + " - " + ex.Description)
fmt.Println(indent + fmt.Sprintf(" score: %f", res.Value)) fmt.Println(indent + fmt.Sprintf(" score: %f", res.Value))
@@ -135,7 +135,7 @@ func validateSearch(conf *cfg.Config, queries []string) error {
res, err := validate. res, err := validate.
Do(context.Background()) Do(context.Background())
if err != nil { if err != nil {
return fmt.Errorf("failed to validate search (esdsl): %s", esErrorString(err)) return fmt.Errorf("failed to validate search (esdsl): %w", esErrorString(err))
} }
slog.Debug("ES result", "search", res) slog.Debug("ES result", "search", res)
@@ -149,32 +149,13 @@ func validateSearch(conf *cfg.Config, queries []string) error {
return nil return nil
} }
func Debug(conf *cfg.Config) error {
res, err := conf.DefaultCluster.ES().Search().
Index(conf.Index).
Size(0).
Aggregations(map[string]types.Aggregations{
"min_ts": *esdsl.NewMinAggregation().Field("@timestamp").AggregationsCaster(),
"max_ts": *esdsl.NewMaxAggregation().Field("@timestamp").AggregationsCaster(),
}).
Do(context.Background())
if err != nil {
return err
}
repr.Println(res)
return nil
}
func searchOnce(conf *cfg.Config, search *search.Search) error { func searchOnce(conf *cfg.Config, search *search.Search) error {
res, err := search. res, err := search.
From(conf.From). From(conf.From).
Size(conf.To). Size(conf.To).
Do(context.Background()) Do(context.Background())
if err != nil { if err != nil {
return fmt.Errorf("failed to run search (esdsl): %s", esErrorString(err)) return fmt.Errorf("failed to run search (esdsl): %w", esErrorString(err))
} }
slog.Debug("ES result", "search", res) slog.Debug("ES result", "search", res)
@@ -187,9 +168,10 @@ func searchOnce(conf *cfg.Config, search *search.Search) error {
// https://www.elastic.co/docs/reference/elasticsearch/clients/go/using-the-api/searching#_pit_search_after // https://www.elastic.co/docs/reference/elasticsearch/clients/go/using-the-api/searching#_pit_search_after
func searchPit(conf *cfg.Config, req *search.Request) error { func searchPit(conf *cfg.Config, req *search.Request) error {
ctx := context.Background() ctx := context.Background()
pit, err := conf.DefaultCluster.ES().OpenPointInTime(conf.Index).KeepAlive("1m").Do(ctx) pit, err := conf.DefaultCluster.ES().OpenPointInTime(conf.Index).KeepAlive("1m").Do(ctx)
if err != nil { if err != nil {
return fmt.Errorf("failed to open point-in-time request for search: %s", err) return fmt.Errorf("failed to open point-in-time request for search: %w", err)
} }
defer func() { defer func() {
_, err := conf.DefaultCluster.ES().ClosePointInTime().Id(pit.Id).Do(ctx) _, err := conf.DefaultCluster.ES().ClosePointInTime().Id(pit.Id).Do(ctx)
@@ -212,7 +194,7 @@ func searchPit(conf *cfg.Config, req *search.Request) error {
for { for {
res, err := search.Do(ctx) res, err := search.Do(ctx)
if err != nil { if err != nil {
return fmt.Errorf("failed to run search (esdsl pit): %s", esErrorString(err)) return fmt.Errorf("failed to run search (esdsl pit): %w", esErrorString(err))
} }
if len(res.Hits.Hits) == 0 { if len(res.Hits.Hits) == 0 {
@@ -242,7 +224,7 @@ func searchTail(conf *cfg.Config, search *search.Search) error {
for { for {
res, err := search.Do(context.Background()) res, err := search.Do(context.Background())
if err != nil { if err != nil {
return fmt.Errorf("failed to run search (esdsl): %s", esErrorString(err)) return fmt.Errorf("failed to run search (esdsl): %w", esErrorString(err))
} }
slog.Debug("ES result", "search", res) slog.Debug("ES result", "search", res)

View File

@@ -63,6 +63,7 @@ type filter struct {
// must match literally, and range filters using -r "@timestamp:2026-05-28T10:00:00 to now" // must match literally, and range filters using -r "@timestamp:2026-05-28T10:00:00 to now"
func NewFilter(query string) (*filter, error) { func NewFilter(query string) (*filter, error) {
var separator string var separator string
var criteria int // we use the constants on top for this var criteria int // we use the constants on top for this
switch { switch {
@@ -76,7 +77,7 @@ func NewFilter(query string) (*filter, error) {
part := strings.Split(query, separator) part := strings.Split(query, separator)
if len(part) != 2 { if len(part) != 2 {
return nil, fmt.Errorf("search queries must be in the form field<sep>pattern where <sep> must be one of: = or !=") return nil, errors.New("search queries must be in the form field<sep>pattern where <sep> must be one of: = or !=")
} }
flt := &filter{term: part[0], filter: part[1], criteria: criteria} flt := &filter{term: part[0], filter: part[1], criteria: criteria}
@@ -234,7 +235,6 @@ func addFilters(conf *cfg.Config) ([]types.QueryVariant, error) {
} }
filters[count-1] = rng filters[count-1] = rng
} }
return filters, nil return filters, nil
@@ -247,7 +247,6 @@ func addSort(conf *cfg.Config, search *search.Search) *search.Search {
Index(conf.Index). Index(conf.Index).
Fields(conf.SortBy). Fields(conf.SortBy).
Do(context.Background()) Do(context.Background())
if err != nil { if err != nil {
// whatever it was, do not add Sort() // whatever it was, do not add Sort()
slog.Debug("get field capabilities", "field", conf.SortBy, "error", esErrorString(err)) slog.Debug("get field capabilities", "field", conf.SortBy, "error", esErrorString(err))

View File

@@ -40,8 +40,7 @@ func colorzizeShard(conf *cfg.Config, state, name string) string {
} }
func resolvePrirep(state string) string { func resolvePrirep(state string) string {
switch state { if state == "p" {
case "p":
return "primary" return "primary"
} }
@@ -86,7 +85,7 @@ func ShardList(conf *cfg.Config) error {
res, err := conf.DefaultCluster.ES().Cat.Shards(). res, err := conf.DefaultCluster.ES().Cat.Shards().
Do(context.Background()) Do(context.Background())
if err != nil { if err != nil {
return fmt.Errorf("failed to get shards: %s", esErrorString(err)) return fmt.Errorf("failed to get shards: %w", esErrorString(err))
} }
shardlist := filterShards(conf, res) shardlist := filterShards(conf, res)
@@ -107,7 +106,7 @@ func printShards(conf *cfg.Config, shardlist shards.Response) error {
for idx, shard := range shardlist { for idx, shard := range shardlist {
name := colorzizeShard(conf, *shard.State, *shard.Index) name := colorzizeShard(conf, *shard.State, *shard.Index)
table.Entries[idx] = []string{ table.Entries[idx] = []any{
name, name,
*shard.Shard, *shard.Shard,
resolvePrirep(*shard.Prirep), resolvePrirep(*shard.Prirep),
@@ -125,6 +124,7 @@ func printShards(conf *cfg.Config, shardlist shards.Response) error {
} }
table.Sort() table.Sort()
if err := table.Print(); err != nil { if err := table.Print(); err != nil {
return err return err
} }
@@ -136,7 +136,7 @@ func ShardShow(conf *cfg.Config, index string) error {
res, err := conf.DefaultCluster.ES().Cat.Shards().Index(index). res, err := conf.DefaultCluster.ES().Cat.Shards().Index(index).
Do(context.Background()) Do(context.Background())
if err != nil { if err != nil {
return fmt.Errorf("failed to get shards: %s", esErrorString(err)) return fmt.Errorf("failed to get shards: %w", esErrorString(err))
} }
slog.Debug("ES result", "shards", res) slog.Debug("ES result", "shards", res)
@@ -145,3 +145,63 @@ func ShardShow(conf *cfg.Config, index string) error {
return printShards(conf, res) return printShards(conf, res)
} }
func ShardAllocation(conf *cfg.Config, index string) error {
explain := conf.DefaultCluster.ES().Cluster.AllocationExplain().
Index(index).
Primary(conf.Primary).
Shard(conf.Shards)
if conf.FromNode != "" {
explain.CurrentNode(conf.FromNode)
}
res, err := explain.Do(context.Background())
if err != nil {
return fmt.Errorf("failed to get shard allocation explain: %w", esErrorString(err))
}
slog.Debug("ES result", "explain", res)
currentNode := res.CurrentNode
table := printer.NewTable(conf, 2, 10)
table.Addheaders("shard allocation setting", "value")
roles := make([]string, len(currentNode.Roles))
for idx, role := range currentNode.Roles {
roles[idx] = role.Name
}
table.Entries = [][]any{
{"Index", index},
{"Current state", res.CurrentState},
{"Current node", currentNode.Name},
{"Current k8s node", currentNode.Attributes["k8s_node_name"]},
{"Current node address", currentNode.TransportAddress},
{"Current node id", currentNode.Id},
{"Current node weight", currentNode.WeightRanking},
{"Current node roles", roles},
{"Can rebalance cluster", res.CanRebalanceCluster.Name},
{"Can rebalance to another node", res.CanRebalanceToOtherNode.Name},
{"Can remain on current node", res.CanRemainOnCurrentNode.Name},
}
if res.CurrentState == "unassigned" {
table.AddRow("Unassignment reason", res.UnassignedInfo.Reason.String()+" at "+res.UnassignedInfo.At.(string))
}
for _, nodeDecision := range res.NodeAllocationDecisions {
for _, decider := range nodeDecision.Deciders {
table.AddRow("Allocation decider", decider.Decider)
table.AddRow(" -> decision", decider.Decision.String())
table.AddRow(" -> explanation", decider.Explanation)
}
}
if err := table.Print(); err != nil {
return err
}
return nil
}

View File

@@ -45,7 +45,7 @@ func SnapshotList(conf *cfg.Config) error {
// get partial indicies // get partial indicies
ires, err := conf.DefaultCluster.ES().Cat.Indices().Do(context.Background()) ires, err := conf.DefaultCluster.ES().Cat.Indices().Do(context.Background())
if err != nil { if err != nil {
return fmt.Errorf("failed to get indicies: %s", esErrorString(err)) return fmt.Errorf("failed to get indicies: %w", esErrorString(err))
} }
indicies := map[string]int{} indicies := map[string]int{}
@@ -58,7 +58,7 @@ func SnapshotList(conf *cfg.Config) error {
// get snapshots // get snapshots
sres, err := conf.DefaultCluster.ES().Cat.Snapshots().Do(context.Background()) sres, err := conf.DefaultCluster.ES().Cat.Snapshots().Do(context.Background())
if err != nil { if err != nil {
return fmt.Errorf("failed to get snapshots: %s", esErrorString(err)) return fmt.Errorf("failed to get snapshots: %w", esErrorString(err))
} }
slog.Debug("ES result", "indicies", sres) slog.Debug("ES result", "indicies", sres)
@@ -88,7 +88,7 @@ func SnapshotList(conf *cfg.Config) error {
table.Addheaders("name", "index", "start", "orphaned", "status") table.Addheaders("name", "index", "start", "orphaned", "status")
for idx, snap := range snapshots { for idx, snap := range snapshots {
table.Entries[idx] = []string{ table.Entries[idx] = []any{
snap.Name, snap.Name,
snap.Forindex, snap.Forindex,
snap.Start, snap.Start,
@@ -98,6 +98,7 @@ func SnapshotList(conf *cfg.Config) error {
} }
table.Sort() table.Sort()
if err := table.Print(); err != nil { if err := table.Print(); err != nil {
return err return err
} }
@@ -108,7 +109,7 @@ func SnapshotList(conf *cfg.Config) error {
func SnapshotShow(conf *cfg.Config, snapshot string) error { func SnapshotShow(conf *cfg.Config, snapshot string) error {
res, err := conf.DefaultCluster.ES().Snapshot.Get("*", snapshot).Do(context.Background()) res, err := conf.DefaultCluster.ES().Snapshot.Get("*", snapshot).Do(context.Background())
if err != nil { if err != nil {
return fmt.Errorf("failed to get snapshot: %s", esErrorString(err)) return fmt.Errorf("failed to get snapshot: %w", esErrorString(err))
} }
slog.Debug("ES result", "snapshot", res) slog.Debug("ES result", "snapshot", res)
@@ -122,20 +123,20 @@ func SnapshotShow(conf *cfg.Config, snapshot string) error {
snap := res.Snapshots[0] snap := res.Snapshots[0]
table.Entries = [][]string{ table.Entries = [][]any{
{"snapshot", snapshot}, {"snapshot", snapshot},
{"uuid", snap.Uuid}, {"uuid", snap.Uuid},
{"repository", *snap.Repository}, {"repository", *snap.Repository},
{"version_id", fmt.Sprintf("%d", snap.VersionId)}, {"version_id", snap.VersionId},
{"version", *snap.Version}, {"version", *snap.Version},
{"include_global_state", fmt.Sprintf("%t", *snap.IncludeGlobalState)}, {"include_global_state", *snap.IncludeGlobalState},
{"state", *snap.State}, {"state", *snap.State},
{"start_time", fmt.Sprintf("%s", snap.StartTime)}, {"start_time", snap.StartTime},
{"end_time", fmt.Sprintf("%s", snap.EndTime)}, {"end_time", snap.EndTime},
{"duration_in_millis", fmt.Sprintf("%d", *snap.DurationInMillis)}, {"duration_in_millis", *snap.DurationInMillis},
{"shards-total", fmt.Sprintf("%d", snap.Shards.Total)}, {"shards-total", snap.Shards.Total},
{"shards-failed", fmt.Sprintf("%d", snap.Shards.Failed)}, {"shards-failed", snap.Shards.Failed},
{"shards-successful", fmt.Sprintf("%d", snap.Shards.Successful)}, {"shards-successful", snap.Shards.Successful},
} }
if err := table.Print(); err != nil { if err := table.Print(); err != nil {

View File

@@ -30,15 +30,15 @@ import (
func TaskList(conf *cfg.Config) error { func TaskList(conf *cfg.Config) error {
res, err := conf.DefaultCluster.ES().Cat.Tasks(). res, err := conf.DefaultCluster.ES().Cat.Tasks().
Do(context.Background()) Do(context.Background())
if err != nil { if err != nil {
return fmt.Errorf("failed to get index templates: %s", esErrorString(err)) return fmt.Errorf("failed to get index templates: %w", esErrorString(err))
} }
slog.Debug("res", "tasks", res) slog.Debug("res", "tasks", res)
table := printer.NewTable(conf, 8, len(res)) table := printer.NewTable(conf, 8, len(res))
table.Addheaders("task id", "action", "start time", "run time", "node", "type") table.Addheaders("task id", "action", "start time", "run time", "node", "type")
for idx, task := range res { for idx, task := range res {
var ts time.Time var ts time.Time
@@ -49,10 +49,10 @@ func TaskList(conf *cfg.Config) error {
ts = time.Unix(int64(epc/1000), 0) ts = time.Unix(int64(epc/1000), 0)
} }
table.Entries[idx] = []string{ table.Entries[idx] = []any{
*task.TaskId, *task.TaskId,
*task.Action, *task.Action,
ts.Format("2006-01-02T15:04:05"), ts,
*task.RunningTime, *task.RunningTime,
*task.Node, *task.Node,
*task.Type, *task.Type,
@@ -66,9 +66,8 @@ func TaskCancel(conf *cfg.Config, taskid string) error {
_, err := conf.DefaultCluster.ES().Tasks.Cancel(). _, err := conf.DefaultCluster.ES().Tasks.Cancel().
TaskId(taskid). TaskId(taskid).
Do(context.Background()) Do(context.Background())
if err != nil { if err != nil {
return fmt.Errorf("failed to cancel task: %s", esErrorString(err)) return fmt.Errorf("failed to cancel task: %w", esErrorString(err))
} }
return nil return nil

31
pkg/printer/bytes.go Normal file
View File

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

99
pkg/printer/cast.go Normal file
View File

@@ -0,0 +1,99 @@
/*
Copyright © 2026 Thomas von Dein
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package printer
import (
"fmt"
"strconv"
"strings"
"time"
"github.com/elastic/go-elasticsearch/v9/typedapi/types"
)
func any2string(in any) string {
//nolint:gocritic
switch val := in.(type) {
case string:
return val
case bool:
return strconv.FormatBool(val)
case int64:
return strconv.FormatInt(val, 10)
case int:
return strconv.Itoa(val)
case float64:
return fmt.Sprintf("%.2f", val)
case []string:
return strings.Join(val, ",")
case ByteSize:
return val.String()
case time.Time:
return val.Format("2006-01-02 15:04:05")
case []byte:
return string(val)
case nil:
return "null"
case types.Percentage, types.DateTime:
return val.(string)
}
return ""
}
func (data *Table) preprocessRows() {
if data.processed {
// only do it once
return
}
// convert entries to strings
data.rows = make([][]string, len(data.Entries))
for rowidx, entries := range data.Entries {
data.rows[rowidx] = make([]string, len(entries))
for colidx, entry := range data.Entries[rowidx] {
data.rows[rowidx][colidx] = any2string(entry)
}
}
// determine header lenght's
for idx, head := range data.Headers {
data.lenHeaders[idx] = visibleLen(head)
}
// determine max width per column
for _, entries := range data.rows {
currentWidth := 0
for idx, entry := range entries {
length := visibleLen(entry)
if data.lenHeaders[idx] < length {
if length > currentWidth+data.maxwidth {
data.lenHeaders[idx] = data.maxwidth - currentWidth
} else {
data.lenHeaders[idx] = length
}
}
currentWidth += data.lenHeaders[idx]
}
}
data.processed = true
}

View File

@@ -33,7 +33,6 @@ func PrintDocs(conf *cfg.Config, hits []types.Hit) {
for _, hit := range hits { for _, hit := range hits {
PrintDoc(conf, hit) PrintDoc(conf, hit)
} }
} }
func PrintDoc(conf *cfg.Config, hit types.Hit) { func PrintDoc(conf *cfg.Config, hit types.Hit) {
@@ -52,6 +51,6 @@ func PrintDoc(conf *cfg.Config, hit types.Hit) {
value := gjson.Get(docjson, conf.Path) value := gjson.Get(docjson, conf.Path)
fmt.Println(value.String()) fmt.Println(value.String())
} else { } else {
fmt.Print(docjson) fmt.Println(docjson)
} }
} }

View File

@@ -34,12 +34,14 @@ var (
titleStyle = func() lipgloss.Style { titleStyle = func() lipgloss.Style {
b := lipgloss.RoundedBorder() b := lipgloss.RoundedBorder()
b.Right = "├" b.Right = "├"
return lipgloss.NewStyle().BorderStyle(b).Padding(0, 1) return lipgloss.NewStyle().BorderStyle(b).Padding(0, 1)
}() }()
infoStyle = func() lipgloss.Style { infoStyle = func() lipgloss.Style {
b := lipgloss.RoundedBorder() b := lipgloss.RoundedBorder()
b.Left = "┤" b.Left = "┤"
return titleStyle.BorderStyle(b) return titleStyle.BorderStyle(b)
}() }()
) )
@@ -56,10 +58,9 @@ func (m model) Init() tea.Cmd {
} }
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var ( var cmd tea.Cmd
cmd tea.Cmd
cmds []tea.Cmd cmds := make([]tea.Cmd, 1)
)
switch msg := msg.(type) { switch msg := msg.(type) {
case tea.KeyMsg: case tea.KeyMsg:
@@ -90,7 +91,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Handle keyboard and mouse events in the viewport // Handle keyboard and mouse events in the viewport
m.viewport, cmd = m.viewport.Update(msg) m.viewport, cmd = m.viewport.Update(msg)
cmds = append(cmds, cmd) cmds[0] = cmd
return m, tea.Batch(cmds...) return m, tea.Batch(cmds...)
} }
@@ -99,6 +100,7 @@ func (m model) View() string {
if !m.ready { if !m.ready {
return "\n Initializing..." return "\n Initializing..."
} }
return fmt.Sprintf("%s\n%s\n%s", m.headerView(), m.viewport.View(), m.footerView()) return fmt.Sprintf("%s\n%s\n%s", m.headerView(), m.viewport.View(), m.footerView())
} }
@@ -106,30 +108,25 @@ func (m model) headerView() string {
// title := titleStyle.Render("RPN Help Overview") // title := titleStyle.Render("RPN Help Overview")
title := titleStyle.Render(m.title) title := titleStyle.Render(m.title)
line := strings.Repeat("─", max(0, m.viewport.Width-lipgloss.Width(title))) line := strings.Repeat("─", max(0, m.viewport.Width-lipgloss.Width(title)))
return lipgloss.JoinHorizontal(lipgloss.Center, title, line) return lipgloss.JoinHorizontal(lipgloss.Center, title, line)
} }
func (m model) footerView() string { func (m model) footerView() string {
info := infoStyle.Render(fmt.Sprintf("%3.f%%", m.viewport.ScrollPercent()*100)) info := infoStyle.Render(fmt.Sprintf("%3.f%%", m.viewport.ScrollPercent()*100))
line := strings.Repeat("─", max(0, m.viewport.Width-lipgloss.Width(info))) line := strings.Repeat("─", max(0, m.viewport.Width-lipgloss.Width(info)))
return lipgloss.JoinHorizontal(lipgloss.Center, line, info) return lipgloss.JoinHorizontal(lipgloss.Center, line, info)
} }
func max(a, b int) int {
if a > b {
return a
}
return b
}
func Pager(title, message string) { func Pager(title, message string) {
p := tea.NewProgram( pager := tea.NewProgram(
model{content: message, title: title}, model{content: message, title: title},
tea.WithAltScreen(), // use the full size of the terminal in its "alternate screen buffer" tea.WithAltScreen(), // use the full size of the terminal in its "alternate screen buffer"
tea.WithMouseCellMotion(), // turn on mouse support so we can track the mouse wheel tea.WithMouseCellMotion(), // turn on mouse support so we can track the mouse wheel
) )
if _, err := p.Run(); err != nil { if _, err := pager.Run(); err != nil {
fmt.Println("could not run pager:", err) fmt.Println("could not run pager:", err)
os.Exit(1) os.Exit(1)
} }

View File

@@ -25,42 +25,60 @@ import (
"strings" "strings"
"codeberg.org/scip/esctl/pkg/cfg" "codeberg.org/scip/esctl/pkg/cfg"
"github.com/olekukonko/tablewriter" "github.com/seeruk/go-wordwrap"
"github.com/olekukonko/tablewriter/renderer"
"github.com/olekukonko/tablewriter/tw"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
type Table struct { type Table struct {
Mode string // table, markdown, json Mode string // tsv, json, yaml
Headers []string Headers []string
Entries [][]string Entries [][]any
rows [][]string // representation used for printing
processed bool
lenHeaders []int lenHeaders []int
alignInts bool alignInts bool
maxwidth int
} }
func NewTable(conf *cfg.Config, columns, rows int) *Table { func NewTable(conf *cfg.Config, columns, rows int) *Table {
table := Table{Mode: conf.Output} table := Table{Mode: conf.Output, maxwidth: cfg.GetTermWidth()}
table.Headers = make([]string, columns) table.Headers = make([]string, columns)
table.Entries = make([][]string, rows) table.Entries = make([][]any, rows)
table.lenHeaders = make([]int, columns) table.lenHeaders = make([]int, columns)
table.alignInts = conf.AlignInts table.alignInts = conf.AlignInts
return &table return &table
} }
func (data *Table) Print() error { func NewTableEmpty(conf *cfg.Config) *Table {
switch data.Mode { table := Table{Mode: conf.Output, maxwidth: cfg.GetTermWidth()}
case "markdown", "md": table.alignInts = conf.AlignInts
return data.PrintMarkdown()
return &table
}
func (table *Table) WithHeaders(headers ...string) *Table {
count := len(headers)
table.Entries = [][]any{}
table.lenHeaders = make([]int, count)
table.Headers = make([]string, count)
table.Addheaders(headers...)
return table
}
func (table *Table) Print() error {
switch table.Mode {
case "json": case "json":
return data.PrintJSON() return table.PrintJSON()
case "yaml": case "yaml":
return data.PrintYAML() return table.PrintYAML()
default: default:
return data.PrintTSV() return table.PrintTSV()
} }
} }
@@ -68,236 +86,140 @@ var (
ansiCtrlSeq = regexp.MustCompile(`\033.[0-9;]+m`) ansiCtrlSeq = regexp.MustCompile(`\033.[0-9;]+m`)
) )
// needed for json and yaml output func (table *Table) PrintYAML() error {
func (data *Table) toMap() []map[string]string { raw := table.toMap()
raw := make([]map[string]string, len(data.Entries))
for idx, entries := range data.Entries { body, err := yaml.Marshal(raw)
raw[idx] = make(map[string]string, len(data.Headers)) if err != nil {
return fmt.Errorf("failed to produce YAML output: %w", err)
}
fmt.Println(string(body))
return nil
}
func (table *Table) PrintJSON() error {
raw := table.toMap()
body, err := json.MarshalIndent(raw, "", " ")
if err != nil {
return fmt.Errorf("failed to produce JSON output: %w", err)
}
fmt.Println(string(body))
return nil
}
func (table *Table) PrintTSV() error {
// length's, convert cell types
table.preprocessRows()
// output headers
for idx, header := range table.Headers {
if idx+1 != len(table.Headers) {
fmt.Print(header, strings.Repeat(" ", table.lenHeaders[idx]-visibleLen(header)))
} else {
// no padding for last header
fmt.Print(header)
}
if idx < len(table.Headers)-1 {
fmt.Print(" ")
}
}
fmt.Println()
for _, entries := range table.rows {
currentWidth := 0
for idx, entry := range entries {
length := visibleLen(entry)
if length+currentWidth > table.maxwidth && table.maxwidth-currentWidth > 1 {
// text is too wide to be put into one line, wrap it
wrapper := wordwrap.Wrapper(table.maxwidth-currentWidth, false)
wrapped := wrapper(entry)
// and indent it
for idx, line := range strings.Split(wrapped, "\n") {
if idx == 0 {
entry = line
} else {
entry += "\n " + strings.Repeat(" ", currentWidth) + line
}
}
}
currentWidth += table.lenHeaders[idx]
switch {
case isInt(entry) && table.alignInts:
// align right
fmt.Print(strings.Repeat(" ", table.lenHeaders[idx]-length), entry)
case length < table.lenHeaders[idx] && idx+1 != len(entries):
// pad right, if required
fmt.Print(entry, strings.Repeat(" ", table.lenHeaders[idx]-length))
default:
// no padding for last entry
fmt.Print(entry)
}
if idx < len(table.Headers)-1 {
fmt.Print(" ")
}
}
fmt.Println()
}
return nil
}
func (table *Table) Sort() {
// sanity checks
if len(table.Entries) == 0 {
return
}
table.preprocessRows()
sort.Slice(table.rows, func(i, j int) bool {
return table.rows[i][0] < table.rows[j][0]
})
}
func (table *Table) Addheaders(headers ...string) {
for idx, header := range headers {
switch table.Mode {
case "json", "yaml":
table.Headers[idx] = strings.ReplaceAll(strings.ToLower(header), " ", "_")
default:
table.Headers[idx] = bold(strings.ReplaceAll(strings.ToUpper(header), " ", "-"))
}
}
}
func (table *Table) AddRow(fields ...any) {
table.Entries = append(table.Entries, fields)
}
// needed for json and yaml output
func (table *Table) toMap() []map[string]any {
raw := make([]map[string]any, len(table.Entries))
for idx, entries := range table.Entries {
raw[idx] = make(map[string]any, len(table.Headers))
for eidx, entry := range entries { for eidx, entry := range entries {
raw[idx][data.Headers[eidx]] = entry raw[idx][table.Headers[eidx]] = entry
} }
} }
return raw return raw
} }
func (data *Table) PrintYAML() error {
raw := data.toMap()
body, err := yaml.Marshal(raw)
if err != nil {
return fmt.Errorf("failed to produce YAML output: %s", err)
}
fmt.Println(string(body))
return nil
}
func (data *Table) PrintJSON() error {
raw := data.toMap()
body, err := json.MarshalIndent(raw, "", " ")
if err != nil {
return fmt.Errorf("failed to produce JSON output: %s", err)
}
fmt.Println(string(body))
return nil
}
// Well. tablewriter should be able to print in TSV mode, but it
// doesn't work because of the various ansic color escapes we're
// using. Therefore we do it the hard way and use our own TSV printer.
func (data *Table) PrintTSV() error {
// determine lenght's
for idx, head := range data.Headers {
data.lenHeaders[idx] = visibleLen(head)
}
for _, entries := range data.Entries {
for idx, entry := range entries {
length := visibleLen(entry)
if data.lenHeaders[idx] < length {
data.lenHeaders[idx] = length
}
}
}
// output
for idx, header := range data.Headers {
fmt.Print(header, strings.Repeat(" ", data.lenHeaders[idx]-visibleLen(header)))
if idx < len(data.Headers)-1 {
fmt.Print(" ")
}
}
fmt.Println()
for _, entries := range data.Entries {
for idx, entry := range entries {
length := visibleLen(entry)
if isInt(entry) && data.alignInts {
// align right
fmt.Print(strings.Repeat(" ", data.lenHeaders[idx]-length), entry)
} else {
fmt.Print(entry, strings.Repeat(" ", data.lenHeaders[idx]-length))
}
if idx < len(data.Headers)-1 {
fmt.Print(" ")
}
}
fmt.Println()
}
return nil
}
func (data *Table) PrintTabularFail() error {
OFS := " "
tableString := &strings.Builder{}
styleTSV := tw.NewSymbolCustom("space").WithColumn("\t")
table := tablewriter.NewTable(tableString,
tablewriter.WithRenderer(
renderer.NewBlueprint(tw.Rendition{
Borders: tw.BorderNone,
Symbols: styleTSV,
Settings: tw.Settings{
Separators: tw.SeparatorsNone,
Lines: tw.LinesNone,
},
})),
tablewriter.WithConfig(tablewriter.Config{
Header: tw.CellConfig{
Formatting: tw.CellFormatting{
AutoFormat: tw.Off,
},
Padding: tw.CellPadding{Global: tw.Padding{Left: "", Right: OFS}},
},
Row: tw.CellConfig{
Formatting: tw.CellFormatting{
AutoWrap: tw.WrapNone,
Alignment: tw.AlignLeft,
},
Padding: tw.CellPadding{Global: tw.Padding{Right: OFS}},
},
Debug: true,
}),
)
table.Header(data.Headers)
if err := table.Bulk(data.Entries); err != nil {
return fmt.Errorf("failed to add data to table renderer: %s", err)
}
if err := table.Render(); err != nil {
return fmt.Errorf("failed to render table: %s", err)
}
// we need to trim our output here, because tablewriter appends
// excess whitespace to our rows.
cleanedString := &strings.Builder{}
for _, row := range strings.Split(strings.TrimSpace(tableString.String()), "\n") {
cleanedString.WriteString(strings.TrimSpace(row))
cleanedString.WriteString("\n")
}
fmt.Println(cleanedString.String())
return nil
}
func (data *Table) PrintMarkdown() error {
tableString := &strings.Builder{}
table := tablewriter.NewTable(tableString,
tablewriter.WithRenderer(
renderer.NewBlueprint(
tw.Rendition{
Borders: tw.Border{
Left: tw.On,
Right: tw.On,
Top: tw.Off,
Bottom: tw.Off,
},
Settings: tw.Settings{
Separators: tw.Separators{
ShowHeader: tw.On,
ShowFooter: tw.Off,
BetweenRows: tw.Off,
BetweenColumns: 0,
},
},
Symbols: tw.NewSymbols(tw.StyleMarkdown),
})),
tablewriter.WithConfig(
tablewriter.Config{
Header: tw.CellConfig{
Formatting: tw.CellFormatting{
Alignment: tw.AlignLeft,
AutoFormat: tw.Off,
},
},
Row: tw.CellConfig{
Formatting: tw.CellFormatting{
Alignment: tw.AlignLeft,
},
},
},
),
)
table.Header(data.Headers)
if err := table.Bulk(data.Entries); err != nil {
return fmt.Errorf("failed to add data to table renderer: %s", err)
}
if err := table.Render(); err != nil {
return fmt.Errorf("failed to render table: %s", err)
}
fmt.Println(tableString.String())
return nil
}
func (data *Table) Sort() {
// sanity checks
if len(data.Entries) == 0 {
return
}
sort.Slice(data.Entries, func(i, j int) bool {
return data.Entries[i][0] < data.Entries[j][0]
})
}
func (data *Table) Addheaders(headers ...string) {
for idx, header := range headers {
switch data.Mode {
case "json", "yaml":
data.Headers[idx] = strings.ReplaceAll(strings.ToLower(header), " ", "_")
default:
data.Headers[idx] = bold(strings.ReplaceAll(strings.ToUpper(header), " ", "-"))
}
}
}
func (data *Table) AddRow(fields ...string) {
data.Entries = append(data.Entries, fields)
}
// return the length of a string but only visible chars, w/o ansi color escapes // return the length of a string but only visible chars, w/o ansi color escapes
func visibleLen(word string) int { func visibleLen(word string) int {
return len(ansiCtrlSeq.ReplaceAllLiteralString(word, "")) return len(ansiCtrlSeq.ReplaceAllLiteralString(word, ""))

6
t/.env Normal file
View File

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

46
t/Makefile Normal file
View File

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

77
t/README.md Normal file
View File

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

7
t/cluster.yaml Normal file
View File

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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