* Add stub source and elastic API funcs * Spawn workers and ship chunks * Now successfully detects a credential - Added tests - Added some documentation comments - Threaded the passed context through to all the API requests * Linting fixes * Add integration tests and resolve some bugs they uncovered * Logstash -> Elasticsearch * Add support for --index-pattern * Add support for --query-json * Use structs instead of string building to construct a search body * Support --since-timestamp * Implement additional authentication methods * Fix some small bugs * Refactoring to support --best-effort-scan * Finish implementation of --best-effort-scan * Implement scan catch-up * Finish connecting support for nodes CLI arg * Add some integration tests around the catchup mechanism * go mod tidy * Fix some linting issues * Remove some debugging Prints * Move off of _doc * Remove informational Printf and add informational logging * Remove debugging logging * Copy the index from the outer loop as well * Don't burn up the ES API with rapid requests if there's no work to do in subsequent scans * No need to export UnitOfWork.AddSearch * Use a better name for the range query variable when building the timestamp range clause in searches * Replace some unlocking defers with explicit unlocks to make the synchronized part of the code clearer * found -> ok * Remove superfluous buildElasticClient method --------- Co-authored-by: Charlie Gunyon <[email protected]>
473 lines
10 KiB
Go
473 lines
10 KiB
Go
package elasticsearch
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
es "github.com/elastic/go-elasticsearch/v8"
|
|
"github.com/elastic/go-elasticsearch/v8/esapi"
|
|
)
|
|
|
|
const PAGE_SIZE = 10
|
|
|
|
type IndexStatus int
|
|
|
|
type FilterParams struct {
|
|
indexPattern string
|
|
queryJSON string
|
|
sinceTimestamp string
|
|
}
|
|
|
|
type PointInTime struct {
|
|
ID string `json:"id"`
|
|
KeepAlive string `json:"keep_alive"`
|
|
}
|
|
|
|
type SearchRequestBody struct {
|
|
PIT PointInTime `json:"pit"`
|
|
Sort []string `json:"sort"`
|
|
SearchAfter []int `json:"search_after,omitempty"`
|
|
Query map[string]any `json:"query,omitempty"`
|
|
}
|
|
|
|
type Document struct {
|
|
id string
|
|
timestamp string
|
|
message string
|
|
}
|
|
|
|
type Index struct {
|
|
name string
|
|
documentCount int
|
|
latestTimestamp time.Time
|
|
latestTimestampLastRun time.Time
|
|
latestDocumentIDs []string
|
|
lock sync.RWMutex
|
|
}
|
|
|
|
type Indices struct {
|
|
indices []*Index
|
|
documentCount int
|
|
processedDocumentsCount int
|
|
filterParams *FilterParams
|
|
lock sync.RWMutex
|
|
}
|
|
|
|
type elasticSearchRequest interface {
|
|
Do(providedCtx context.Context, transport esapi.Transport) (*esapi.Response, error)
|
|
}
|
|
|
|
func (fp *FilterParams) Query(latestTimestamp time.Time) (map[string]any, error) {
|
|
timestampRangeQueryClause := make(map[string]any)
|
|
|
|
if fp.queryJSON != "" {
|
|
err := json.Unmarshal([]byte(fp.queryJSON), ×tampRangeQueryClause)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
if !latestTimestamp.IsZero() {
|
|
gte := make(map[string]string)
|
|
gte["gte"] = latestTimestamp.Format(time.RFC3339)
|
|
|
|
timestamp := make(map[string]map[string]string)
|
|
timestamp["@timestamp"] = gte
|
|
|
|
timestampRangeQueryClause["range"] = timestamp
|
|
} else if fp.sinceTimestamp != "" {
|
|
gte := make(map[string]string)
|
|
gte["gte"] = fp.sinceTimestamp
|
|
|
|
timestamp := make(map[string]map[string]string)
|
|
timestamp["@timestamp"] = gte
|
|
|
|
timestampRangeQueryClause["range"] = timestamp
|
|
}
|
|
|
|
query := make(map[string]any)
|
|
query["query"] = timestampRangeQueryClause
|
|
|
|
return query, nil
|
|
}
|
|
|
|
func NewIndex() *Index {
|
|
return &Index{}
|
|
}
|
|
|
|
func (i *Index) DocumentAlreadySeen(document *Document) bool {
|
|
parsedTimestamp, err := time.Parse(time.RFC3339, document.timestamp)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
|
|
// We mutate the index in different ways depending on whether the timestamp
|
|
// is newer, equal, or older than the its current latest timestamp, so
|
|
// everything at this point must be write synchronized.
|
|
i.lock.Lock()
|
|
defer i.lock.Unlock()
|
|
|
|
if parsedTimestamp.After(i.latestTimestamp) {
|
|
i.latestTimestamp = parsedTimestamp
|
|
i.latestDocumentIDs = i.latestDocumentIDs[:0]
|
|
return false
|
|
}
|
|
|
|
if i.latestTimestamp.Equal(i.latestTimestampLastRun) &&
|
|
slices.Contains(i.latestDocumentIDs, document.id) {
|
|
return true
|
|
}
|
|
|
|
i.latestDocumentIDs = append(i.latestDocumentIDs, document.id)
|
|
return false
|
|
}
|
|
|
|
func (i *Index) UpdateLatestTimestampLastRun() {
|
|
i.lock.Lock()
|
|
i.latestTimestampLastRun = i.latestTimestamp
|
|
i.lock.Unlock()
|
|
}
|
|
|
|
func makeElasticSearchRequest(
|
|
ctx context.Context,
|
|
transport esapi.Transport,
|
|
req elasticSearchRequest,
|
|
) (map[string]any, error) {
|
|
res, err := req.Do(ctx, transport)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
defer res.Body.Close()
|
|
|
|
rawData, err := io.ReadAll(res.Body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
data := make(map[string]any)
|
|
|
|
err = json.Unmarshal(rawData, &data)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return data, nil
|
|
}
|
|
|
|
func fetchIndexNames(
|
|
ctx context.Context,
|
|
client *es.TypedClient,
|
|
indexPattern string,
|
|
) ([]string, error) {
|
|
req := esapi.IndicesGetRequest{
|
|
Index: []string{indexPattern},
|
|
}
|
|
|
|
data, err := makeElasticSearchRequest(ctx, client, req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
names := make([]string, len(data))
|
|
count := 0
|
|
|
|
for indexName := range data {
|
|
names[count] = indexName
|
|
count++
|
|
}
|
|
|
|
return names, nil
|
|
}
|
|
|
|
func fetchIndexDocumentCount(
|
|
ctx context.Context,
|
|
client *es.TypedClient,
|
|
indexName string,
|
|
query map[string]any,
|
|
) (int, error) {
|
|
size := 0
|
|
|
|
req := esapi.SearchRequest{
|
|
Index: []string{indexName},
|
|
SearchType: "query_then_fetch",
|
|
Size: &size,
|
|
}
|
|
|
|
if len(query["query"].(map[string]any)) > 0 {
|
|
body, err := json.MarshalIndent(query, "", " ")
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
req.Body = strings.NewReader(string(body))
|
|
}
|
|
|
|
data, err := makeElasticSearchRequest(ctx, client, req)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
hits, ok := data["hits"].(map[string]any)
|
|
if !ok {
|
|
return 0, errors.New("No hits in response")
|
|
}
|
|
|
|
total, ok := hits["total"].(map[string]any)
|
|
if !ok {
|
|
return 0, errors.New("No total in hits")
|
|
}
|
|
|
|
count, ok := total["value"].(float64)
|
|
if !ok {
|
|
return 0, errors.New("No value in total")
|
|
}
|
|
|
|
return int(count), nil
|
|
}
|
|
|
|
func createPITForSearch(
|
|
ctx context.Context,
|
|
client *es.TypedClient,
|
|
docSearch *DocumentSearch,
|
|
) (string, error) {
|
|
req := esapi.OpenPointInTimeRequest{
|
|
Index: []string{docSearch.index.name},
|
|
KeepAlive: "1m",
|
|
}
|
|
|
|
data, err := makeElasticSearchRequest(ctx, client, req)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
pitID, ok := data["id"].(string)
|
|
if !ok {
|
|
return "", errors.New("No id in response")
|
|
}
|
|
|
|
return pitID, nil
|
|
}
|
|
|
|
// Processes documents fetched by a search, returns the number of documents
|
|
// fetched.
|
|
func processSearchedDocuments(
|
|
ctx context.Context,
|
|
client *es.TypedClient,
|
|
docSearch *DocumentSearch,
|
|
processDocument func(document *Document) error,
|
|
) (int, error) {
|
|
pitID, err := createPITForSearch(ctx, client, docSearch)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
documentsFetched := 0
|
|
documentsProcessed := 0
|
|
sort := []int{}
|
|
|
|
for documentsProcessed < docSearch.documentCount {
|
|
searchReqBody := SearchRequestBody{
|
|
PIT: PointInTime{
|
|
ID: pitID,
|
|
KeepAlive: "1m",
|
|
},
|
|
Sort: []string{"_shard_doc"},
|
|
}
|
|
|
|
query, err := docSearch.filterParams.Query(docSearch.index.latestTimestamp)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
searchReqBody.Query = query["query"].(map[string]any)
|
|
|
|
if len(sort) > 0 {
|
|
searchReqBody.SearchAfter = sort
|
|
}
|
|
|
|
body, err := json.MarshalIndent(searchReqBody, "", " ")
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
req := esapi.SearchRequest{
|
|
Body: strings.NewReader(string(body)),
|
|
}
|
|
|
|
// If we've yet to reach our offset, or if we're still in the "skip" phase
|
|
// of scanning, don't actually fetch any document bodies.
|
|
skipCount := docSearch.offset + docSearch.skipCount
|
|
processingDocuments := documentsFetched+PAGE_SIZE > skipCount
|
|
if processingDocuments {
|
|
req.SourceIncludes = []string{"@timestamp", "message"}
|
|
} else {
|
|
req.SourceExcludes = []string{"*"}
|
|
req.SearchType = "query_then_fetch"
|
|
}
|
|
|
|
searchResults, err := makeElasticSearchRequest(ctx, client, req)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
topLevelHits, ok := searchResults["hits"].(map[string]any)
|
|
if !ok {
|
|
apiErr, ok := searchResults["error"].(map[string]any)
|
|
if ok {
|
|
return 0, fmt.Errorf("Error fetching search results: %v\n", apiErr)
|
|
}
|
|
continue
|
|
}
|
|
|
|
hits, ok := topLevelHits["hits"].([]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
if len(hits) == 0 {
|
|
break
|
|
}
|
|
|
|
for _, jsonHit := range hits {
|
|
documentsFetched++
|
|
|
|
hit, ok := jsonHit.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
jsonSort, ok := hit["sort"].([]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
sort = sort[:0]
|
|
for _, elem := range jsonSort {
|
|
sort = append(sort, int(elem.(float64)))
|
|
}
|
|
|
|
if documentsFetched <= skipCount {
|
|
continue
|
|
}
|
|
|
|
id, ok := hit["_id"].(string)
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
source, ok := hit["_source"].(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
timestamp, ok := source["@timestamp"].(string)
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
message, ok := source["message"].(string)
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
document := Document{
|
|
id: id,
|
|
timestamp: timestamp,
|
|
message: message,
|
|
}
|
|
|
|
if err = processDocument(&document); err != nil {
|
|
return 0, nil
|
|
}
|
|
|
|
documentsProcessed++
|
|
}
|
|
}
|
|
|
|
return documentsProcessed, nil
|
|
}
|
|
|
|
// Returns the number of documents processed within these indices
|
|
func (indices *Indices) GetProcessedDocumentCount() int {
|
|
indices.lock.RLock()
|
|
processedDocumentsCount := indices.processedDocumentsCount
|
|
indices.lock.RUnlock()
|
|
|
|
return processedDocumentsCount
|
|
}
|
|
|
|
// Adds documents processed to the count, used for progress
|
|
func (indices *Indices) UpdateProcessedDocumentCount(additionalDocumentsProcessed int) {
|
|
indices.lock.Lock()
|
|
indices.processedDocumentsCount += additionalDocumentsProcessed
|
|
indices.lock.Unlock()
|
|
}
|
|
|
|
// Updates a set of indices from an Elasticsearch cluster. If an index has been
|
|
// deleted it will be removed; if it's been added it'll be added; if its
|
|
// document count has changed (based on filterParams and latestTimestamp) it'll
|
|
// be updated.
|
|
func (indices *Indices) Update(
|
|
ctx context.Context,
|
|
client *es.TypedClient,
|
|
) error {
|
|
indexNames, err := fetchIndexNames(ctx, client, indices.filterParams.indexPattern)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
indicesByName := make(map[string]*Index)
|
|
if indices.indices != nil {
|
|
for _, index := range indices.indices {
|
|
indicesByName[index.name] = index
|
|
}
|
|
}
|
|
|
|
newIndicesByName := make(map[string]*Index)
|
|
for _, name := range indexNames {
|
|
index, ok := indicesByName[name]
|
|
if ok {
|
|
newIndicesByName[name] = index
|
|
} else {
|
|
index = NewIndex()
|
|
index.name = name
|
|
newIndicesByName[name] = index
|
|
}
|
|
}
|
|
|
|
for _, indexName := range indexNames {
|
|
// This can't be an index we don't know about because we passed indexNames
|
|
index := newIndicesByName[indexName]
|
|
|
|
query, err := indices.filterParams.Query(index.latestTimestamp)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
documentCount, err := fetchIndexDocumentCount(ctx, client, indexName, query)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
index.documentCount = documentCount
|
|
}
|
|
|
|
indices.indices = make([]*Index, 0, len(newIndicesByName))
|
|
indices.documentCount = 0
|
|
indices.processedDocumentsCount = 0
|
|
|
|
for _, index := range newIndicesByName {
|
|
indices.indices = append(indices.indices, index)
|
|
indices.documentCount += index.documentCount
|
|
}
|
|
|
|
return nil
|
|
}
|