diff --git a/cmd/cluster.go b/cmd/cluster.go
new file mode 100644
index 0000000..5432d40
--- /dev/null
+++ b/cmd/cluster.go
@@ -0,0 +1,78 @@
+/*
+Copyright © 2026 Thomas von Dein
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with this program. If not, see .
+*/
+package cmd
+
+import (
+ "context"
+ "errors"
+
+ "codeberg.org/scip/esctl/pkg/cfg"
+ "codeberg.org/scip/esctl/pkg/es"
+
+ "github.com/urfave/cli/v3"
+)
+
+func Cluster(conf *cfg.Config) *cli.Command {
+ return &cli.Command{
+ Name: "cluster",
+ Aliases: []string{"c"},
+ Usage: "manage cluster[s]",
+
+ Commands: []*cli.Command{
+ Compare(conf),
+ },
+ }
+}
+
+func Compare(conf *cfg.Config) *cli.Command {
+ return &cli.Command{
+ Name: "compare",
+ Aliases: []string{"c"},
+ Usage: "compare cluster[s] (yaml config with 2 clusters required)",
+
+ Flags: []cli.Flag{
+ &cli.StringFlag{
+ Name: "exclude",
+ Usage: "regexp of indicies to exclude",
+ Destination: &conf.Exclude,
+ Aliases: []string{"e"},
+ },
+ },
+
+ Action: func(ctx context.Context, cmd *cli.Command) error {
+ leader := cmd.Args().Get(0)
+ follower := cmd.Args().Get(1)
+
+ if leader == "" || follower == "" {
+ return errors.New("no leader and follower aliases specified")
+ }
+
+ _, hasLeader := conf.Clusters[leader]
+ _, hasFollower := conf.Clusters[follower]
+
+ if !hasLeader || !hasFollower {
+ return errors.New("either leader or follower alias not configured")
+ }
+
+ if err := es.ClusterCompare(conf, leader, follower); err != nil {
+ return err
+ }
+
+ return nil
+ },
+ }
+}
diff --git a/cmd/root.go b/cmd/root.go
index 645899e..aa21150 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -76,11 +76,12 @@ func Main() int {
Search(conf),
Index(conf),
Snapshot(conf),
+ Cluster(conf),
},
Before: func(ctx context.Context, cmd *cli.Command) (context.Context, error) {
if err := conf.Init(); err != nil {
- Finish(err)
+ return nil, err
}
log.Init(conf)
diff --git a/pkg/cfg/config.go b/pkg/cfg/config.go
index 13a3013..67d575a 100644
--- a/pkg/cfg/config.go
+++ b/pkg/cfg/config.go
@@ -44,10 +44,11 @@ type Config struct {
Debug bool // -d
Clusters map[string]*Cluster
DefaultCluster *Cluster
- From, To, MaxItems int
- Index string
- Filter []string
- Failed, Partials bool
+ Index string // index: -i
+ Failed, Partials bool // index: flags
+ From, To, MaxItems int // search: flags
+ Filter []string // search: -F
+ Exclude string // cluster compare: -e (regexp)
}
func NewConfig() *Config {
diff --git a/pkg/es/cluster.go b/pkg/es/cluster.go
new file mode 100644
index 0000000..a6bf21c
--- /dev/null
+++ b/pkg/es/cluster.go
@@ -0,0 +1,120 @@
+/*
+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 .
+*/
+package es
+
+import (
+ "context"
+ "fmt"
+ "regexp"
+
+ "codeberg.org/scip/esctl/pkg/cfg"
+ "github.com/elastic/go-elasticsearch/v9/typedapi/types"
+)
+
+const (
+ DefaultExclude = `(part|monitoring|.internal|metrics-endpoint)`
+)
+
+type ClusterIndices map[string]map[string]*types.IndicesRecord
+
+func ClusterCompare(conf *cfg.Config, leader, follower string) error {
+ indices := ClusterIndices{}
+
+ for _, alias := range []string{leader, follower} {
+ cat := conf.Clusters[alias].ES.Cat.Indices().
+ // we need to add custom request headers, required for older ES instances
+ Header("content-type", "application/json").
+ Header("accept", "application/json")
+
+ res, err := cat.Do(context.Background())
+ if err != nil {
+ return fmt.Errorf("Error getting indicies on %s: %s", alias, err)
+ }
+
+ indices[alias] = make(map[string]*types.IndicesRecord, len(res))
+
+ for _, index := range res {
+ indices[alias][*index.Index] = &index
+ }
+ }
+
+ findIndicesOnlyOnMaster(conf, indices, leader, follower)
+
+ return nil
+}
+
+// look for indicies only on leader
+func findIndicesOnlyOnMaster(conf *cfg.Config, indices ClusterIndices, leader, follower string) {
+ exclude := regexp.MustCompile(DefaultExclude)
+ if conf.Exclude != "" {
+ exclude = regexp.MustCompile(conf.Exclude)
+ }
+
+ indexOnlyOnLeader := map[string]*types.IndicesRecord{}
+ for name, index := range indices[leader] {
+ if exclude.MatchString(name) {
+ continue
+ }
+
+ _, followerHasIt := indices[follower][name]
+ if !followerHasIt {
+ // fetch index details
+ res, err := conf.Clusters[leader].ES.Indices.Get(name).
+ Header("content-type", "application/json").
+ Header("accept", "application/json").
+ Do(context.Background())
+ if err != nil {
+ continue // ignore it then
+ }
+
+ _, defined := res[name]
+ if !defined {
+ // json response map didn't contain the index
+ continue
+ }
+
+ isWritable := false
+ for _, alias := range res[name].Aliases {
+ if *alias.IsWriteIndex {
+ isWritable = true
+ break
+ }
+ }
+
+ if isWritable {
+ // ignore index if associated alias index is writing
+ continue
+ }
+
+ indexOnlyOnLeader[name] = index
+ }
+ }
+
+ idx := 0
+ table := NewTable(3, len(indexOnlyOnLeader))
+ table.Addheaders("index only on leader", "size", "docscount")
+
+ for name, index := range indexOnlyOnLeader {
+ name := Colorize("red", name)
+
+ table.entries[idx] = []string{name, *index.DatasetSize, *index.DocsCount}
+ idx++
+ }
+
+ table.Sort()
+ table.PrintMarkdown()
+}
diff --git a/pkg/es/index.go b/pkg/es/index.go
index e7d7ecf..4585cdd 100644
--- a/pkg/es/index.go
+++ b/pkg/es/index.go
@@ -26,7 +26,10 @@ import (
)
func IndexList(conf *cfg.Config) error {
- cat := conf.DefaultCluster.ES.Cat.Indices()
+ cat := conf.DefaultCluster.ES.Cat.Indices().
+ // we need to add custom request headers, required for older ES instances
+ Header("content-type", "application/json").
+ Header("accept", "application/json")
if conf.Failed {
cat = cat.Health(healthstatus.Red)
@@ -67,7 +70,11 @@ func IndexList(conf *cfg.Config) error {
}
func IndexShow(conf *cfg.Config, index string) error {
- res, err := conf.DefaultCluster.ES.Indices.Get(index).Do(context.Background())
+ res, err := conf.DefaultCluster.ES.Indices.Get(index).
+ // we need to add custom request headers, required for older ES instances
+ Header("content-type", "application/json").
+ Header("accept", "application/json").
+ Do(context.Background())
if err != nil {
return fmt.Errorf("failed to get index: %s", err)
}