2026-04-21 10:50:09 +02:00
|
|
|
/*
|
|
|
|
|
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"
|
2026-06-08 14:00:22 +02:00
|
|
|
"encoding/json"
|
2026-04-21 10:50:09 +02:00
|
|
|
"fmt"
|
2026-05-29 10:19:18 +02:00
|
|
|
"log"
|
2026-04-21 15:05:15 +02:00
|
|
|
"log/slog"
|
2026-05-29 10:19:18 +02:00
|
|
|
"time"
|
2026-04-21 10:50:09 +02:00
|
|
|
|
|
|
|
|
"codeberg.org/scip/esctl/pkg/cfg"
|
2026-05-29 10:19:18 +02:00
|
|
|
"codeberg.org/scip/esctl/pkg/printer"
|
|
|
|
|
"github.com/elastic/go-elasticsearch/v9/typedapi/core/search"
|
|
|
|
|
"github.com/elastic/go-elasticsearch/v9/typedapi/esdsl"
|
2026-06-08 14:00:22 +02:00
|
|
|
"github.com/elastic/go-elasticsearch/v9/typedapi/indices/validatequery"
|
2026-05-29 10:19:18 +02:00
|
|
|
"github.com/elastic/go-elasticsearch/v9/typedapi/types"
|
|
|
|
|
"github.com/elastic/go-elasticsearch/v9/typedapi/types/enums/sortorder"
|
2026-06-08 14:00:22 +02:00
|
|
|
"github.com/tidwall/gjson"
|
2026-05-29 10:19:18 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
MAXPAGE = 5000
|
2026-04-21 10:50:09 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
/*
|
|
|
|
|
Execute an ES search.
|
|
|
|
|
|
|
|
|
|
q is the actual search query given as arg to the 'search' cmd
|
|
|
|
|
additional filters can be given as -F key=value
|
|
|
|
|
*/
|
2026-05-12 18:28:57 +02:00
|
|
|
func Search(conf *cfg.Config, queries []string) error {
|
2026-06-08 14:00:22 +02:00
|
|
|
if conf.Validate {
|
|
|
|
|
return validateSearch(conf, queries)
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-24 09:25:50 +02:00
|
|
|
searchEs := conf.DefaultCluster.ES().Search().Index(conf.Index)
|
2026-05-12 18:28:57 +02:00
|
|
|
|
2026-05-29 11:05:57 +02:00
|
|
|
queryCaster, err := prepareQuery(conf, queries)
|
2026-05-29 10:19:18 +02:00
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-29 11:05:57 +02:00
|
|
|
req := &search.Request{Query: queryCaster}
|
|
|
|
|
|
|
|
|
|
searchEs.Request(req)
|
2026-05-29 10:19:18 +02:00
|
|
|
|
2026-06-01 12:30:41 +02:00
|
|
|
searchEs = addSort(conf, searchEs)
|
|
|
|
|
|
2026-06-08 14:00:22 +02:00
|
|
|
switch {
|
|
|
|
|
case conf.Tail:
|
2026-05-29 11:05:57 +02:00
|
|
|
return searchTail(conf, searchEs)
|
2026-06-08 14:00:22 +02:00
|
|
|
case conf.Explain:
|
|
|
|
|
return explainSearch(conf, searchEs)
|
2026-06-01 12:30:41 +02:00
|
|
|
default:
|
2026-05-29 10:19:18 +02:00
|
|
|
if conf.To > MAXPAGE {
|
|
|
|
|
return searchPit(conf, req)
|
|
|
|
|
} else {
|
2026-05-29 11:05:57 +02:00
|
|
|
return searchOnce(conf, searchEs)
|
2026-05-12 18:28:57 +02:00
|
|
|
}
|
2026-05-29 10:19:18 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-08 14:00:22 +02:00
|
|
|
func explainSearch(conf *cfg.Config, search *search.Search) error {
|
|
|
|
|
res, err := search.
|
|
|
|
|
Explain(true).
|
|
|
|
|
Size(1). // one's enough for explain
|
|
|
|
|
Do(context.Background())
|
|
|
|
|
if err != nil {
|
2026-07-07 09:34:36 +02:00
|
|
|
return fmt.Errorf("failed to call explain search (esdsl): %w", esErrorString(err))
|
2026-06-08 14:00:22 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if conf.Debug {
|
2026-06-08 14:11:26 +02:00
|
|
|
raw, err := json.Marshal(res)
|
|
|
|
|
if err != nil {
|
2026-07-07 09:34:36 +02:00
|
|
|
return fmt.Errorf("failed to marshal explain result: %w", err)
|
2026-06-08 14:11:26 +02:00
|
|
|
}
|
|
|
|
|
|
2026-06-08 14:00:22 +02:00
|
|
|
value := gjson.Get(string(raw), "hits.hits.0._explanation")
|
|
|
|
|
fmt.Println(value.String())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if len(res.Hits.Hits) > 0 {
|
|
|
|
|
ex := res.Hits.Hits[0].Explanation_
|
|
|
|
|
fmt.Println(ex.Description)
|
|
|
|
|
fmt.Println(ex.Value)
|
|
|
|
|
|
|
|
|
|
// recurse into explanation details (it's a tree)
|
|
|
|
|
for _, ex := range ex.Details {
|
|
|
|
|
explain(&ex, " ")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func explain(res *types.ExplanationDetail, indent string) {
|
|
|
|
|
fmt.Println(indent + "- " + res.Description)
|
|
|
|
|
for _, ex := range res.Details {
|
|
|
|
|
fmt.Println(indent + " - " + ex.Description)
|
|
|
|
|
fmt.Println(indent + fmt.Sprintf(" score: %f", res.Value))
|
|
|
|
|
|
|
|
|
|
explain(&ex, indent+" ")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func validateSearch(conf *cfg.Config, queries []string) error {
|
2026-06-24 09:25:50 +02:00
|
|
|
validate := conf.DefaultCluster.ES().Indices.ValidateQuery()
|
2026-06-08 14:00:22 +02:00
|
|
|
|
|
|
|
|
queryCaster, err := prepareQuery(conf, queries)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
req := &validatequery.Request{Query: queryCaster}
|
|
|
|
|
|
|
|
|
|
validate.Request(req)
|
|
|
|
|
|
|
|
|
|
res, err := validate.
|
|
|
|
|
Do(context.Background())
|
|
|
|
|
if err != nil {
|
2026-07-07 09:34:36 +02:00
|
|
|
return fmt.Errorf("failed to validate search (esdsl): %w", esErrorString(err))
|
2026-06-08 14:00:22 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
slog.Debug("ES result", "search", res)
|
|
|
|
|
|
|
|
|
|
if res.Valid {
|
|
|
|
|
fmt.Println(printer.Colorize(conf, "green", "valid"))
|
|
|
|
|
} else {
|
|
|
|
|
fmt.Println(printer.Colorize(conf, "red", "invalid"))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-29 10:19:18 +02:00
|
|
|
func searchOnce(conf *cfg.Config, search *search.Search) error {
|
|
|
|
|
res, err := search.
|
|
|
|
|
From(conf.From).
|
|
|
|
|
Size(conf.To).
|
|
|
|
|
Do(context.Background())
|
2026-04-21 10:50:09 +02:00
|
|
|
if err != nil {
|
2026-07-07 09:34:36 +02:00
|
|
|
return fmt.Errorf("failed to run search (esdsl): %w", esErrorString(err))
|
2026-04-21 10:50:09 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-21 15:05:15 +02:00
|
|
|
slog.Debug("ES result", "search", res)
|
|
|
|
|
|
2026-06-08 12:09:01 +02:00
|
|
|
printer.PrintDocs(conf, res.Hits.Hits)
|
2026-05-29 10:19:18 +02:00
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// https://www.elastic.co/docs/reference/elasticsearch/clients/go/using-the-api/searching#_pit_search_after
|
|
|
|
|
func searchPit(conf *cfg.Config, req *search.Request) error {
|
|
|
|
|
ctx := context.Background()
|
2026-06-24 09:25:50 +02:00
|
|
|
pit, err := conf.DefaultCluster.ES().OpenPointInTime(conf.Index).KeepAlive("1m").Do(ctx)
|
2026-05-29 10:19:18 +02:00
|
|
|
if err != nil {
|
2026-07-07 09:34:36 +02:00
|
|
|
return fmt.Errorf("failed to open point-in-time request for search: %w", err)
|
2026-05-29 10:19:18 +02:00
|
|
|
}
|
|
|
|
|
defer func() {
|
2026-06-24 09:25:50 +02:00
|
|
|
_, err := conf.DefaultCluster.ES().ClosePointInTime().Id(pit.Id).Do(ctx)
|
2026-05-29 10:19:18 +02:00
|
|
|
if err != nil {
|
|
|
|
|
log.Fatalf("failed to close PIT: %s", err)
|
|
|
|
|
}
|
|
|
|
|
}()
|
|
|
|
|
|
2026-06-24 09:25:50 +02:00
|
|
|
search := conf.DefaultCluster.ES().Search().
|
2026-05-29 10:19:18 +02:00
|
|
|
Request(req).
|
|
|
|
|
Pit(esdsl.NewPointInTimeReference().
|
|
|
|
|
Id(pit.Id).
|
|
|
|
|
KeepAlive(esdsl.NewDuration().String("1m"))).
|
|
|
|
|
Sort(esdsl.NewSortOptions().
|
|
|
|
|
AddSortOption("_shard_doc", esdsl.NewFieldSort(sortorder.Asc))).
|
|
|
|
|
Size(conf.To)
|
|
|
|
|
|
2026-06-01 12:30:41 +02:00
|
|
|
search = addSort(conf, search)
|
|
|
|
|
|
2026-05-29 10:19:18 +02:00
|
|
|
for {
|
|
|
|
|
res, err := search.Do(ctx)
|
|
|
|
|
if err != nil {
|
2026-07-07 09:34:36 +02:00
|
|
|
return fmt.Errorf("failed to run search (esdsl pit): %w", esErrorString(err))
|
2026-05-29 10:19:18 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if len(res.Hits.Hits) == 0 {
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-08 12:09:01 +02:00
|
|
|
printer.PrintDocs(conf, res.Hits.Hits)
|
2026-05-29 10:19:18 +02:00
|
|
|
|
|
|
|
|
last := res.Hits.Hits[len(res.Hits.Hits)-1]
|
|
|
|
|
search = search.SearchAfterValues(last.Sort)
|
|
|
|
|
|
|
|
|
|
if res.PitId != nil {
|
|
|
|
|
search = search.Pit(esdsl.NewPointInTimeReference().
|
|
|
|
|
Id(*res.PitId).
|
|
|
|
|
KeepAlive(esdsl.NewDuration().String("1m")))
|
2026-05-22 13:50:17 +02:00
|
|
|
}
|
2026-04-21 10:50:09 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
}
|
2026-05-29 10:19:18 +02:00
|
|
|
|
|
|
|
|
func searchTail(conf *cfg.Config, search *search.Search) error {
|
|
|
|
|
docs := map[string]int{}
|
|
|
|
|
|
|
|
|
|
fmt.Println("enter ctrl-c to abort...")
|
|
|
|
|
|
|
|
|
|
for {
|
|
|
|
|
res, err := search.Do(context.Background())
|
|
|
|
|
if err != nil {
|
2026-07-07 09:34:36 +02:00
|
|
|
return fmt.Errorf("failed to run search (esdsl): %w", esErrorString(err))
|
2026-05-29 10:19:18 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
slog.Debug("ES result", "search", res)
|
|
|
|
|
|
|
|
|
|
for _, hit := range res.Hits.Hits {
|
|
|
|
|
_, exists := docs[*hit.Id_]
|
|
|
|
|
if exists {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
printer.PrintDoc(conf, hit)
|
2026-06-08 12:09:01 +02:00
|
|
|
fmt.Println()
|
2026-05-29 10:19:18 +02:00
|
|
|
|
|
|
|
|
docs[*hit.Id_] = 1
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
time.Sleep(100 * time.Millisecond)
|
|
|
|
|
}
|
|
|
|
|
}
|