Compare commits

..

14 Commits

Author SHA1 Message Date
355e3051b4 updtree 2026-06-22 14:13:15 +02:00
T. von Dein
a81d6b94e9 enhance/clusterstatus (#44)
fixes #35
2026-06-22 14:11:32 +02:00
T. von Dein
9b5ba0775b add ilm management support (#43) 2026-06-22 13:52:02 +02:00
T. von Dein
802217158c feature/add-profiler (#42) 2026-06-18 08:54:00 +02:00
5d345aaa9a fixed another panic 2026-06-17 14:26:28 +02:00
2f029304f3 fix panic and width determination 2026-06-17 14:16:26 +02:00
49bc2c7acb use real terminal width to determine markdown width in 'api show' 2026-06-17 13:33:03 +02:00
T. von Dein
f1ff361c12 add API viewer, mv repl subcommand to api (#41) 2026-06-17 13:11:09 +02:00
072e949147 upd command tree 2026-06-16 13:51:46 +02:00
T. von Dein
ce4a60f8e2 add alias+datastream rollover, and auto rollover on index tpl change (#40)
fixes #37
2026-06-16 13:48:41 +02:00
T. von Dein
97004e0957 add task support (#39)
fixes #34
2026-06-16 08:25:25 +02:00
T. von Dein
d1f3082e5a refactored IndexTemplateModify() (#38) 2026-06-16 08:22:02 +02:00
T. von Dein
d0d1d69791 add index template {ls,sh} (#33) 2026-06-15 15:18:21 +02:00
T. von Dein
ec1ce56b2d add datastream support, refactor custom completion (#32) 2026-06-10 15:43:14 +02:00
40 changed files with 164464 additions and 459 deletions

2
.gitignore vendored
View File

@@ -30,3 +30,5 @@ esctl
*.sh *.sh
*.json *.json
*.log *.log
cpu.profile

View File

@@ -2,7 +2,7 @@ matrix:
platform: platform:
- linux/amd64 - linux/amd64
goversion: goversion:
- 1.25 - 1.25.8
labels: labels:
platform: ${platform} platform: ${platform}

View File

@@ -23,17 +23,27 @@ UID = root
GID = 0 GID = 0
HAVE_POD := $(shell pod2text -h 2>/dev/null) HAVE_POD := $(shell pod2text -h 2>/dev/null)
BRANCH = $(shell git branch --show-current) BRANCH = $(shell git branch --show-current)
COMMIT = $(shell git rev-parse --short=8 HEAD) COMMIT = $(shell git rev-parse --short=8 HEAD)
BUILD = $(shell date +%Y.%m.%d.%H%M%S) BUILD = $(shell date +%Y.%m.%d.%H%M%S)
GOVERSION = $(shell grep -E "^go" go.mod | cut -d' ' -f2) GOVERSION = $(shell grep -E "^go" go.mod | cut -d' ' -f2)
APIVERSION = $(shell grep github.com/elastic/go-elasticsearch go.mod | cut -d' ' -f2) APIVERSION = $(shell grep github.com/elastic/go-elasticsearch go.mod | cut -d' ' -f2)
APISPEC = "https://www.elastic.co/docs/api/doc/elasticsearch.json"
all: buildlocal all: buildlocal
buildlocal: # pkg/es/openspec.go:
# echo "package es" > pkg/es/openspec.go
# echo >> pkg/es/openspec.go
# echo "const Openspec = \`" >> pkg/es/openspec.go
# curl -o - $(APISPEC) | sed "s/\`/'/g" >> pkg/es/openspec.go
# echo "\`" >> pkg/es/openspec.go
assets/openapi.json:
curl -o assets/openapi.json $(APISPEC)
buildlocal: assets/openapi.json
CGO_LDFLAGS='-static' go build -tags osusergo,netgo -ldflags "-extldflags=-static" \ CGO_LDFLAGS='-static' go build -tags osusergo,netgo -ldflags "-extldflags=-static" \
-ldflags "-X codeberg.org/scip/esctl/pkg/cfg.BRANCH=$(BRANCH) \ -ldflags "-X codeberg.org/scip/esctl/pkg/cfg.BRANCH=$(BRANCH) \
-X codeberg.org/scip/esctl/pkg/cfg.COMMIT=$(COMMIT) \ -X codeberg.org/scip/esctl/pkg/cfg.COMMIT=$(COMMIT) \
@@ -50,7 +60,7 @@ install: buildlocal
install -o $(UID) -g $(GID) -m 444 $(tool).1 $(PREFIX)/man/man1/ install -o $(UID) -g $(GID) -m 444 $(tool).1 $(PREFIX)/man/man1/
clean: clean:
rm -rf $(tool) coverage.out testdata t/out rm -rf $(tool) coverage.out testdata t/out pkg/es/openspec.go
test: clean test: clean
mkdir -p t/out mkdir -p t/out
@@ -96,5 +106,8 @@ show-versions: buildlocal
@echo "### go version used for building:" @echo "### go version used for building:"
@grep -m 1 go go.mod @grep -m 1 go go.mod
# lint: .PHONY: profile
# golangci-lint run -p bugs -p unused profile: buildlocal
./esctl api ls --profile-file cpu.profile
go tool pprof -text esctl cpu.profile
go tool pprof --http localhost:8888 ./esctl cpu.profile

View File

@@ -51,6 +51,10 @@ Features:
Command tree: Command tree:
```console ```console
api
list
repl
show
ccr ccr
follower follower
add add
@@ -70,6 +74,12 @@ Command tree:
list list
set set
status status
datastream
create
delete
list
rollover
show
debug debug
doc doc
add add
@@ -77,23 +87,36 @@ Command tree:
show show
help help
help-jsonpath help-jsonpath
ilm
create
list
retry
show
status
index index
alias alias
create create
delete delete
list list
rollover
allocation allocation
close close
create create
delete delete
fields fields
ilm
list list
modify modify
show show
template
create
delete
list
modify
show
node node
list list
show show
repl
role role
diff diff
list list
@@ -105,6 +128,9 @@ Command tree:
snapshot snapshot
list list
show show
task
cancel
list
version version
``` ```

43
assets/assets.go Normal file
View File

@@ -0,0 +1,43 @@
/*
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 assets
import (
"embed"
"github.com/go-openapi/loads"
"github.com/go-openapi/swag/loading"
)
//go:embed *.json
var AssetFS embed.FS
var OpenAPI *loads.Document
func LoadAssetOpenApi() {
doc, err := loads.Spec(
"openapi.json",
loads.WithLoadingOptions(
loading.WithFS(AssetFS),
))
if err != nil {
panic(err)
}
OpenAPI = doc
}

160985
assets/openapi.json Normal file

File diff suppressed because one or more lines are too long

90
cmd/api.go Normal file
View File

@@ -0,0 +1,90 @@
/*
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 Api(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "api",
Usage: "manage apiuments",
Commands: []*cli.Command{
ApiList(conf),
ApiShow(conf),
ApiRepl(conf),
},
}
}
func ApiList(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "list",
Aliases: []string{"ls"},
Usage: "list index of API calls",
UsageText: "list [<pattern>]",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "tag",
Usage: "list only operations matching <tag>",
Destination: &conf.Tag,
Aliases: []string{"t"},
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
return es.ApiList(conf, cmd.Args().Get(0))
},
}
}
func ApiShow(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "show",
Aliases: []string{"sh"},
Usage: "show an API doc",
UsageText: "show <pattern> [<verb>]",
Action: func(ctx context.Context, cmd *cli.Command) error {
return es.ApiShow(conf, cmd.Args().Get(0), cmd.Args().Get(1))
},
ShellComplete: func(ctx context.Context, cmd *cli.Command) {
complete(cmd, Capi)
},
}
}
func ApiRepl(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "repl",
Aliases: []string{"shell"},
Usage: "interactive API repl",
Action: func(ctx context.Context, cmd *cli.Command) error {
return es.ApiRepl(conf)
},
}
}

View File

@@ -59,7 +59,7 @@ func CcrStatus(conf *cfg.Config) *cli.Command {
}, },
ShellComplete: func(ctx context.Context, cmd *cli.Command) { ShellComplete: func(ctx context.Context, cmd *cli.Command) {
completeCluster(cmd) complete(cmd, Ccluster)
}, },
Action: func(ctx context.Context, cmd *cli.Command) error { Action: func(ctx context.Context, cmd *cli.Command) error {
@@ -115,7 +115,7 @@ func CcrRemoteInfo(conf *cfg.Config) *cli.Command {
UsageText: "info [options] [<index>]", UsageText: "info [options] [<index>]",
ShellComplete: func(ctx context.Context, cmd *cli.Command) { ShellComplete: func(ctx context.Context, cmd *cli.Command) {
completeIndex(cmd) complete(cmd, Cindex)
}, },
Action: func(ctx context.Context, cmd *cli.Command) error { Action: func(ctx context.Context, cmd *cli.Command) error {

View File

@@ -60,7 +60,7 @@ func CcrFollowerRenew(conf *cfg.Config) *cli.Command {
}, },
ShellComplete: func(ctx context.Context, cmd *cli.Command) { ShellComplete: func(ctx context.Context, cmd *cli.Command) {
completeIndex(cmd) complete(cmd, Cindex)
}, },
Action: func(ctx context.Context, cmd *cli.Command) error { Action: func(ctx context.Context, cmd *cli.Command) error {
@@ -82,7 +82,7 @@ func CcrFollowerResume(conf *cfg.Config) *cli.Command {
UsageText: "resume [options] <index>", UsageText: "resume [options] <index>",
ShellComplete: func(ctx context.Context, cmd *cli.Command) { ShellComplete: func(ctx context.Context, cmd *cli.Command) {
completeIndex(cmd) complete(cmd, Cindex)
}, },
Action: func(ctx context.Context, cmd *cli.Command) error { Action: func(ctx context.Context, cmd *cli.Command) error {
@@ -104,7 +104,7 @@ func CcrFollowerPause(conf *cfg.Config) *cli.Command {
UsageText: "pause [options] <index>", UsageText: "pause [options] <index>",
ShellComplete: func(ctx context.Context, cmd *cli.Command) { ShellComplete: func(ctx context.Context, cmd *cli.Command) {
completeIndex(cmd) complete(cmd, Cindex)
}, },
Action: func(ctx context.Context, cmd *cli.Command) error { Action: func(ctx context.Context, cmd *cli.Command) error {
@@ -126,7 +126,7 @@ func CcrFollowerUnfollow(conf *cfg.Config) *cli.Command {
UsageText: "unfollow [options] <index>", UsageText: "unfollow [options] <index>",
ShellComplete: func(ctx context.Context, cmd *cli.Command) { ShellComplete: func(ctx context.Context, cmd *cli.Command) {
completeIndex(cmd) complete(cmd, Cindex)
}, },
Action: func(ctx context.Context, cmd *cli.Command) error { Action: func(ctx context.Context, cmd *cli.Command) error {
@@ -158,7 +158,7 @@ func CcrFollowerAdd(conf *cfg.Config) *cli.Command {
}, },
ShellComplete: func(ctx context.Context, cmd *cli.Command) { ShellComplete: func(ctx context.Context, cmd *cli.Command) {
completeIndex(cmd) complete(cmd, Cindex)
}, },
Action: func(ctx context.Context, cmd *cli.Command) error { Action: func(ctx context.Context, cmd *cli.Command) error {
@@ -181,7 +181,7 @@ func CcrFollowerDelete(conf *cfg.Config) *cli.Command {
UsageText: "delete <index>", UsageText: "delete <index>",
ShellComplete: func(ctx context.Context, cmd *cli.Command) { ShellComplete: func(ctx context.Context, cmd *cli.Command) {
completeIndex(cmd) complete(cmd, Cindex)
}, },
Action: func(ctx context.Context, cmd *cli.Command) error { Action: func(ctx context.Context, cmd *cli.Command) error {
@@ -205,7 +205,7 @@ func CcrFollowerShow(conf *cfg.Config) *cli.Command {
UsageText: "show <index>", UsageText: "show <index>",
ShellComplete: func(ctx context.Context, cmd *cli.Command) { ShellComplete: func(ctx context.Context, cmd *cli.Command) {
completeIndex(cmd) complete(cmd, Cindex)
}, },
Action: func(ctx context.Context, cmd *cli.Command) error { Action: func(ctx context.Context, cmd *cli.Command) error {

View File

@@ -27,7 +27,7 @@ import (
) )
const ( const (
SETTINGS = `https://www.elastic.co/docs/reference/elasticsearch/configuration-reference` SETTINGS = ``
) )
func ClusterSettings(conf *cfg.Config) *cli.Command { func ClusterSettings(conf *cfg.Config) *cli.Command {
@@ -85,7 +85,9 @@ func ClusterSettingsSet(conf *cfg.Config) *cli.Command {
Name: "set", Name: "set",
Usage: "set|update cluster settings", Usage: "set|update cluster settings",
Aliases: []string{"set", "update"}, Aliases: []string{"set", "update"},
UsageText: "set [options] setting:value [setting:value ...]\n\nReference: " + SETTINGS, UsageText: "set [options] setting:value [setting:value ...]",
CustomHelpTemplate: addReference(
"https://www.elastic.co/docs/reference/elasticsearch/configuration-reference"),
Flags: []cli.Flag{ Flags: []cli.Flag{
&cli.BoolFlag{ &cli.BoolFlag{

View File

@@ -25,7 +25,16 @@ import (
"github.com/urfave/cli/v3" "github.com/urfave/cli/v3"
) )
func completeIndex(cmd *cli.Command) { const (
Cindex = iota
Cdatastream
Crole
Ccluster
Capi
Cilm
)
func complete(cmd *cli.Command, what int) {
if cmd.NArg() > 0 { if cmd.NArg() > 0 {
return return
} }
@@ -37,53 +46,31 @@ func completeIndex(cmd *cli.Command) {
return return
} }
indices, err := es.IndexNames(conf) var list []string
var err error
switch what {
case Cindex:
list, err = es.IndexNames(conf)
case Crole:
list, err = es.RoleNames(conf)
case Ccluster:
for cluster := range conf.Clusters {
list = append(list, cluster)
}
case Cdatastream:
list, err = es.DatastreamNames(conf)
case Capi:
list = es.ApiPathNames()
case Cilm:
list, err = es.IlmNames(conf)
}
if err != nil { if err != nil {
return return
} }
for _, index := range indices { for _, item := range list {
fmt.Println(index) fmt.Println(item)
}
}
func completeRole(cmd *cli.Command) {
if cmd.NArg() > 0 {
return
}
// FIXME: config should load from root.Before(), see https://github.com/urfave/cli/issues/2348
// workaround: load it directly here
conf := cfg.NewConfig()
if err := conf.Init(); err != nil {
return
}
roles, err := es.RoleNames(conf)
if err != nil {
return
}
for _, role := range roles {
fmt.Println(role)
}
}
func completeCluster(cmd *cli.Command) {
if cmd.NArg() > 0 {
return
}
// FIXME: config should load from root.Before(), see https://github.com/urfave/cli/issues/2348
// workaround: load it directly here
conf := cfg.NewConfig()
if err := conf.Init(); err != nil {
return
}
for cluster := range conf.Clusters {
fmt.Println(cluster)
} }
} }

220
cmd/datastream.go Normal file
View File

@@ -0,0 +1,220 @@
/*
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 Datastream(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "datastream",
Aliases: []string{"ds"},
Usage: "manage data streams",
Commands: []*cli.Command{
DatastreamList(conf),
DatastreamShow(conf),
DatastreamCreate(conf),
DatastreamDelete(conf),
DatastreamRollover(conf),
},
}
}
func DatastreamList(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "list",
Aliases: []string{"ls"},
Usage: "list indicies",
Flags: []cli.Flag{
&cli.IntFlag{
Name: "max",
Usage: "max number of items to process",
Destination: &conf.MaxItems,
Aliases: []string{"m"},
},
&cli.BoolFlag{
Name: "hidden",
Usage: "include hidden data streams",
Destination: &conf.Hidden,
Aliases: []string{"H"},
},
&cli.StringSliceFlag{
Name: "filter",
Usage: "show only data streams matching the filter",
Destination: &conf.Filter,
Aliases: []string{"F"},
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
return es.DatastreamList(conf)
},
}
}
func DatastreamShow(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "show",
Aliases: []string{"sh"},
Usage: "show details about an data stream",
Action: func(ctx context.Context, cmd *cli.Command) error {
index := cmd.Args().Get(0)
if index == "" {
return errors.New("no data stream specified")
}
return es.DatastreamShow(conf, cmd.Args().Get(0))
},
ShellComplete: func(ctx context.Context, cmd *cli.Command) {
complete(cmd, Cdatastream)
},
}
}
func DatastreamCreate(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "create",
Aliases: []string{"+"},
Usage: "create a new data stream",
UsageText: "create [options] <name>",
Action: func(ctx context.Context, cmd *cli.Command) error {
index := cmd.Args().Get(0)
if index == "" {
return errors.New("no data stream specified")
}
return es.DatastreamCreate(conf, cmd.Args().Get(0))
},
ShellComplete: func(ctx context.Context, cmd *cli.Command) {
complete(cmd, Cdatastream)
},
}
}
func DatastreamDelete(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "delete",
Aliases: []string{"rm"},
Usage: "delete a data stream",
UsageText: "delete [options] <name>",
Action: func(ctx context.Context, cmd *cli.Command) error {
index := cmd.Args().Get(0)
if index == "" {
return errors.New("no data stream specified")
}
return es.DatastreamDelete(conf, cmd.Args().Get(0))
},
ShellComplete: func(ctx context.Context, cmd *cli.Command) {
complete(cmd, Cdatastream)
},
}
}
func DatastreamRollover(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "rollover",
Aliases: []string{"roll"},
Usage: "roll over a data stream",
UsageText: "rollover <data stream>",
ShellComplete: func(ctx context.Context, cmd *cli.Command) {
complete(cmd, Cindex)
},
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "wait",
Usage: "wait for active shards",
Destination: &conf.Wait,
Aliases: []string{"w"},
},
&cli.BoolFlag{
Name: "dry-run",
Usage: "checks conditions but does not perform a rollover",
Destination: &conf.Wait,
Aliases: []string{"n"},
},
&cli.StringFlag{
Name: "max-age",
Usage: "roll over after max age (eg: 7d, 2m, 8h)",
Destination: &conf.MaxAge,
},
&cli.IntFlag{
Name: "max-docs",
Usage: "roll over after max docs",
Destination: &conf.MaxDocs,
},
&cli.IntFlag{
Name: "max-primary-shard-size",
Usage: "roll over when primary shard size reaches size",
Destination: &conf.MaxShardSize,
},
&cli.IntFlag{
Name: "max-primary-shard-docs",
Usage: "roll over after max docs in primary shard reached",
Destination: &conf.MaxShardDocs,
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
alias := cmd.Args().Get(0)
if alias == "" {
return errors.New("no data stream specified")
}
return es.DatastreamRollover(conf, alias)
},
}
}
func DatastreamIlm(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "ilm",
Usage: "show ilm status",
UsageText: "ds ilm <name>",
ShellComplete: func(ctx context.Context, cmd *cli.Command) {
complete(cmd, Cdatastream)
},
Action: func(ctx context.Context, cmd *cli.Command) error {
ds := cmd.Args().Get(0)
if ds == "" {
return errors.New("no ds name specified")
}
return es.IlmExplain(conf, ds)
},
}
}

277
cmd/ilm.go Normal file
View File

@@ -0,0 +1,277 @@
/*
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 Ilm(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "ilm",
Usage: "manage index lifecycle",
Commands: []*cli.Command{
IlmRetry(conf),
IlmStatus(conf),
IlmList(conf),
IlmShow(conf),
IlmCreate(conf),
},
}
}
func IlmRetry(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "retry",
Usage: "retry applying an ILM profile to an index",
UsageText: "retry <index>",
Action: func(ctx context.Context, cmd *cli.Command) error {
return es.IlmRetry(conf, cmd.Args().Get(0))
},
}
}
func IlmStatus(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "status",
Usage: "get the current index lifecycle management status",
Action: func(ctx context.Context, cmd *cli.Command) error {
return es.IlmStatus(conf)
},
}
}
func IlmList(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "list",
Aliases: []string{"ls"},
Usage: "list index lifecycle policies",
UsageText: "list [<policy-pattern>]",
Action: func(ctx context.Context, cmd *cli.Command) error {
return es.IlmList(conf, cmd.Args().Get(0))
},
}
}
func IlmShow(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "show",
Aliases: []string{"sh"},
Usage: "show details about an index lifecycle policy",
UsageText: "show <policy>",
Action: func(ctx context.Context, cmd *cli.Command) error {
policy := cmd.Args().Get(0)
if policy == "" {
return errors.New("no policy specified")
}
return es.IlmShow(conf, cmd.Args().Get(0))
},
ShellComplete: func(ctx context.Context, cmd *cli.Command) {
complete(cmd, Cilm)
},
}
}
/*
*
{
"policy": {
"phases": {
"hot": {
"min_age": "0ms",
"actions": {
"rollover": {
"max_primary_shard_size": "50gb",
"max_age": "1d",
"max_docs": 100000000
},
"set_priority": {
"priority": 100
}
}
},
"warm": {
"min_age": "7d",
"actions": {
"migrate": {},
"shrink": {
"number_of_shards": 1
},
"forcemerge": {
"max_num_segments": 1
},
"set_priority": {
"priority": 50
}
}
},
"cold": {
"min_age": "30d",
"actions": {
"migrate": {},
"searchable_snapshot": {
"snapshot_repository": "s3-logs-archive",
"force_merge_index": true
},
"set_priority": {
"priority": 0
}
}
},
"delete": {
"min_age": "90d",
"actions": {
"delete": {
"delete_searchable_snapshot": true
}
}
}
}
}
}
*/
func IlmCreate(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "create",
Aliases: []string{"+"},
Usage: "create a index lifecycle policy",
UsageText: "create [options] <policy>",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "description",
Usage: "policy description",
Destination: &conf.Ilm.Description,
Aliases: []string{"D"},
},
// hot
&cli.StringFlag{
Name: "hot-min-age",
Usage: "hot phase: min age",
Destination: &conf.Ilm.HotMinAge,
},
&cli.StringFlag{
Name: "hot-rollover-max-age",
Usage: "hot phase: rollover after",
Destination: &conf.Ilm.HotRolloverMaxAge,
},
&cli.Int64Flag{
Name: "hot-rollover-max-primary-shard-size",
Usage: "hot phase: max primary shard size",
Destination: &conf.Ilm.HotRolloverMaxPrimaryShardSize,
},
&cli.Int64Flag{
Name: "hot-rollover-max-docs",
Usage: "hot phase: max docs",
Destination: &conf.Ilm.HotRolloverMaxDocs,
},
&cli.BoolFlag{
Name: "hot-readonly",
Usage: "hot phase: make index ro before rollover",
Destination: &conf.Ilm.HotReadonly,
},
// warm
&cli.StringFlag{
Name: "warm-min-age",
Usage: "warm phase: min age",
Destination: &conf.Ilm.WarmMinAge,
},
&cli.IntFlag{
Name: "warm-force-merge-segments",
Usage: "warm phase: force merge segments per shard",
Destination: &conf.Ilm.WarmForceMerge,
},
&cli.IntFlag{
Name: "warm-shrink-shards",
Usage: "warm phase: shrink on number of shards",
Destination: &conf.Ilm.WarmShrinkShards,
},
&cli.IntFlag{
Name: "warm-priority",
Usage: "warm phase: priority",
Destination: &conf.Ilm.WarmPriority,
},
// cold
&cli.StringFlag{
Name: "cold-min-age",
Usage: "cold phase: min age",
Destination: &conf.Ilm.ColdMinAge,
},
&cli.StringFlag{
Name: "cold-searchable-snapshot-repo",
Usage: "cold phase: searchable snapshot repo",
Destination: &conf.Ilm.ColdSearchableSnapshotRepo,
},
&cli.IntFlag{
Name: "cold-force-merge-segments",
Usage: "cold phase: force merge segments per shard",
Destination: &conf.Ilm.ColdForceMerge,
},
&cli.IntFlag{
Name: "cold-priority",
Usage: "cold phase: priority",
Destination: &conf.Ilm.ColdPriority,
},
// frozen
&cli.StringFlag{
Name: "frozen-min-age",
Usage: "frozen phase: min age",
Destination: &conf.Ilm.FrozenMinAge,
},
// delete
&cli.StringFlag{
Name: "delete-min-age",
Usage: "delete phase: min age",
Destination: &conf.Ilm.DeleteMinAge,
},
&cli.BoolFlag{
Name: "delete-searchable-snapshots",
Usage: "delete phase: delete searchable snapshots",
Destination: &conf.Ilm.DeleteSearchableSnapshots,
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
policy := cmd.Args().Get(0)
if policy == "" {
return errors.New("no policy name specified")
}
return es.IlmCreate(conf, cmd.Args().Get(0))
},
}
}

View File

@@ -41,8 +41,12 @@ func Index(conf *cfg.Config) *cli.Command {
IndexClose(conf), IndexClose(conf),
IndexAllocation(conf), IndexAllocation(conf),
IndexModify(conf), IndexModify(conf),
IndexAlias(conf),
IndexFields(conf), IndexFields(conf),
IndexIlm(conf),
// sub commands
IndexAlias(conf),
IndexTemplate(conf),
}, },
} }
} }
@@ -134,7 +138,7 @@ func IndexShow(conf *cfg.Config) *cli.Command {
}, },
ShellComplete: func(ctx context.Context, cmd *cli.Command) { ShellComplete: func(ctx context.Context, cmd *cli.Command) {
completeIndex(cmd) complete(cmd, Cindex)
}, },
} }
} }
@@ -165,6 +169,7 @@ Valid field mapping types: integer, text, date, keyword`,
Usage: "number of replicas to create", Usage: "number of replicas to create",
Destination: &conf.Replicas, Destination: &conf.Replicas,
Aliases: []string{"r"}, Aliases: []string{"r"},
Value: 1,
}, },
}, },
@@ -189,7 +194,7 @@ func IndexDelete(conf *cfg.Config) *cli.Command {
UsageText: "delete <index>", UsageText: "delete <index>",
ShellComplete: func(ctx context.Context, cmd *cli.Command) { ShellComplete: func(ctx context.Context, cmd *cli.Command) {
completeIndex(cmd) complete(cmd, Cindex)
}, },
Action: func(ctx context.Context, cmd *cli.Command) error { Action: func(ctx context.Context, cmd *cli.Command) error {
@@ -210,7 +215,7 @@ func IndexClose(conf *cfg.Config) *cli.Command {
UsageText: "close <index>", UsageText: "close <index>",
ShellComplete: func(ctx context.Context, cmd *cli.Command) { ShellComplete: func(ctx context.Context, cmd *cli.Command) {
completeIndex(cmd) complete(cmd, Cindex)
}, },
Action: func(ctx context.Context, cmd *cli.Command) error { Action: func(ctx context.Context, cmd *cli.Command) error {
@@ -231,7 +236,7 @@ func IndexModify(conf *cfg.Config) *cli.Command {
UsageText: "modify <index[,index,...]|_all>", UsageText: "modify <index[,index,...]|_all>",
ShellComplete: func(ctx context.Context, cmd *cli.Command) { ShellComplete: func(ctx context.Context, cmd *cli.Command) {
completeIndex(cmd) complete(cmd, Cindex)
}, },
Flags: []cli.Flag{ Flags: []cli.Flag{
@@ -240,6 +245,7 @@ func IndexModify(conf *cfg.Config) *cli.Command {
Usage: "number of replicas", Usage: "number of replicas",
Destination: &conf.Replicas, Destination: &conf.Replicas,
Aliases: []string{"r"}, Aliases: []string{"r"},
Value: 1,
}, },
}, },
@@ -282,7 +288,7 @@ func IndexFields(conf *cfg.Config) *cli.Command {
}, },
ShellComplete: func(ctx context.Context, cmd *cli.Command) { ShellComplete: func(ctx context.Context, cmd *cli.Command) {
completeIndex(cmd) complete(cmd, Cindex)
}, },
Action: func(ctx context.Context, cmd *cli.Command) error { Action: func(ctx context.Context, cmd *cli.Command) error {
@@ -295,3 +301,24 @@ func IndexFields(conf *cfg.Config) *cli.Command {
}, },
} }
} }
func IndexIlm(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "ilm",
Usage: "show ilm status",
UsageText: "index ilm <index>",
ShellComplete: func(ctx context.Context, cmd *cli.Command) {
complete(cmd, Cindex)
},
Action: func(ctx context.Context, cmd *cli.Command) error {
index := cmd.Args().Get(0)
if index == "" {
return errors.New("no index specified")
}
return es.IlmExplain(conf, index)
},
}
}

View File

@@ -36,8 +36,8 @@ func IndexAlias(conf *cfg.Config) *cli.Command {
IndexAliasCreate(conf), IndexAliasCreate(conf),
IndexAliasList(conf), IndexAliasList(conf),
IndexAliasDelete(conf), IndexAliasDelete(conf),
// FIXME: implement IndexAliasShow + IndexAliasAdd IndexAliasRollover(conf),
//IndexAliasShow(conf), // FIXME: implement IndexAliasAdd
//IndexAliasAdd(conf), // see https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-indices-update-aliases //IndexAliasAdd(conf), // see https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-indices-update-aliases
}, },
} }
@@ -51,7 +51,7 @@ func IndexAliasCreate(conf *cfg.Config) *cli.Command {
UsageText: "create <index> <alias>", UsageText: "create <index> <alias>",
ShellComplete: func(ctx context.Context, cmd *cli.Command) { ShellComplete: func(ctx context.Context, cmd *cli.Command) {
completeIndex(cmd) complete(cmd, Cindex)
}, },
Action: func(ctx context.Context, cmd *cli.Command) error { Action: func(ctx context.Context, cmd *cli.Command) error {
@@ -75,7 +75,7 @@ func IndexAliasDelete(conf *cfg.Config) *cli.Command {
UsageText: "delete <index> <alias>", UsageText: "delete <index> <alias>",
ShellComplete: func(ctx context.Context, cmd *cli.Command) { ShellComplete: func(ctx context.Context, cmd *cli.Command) {
completeIndex(cmd) complete(cmd, Cindex)
}, },
Action: func(ctx context.Context, cmd *cli.Command) error { Action: func(ctx context.Context, cmd *cli.Command) error {
@@ -91,6 +91,64 @@ func IndexAliasDelete(conf *cfg.Config) *cli.Command {
} }
} }
func IndexAliasRollover(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "rollover",
Aliases: []string{"roll"},
Usage: "roll over an index alias",
UsageText: "rollover <alias>",
ShellComplete: func(ctx context.Context, cmd *cli.Command) {
complete(cmd, Cindex)
},
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "wait",
Usage: "wait for active shards",
Destination: &conf.Wait,
Aliases: []string{"w"},
},
&cli.BoolFlag{
Name: "dry-run",
Usage: "checks conditions but does not perform a rollover",
Destination: &conf.Wait,
Aliases: []string{"n"},
},
&cli.StringFlag{
Name: "max-age",
Usage: "roll over after max age (eg: 7d, 2m, 8h)",
Destination: &conf.MaxAge,
},
&cli.IntFlag{
Name: "max-docs",
Usage: "roll over after max docs",
Destination: &conf.MaxDocs,
},
&cli.IntFlag{
Name: "max-primary-shard-size",
Usage: "roll over when primary shard size reaches size",
Destination: &conf.MaxShardSize,
},
&cli.IntFlag{
Name: "max-primary-shard-docs",
Usage: "roll over after max docs in primary shard reached",
Destination: &conf.MaxShardDocs,
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
alias := cmd.Args().Get(0)
if alias == "" {
return errors.New("no alias specified")
}
return es.IndexAliasRollover(conf, alias)
},
}
}
func IndexAliasList(conf *cfg.Config) *cli.Command { func IndexAliasList(conf *cfg.Config) *cli.Command {
return &cli.Command{ return &cli.Command{
Name: "list", Name: "list",

216
cmd/index_template.go Normal file
View File

@@ -0,0 +1,216 @@
/*
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"
"fmt"
"slices"
"codeberg.org/scip/esctl/pkg/cfg"
"codeberg.org/scip/esctl/pkg/es"
"github.com/urfave/cli/v3"
)
func IndexTemplate(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "template",
Aliases: []string{"tpl"},
Usage: "manage index templates",
Commands: []*cli.Command{
IndexTemplateList(conf),
IndexTemplateShow(conf),
IndexTemplateCreate(conf, false),
IndexTemplateCreate(conf, true),
IndexTemplateDelete(conf),
},
}
}
func IndexTemplateList(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "list",
Aliases: []string{"ls"},
Usage: "list index templates",
Action: func(ctx context.Context, cmd *cli.Command) error {
return es.IndexTemplateList(conf)
},
}
}
func IndexTemplateShow(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "show",
Aliases: []string{"sh"},
Usage: "show details about an index template",
Action: func(ctx context.Context, cmd *cli.Command) error {
index := cmd.Args().Get(0)
if index == "" {
return errors.New("no index template specified")
}
return es.IndexTemplateShow(conf, cmd.Args().Get(0))
},
ShellComplete: func(ctx context.Context, cmd *cli.Command) {
complete(cmd, Cindex)
},
}
}
func IndexTemplateCreate(conf *cfg.Config, modify bool) *cli.Command {
name := "create"
alias := "+"
required := true
if modify {
name = "modify"
alias = "mod"
required = false
}
return &cli.Command{
Name: name,
Aliases: []string{alias},
Usage: name + " a new index template",
UsageText: name + ` <name> <fieldmapping:type>...`,
CustomHelpTemplate: addReference(
`Valid field mapping types: integer, text, date, keyword
Doc for index settings:
https://www.elastic.co/docs/reference/elasticsearch/index-settings
`),
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "data-stream",
Usage: "template creates a data stream",
Destination: &conf.Stream,
Aliases: []string{"S"},
},
&cli.BoolFlag{
Name: "allow-auto-create",
Usage: "automatically create indices",
Destination: &conf.AutoCreate,
Aliases: []string{"a"},
},
&cli.BoolFlag{
Name: "rollover",
Usage: "automatically rollover associated aliases",
Destination: &conf.Rollover,
Aliases: []string{"R"},
},
&cli.StringFlag{
Name: "mode",
Usage: "index mode, one of: standard, timeseries, logsdb or lookup",
Destination: &conf.Mode,
Aliases: []string{"m"},
},
&cli.StringFlag{
Name: "retention",
Usage: "data retention, e.g. -1 (infinitely), 30d, 3m or 1y (data stream only)",
Destination: &conf.Retention,
Aliases: []string{"r"},
Required: required,
},
&cli.IntFlag{
Name: "priority",
Usage: "index template priority",
Destination: &conf.Priority,
Aliases: []string{"p"},
},
&cli.StringSliceFlag{
Name: "index-pattern",
Usage: "index matching pattern",
Destination: &conf.Patterns,
Aliases: []string{"i"},
Required: required,
},
&cli.StringSliceFlag{
Name: "settings",
Usage: "individual template settings, format: name:value, multiple possible",
Destination: &conf.Settings,
Aliases: []string{"s"},
},
&cli.StringSliceFlag{
Name: "component",
Usage: "include component template, multiple possible",
Destination: &conf.Components,
Aliases: []string{"c"},
},
&cli.StringSliceFlag{
Name: "meta",
Usage: "custom meta field, format: name:value, multiple possible",
Destination: &conf.Meta,
Aliases: []string{"M"},
},
&cli.StringSliceFlag{
Name: "aliases",
Usage: "set up aliases to associate with indices",
Destination: &conf.Aliases,
Aliases: []string{"A"},
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
args := cmd.Args()
if args.Len() == 0 {
return fmt.Errorf("no name specified")
}
if conf.Mode != "" {
if !slices.Contains([]string{"standard", "timeseries", "logsdb", "lookup"}, conf.Mode) {
return errors.New("mode must be one of: standard, timeseries, logsdb or lookup")
}
}
mappings := args.Slice()[1:]
if modify {
return es.IndexTemplateModify(conf, args.Get(0), mappings)
}
return es.IndexTemplateCreate(conf, args.Get(0), mappings)
},
}
}
func IndexTemplateDelete(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "delete",
Aliases: []string{"rm"},
Usage: "delete an index template",
Action: func(ctx context.Context, cmd *cli.Command) error {
index := cmd.Args().Get(0)
if index == "" {
return errors.New("no index template specified")
}
return es.IndexTemplateDelete(conf, cmd.Args().Get(0))
},
ShellComplete: func(ctx context.Context, cmd *cli.Command) {
complete(cmd, Cindex)
},
}
}

View File

@@ -15,24 +15,3 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>. along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
package cmd package cmd
import (
"context"
"codeberg.org/scip/esctl/pkg/cfg"
"codeberg.org/scip/esctl/pkg/es"
"github.com/urfave/cli/v3"
)
func Repl(conf *cfg.Config) *cli.Command {
return &cli.Command{
Name: "repl",
Aliases: []string{"shell"},
Usage: "interactive API repl",
Action: func(ctx context.Context, cmd *cli.Command) error {
return es.Repl(conf)
},
}
}

View File

@@ -68,7 +68,7 @@ func RoleShow(conf *cfg.Config) *cli.Command {
UsageText: "show [options] <role>", UsageText: "show [options] <role>",
ShellComplete: func(ctx context.Context, cmd *cli.Command) { ShellComplete: func(ctx context.Context, cmd *cli.Command) {
completeRole(cmd) complete(cmd, Crole)
}, },
Action: func(ctx context.Context, cmd *cli.Command) error { Action: func(ctx context.Context, cmd *cli.Command) error {

View File

@@ -19,7 +19,9 @@ package cmd
import ( import (
"context" "context"
"fmt" "fmt"
golog "log"
"os" "os"
"runtime/pprof"
"codeberg.org/scip/esctl/pkg/cfg" "codeberg.org/scip/esctl/pkg/cfg"
"codeberg.org/scip/esctl/pkg/es" "codeberg.org/scip/esctl/pkg/es"
@@ -83,6 +85,12 @@ func Main() int {
Usage: "output mode (tsv, markdown, json, yaml) default: tsv", Usage: "output mode (tsv, markdown, json, yaml) default: tsv",
Destination: &conf.Output, Destination: &conf.Output,
}, },
&cli.StringFlag{
Name: "profile-file",
Usage: "golang profiler output file",
Destination: &conf.ProfileFile,
Hidden: true,
},
}, },
Commands: []*cli.Command{ Commands: []*cli.Command{
@@ -90,15 +98,18 @@ func Main() int {
Cluster(conf), Cluster(conf),
Ccr(conf), Ccr(conf),
Index(conf), Index(conf),
Ilm(conf),
Datastream(conf),
Shard(conf), Shard(conf),
Snapshot(conf), Snapshot(conf),
Node(conf), Node(conf),
Doc(conf), Doc(conf),
Repl(conf),
Version(conf), Version(conf),
Debug(conf), Debug(conf),
Roles(conf), Roles(conf),
Task(conf),
HelpJsonPath(conf), HelpJsonPath(conf),
Api(conf),
}, },
Before: func(ctx context.Context, cmd *cli.Command) (context.Context, error) { Before: func(ctx context.Context, cmd *cli.Command) (context.Context, error) {
@@ -113,6 +124,26 @@ func Main() int {
log.Init(conf) 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 nil, nil
}, },
} }
@@ -146,6 +177,20 @@ friends.#(first%"D*").last >> "Murphy"
friends.#(first!%"D*").last >> "Craig" friends.#(first!%"D*").last >> "Craig"
friends.#(nets.#(=="fb"))#.first >> ["Dale","Roger"] friends.#(nets.#(=="fb"))#.first >> ["Dale","Roger"]
To extract more than one field, use:
{"ns":source.namespace_name,"time":source.@timestamp} >>
{
"ns": "3f80316965c64405-275a75782e984bcc8d82",
"time": "2026-06-10T04:23:38.914080361+00:00"
}
{
"ns": "3f80316965c64405-f0e1dcef668e45bb9afd",
"time": "2026-06-10T09:39:00.554892542+00:00"
}
Documentation: https://github.com/tidwall/gjson/blob/master/SYNTAX.md` Documentation: https://github.com/tidwall/gjson/blob/master/SYNTAX.md`
return &cli.Command{ return &cli.Command{

View File

@@ -25,7 +25,7 @@ import (
"github.com/urfave/cli/v3" "github.com/urfave/cli/v3"
) )
const SearchUsage = `<sep> might be one of: const SearchUsage = `field <sep> might be one of:
=: Must match =: Must match
!=: Must not match !=: Must not match
@@ -43,15 +43,15 @@ For datetime range format refer to:
https://www.elastic.co/docs/reference/elasticsearch/rest-apis/common-options#date-math https://www.elastic.co/docs/reference/elasticsearch/rest-apis/common-options#date-math
For timestamp formats refer to: For timestamp formats refer to:
https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/mapping-date-format https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/mapping-date-format`
`
func Search(conf *cfg.Config) *cli.Command { func Search(conf *cfg.Config) *cli.Command {
return &cli.Command{ return &cli.Command{
Name: "search", Name: "search",
Aliases: []string{"/"}, Aliases: []string{"/"},
Usage: "search within an index", Usage: "search within an index",
UsageText: "search [options] [<[field<sep>]pattern> ...]\n" + SearchUsage, UsageText: "search [options] [<[field<sep>]pattern> ...]\n",
CustomHelpTemplate: addReference(SearchUsage),
Flags: []cli.Flag{ Flags: []cli.Flag{
&cli.StringFlag{ &cli.StringFlag{

69
cmd/tasks.go Normal file
View File

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

35
cmd/utilities.go Normal file
View File

@@ -0,0 +1,35 @@
/*
Copyright © 2026 Thomas von Dein
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package cmd
import (
"fmt"
"strings"
"github.com/urfave/cli/v3"
)
func addReference(ref string) string {
indentedRef := []string{}
for _, line := range strings.Split(ref, "\n") {
indentedRef = append(indentedRef, " "+line)
}
return fmt.Sprintf("%s\nREFERENCE:\n%s\n",
cli.SubcommandHelpTemplate,
strings.Join(indentedRef, "\n"))
}

55
go.mod
View File

@@ -14,42 +14,83 @@
// 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.0 go 1.25.8
require ( require (
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/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
github.com/elastic/elastic-transport-go/v8 v8.11.0 github.com/elastic/elastic-transport-go/v8 v8.11.0
github.com/elastic/go-elasticsearch/v9 v9.3.4 github.com/elastic/go-elasticsearch/v9 v9.3.4
github.com/fatih/color v1.19.0 github.com/fatih/color v1.19.0
github.com/go-openapi/loads v0.24.0
github.com/go-openapi/spec v0.22.5
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/olekukonko/tablewriter v1.1.4
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.9.1-0.20260524212652-be8b79d0c8de github.com/urfave/cli/v3 v3.9.1-0.20260524212652-be8b79d0c8de
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )
require ( require (
github.com/MichaelMure/go-term-text v0.3.1 // indirect
github.com/alecthomas/chroma v0.7.1 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/clipperhouse/displaywidth v0.10.0 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/clipperhouse/uax29/v2 v2.6.0 // indirect github.com/charmbracelet/x/ansi v0.8.0 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964 // indirect
github.com/disintegration/imaging v1.6.2 // indirect
github.com/dlclark/regexp2 v1.11.0 // indirect
github.com/eliukblau/pixterm/pkg/ansimage v0.0.0-20191210081756-9fb6cf8c2f75 // indirect
github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-openapi/analysis v0.25.2 // indirect
github.com/go-openapi/errors v0.22.8 // indirect
github.com/go-openapi/jsonpointer v0.23.1 // indirect
github.com/go-openapi/jsonreference v0.21.6 // indirect
github.com/go-openapi/strfmt v0.26.3 // indirect
github.com/go-openapi/swag/conv v0.26.1 // indirect
github.com/go-openapi/swag/jsonname v0.26.1 // indirect
github.com/go-openapi/swag/jsonutils v0.26.1 // indirect
github.com/go-openapi/swag/mangling v0.26.1 // indirect
github.com/go-openapi/swag/stringutils 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-viper/mapstructure/v2 v2.5.0 // indirect
github.com/goccy/go-json v0.10.6 // indirect github.com/goccy/go-json v0.10.6 // indirect
github.com/lmittmann/tint v1.1.3 // indirect github.com/gomarkdown/markdown v0.0.0-20191123064959-2c17d62f5098 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/kyokomi/emoji/v2 v2.2.8 // indirect
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-runewidth v0.0.23 // indirect github.com/mattn/go-runewidth v0.0.23 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/oklog/ulid/v2 v2.1.1 // indirect
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect
github.com/olekukonko/errors v1.2.0 // indirect github.com/olekukonko/errors v1.2.0 // indirect
github.com/olekukonko/ll v0.1.8 // indirect github.com/olekukonko/ll v0.1.8 // indirect
github.com/tidwall/gjson v1.19.0 // 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
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/otel v1.35.0 // indirect go.opentelemetry.io/otel v1.35.0 // indirect
go.opentelemetry.io/otel/metric v1.35.0 // indirect go.opentelemetry.io/otel/metric v1.35.0 // indirect
go.opentelemetry.io/otel/trace v1.35.0 // indirect go.opentelemetry.io/otel/trace v1.35.0 // indirect
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/sys v0.42.0 // indirect golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect
golang.org/x/image v0.0.0-20191206065243-da761ea9ff43 // indirect
golang.org/x/net v0.55.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
) )

192
go.sum
View File

@@ -1,29 +1,60 @@
github.com/MichaelMure/go-term-markdown v0.1.4 h1:Ir3kBXDUtOX7dEv0EaQV8CNPpH+T7AfTh0eniMOtNcs=
github.com/MichaelMure/go-term-markdown v0.1.4/go.mod h1:EhcA3+pKYnlUsxYKBJ5Sn1cTQmmBMjeNlpV8nRb+JxA=
github.com/MichaelMure/go-term-text v0.3.1 h1:Kw9kZanyZWiCHOYu9v/8pWEgDQ6UVN9/ix2Vd2zzWf0=
github.com/MichaelMure/go-term-text v0.3.1/go.mod h1:QgVjAEDUnRMlzpS6ky5CGblux7ebeiLnuy9dAaFZu8o=
github.com/alecthomas/assert v0.0.0-20170929043011-405dbfeb8e38 h1:smF2tmSOzy2Mm+0dGI2AIUHY+w0BUc+4tn40djz7+6U=
github.com/alecthomas/assert v0.0.0-20170929043011-405dbfeb8e38/go.mod h1:r7bzyVFMNntcxPZXK3/+KdruV1H5KSlyVY0gc+NgInI=
github.com/alecthomas/chroma v0.7.1 h1:G1i02OhUbRi2nJxcNkwJaY/J1gHXj9tt72qN6ZouLFQ=
github.com/alecthomas/chroma v0.7.1/go.mod h1:gHw09mkX1Qp80JlYbmN9L3+4R5o6DJJ3GRShh+AICNc=
github.com/alecthomas/colour v0.0.0-20160524082231-60882d9e2721 h1:JHZL0hZKJ1VENNfmXvHbgYlbUOvpzYzvy2aZU5gXVeo=
github.com/alecthomas/colour v0.0.0-20160524082231-60882d9e2721/go.mod h1:QO9JBoKquHd+jz9nshCh40fOfO+JzsoXy8qTHF68zU0=
github.com/alecthomas/kong v0.2.1-0.20190708041108-0548c6b1afae/go.mod h1:+inYUSluD+p4L8KdviBSgzcqEjUQOfC5fQDRFuc36lI=
github.com/alecthomas/repr v0.0.0-20180818092828-117648cd9897/go.mod h1:xTS7Pm1pD1mvyM075QCDSRqH6qRLXylzS24ZTpRiSzQ=
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= 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/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/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/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
github.com/charmbracelet/x/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/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM=
github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ= github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ=
github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI= github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI=
github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk= github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk=
github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04=
github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8=
github.com/clipperhouse/displaywidth v0.10.0 h1:GhBG8WuerxjFQQYeuZAeVTuyxuX+UraiZGD4HJQ3Y8g= github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
github.com/clipperhouse/displaywidth v0.10.0/go.mod h1:XqJajYsaiEwkxOj4bowCTMcT1SgvHo9flfF3jQasdbs= github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/uax29/v2 v2.6.0 h1:z0cDbUV+aPASdFb2/ndFnS9ts/WNXgTNNGFoKXuhpos= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.6.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964 h1:y5HC9v93H5EPKqaS1UYVg1uYah5Xf51mBfIoWehClUQ=
github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964/go.mod h1:Xd9hchkHSWYkEqJwUGisez3G1QY8Ryz0sdWrLPMGjLk=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/dlclark/regexp2 v1.1.6/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/elastic/elastic-transport-go/v8 v8.9.0 h1:KeT/2P54F0xS0S8Y3Pf+tFDg4HmBgReQMB+BMz8dDAs=
github.com/elastic/elastic-transport-go/v8 v8.9.0/go.mod h1:ssMTvNS2hwf7CaiGsRRsx4gQHFZ/jS/DkLcISxekWzc=
github.com/elastic/elastic-transport-go/v8 v8.11.0 h1:taYmqC2M6+fZt/+W+ENYh/W5L9+KrlJGOSbEJs8egWc= github.com/elastic/elastic-transport-go/v8 v8.11.0 h1:taYmqC2M6+fZt/+W+ENYh/W5L9+KrlJGOSbEJs8egWc=
github.com/elastic/elastic-transport-go/v8 v8.11.0/go.mod h1:DZQ0szCNywc9F+C9l/Kkd4n69SvJVj0I3yK1Of7s3l8= github.com/elastic/elastic-transport-go/v8 v8.11.0/go.mod h1:DZQ0szCNywc9F+C9l/Kkd4n69SvJVj0I3yK1Of7s3l8=
github.com/elastic/go-elasticsearch/v9 v9.3.2 h1:nvtvfN/Gsp/rzUPz/9yILwDAsYJ3s5L0VmhR16zPKCA=
github.com/elastic/go-elasticsearch/v9 v9.3.2/go.mod h1:ubKUMJCJbX5V/gW5MIn2NQZyaEZ61ubXwJmD5UMNrM8=
github.com/elastic/go-elasticsearch/v9 v9.3.4 h1:vnqXl6jnlA+ZwlfRaKF9BR9woZC1KSB17gtyvgf8zVU= github.com/elastic/go-elasticsearch/v9 v9.3.4 h1:vnqXl6jnlA+ZwlfRaKF9BR9woZC1KSB17gtyvgf8zVU=
github.com/elastic/go-elasticsearch/v9 v9.3.4/go.mod h1:ubKUMJCJbX5V/gW5MIn2NQZyaEZ61ubXwJmD5UMNrM8= github.com/elastic/go-elasticsearch/v9 v9.3.4/go.mod h1:ubKUMJCJbX5V/gW5MIn2NQZyaEZ61ubXwJmD5UMNrM8=
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= github.com/eliukblau/pixterm/pkg/ansimage v0.0.0-20191210081756-9fb6cf8c2f75 h1:vbix8DDQ/rfatfFr/8cf/sJfIL69i4BcZfjrVOxsMqk=
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= github.com/eliukblau/pixterm/pkg/ansimage v0.0.0-20191210081756-9fb6cf8c2f75/go.mod h1:0gZuvTO1ikSA5LtTI6E13LEOdWQNjIo5MTQOvrV0eFg=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
@@ -31,33 +62,103 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-openapi/analysis v0.25.2 h1:I0vy4n3alz+DHTiN1PRhCb7QZxkK6g5YmswZKv2TKuw=
github.com/go-openapi/analysis v0.25.2/go.mod h1:Uhs1t/2XR10EnwONYILGEzw8gcfGIG5Xk5K2AxnhqDo=
github.com/go-openapi/errors v0.22.8 h1:oP7sW7TWc3wFFjrzzj0nI83H2qMBkNjNfSd+XRejk/I=
github.com/go-openapi/errors v0.22.8/go.mod h1:BuUoHcYrU6E7V9gfj1I5wLQqgtIHnup/alXZ8KdgQ0w=
github.com/go-openapi/jsonpointer v0.23.1 h1:1HBACs7XIwR2RcmItfdSFlALhGbe6S92p0ry4d1GWg4=
github.com/go-openapi/jsonpointer v0.23.1/go.mod h1:iWRmZTrGn7XwYhtPt/fvdSFj1OfNBngqRT2UG3BxSqY=
github.com/go-openapi/jsonreference v0.21.6 h1:NZ5nGfnaM1n4I43Xjm1e5/M2GjOwQwndQz22uhxwD+Y=
github.com/go-openapi/jsonreference v0.21.6/go.mod h1:xzbgtQ3ZbWxvET3AxdzCJlJt6vkovbf+IfSPJjD0tUY=
github.com/go-openapi/loads v0.24.0 h1:4LLorXRPTzIN9V6ngMUZbAscsBOUBk3Oa8cClu/bFrQ=
github.com/go-openapi/loads v0.24.0/go.mod h1:xQMgX+hw5xRAhGrcDXxeMw78IFqUpIzhleu3HqPhyF4=
github.com/go-openapi/spec v0.22.5 h1:KhO7RBlKQfonUWX2WzQCoLIXVA6AcNqDGZ3a1Dutdlo=
github.com/go-openapi/spec v0.22.5/go.mod h1:vxpOtMya5TXtENXKE5bKqv5NjocVhyhxHrlZfvKnZ74=
github.com/go-openapi/strfmt v0.26.3 h1:rzmslHarJgBbf2qfGge+X3htclQfmXqBZMm0Too0HhU=
github.com/go-openapi/strfmt v0.26.3/go.mod h1:a5nsUw0oRpQzZeOwx8bi6cKbzFZslpbCKt1LEot+KnQ=
github.com/go-openapi/swag/conv v0.26.1 h1:slr5FVkg9Wc3Y5zcwenD8Sd/PQ94b2I/QJI7N7KTBpg=
github.com/go-openapi/swag/conv v0.26.1/go.mod h1:mvQXgPptZk9GTrFgGwWvT4q+dN+zQej9JfmGwnipz1A=
github.com/go-openapi/swag/jsonname v0.26.1 h1:VReupaV6WxlAsCn0e4DUfgV6bPmINnPpyJDLqSfNPcE=
github.com/go-openapi/swag/jsonname v0.26.1/go.mod h1:OvdW6BoWoj33pTfi7x9vFrgmT+fk7aw0BRwvCE0YOuc=
github.com/go-openapi/swag/jsonutils v0.26.1 h1:2hdBfFkHg+7Wrz2VsCbeyR6hzkRDs7AztnMR2u84yOY=
github.com/go-openapi/swag/jsonutils v0.26.1/go.mod h1:U+RMJH3wa+6BRiphuRtIyI8fW9HPFqFQ4sHk2oRx0UQ=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.1 h1:1CD7NiLLb/TXl3tOnFYU4b+mNfb5rtgHkaA+q7RMYYQ=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.1/go.mod h1:ZWafc8nMdYzTE3uYY6W86f0n46+IF0g4uUyRhJw/kXc=
github.com/go-openapi/swag/loading v0.26.1 h1:E9K4wqXeROlhjFQ13K9zMz6ojFGXIggGe+ad1odrK9w=
github.com/go-openapi/swag/loading v0.26.1/go.mod h1:3qvRIlWzWdq1HvmldwmuJ2ohpcAryN6xVt2OTKd0/7E=
github.com/go-openapi/swag/mangling v0.26.1 h1:gpYI4WuPKFJJVjV5cDLGlDVJhFIxYjQc7yN5eEb4CqM=
github.com/go-openapi/swag/mangling v0.26.1/go.mod h1:POETDH01hqAdASXfw7ISEd9bCOE6xBHOt8NHmGZRmYM=
github.com/go-openapi/swag/stringutils v0.26.1 h1:f88uYyTso7TnHrKM/bUBsQ5e2wKf37cpgo6pvbzd9yU=
github.com/go-openapi/swag/stringutils v0.26.1/go.mod h1:Sc6d3bU8fgk5AyZR8/8jEQ+Is/Ald+TD/IIggPN8UJk=
github.com/go-openapi/swag/typeutils v0.26.1 h1:yg42FgMzRR6PVQ3M3qHz1s+Y6/P4HoJ3cBarXa3OVnU=
github.com/go-openapi/swag/typeutils v0.26.1/go.mod h1:VfnV+oUtSP2vCSCn2aJgnr8OevUYemyIzzS1VOzS10o=
github.com/go-openapi/swag/yamlutils v0.26.1 h1:0TSLK+lXs9vfIhAWzBeI/lOzEnIoot6WTCO1aAeWFTk=
github.com/go-openapi/swag/yamlutils v0.26.1/go.mod h1:7W5b7PRX9MxwL7TjeG7H8HkyBGRsIDRObhyMWFgBI2M=
github.com/go-openapi/testify/enable/yaml/v2 v2.5.1 h1:q9NtHwK4qHF7yZziBPvZyv7zWAIk8ok88Gh2mR6Jpc8=
github.com/go-openapi/testify/enable/yaml/v2 v2.5.1/go.mod h1:JW0MXIotCYps/XsgJnG3a8Q7rE5xAiBwoOD5OfaIQBk=
github.com/go-openapi/testify/v2 v2.5.1 h1:TMdhCaw8fUNraVSf3Omoob1dO/AzBfhtFAPW0an6sBo=
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/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 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/lmittmann/tint v1.1.3 h1:Hv4EaHWXQr+GTFnOU4VKf8UvAtZgn0VuKT+G0wFlO3I= github.com/gomarkdown/markdown v0.0.0-20191123064959-2c17d62f5098 h1:Qxs3bNRWe8GTcKMxYOSXm0jx6j0de8XUtb/fsP3GZ0I=
github.com/lmittmann/tint v1.1.3/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE= github.com/gomarkdown/markdown v0.0.0-20191123064959-2c17d62f5098/go.mod h1:aii0r/K0ZnHv7G0KF7xy1v0A7s2Ljrb5byB7MO5p6TU=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kyokomi/emoji/v2 v2.2.8 h1:jcofPxjHWEkJtkIbcLHvZhxKgCPl6C7MyjTrD4KDqUE=
github.com/kyokomi/emoji/v2 v2.2.8/go.mod h1:JUcn42DTdsXJo1SWanHh4HKDEyPaR5CqkmoirZZP9qE=
github.com/lucasb-eyer/go-colorful v1.0.3/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs= github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s=
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 h1:zrbMGy9YXpIeTnGj4EljqMiZsIcE09mmF8XsD5AYOJc=
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6/go.mod h1:rEKTHC9roVVicUIfZK7DYrdIoM0EOr8mK1Hj5s3JjH0= github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6/go.mod h1:rEKTHC9roVVicUIfZK7DYrdIoM0EOr8mK1Hj5s3JjH0=
github.com/olekukonko/errors v1.2.0 h1:10Zcn4GeV59t/EGqJc8fUjtFT/FuUh5bTMzZ1XwmCRo= github.com/olekukonko/errors v1.2.0 h1:10Zcn4GeV59t/EGqJc8fUjtFT/FuUh5bTMzZ1XwmCRo=
github.com/olekukonko/errors v1.2.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y= github.com/olekukonko/errors v1.2.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y=
github.com/olekukonko/ll v0.1.6 h1:lGVTHO+Qc4Qm+fce/2h2m5y9LvqaW+DCN7xW9hsU3uA=
github.com/olekukonko/ll v0.1.6/go.mod h1:NVUmjBb/aCtUpjKk75BhWrOlARz3dqsM+OtszpY4o88=
github.com/olekukonko/ll v0.1.8 h1:ysHCJRGHYKzmBSdz9w5AySztx7lG8SQY+naTGYUbsz8= github.com/olekukonko/ll v0.1.8 h1:ysHCJRGHYKzmBSdz9w5AySztx7lG8SQY+naTGYUbsz8=
github.com/olekukonko/ll v0.1.8/go.mod h1:RPRC6UcscfFZgjo1nulkfMH5IM0QAYim0LfnMvUuozw= github.com/olekukonko/ll v0.1.8/go.mod h1:RPRC6UcscfFZgjo1nulkfMH5IM0QAYim0LfnMvUuozw=
github.com/olekukonko/tablewriter v1.1.4 h1:ORUMI3dXbMnRlRggJX3+q7OzQFDdvgbN9nVWj1drm6I= github.com/olekukonko/tablewriter v1.1.4 h1:ORUMI3dXbMnRlRggJX3+q7OzQFDdvgbN9nVWj1drm6I=
github.com/olekukonko/tablewriter v1.1.4/go.mod h1:+kedxuyTtgoZLwif3P1Em4hARJs+mVnzKxmsCL/C5RY= github.com/olekukonko/tablewriter v1.1.4/go.mod h1:+kedxuyTtgoZLwif3P1Em4hARJs+mVnzKxmsCL/C5RY=
github.com/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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ=
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU= github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU=
github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc= github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
@@ -66,30 +167,49 @@ 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.8.0 h1:XqKPrm0q4P0q5JpoclYoCAv0/MIvH/jZ2umzuf8pNTI=
github.com/urfave/cli/v3 v3.8.0/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso=
github.com/urfave/cli/v3 v3.9.0 h1:AV9lIiPv3ukYnxunaCUsHnEozptYmDN2F0+yWqLMn/c=
github.com/urfave/cli/v3 v3.9.0/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso=
github.com/urfave/cli/v3 v3.9.1-0.20260524212652-be8b79d0c8de h1:ESKPiS7inVoBnv4FmgGNZdjWBI/wmvaragoyD3D9nM4= github.com/urfave/cli/v3 v3.9.1-0.20260524212652-be8b79d0c8de 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.9.1-0.20260524212652-be8b79d0c8de/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ=
go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y=
go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M= go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M=
go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE= go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE=
go.opentelemetry.io/otel/sdk v1.29.0 h1:vkqKjk7gwhS8VaWb0POZKmIEDimRCMsopNYnriHyryo=
go.opentelemetry.io/otel/sdk v1.29.0/go.mod h1:pM8Dx5WKnvxLCb+8lG1PRNIDxu9g9b9g59Qr7hfAAok=
go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs=
go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/dl v0.0.0-20190829154251-82a15e2f2ead/go.mod h1:IUMfjQLJQd4UTqG1Z90tenwKoCX93Gn3MAQJMOSBsDQ=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.0.0-20191206065243-da761ea9ff43 h1:gQ6GUSD102fPgli+Yb4cR/cGaHF7tNBt+GYoRCpGC7s=
golang.org/x/image v0.0.0-20191206065243-da761ea9ff43/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/sys v0.0.0-20181128092732-4ed8d59d0b35/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-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.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -34,7 +34,7 @@ import (
) )
const ( const (
Version string = `v0.0.18` Version string = `v0.0.19`
) )
var ( var (
@@ -48,41 +48,70 @@ type Cluster struct {
} }
type Config struct { type Config struct {
ConfigFile string // -c ConfigFile string // -c
CurrentCluster string // -C CurrentCluster string // -C
Debug bool // -d Debug bool // -d
Output string // -o <mode> Output string // -o <mode>
Clusters map[string]*Cluster Clusters map[string]*Cluster
DefaultCluster *Cluster DefaultCluster *Cluster
Index string // index: -i HaveJQ bool // determined at runtime by ourselfes
Failed, Partials bool // index: flags ProfileFile string // for internal use (golang profiling)
Shards, Replicas int // index create+allocation: -s -r
Wait bool // index create: -w Index string // index: -i
Primary bool // index allocation: -p Failed, Partials bool // index: flags
Searchable bool // index fields: -s Shards, Replicas int // index create+allocation: -s -r
Aggretable bool // index fields: -a Wait bool // index create: -w
From, To, MaxItems int // search: flags Primary bool // index allocation: -p
Filter []string // search: -F Searchable bool // index fields: -s
Path string // search+doc sh: -p Aggretable bool // index fields: -a
Subhelp bool // search+doc sh: -H
Tail bool // search: -f [tail] Priority int // index template create: -p
Or bool // search: -O Settings []string // index template create: -s
Range string // search: -r Patterns []string // index template create: -i
TimestampFormat string // search: --timestamp-format Components []string // index template create: -c
Explain bool // search: -e Meta []string // index template create: -M
Validate bool // search: --validate Aliases []string // index template create: -A
SortBy string // sort: -k Stream bool // index template create: -S
Ascending bool // sort: -a AutoCreate bool // index template create: -a
Exclude string // cluster compare: -e (regexp) Mode string // index template create: -a
All, Verbose bool // cluster status: -a -v Retention string // index template create: -r
Persistent, Transient, Default bool // -p -t -D cluster settings set Rollover bool // index template create: -R
Force bool // ccr follower renew: -f
HaveJQ bool // determined at runtime by ourselfes From, To, MaxItems int // search: flags
DebugHTTP bool // root: --debug-http Filter []string // search: -F
Separator string // role diff: -s Path string // search+doc sh: -p
NotDeployed bool // role diff: -n Subhelp bool // search+doc sh: -H
Undefined bool // role diff: -u Tail bool // search: -f [tail]
Diff bool // role diff: -D Or bool // search: -O
Range string // search: -r
TimestampFormat string // search: --timestamp-format
Explain bool // search: -e
Validate bool // search: --validate
SortBy string // sort: -k
Ascending bool // sort: -a
Exclude string // cluster compare: -e (regexp)
All, Verbose bool // cluster status: -a -v
Persistent, Transient, Default bool // cluster settings set: -p -t -D
Force bool // ccr follower renew: -f
DebugHTTP bool // root: --debug-http
Separator string // role diff: -s
NotDeployed bool // role diff: -n
Undefined bool // role diff: -u
Diff bool // role diff: -D
Hidden bool // ds ls: -H
// rollover
MaxAge string
MaxDocs, MaxShardSize, MaxShardDocs int // roll over
DryRun bool // rollover: -n
Tag string // api ls: -t
Ilm Ilm // ilm create
} }
func NewConfig() *Config { func NewConfig() *Config {

72
pkg/cfg/ilm.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 cfg
type Ilm struct {
Description string
HotMinAge string
HotRolloverMaxAge string
HotRolloverMaxPrimaryShardSize int64
HotRolloverMaxDocs int64
HotReadonly bool
WarmMinAge string
WarmForceMerge int
WarmShrinkShards int
WarmPriority int
ColdMinAge string
ColdSearchableSnapshotRepo string
ColdForceMerge int
ColdPriority int
FrozenMinAge string
DeleteMinAge string
DeleteSearchableSnapshots bool
}
func (cfg *Ilm) HaveHot() bool {
return cfg.HotMinAge != "" ||
cfg.HotRolloverMaxAge != "" ||
cfg.HotRolloverMaxDocs > 0 ||
cfg.HotRolloverMaxPrimaryShardSize > 0 ||
cfg.HotReadonly
}
func (cfg *Ilm) HaveWarm() bool {
return cfg.WarmMinAge != "" ||
cfg.WarmForceMerge > 0 ||
cfg.WarmShrinkShards > 0 ||
cfg.WarmPriority > 0
}
func (cfg *Ilm) HaveCold() bool {
return cfg.ColdMinAge != "" ||
cfg.ColdSearchableSnapshotRepo != "" ||
cfg.ColdForceMerge > 0 ||
cfg.ColdPriority > 0
}
func (cfg *Ilm) HaveFrozen() bool {
return cfg.FrozenMinAge != ""
}
func (cfg *Ilm) HaveDelete() bool {
return cfg.DeleteSearchableSnapshots || cfg.DeleteMinAge != ""
}

526
pkg/es/api.go Normal file
View File

@@ -0,0 +1,526 @@
/*
Copyright © 2026 Thomas von Dein
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package es
import (
"bufio"
"bytes"
"context"
"crypto/tls"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"os/exec"
"regexp"
"slices"
"strings"
"codeberg.org/scip/esctl/assets"
"codeberg.org/scip/esctl/pkg/cfg"
"codeberg.org/scip/esctl/pkg/printer"
markdown "github.com/MichaelMure/go-term-markdown"
"github.com/charmbracelet/lipgloss"
"github.com/chzyer/readline"
"github.com/go-openapi/spec"
"golang.org/x/term"
)
const (
DefaultMargin = 4
intro = `Input format: verb path [data]"
Example:
post /yourindex/_ccr/pause_follow
put /yourindex/_settings {"number_of_replicas": 1}
You can also put multiline JSON after the path like:
put /yourindex/_settings
{
"number_of_replicas": 1
}
If you do NOT supply a JSON in the first line, you need to hit ENTER
twice to complete.`
)
// holds an API operation via go-openapi/spec
type Op struct {
Verb string
Op *spec.Operation
}
type Param struct {
Description, Param string
}
// API operation parameter type
type Params struct {
Path []Param
Query []Param
}
func ApiRepl(conf *cfg.Config) error {
verbs := []string{"post", "get", "put", "delete"}
fmt.Println(intro)
fmt.Println()
reader, err := readline.NewEx(&readline.Config{
Prompt: "> ",
HistoryFile: os.Getenv("HOME") + "/.config/esctl/history",
HistoryLimit: 500,
InterruptPrompt: "^C",
EOFPrompt: "exit",
HistorySearchFold: true,
})
if err != nil {
return fmt.Errorf("failed to initialize readline lib: %s", err)
}
for {
text, err := reader.Readline()
if err != nil {
break
}
text = strings.TrimSpace(text)
if text == "" {
continue
}
parts := strings.SplitN(strings.TrimSpace(text), " ", 3)
if len(parts) < 2 {
fmt.Println("error: you need to input a verb, uri [and post data]")
continue
}
if !slices.Contains(verbs, strings.ToLower(parts[0])) {
fmt.Println("error: verb must be one of " + strings.Join(verbs, ","))
continue
}
if !strings.HasPrefix(parts[1], "/") {
parts[1] = "/" + parts[1]
}
json := ""
if len(parts) == 3 {
// put /uri {json}
json = parts[2]
}
// put /uri/<Ret> [json]
data, err := readJSON(json)
if err != nil {
fmt.Println(err)
}
raw, err := CallAPI(conf, parts[0], parts[1], data)
if err != nil {
fmt.Printf("failed to call API: %s\n", esErrorString(err))
}
if err := prettyfiJson(conf, raw); err != nil {
fmt.Println(err)
}
}
return nil
}
func encodeAuth(username, password string) string {
return base64.StdEncoding.EncodeToString([]byte(username + ":" + password))
}
func CallAPI(conf *cfg.Config, verb, path, data string) ([]byte, error) {
verb = strings.ToUpper(verb)
// we're using port-forwards anyway
tr := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
client := &http.Client{Transport: tr}
req, err := http.NewRequest(verb, conf.DefaultCluster.Uri+path, bytes.NewBuffer([]byte(data)))
if err != nil {
return nil, err
}
req.Header.Add("Content-Type", "application/json")
req.Header.Add("accept", "application/json")
req.Header.Add("Authorization", "Basic "+encodeAuth(conf.DefaultCluster.User, conf.DefaultCluster.Pass))
// actually execute the request
resp, err := client.Do(req)
if err != nil {
return nil, err
}
// Read and print response
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %s", err)
}
return body, nil
}
func prettyfiJson(conf *cfg.Config, raw []byte) error {
if conf.HaveJQ {
cmd := exec.CommandContext(context.Background(), "jq", "-C")
cmd.Stdin = bytes.NewReader(raw)
var out bytes.Buffer
cmd.Stdout = &out
err := cmd.Run()
if err != nil {
return err
}
fmt.Println(out.String())
return nil
}
var pretty bytes.Buffer
err := json.Indent(&pretty, raw, "", "\t")
if err != nil {
return fmt.Errorf("json parse error: %s", err)
}
fmt.Println(pretty.String())
return nil
}
// interactively read arbitrary JSON data from STDIN, which is
// virtually a repl inside the primary repl
func readJSON(input string) (string, error) {
data := ""
if input != "" {
data = input
} else {
scanner := bufio.NewScanner(os.Stdin)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
break
}
data += line
}
}
if data == "" {
return data, nil
}
// validate
check := map[string]any{}
err := json.Unmarshal([]byte(data), &check)
if err != nil {
return "", fmt.Errorf("error: input data is not proper JSON: %s", err)
}
return data, nil
}
func ApiList(conf *cfg.Config, pattern string) error {
assets.LoadAssetOpenApi()
filter := regexp.MustCompile(pattern)
table := printer.NewTable(conf, 4, 0)
table.Addheaders("path", "http verb", "tag", "description")
for path, item := range assets.OpenAPI.Spec().Paths.Paths {
if pattern != "" {
if !filter.MatchString(path) {
continue
}
}
ops := findOperation(item.PathItemProps)
for _, op := range ops {
tags := findTags(op)
if conf.Tag != "" {
if !slices.Contains(tags, conf.Tag) {
continue
}
}
table.AddRow(
path,
op.Verb,
strings.Join(tags, ","),
strings.TrimSpace(op.Op.Summary),
)
}
}
table.Sort()
return table.Print()
}
// for completion
func ApiPathNames() []string {
assets.LoadAssetOpenApi()
paths := []string{}
for path := range assets.OpenAPI.Spec().Paths.Paths {
paths = append(paths, path)
}
return paths
}
func ApiShow(conf *cfg.Config, showpath, verb string) error {
assets.LoadAssetOpenApi()
op, err := matchOperation(showpath, verb)
if err != nil {
return err
}
slog.Debug("api operation", "op", op)
cleanMarkup := regexp.MustCompile(`<[^<>]+>`)
width := getTermWidth()
params := getApiParameters(op, showpath, width)
sample := getApiExample(op)
description := markdown.Render(cleanMarkup.ReplaceAllString(op.Op.Description, ""), width, DefaultMargin)
var bold = lipgloss.NewStyle().
Bold(true)
var paragraph = lipgloss.NewStyle().
MarginBottom(1).
MarginLeft(DefaultMargin)
var boldparagraph = lipgloss.NewStyle().
MarginBottom(1).
MarginLeft(DefaultMargin).
Bold(true)
var indentparagraph = lipgloss.NewStyle().
MarginBottom(1).
MarginLeft(2)
fmt.Println(bold.Render("ID: " + op.Op.ID))
fmt.Println(paragraph.Render(fmt.Sprintf("%s %s", op.Verb, showpath)))
fmt.Println(bold.Render("Tags"))
fmt.Println(paragraph.Render(strings.Join(op.Op.Tags, ", ")))
fmt.Println(bold.Render("Summary"))
fmt.Println(paragraph.Render(strings.TrimSpace(op.Op.Summary)))
fmt.Println(bold.Render("Description"))
fmt.Println(string(description)) // already indented
if len(params.Path) > 0 {
fmt.Println(bold.Render("Path Parameters"))
for _, param := range params.Path {
fmt.Println(boldparagraph.Render(param.Param))
if param.Description != "" {
fmt.Println(indentparagraph.Render(param.Description))
}
}
}
if len(params.Query) > 0 {
fmt.Println(bold.Render("Query Parameters"))
for _, param := range params.Query {
fmt.Println(boldparagraph.Render(param.Param))
if param.Description != "" {
fmt.Println(indentparagraph.Render(param.Description))
}
}
}
if sample != "" {
fmt.Println(bold.Render("Example"))
fmt.Println(paragraph.Render(sample))
}
return nil
}
// find all tags associated with op
func findTags(op *Op) []string {
return op.Op.Tags
}
// Get API parameters
func getApiParameters(op *Op, path string, width int) Params {
params := Params{}
pathParams := []string{}
pathParamsReg := regexp.MustCompile(`{([a-z_]+)}`)
// find params in url path
for _, match := range pathParamsReg.FindAllStringSubmatch(path, -1) {
if len(match) == 2 {
pathParams = append(pathParams, match[1])
}
}
empty := spec.ParamProps{}
for _, param := range op.Op.Parameters {
if param.ParamProps == empty {
for _, token := range param.Refable.Ref.Ref.GetPointer().DecodedTokens() {
parts := strings.Split(token, "-")
if len(parts) == 2 {
param := parts[1]
if slices.Contains(pathParams, param) {
params.Path = append(params.Path, Param{Param: param})
} else {
params.Query = append(params.Query, Param{Param: param})
}
}
}
} else {
par := Param{
Description: string(markdown.Render(param.Description, width, DefaultMargin)),
Param: param.Name,
}
if param.In == "query" {
params.Query = append(params.Query, par)
} else {
params.Path = append(params.Path, par)
}
}
}
return params
}
// Get console example
func getApiExample(op *Op) string {
sample := ""
samples, sampleexist := op.Op.Extensions["x-codeSamples"]
if sampleexist {
for _, itemany := range samples.([]any) {
item := itemany.(map[string]any)
lang, haslang := item["lang"]
if haslang {
if lang == "Console" {
source, hassource := item["source"]
if hassource {
sample = strings.TrimSpace(source.(string))
}
}
}
}
}
return sample
}
// Find an API operation which matches given path and verb.
// If no verb is given and only 1 op exists, return this one,
// otherwise showpath+verb have to match precisely.
func matchOperation(showpath, verb string) (*Op, error) {
ops := []*Op{}
op := &Op{}
var found bool
for path, item := range assets.OpenAPI.Spec().Paths.Paths {
if path == showpath {
ops = findOperation(item.PathItemProps)
break
}
}
if len(ops) == 0 {
return nil, errors.New("no matching API call found")
}
if len(ops) == 1 && verb == "" {
return ops[0], nil
}
for _, item := range ops {
if strings.ToLower(item.Verb) == verb {
op = item
found = true
break
}
}
if !found {
return nil, errors.New("no matching API call found for given path+verb")
}
return op, nil
}
func findOperation(item spec.PathItemProps) []*Op {
ops := []*Op{}
if item.Post != nil {
ops = append(ops, &Op{"POST", item.Post})
}
if item.Get != nil {
ops = append(ops, &Op{"GET", item.Get})
}
if item.Delete != nil {
ops = append(ops, &Op{"DELETE", item.Delete})
}
if item.Put != nil {
ops = append(ops, &Op{"PUT", item.Put})
}
if item.Patch != nil {
ops = append(ops, &Op{"PATCH", item.Patch})
}
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

@@ -27,6 +27,8 @@ 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/ccr/stats"
"github.com/elastic/go-elasticsearch/v9/typedapi/cluster/health" "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"
@@ -39,17 +41,21 @@ const (
ResponseInfo ResponseInfo
ResponseCcr ResponseCcr
ResponseStats ResponseStats
ResponseIndices
ResponseTasks
) )
type ClusterIndices map[string]map[string]*types.IndicesRecord type ClusterIndices map[string]map[string]*types.IndicesRecord
type apiResponse struct { type apiResponse struct {
error error error error
info *info.Response info *info.Response
health *health.Response health *health.Response
ccr *stats.Response ccr *stats.Response
stats *clusterstats.Response stats *clusterstats.Response
which int indices *indices.Response
tasks *tasks.Response
which int
} }
func ClusterList(conf *cfg.Config) error { func ClusterList(conf *cfg.Config) error {
@@ -94,7 +100,7 @@ func ClusterList(conf *cfg.Config) error {
// 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 ClusterStatus(conf *cfg.Config) error {
clusters := []string{} clusters := []string{}
gocount := 3 gocount := 5
if conf.Verbose { if conf.Verbose {
gocount++ gocount++
} }
@@ -120,6 +126,8 @@ func ClusterStatus(conf *cfg.Config) error {
go getClusterData(es, wg, responses, "health") go getClusterData(es, wg, responses, "health")
go getClusterData(es, wg, responses, "info") go getClusterData(es, wg, responses, "info")
go getClusterData(es, wg, responses, "ccrstats") go getClusterData(es, wg, responses, "ccrstats")
go getClusterData(es, wg, responses, "indices")
go getClusterData(es, wg, responses, "tasks")
if conf.Verbose { if conf.Verbose {
go getClusterData(es, wg, responses, "stats") go getClusterData(es, wg, responses, "stats")
@@ -131,6 +139,8 @@ func ClusterStatus(conf *cfg.Config) error {
var info *info.Response var info *info.Response
var ccrstats *stats.Response var ccrstats *stats.Response
var clusterstats *clusterstats.Response var clusterstats *clusterstats.Response
var indexstats *indices.Response
var taskstatus *tasks.Response
for i := 0; i < gocount; i++ { for i := 0; i < gocount; i++ {
r := <-responses r := <-responses
@@ -148,11 +158,17 @@ func ClusterStatus(conf *cfg.Config) error {
info = r.info info = r.info
case ResponseStats: case ResponseStats:
clusterstats = r.stats clusterstats = r.stats
case ResponseIndices:
indexstats = r.indices
case ResponseTasks:
taskstatus = r.tasks
} }
} }
slog.Debug("ES result", "cluster health", clusterhealth) slog.Debug("ES result", "cluster health", clusterhealth)
isleader := len(ccrstats.AutoFollowStats.AutoFollowedClusters) == 0
ccrfollowing := "" ccrfollowing := ""
if len(ccrstats.AutoFollowStats.AutoFollowedClusters) > 0 { if len(ccrstats.AutoFollowStats.AutoFollowedClusters) > 0 {
// is following another cluster // is following another cluster
@@ -163,6 +179,22 @@ func ClusterStatus(conf *cfg.Config) error {
) )
} }
// look for red indices, if any
redindices := 0
for _, index := range *indexstats {
if *index.Health == "red" {
redindices++
}
}
// look for long running tasks
longtasks := 0
for _, task := range *taskstatus {
if strings.Contains(*task.RunningTime, "d") {
longtasks++
}
}
table := printer.NewTable(conf, 2, 7) table := printer.NewTable(conf, 2, 7)
table.Addheaders(cluster, "status") table.Addheaders(cluster, "status")
@@ -170,10 +202,22 @@ func ClusterStatus(conf *cfg.Config) error {
{"Cluster Name", clusterhealth.ClusterName}, {"Cluster Name", clusterhealth.ClusterName},
{"ES Status", printer.Colorize(conf, clusterhealth.Status.Name, clusterhealth.Status.Name)}, {"ES Status", printer.Colorize(conf, clusterhealth.Status.Name, clusterhealth.Status.Name)},
{"ES Version", info.Version.Int}, {"ES Version", info.Version.Int},
{"Is Leader", fmt.Sprintf("%t", isleader)},
{"Active Shards", fmt.Sprintf("%d", clusterhealth.ActiveShards)}, {"Active Shards", fmt.Sprintf("%d", clusterhealth.ActiveShards)},
{"Active Primary Shards", fmt.Sprintf("%d", clusterhealth.ActivePrimaryShards)}, {"Active Primary Shards", fmt.Sprintf("%d", clusterhealth.ActivePrimaryShards)},
{"Unassigned Shards", fmt.Sprintf("%d", clusterhealth.UnassignedShards)},
{"Unassigned Primary Shards", fmt.Sprintf("%d", clusterhealth.UnassignedPrimaryShards)},
{"Pending Tasks", fmt.Sprintf("%d", clusterhealth.NumberOfPendingTasks)},
{"Nodes", fmt.Sprintf("%d", clusterhealth.NumberOfNodes)}, {"Nodes", fmt.Sprintf("%d", clusterhealth.NumberOfNodes)},
{"AutoFollow (success/failed indices)", ccrfollowing}, {"Red Indices", fmt.Sprintf("%d", redindices)},
{"Long Running Tasks", fmt.Sprintf("%d", longtasks)},
}
if !isleader {
table.Entries = append(table.Entries, [][]string{
{"AutoFollow (success/failed indices)", ccrfollowing},
{"Followed Indices", fmt.Sprintf("%d", len(ccrstats.FollowStats.Indices))},
}...)
} }
if conf.Verbose { if conf.Verbose {
@@ -202,11 +246,8 @@ func gatherClusterStats(conf *cfg.Config, clusterstats *clusterstats.Response, t
clusterstats.Nodes.Jvm.Versions[0].VmVersion}, " ") clusterstats.Nodes.Jvm.Versions[0].VmVersion}, " ")
} }
isleader := checkClusterIsLeader(conf, conf.CurrentCluster)
table.Entries = append(table.Entries, [][]string{ table.Entries = append(table.Entries, [][]string{
{"Indicies", fmt.Sprintf("%d", clusterstats.Indices.Count)}, {"Indicies", fmt.Sprintf("%d", clusterstats.Indices.Count)},
{"Is Leader", fmt.Sprintf("%t", isleader)},
{"Docs", fmt.Sprintf("%d", clusterstats.Indices.Docs.Count)}, {"Docs", fmt.Sprintf("%d", clusterstats.Indices.Docs.Count)},
{"Total Size", humanize.Bytes(uint64(clusterstats.Indices.Docs.TotalSizeInBytes))}, {"Total Size", humanize.Bytes(uint64(clusterstats.Indices.Docs.TotalSizeInBytes))},
{"Total Queries", fmt.Sprintf("%d", querycount)}, {"Total Queries", fmt.Sprintf("%d", querycount)},
@@ -217,7 +258,7 @@ func gatherClusterStats(conf *cfg.Config, clusterstats *clusterstats.Response, t
humanize.Bytes(uint64(clusterstats.Indices.Store.SizeInBytes)), humanize.Bytes(uint64(clusterstats.Indices.Store.SizeInBytes)),
humanize.Bytes(uint64(*clusterstats.Indices.Store.TotalDataSetSizeInBytes)), humanize.Bytes(uint64(*clusterstats.Indices.Store.TotalDataSetSizeInBytes)),
)}, )},
{"JVM Heap", fmt.Sprintf( {"JVM Heap Memory", fmt.Sprintf(
"%s/%s", "%s/%s",
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)),

View File

@@ -27,36 +27,6 @@ import (
"github.com/urfave/cli/v3" "github.com/urfave/cli/v3"
) )
// recursively traverse the raw settings hash and build a flat map
// consisting of the translated path and its value.
//
// e.g.
// logger:
//
// org:
// elasticsearch:
// transport:
// OutboundHandler: "ERROR"
//
// gets:
//
// logger.org.elasticsearch.transport.OutboundHandler: "ERROR"
func getJsonPath(raw map[string]any, topic string) map[string]string {
paths := map[string]string{}
for name, data := range raw {
path := topic + "." + name
switch value := data.(type) {
case string:
paths[path] = value
case map[string]any:
paths = getJsonPath(value, path)
}
}
return paths
}
func ClusterSettingsList(conf *cfg.Config) error { func ClusterSettingsList(conf *cfg.Config) error {
res, err := conf.DefaultCluster.ES.Cluster.GetSettings(). res, err := conf.DefaultCluster.ES.Cluster.GetSettings().
Header("content-type", "application/json"). Header("content-type", "application/json").
@@ -87,7 +57,7 @@ func ClusterSettingsList(conf *cfg.Config) error {
return fmt.Errorf("failed to unmarshall setting for topic %s: %s", topic, err) return fmt.Errorf("failed to unmarshall setting for topic %s: %s", topic, err)
} }
paths := getJsonPath(data, topic) paths := getJsonPath(map[string]string{}, data, topic)
slog.Debug("settings", topic, paths) slog.Debug("settings", topic, paths)
for setting, value := range paths { for setting, value := range paths {

View File

@@ -21,6 +21,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"regexp" "regexp"
"strconv"
"strings" "strings"
"sync" "sync"
@@ -389,6 +390,26 @@ func getClusterData(es *elasticsearch.TypedClient, wg *sync.WaitGroup, reschan c
ar.stats = res ar.stats = res
ar.which = ResponseStats ar.which = ResponseStats
arerr = err arerr = err
case "indices":
res, err := es.Cat.Indices().
Header("content-type", "application/json").
Header("accept", "application/json").
Do(context.Background())
ar.indices = &res
ar.which = ResponseIndices
arerr = err
case "tasks":
res, err := es.Cat.Tasks().
Header("content-type", "application/json").
Header("accept", "application/json").
Do(context.Background())
ar.tasks = &res
ar.which = ResponseTasks
arerr = err
} }
if arerr != nil { if arerr != nil {
@@ -397,3 +418,44 @@ func getClusterData(es *elasticsearch.TypedClient, wg *sync.WaitGroup, reschan c
reschan <- ar reschan <- ar
} }
// recursively traverse the raw settings hash and build a flat map
// consisting of the translated path and its value.
//
// e.g.
// logger:
//
// org:
// elasticsearch:
// transport:
// OutboundHandler: "ERROR"
//
// gets:
//
// logger.org.elasticsearch.transport.OutboundHandler: "ERROR"
func getJsonPath(paths map[string]string, raw map[string]any, topic string) map[string]string {
for name, data := range raw {
path := topic + "." + name
switch value := data.(type) {
case string:
paths[path] = value
case *string:
paths[path] = *value
case int:
paths[path] = strconv.Itoa(value)
case *int:
paths[path] = strconv.Itoa(*value)
case map[string]any:
paths = getJsonPath(paths, value, path)
case []any:
val := []string{}
for _, item := range value {
val = append(val, fmt.Sprintf("%v", item))
}
paths[path] = strings.Join(val, ",")
}
}
return paths
}

259
pkg/es/datastream.go Normal file
View File

@@ -0,0 +1,259 @@
/*
Copyright © 2026 Thomas von Dein
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package es
import (
"context"
"errors"
"fmt"
"log/slog"
"regexp"
"codeberg.org/scip/esctl/pkg/cfg"
"codeberg.org/scip/esctl/pkg/printer"
"github.com/dustin/go-humanize"
"github.com/elastic/go-elasticsearch/v9/typedapi/types"
)
func DatastreamNames(conf *cfg.Config) ([]string, error) {
res, err := conf.DefaultCluster.ES.Indices.GetDataStream().
Header("content-type", "application/json").
Header("accept", "application/json").
Do(context.Background())
if err != nil {
return nil, fmt.Errorf("failed to get data streams: %s", esErrorString(err))
}
dss := make([]string, len(res.DataStreams))
for idx, ds := range res.DataStreams {
dss[idx] = ds.Name
}
return dss, nil
}
func DatastreamList(conf *cfg.Config) error {
res, err := conf.DefaultCluster.ES.Indices.GetDataStream().
Header("content-type", "application/json").
Header("accept", "application/json").
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to get data streams: %s", esErrorString(err))
}
slog.Debug("ES result", "data streams", res)
list := filterDatastreams(conf, res.DataStreams)
size := len(list)
if conf.MaxItems > 0 {
if size > conf.MaxItems {
size = conf.MaxItems
}
}
table := printer.NewTable(conf, 7, size)
table.Addheaders("name", "ilm policy", "hidden", "system", "replicated", "generation", "timestamp field")
for idx, ds := range list {
name := printer.Colorize(conf, ds.Status.String(), ds.Name)
policy := ""
if ds.IlmPolicy != nil {
policy = *ds.IlmPolicy
}
table.Entries[idx] = []string{
name,
policy,
fmt.Sprintf("%t", ds.Hidden),
fmt.Sprintf("%t", *ds.System),
fmt.Sprintf("%t", *ds.Replicated),
fmt.Sprintf("%d", ds.Generation),
ds.TimestampField.Name,
}
if idx == size-1 {
break
}
}
table.Sort()
if err := table.Print(); err != nil {
return err
}
return nil
}
func filterDatastreams(conf *cfg.Config, list []types.DataStream) []types.DataStream {
selected := []types.DataStream{}
for _, ds := range list {
if !conf.Hidden && ds.Hidden {
continue
}
selected = append(selected, ds)
}
if len(conf.Filter) == 0 {
return selected
}
// we support just one filter here, for now
filter := *regexp.MustCompile(conf.Filter[0])
newlist := []types.DataStream{}
for _, ds := range selected {
if filter.MatchString(ds.Name) {
newlist = append(newlist, ds)
}
}
return newlist
}
func DatastreamShow(conf *cfg.Config, dsname string) error {
res, err := conf.DefaultCluster.ES.Indices.GetDataStream().
Name(dsname).
Header("content-type", "application/json").
Header("accept", "application/json").
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to get data stream: %s", esErrorString(err))
}
slog.Debug("ES result", "data stream", res)
if len(res.DataStreams) == 0 {
return errors.New("no data stream found with that name")
}
stats, err := conf.DefaultCluster.ES.Indices.DataStreamsStats().
Name(dsname).
Header("content-type", "application/json").
Header("accept", "application/json").
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to get data stream stats: %s", esErrorString(err))
}
table := printer.NewTable(conf, 11, 2)
table.Addheaders("data stream property", "value")
ds := res.DataStreams[0]
name := printer.Colorize(conf, ds.Status.String(), ds.Name)
policy := ""
if ds.IlmPolicy != nil {
policy = *ds.IlmPolicy
}
table.Entries = [][]string{
{"name", name},
{"ilm policy", policy},
{"hidden", fmt.Sprintf("%t", ds.Hidden)},
{"system", fmt.Sprintf("%t", *ds.System)},
{"replicated", fmt.Sprintf("%t", *ds.Replicated)},
{"generation", fmt.Sprintf("%d", ds.Generation)},
{"timestamp field", ds.TimestampField.Name},
{"backing indices", fmt.Sprintf("%d", stats.BackingIndices)},
{"size", humanize.Bytes(uint64(stats.TotalStoreSizeBytes))},
{"shards-failed", fmt.Sprintf("%d", stats.Shards_.Failed)},
{"shards-successful", fmt.Sprintf("%d", stats.Shards_.Successful)},
}
if err := table.Print(); err != nil {
return err
}
table = printer.NewTable(conf, 5, len(ds.Indices))
table.Addheaders("backing index name", "uuid", "prefer ilm", "ilm policy", "managed by")
for idx, index := range ds.Indices {
policy := ""
if ds.IlmPolicy != nil {
policy = *ds.IlmPolicy
}
table.Entries[idx] = []string{
index.IndexName,
index.IndexUuid,
fmt.Sprintf("%t", *index.PreferIlm),
policy,
index.ManagedBy.Name,
}
if idx < len(ds.Indices) {
fmt.Println()
}
}
table.Sort()
if err := table.Print(); err != nil {
return err
}
return nil
}
func DatastreamCreate(conf *cfg.Config, dsname string) error {
_, err := conf.DefaultCluster.ES.Indices.CreateDataStream(dsname).
Header("content-type", "application/json").
Header("accept", "application/json").
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to create datastream: %s", esErrorString(err))
}
return nil
}
func DatastreamDelete(conf *cfg.Config, dsname string) error {
_, err := conf.DefaultCluster.ES.Indices.DeleteDataStream(dsname).
Header("content-type", "application/json").
Header("accept", "application/json").
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to delete datastream: %s", esErrorString(err))
}
return nil
}
func DatastreamRollover(conf *cfg.Config, ds string) error {
res, err := RolloverAlias(conf, ds)
if err != nil {
return fmt.Errorf("failed to rollover data stream: %s", esErrorString(err))
}
table := printer.NewTable(conf, 2, 5)
table.Addheaders("rollover response", "value")
table.Entries = [][]string{
{"acknowledged", fmt.Sprintf("%t", res.Acknowledged)},
{"rolled over", fmt.Sprintf("%t", res.RolledOver)},
{"shards acknowledged", fmt.Sprintf("%t", res.ShardsAcknowledged)},
{"old index", res.OldIndex},
{"new index", res.NewIndex},
}
return table.Print()
}

301
pkg/es/ilm.go Normal file
View File

@@ -0,0 +1,301 @@
/*
Copyright © 2026 Thomas von Dein
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package es
import (
"context"
"errors"
"fmt"
"log/slog"
"strings"
"codeberg.org/scip/esctl/pkg/cfg"
"codeberg.org/scip/esctl/pkg/printer"
"github.com/alecthomas/repr"
"github.com/elastic/go-elasticsearch/v9/typedapi/esdsl"
"github.com/elastic/go-elasticsearch/v9/typedapi/ilm/putlifecycle"
"github.com/elastic/go-elasticsearch/v9/typedapi/types"
)
func IlmRetry(conf *cfg.Config, index string) error {
_, err := conf.DefaultCluster.ES.Ilm.Retry(index).
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to retry ilm: %s", esErrorString(err))
}
return nil
}
func IlmStatus(conf *cfg.Config) error {
res, err := conf.DefaultCluster.ES.Ilm.GetStatus().
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to get ilm status: %s", esErrorString(err))
}
fmt.Println(res.OperationMode.Name)
return nil
}
func IlmNames(conf *cfg.Config) ([]string, error) {
res, err := conf.DefaultCluster.ES.Ilm.GetLifecycle().
Do(context.Background())
if err != nil {
return nil, fmt.Errorf("failed to get ilm policies: %s", esErrorString(err))
}
names := make([]string, len(res))
idx := 0
for name := range res {
names[idx] = name
}
return names, nil
}
func IlmList(conf *cfg.Config, pattern string) error {
ilm := conf.DefaultCluster.ES.Ilm.GetLifecycle()
if pattern != "" {
ilm.FilterPath(pattern)
}
res, err := ilm.Do(context.Background())
if err != nil {
return fmt.Errorf("failed to get ilm policies: %s", esErrorString(err))
}
if conf.Debug {
repr.Println(res)
}
table := printer.NewTable(conf, 5, 0)
table.Addheaders("ilm policy", "hot", "warm", "frozen", "delete")
for name, ilm := range res {
table.AddRow(name,
ilmPhaseString(ilm.Policy.Phases.Hot, true),
ilmPhaseString(ilm.Policy.Phases.Warm, true),
ilmPhaseString(ilm.Policy.Phases.Frozen, true),
ilmPhaseString(ilm.Policy.Phases.Delete, true),
)
}
table.Sort()
return table.Print()
}
func IlmShow(conf *cfg.Config, policy string) error {
res, err := conf.DefaultCluster.ES.Ilm.GetLifecycle().
Policy(policy).
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to get ilm status: %s", esErrorString(err))
}
if conf.Debug {
repr.Println(res)
}
ilm, exists := res[policy]
if !exists {
return errors.New("no ilm policy retrieved")
}
table := printer.NewTable(conf, 2, 5)
table.Addheaders("ilm policy setting", "value")
table.Entries = [][]string{
{"policy", policy},
{"hot phase", ilmPhaseString(ilm.Policy.Phases.Hot, false)},
{"warm phase", ilmPhaseString(ilm.Policy.Phases.Warm, false)},
{"frozen phase", ilmPhaseString(ilm.Policy.Phases.Frozen, false)},
{"delete phase", ilmPhaseString(ilm.Policy.Phases.Delete, false)},
}
return table.Print()
}
func ilmPhaseString(phase *types.Phase, short bool) string {
if phase == nil {
return ""
}
out := []string{fmt.Sprintf("move-after:%s", phase.MinAge)}
if short {
return strings.Join(out, ",")
}
if phase.Actions.Rollover != nil {
if phase.Actions.Rollover.MaxAge != "" {
out = append(out, fmt.Sprintf("rollover:%s", phase.Actions.Rollover.MaxAge))
if phase.Actions.Rollover.MaxPrimaryShardSize != nil {
out = append(out, fmt.Sprintf("shardsize:%s", phase.Actions.Rollover.MaxPrimaryShardSize))
}
}
}
if phase.Actions.Delete != nil {
out = append(out, fmt.Sprintf("delete-searchable-snapshot:%t", *phase.Actions.Delete.DeleteSearchableSnapshot))
}
if phase.Actions.SearchableSnapshot != nil {
out = append(out, fmt.Sprintf("searchable-snapshot:%s", phase.Actions.SearchableSnapshot.SnapshotRepository))
}
return strings.Join(out, ",")
}
func IlmExplain(conf *cfg.Config, index string) error {
res, err := conf.DefaultCluster.ES.Ilm.ExplainLifecycle(index).
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to get ilm state: %s", esErrorString(err))
}
slog.Debug("ilm status", "ilm", res)
explain, exists := res.Indices[index]
if !exists {
return errors.New("no ilm status retrieved")
}
ilm := explain.(*types.LifecycleExplainManaged)
table := printer.NewTable(conf, 2, 0)
table.Addheaders("ilm status field", "value")
table.Entries = [][]string{
{"index", ilm.Index},
{"ilm policy", *ilm.Policy},
{"action", *ilm.Action},
{"step", *ilm.Step},
{"age", fmt.Sprintf("%s", ilm.Age)},
{"managed", fmt.Sprintf("%t", ilm.Managed)},
{"phase", *ilm.Phase},
{"phase execution", ilmPhaseString(ilm.PhaseExecution.PhaseDefinition, false)},
}
return table.Print()
}
func IlmCreate(conf *cfg.Config, policyname string) error {
ilm := conf.DefaultCluster.ES.Ilm.PutLifecycle(policyname)
cfg := conf.Ilm
var phases types.PhasesVariant = esdsl.NewPhases()
if cfg.HaveHot() {
hot := types.Phase{}
var actions types.IlmActionsVariant = esdsl.NewIlmActions()
rollover := &types.RolloverAction{}
haveroll := false
if cfg.HotMinAge != "" {
hot.MinAge = cfg.HotMinAge
}
if cfg.HotRolloverMaxAge != "" {
rollover.MaxAge = cfg.HotRolloverMaxAge
haveroll = true
}
if cfg.HotRolloverMaxDocs != 0 {
rollover.MaxDocs = &cfg.HotRolloverMaxDocs
haveroll = true
}
if cfg.HotRolloverMaxPrimaryShardSize != 0 {
rollover.MaxPrimaryShardSize = &cfg.HotRolloverMaxPrimaryShardSize
}
if haveroll {
actions.IlmActionsCaster().Rollover = rollover
}
hot.Actions = actions.IlmActionsCaster()
phases.PhasesCaster().Hot = &hot
}
if cfg.HaveWarm() {
warm := types.Phase{}
var actions types.IlmActionsVariant = esdsl.NewIlmActions()
if cfg.WarmForceMerge != 0 {
actions.IlmActionsCaster().Forcemerge = &types.ForceMergeAction{MaxNumSegments: cfg.WarmForceMerge}
}
if cfg.WarmMinAge != "" {
warm.MinAge = cfg.WarmMinAge
}
if cfg.WarmPriority != 0 {
actions.IlmActionsCaster().SetPriority = &types.SetPriorityAction{Priority: &cfg.WarmPriority}
}
if cfg.WarmShrinkShards != 0 {
actions.IlmActionsCaster().Shrink = &types.ShrinkAction{NumberOfShards: &cfg.WarmShrinkShards}
}
warm.Actions = actions.IlmActionsCaster()
phases.PhasesCaster().Warm = &warm
}
if cfg.HaveFrozen() {
froze := phases.PhasesCaster().Frozen
if cfg.FrozenMinAge != "" {
froze.MinAge = cfg.FrozenMinAge
}
}
if cfg.HaveDelete() {
del := phases.PhasesCaster().Delete
delete := types.DeleteAction{}
var actions types.IlmActionsVariant = esdsl.NewIlmActions()
if cfg.DeleteMinAge != "" {
del.MinAge = cfg.DeleteMinAge
}
if cfg.DeleteSearchableSnapshots {
delete.DeleteSearchableSnapshot = &cfg.DeleteSearchableSnapshots
}
actions.IlmActionsCaster().Delete = &delete
del.Actions = actions.IlmActionsCaster()
phases.PhasesCaster().Delete = del
}
put := &putlifecycle.Request{}
policy := &types.IlmPolicy{}
policy.IlmPolicyCaster().Phases = *phases.PhasesCaster()
put.Policy = policy
ilm.Request(put)
_, err := ilm.Do(context.Background())
if err != nil {
return fmt.Errorf("failed create ilm policy: %s", esErrorString(err))
}
return nil
}

View File

@@ -59,6 +59,12 @@ func filterIndices(conf *cfg.Config, list indices.Response) indices.Response {
if !conf.Partials && strings.HasPrefix(*index.Index, "partial-") { if !conf.Partials && strings.HasPrefix(*index.Index, "partial-") {
continue continue
} }
if strings.HasPrefix(*index.Index, ".ds-") {
// ignore data stream backing indicies
continue
}
selectedlist = append(selectedlist, index) selectedlist = append(selectedlist, index)
} }
@@ -120,11 +126,7 @@ func IndexList(conf *cfg.Config) error {
} }
table.Sort() table.Sort()
if err := table.Print(); err != nil { return table.Print()
return err
}
return nil
} }
func IndexShow(conf *cfg.Config, indexpattern string) error { func IndexShow(conf *cfg.Config, indexpattern string) error {

View File

@@ -113,3 +113,23 @@ func IndexAliasDelete(conf *cfg.Config, index, alias string) error {
return nil return nil
} }
func IndexAliasRollover(conf *cfg.Config, alias string) error {
res, err := RolloverAlias(conf, alias)
if err != nil {
return fmt.Errorf("failed to rollover index alias: %s", esErrorString(err))
}
table := printer.NewTable(conf, 2, 5)
table.Addheaders("rollover response", "value")
table.Entries = [][]string{
{"acknowledged", fmt.Sprintf("%t", res.Acknowledged)},
{"rolled over", fmt.Sprintf("%t", res.RolledOver)},
{"shards acknowledged", fmt.Sprintf("%t", res.ShardsAcknowledged)},
{"old index", res.OldIndex},
{"new index", res.NewIndex},
}
return table.Print()
}

503
pkg/es/index_template.go Normal file
View File

@@ -0,0 +1,503 @@
/*
Copyright © 2026 Thomas von Dein
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package es
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"strconv"
"strings"
"codeberg.org/scip/esctl/pkg/cfg"
"codeberg.org/scip/esctl/pkg/printer"
"github.com/elastic/go-elasticsearch/v9/typedapi/esdsl"
"github.com/elastic/go-elasticsearch/v9/typedapi/types"
)
// used for completion
func IndexTemplateList(conf *cfg.Config) error {
res, err := conf.DefaultCluster.ES.Indices.GetIndexTemplate().
Header("content-type", "application/json").
Header("accept", "application/json").
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to get index templates: %s", esErrorString(err))
}
slog.Debug("res", "index templates", res)
table := printer.NewTable(conf, 5, len(res.IndexTemplates))
table.Addheaders("name", "description", "priority")
for idx, tpl := range res.IndexTemplates {
desc, err := json.Marshal(tpl.IndexTemplate.Meta_["description"])
if err != nil {
return fmt.Errorf("failed to unmarshal meta json data: %w", err)
}
table.Entries[idx] = []string{
tpl.Name,
string(desc),
fmt.Sprintf("%d", tpl.IndexTemplate.Priority),
}
}
table.Sort()
return table.Print()
}
func IndexTemplateShow(conf *cfg.Config, tplname string) error {
res, err := conf.DefaultCluster.ES.Indices.GetIndexTemplate().
Name(tplname).
Header("content-type", "application/json").
Header("accept", "application/json").
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to get index template: %s", esErrorString(err))
}
slog.Debug("res", "index template", res)
if len(res.IndexTemplates) != 1 {
return errors.New("multiple or no index template matched the pattern")
}
tpl := res.IndexTemplates[0]
table := printer.NewTable(conf, 2, 6)
table.Addheaders("index template property", "value")
desc, err := json.Marshal(tpl.IndexTemplate.Meta_["description"])
if err != nil {
return fmt.Errorf("failed to unmarshal meta json data: %w", err)
}
hasds := tpl.IndexTemplate.DataStream != nil
aliases := []string{}
for alias := range tpl.IndexTemplate.Template.Aliases {
aliases = append(aliases, alias)
}
table.Entries = [][]string{
{"name", tpl.Name},
{"description", string(desc)},
{"index patterns", strings.Join(tpl.IndexTemplate.IndexPatterns, ",")},
{"composed of", strings.Join(tpl.IndexTemplate.ComposedOf, ",")},
{"data stream enabled", fmt.Sprintf("%t", hasds)},
{"aliases", strings.Join(aliases, ",")},
}
if err := table.Print(); err != nil {
return err
}
table = printer.NewTable(conf, 2, 0)
table.Addheaders("index setting property", "value")
err = getIndexTemplateSettings(conf, tplname, table)
if err != nil {
return nil
}
fmt.Println()
if err := table.Print(); err != nil {
return err
}
table = printer.NewTable(conf, 2, 0)
table.Addheaders("index field mapping", "type")
if tpl.IndexTemplate.Template.Mappings != nil {
for name, field := range tpl.IndexTemplate.Template.Mappings.Properties {
typeval := ""
switch val := field.(type) {
case *types.IntegerNumberProperty:
typeval = val.Type
case *types.KeywordProperty:
typeval = val.Type
case *types.DateProperty:
typeval = val.Type
}
table.Entries = append(table.Entries, []string{
name, typeval,
})
}
fmt.Println()
if err := table.Print(); err != nil {
return err
}
}
return nil
}
func IndexTemplateCreate(conf *cfg.Config, name string, mappings []string) error {
settings := esdsl.NewIndexSettings()
maps := esdsl.NewIndexTemplateMapping()
create := conf.DefaultCluster.ES.Indices.PutIndexTemplate(name).
Header("content-type", "application/json").
Header("accept", "application/json")
if conf.Shards > 0 {
settings = settings.NumberOfShards(strconv.Itoa(conf.Shards))
}
if conf.Replicas > 0 {
settings = settings.NumberOfReplicas(strconv.Itoa(conf.Replicas))
}
if conf.Stream {
create.DataStream(esdsl.NewDataStreamVisibility())
if conf.Retention != "" {
maps.Lifecycle(
esdsl.NewDataStreamLifecycle().
DataRetention(
esdsl.NewDuration().String(conf.Retention)))
}
}
if conf.AutoCreate {
create.AllowAutoCreate(true)
}
if conf.Mode != "" {
settings = settings.Mode(conf.Mode)
}
if len(mappings) > 0 {
typemaps, err := modMappings(mappings)
if err != nil {
return err
}
maps.Mappings(typemaps)
}
if len(conf.Components) > 0 {
create.ComposedOf(conf.Components...)
}
for _, alias := range conf.Aliases {
maps.AddAlias(alias, esdsl.NewAlias())
}
if len(conf.Meta) > 0 {
metadata, err := modMeta(conf, nil)
if err != nil {
return err
}
create.Meta_(esdsl.NewMetadata(metadata))
}
if len(conf.Settings) > 0 {
usersettings, err := modSettings(conf)
if err != nil {
return err
}
settings = settings.IndexSettings(usersettings)
}
maps.Settings(settings)
create.Template(maps)
create.IndexPatterns(conf.Patterns...)
_, err := create.Do(context.Background())
if err != nil {
return fmt.Errorf("failed to create index template: %s", esErrorString(err))
}
return nil
}
// FIXME: func's too long, refactor
// FIXME: it's not yet possible to remove items in lists (aliases, mappings, settings), just overwrite them
func IndexTemplateModify(conf *cfg.Config, name string, mappings []string) error {
maps := esdsl.NewIndexTemplateMapping()
// load existing index mapping
res, err := conf.DefaultCluster.ES.Indices.GetIndexTemplate().
Name(name).
Header("content-type", "application/json").
Header("accept", "application/json").
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to get index template: %s", esErrorString(err))
}
slog.Debug("res", "index template", res)
if len(res.IndexTemplates) != 1 {
return errors.New("multiple or no index template matched the pattern")
}
tpl := res.IndexTemplates[0]
// our modify PUT request
modify := conf.DefaultCluster.ES.Indices.PutIndexTemplate(name).
Header("content-type", "application/json").
Header("accept", "application/json")
// load existing settings, if any
settings := tpl.IndexTemplate.Template.Settings
// pre fill mappings and aliases
maps.Mappings(tpl.IndexTemplate.Template.Mappings)
maps.Aliases(tpl.IndexTemplate.Template.Aliases)
// pre fill components
modify.ComposedOf(tpl.IndexTemplate.ComposedOf...)
// pre fill data stream config
if conf.Stream {
modify.DataStream(esdsl.NewDataStreamVisibility())
if conf.Retention != "" {
maps.Lifecycle(
esdsl.NewDataStreamLifecycle().
DataRetention(
esdsl.NewDuration().String(conf.Retention)))
}
}
modify.AllowAutoCreate(conf.AutoCreate)
if conf.Mode != "" {
*settings.Mode = conf.Mode
}
if len(mappings) > 0 {
typemaps, err := modMappings(mappings)
if err != nil {
return err
}
maps.Mappings(typemaps)
}
if len(conf.Components) > 0 {
modify.ComposedOf(conf.Components...)
}
for _, alias := range conf.Aliases {
maps.AddAlias(alias, esdsl.NewAlias())
}
if len(conf.Meta) > 0 {
// pre fill meta, if any
metadata, err := modMeta(conf, tpl.IndexTemplate.Meta_)
if err != nil {
return err
}
modify.Meta_(esdsl.NewMetadata(metadata))
}
if len(conf.Settings) > 0 {
usersettings, err := modSettings(conf)
if err != nil {
return err
}
settings.IndexSettings = usersettings
}
patterns := tpl.IndexTemplate.IndexPatterns
if len(conf.Patterns) > 0 {
patterns = conf.Patterns
}
maps.Settings(settings)
modify.Template(maps)
modify.IndexPatterns(patterns...)
_, err = modify.Do(context.Background())
if err != nil {
return fmt.Errorf("failed to modify index template: %s", esErrorString(err))
}
if conf.Rollover {
if err := rolloverAliasIndexTemplate(conf, name); err != nil {
return err
}
}
return nil
}
func IndexTemplateDelete(conf *cfg.Config, name string) error {
_, err := conf.DefaultCluster.ES.Indices.DeleteIndexTemplate(name).
Header("content-type", "application/json").
Header("accept", "application/json").
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to delete index template: %s", esErrorString(err))
}
return nil
}
// Based on given index template find the associated index patterns,
// find indices matching those, find their associated aliases and
// rollover all we find. Only run when conf.Rollover==true
func rolloverAliasIndexTemplate(conf *cfg.Config, name string) error {
res, err := conf.DefaultCluster.ES.Indices.GetIndexTemplate().
Name(name).
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to get index template: %s", esErrorString(err))
}
slog.Debug("res", "index template", res)
if len(res.IndexTemplates) != 1 {
return errors.New("multiple or no index template matched the pattern")
}
patterns := res.IndexTemplates[0].IndexTemplate.IndexPatterns
aliases := map[string]int{}
// find all aliases matching the patterns
for _, pattern := range patterns {
res, err := conf.DefaultCluster.ES.Indices.ResolveIndex(pattern).
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to resolve index pattern: %s", esErrorString(err))
}
for _, index := range res.Indices {
for _, alias := range index.Aliases {
aliases[alias] = 1
}
}
}
if len(aliases) == 0 {
return nil
}
table := printer.NewTable(conf, 4, 0)
table.Addheaders("rollover alias", "status", "ack", "new index")
// apply rollover to all matching aliases, if any
for alias := range aliases {
res, err := RolloverAlias(conf, alias)
if err != nil {
return err
}
table.Entries = append(table.Entries, []string{
alias,
fmt.Sprintf("%t", res.RolledOver),
fmt.Sprintf("%t", res.Acknowledged),
res.NewIndex,
})
if err := table.Print(); err != nil {
return err
}
}
return nil
}
func modMappings(mappings []string) (types.TypeMappingVariant, error) {
typemaps := esdsl.NewTypeMapping()
for _, mapping := range mappings {
parts := strings.Split(mapping, ":")
if len(parts) != 2 {
return nil, fmt.Errorf(
"invalid mapping %s, expect <name:type> (type: integer, text, date, keyword)",
mapping)
}
switch parts[1] {
case "text":
typemaps.AddProperty(parts[0], esdsl.NewTextProperty())
case "integer":
typemaps.AddProperty(parts[0], esdsl.NewIntegerNumberProperty())
case "date":
typemaps.AddProperty(parts[0], esdsl.NewDateProperty())
case "keyword":
typemaps.AddProperty(parts[0], esdsl.NewKeywordProperty())
}
}
return typemaps, nil
}
func modMeta(conf *cfg.Config, meta types.Metadata) (map[string]json.RawMessage, error) {
metadata := map[string]json.RawMessage{}
for key, value := range meta {
metadata[key] = value
}
for _, meta := range conf.Meta {
parts := strings.Split(meta, ":")
if len(parts) != 2 {
return nil, errors.New("meta data must be in the form key:value")
}
msg, err := json.Marshal(parts[1])
if err != nil {
return nil, fmt.Errorf("failed to json marshal metadata %s: %w", meta, err)
}
metadata[parts[0]] = msg
}
return metadata, nil
}
func modSettings(conf *cfg.Config) (map[string]json.RawMessage, error) {
usersettings := map[string]json.RawMessage{}
for _, meta := range conf.Settings {
parts := strings.Split(meta, ":")
if len(parts) != 2 {
return nil, errors.New("settings data must be in the form key:value")
}
msg, err := json.Marshal(parts[1])
if err != nil {
return nil, fmt.Errorf("failed to json marshal metadata %s: %w", meta, err)
}
usersettings[parts[0]] = msg
}
return usersettings, nil
}

View File

@@ -0,0 +1,61 @@
package es
import (
"encoding/json"
"fmt"
"codeberg.org/scip/esctl/pkg/cfg"
"codeberg.org/scip/esctl/pkg/printer"
)
type TplTemplateData struct {
Settings map[string]any `json:"settings"`
Aliases map[string]any `json:"aliases"`
Lifecycle map[string]any `json:"lifecycle"`
Mappings map[string]any `json:"mappings"`
}
type TplTemplate struct {
Template TplTemplateData `json:"template"`
}
type TplTpl struct {
IndexTemplate TplTemplate `json:"index_template"`
}
type Tpl struct {
IndexTemplates []TplTpl `json:"index_templates"`
}
func getIndexTemplateSettings(conf *cfg.Config, tplname string, table *printer.Table) error {
raw, err := CallAPI(conf, "GET", "/_index_template/"+tplname, "")
if err != nil {
return err
}
data := Tpl{}
if err = json.Unmarshal(raw, &data); err != nil {
return err
}
if conf.Debug {
if err := prettyfiJson(conf, raw); err != nil {
return err
}
}
if len(data.IndexTemplates) == 0 {
return fmt.Errorf("index_template %s not found", tplname)
}
tpl := data.IndexTemplates[0].IndexTemplate.Template.Settings
for topic, val := range tpl {
paths := getJsonPath(map[string]string{}, val.(map[string]any), topic)
for setting, value := range paths {
table.Entries = append(table.Entries, []string{setting, fmt.Sprintf("%v", value)})
}
}
return nil
}

View File

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

65
pkg/es/rollover.go Normal file
View File

@@ -0,0 +1,65 @@
/*
Copyright © 2026 Thomas von Dein
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package es
import (
"context"
"codeberg.org/scip/esctl/pkg/cfg"
"github.com/elastic/go-elasticsearch/v9/typedapi/esdsl"
"github.com/elastic/go-elasticsearch/v9/typedapi/indices/rollover"
"github.com/elastic/go-elasticsearch/v9/typedapi/types"
)
func RolloverConditions(conf *cfg.Config) types.RolloverConditionsVariant {
cond := esdsl.NewRolloverConditions()
if conf.MaxAge != "" {
cond.MaxAge(esdsl.NewDuration().String(conf.MaxAge))
}
if conf.MaxDocs > 0 {
cond.MaxDocs(int64(conf.MaxDocs))
}
if conf.MaxShardSize > 0 {
bs := esdsl.NewByteSize()
bs.Int64(int64(conf.MaxShardSize))
cond.MaxPrimaryShardSize(bs)
}
if conf.MaxShardDocs > 0 {
cond.MaxPrimaryShardDocs(int64(conf.MaxShardDocs))
}
return cond
}
func RolloverAlias(conf *cfg.Config, alias string) (*rollover.Response, error) {
roll := conf.DefaultCluster.ES.Indices.Rollover(alias)
if conf.Wait {
roll.WaitForActiveShards("all")
}
if conf.DryRun {
roll.DryRun(true)
}
cond := RolloverConditions(conf)
roll.Conditions(cond)
return roll.Do(context.Background())
}

79
pkg/es/task.go Normal file
View File

@@ -0,0 +1,79 @@
/*
Copyright © 2026 Thomas von Dein
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package es
import (
"context"
"fmt"
"log/slog"
"strconv"
"time"
"codeberg.org/scip/esctl/pkg/cfg"
"codeberg.org/scip/esctl/pkg/printer"
)
func TaskList(conf *cfg.Config) error {
res, err := conf.DefaultCluster.ES.Cat.Tasks().
Header("content-type", "application/json").
Header("accept", "application/json").
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to get index templates: %s", esErrorString(err))
}
slog.Debug("res", "tasks", res)
table := printer.NewTable(conf, 8, len(res))
table.Addheaders("task id", "action", "start time", "run time", "node", "type")
for idx, task := range res {
var ts time.Time
epc, err := strconv.Atoi(*task.StartTime)
if err != nil {
ts = time.Now()
} else {
ts = time.Unix(int64(epc/1000), 0)
}
table.Entries[idx] = []string{
*task.TaskId,
*task.Action,
ts.Format("2006-01-02T15:04:05"),
*task.RunningTime,
*task.Node,
*task.Type,
}
}
return table.Print()
}
func TaskCancel(conf *cfg.Config, taskid string) error {
_, err := conf.DefaultCluster.ES.Tasks.Cancel().
TaskId(taskid).
Header("content-type", "application/json").
Header("accept", "application/json").
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to cancel task: %s", esErrorString(err))
}
return nil
}

View File

@@ -292,6 +292,10 @@ func (data *Table) Addheaders(headers ...string) {
} }
} }
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, ""))