Files
Charlie GunyonandCharlie Gunyon 311494e86e Elastic adapter (#2727)
* 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]>
2024-05-24 09:38:20 -05:00

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), &timestampRangeQueryClause)
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
}