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) }