bloodhound-1.0.0.0: tests/Test/EventQueryLanguageSpec.hs
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}
module Test.EventQueryLanguageSpec (spec) where
import Data.Aeson
import Data.Aeson.Key (Key)
import Data.Aeson.KeyMap qualified as KM
import Data.ByteString.Lazy.Char8 qualified as LBS
import Data.HashMap.Strict (singleton)
import Data.List (sortOn)
import Data.Text qualified as T
import Data.Text qualified as Text
import Database.Bloodhound.Client.Cluster (BackendType (..))
import Database.Bloodhound.ElasticSearch7.Client qualified as ClientES7
import Database.Bloodhound.ElasticSearch7.Requests qualified as RequestsES7
import Database.Bloodhound.ElasticSearch7.Types qualified as ES7Types
import Database.Bloodhound.ElasticSearch8.Client qualified as ClientES8
import Database.Bloodhound.ElasticSearch8.Requests qualified as RequestsES8
import Database.Bloodhound.ElasticSearch9.Client qualified as ClientES9
import Database.Bloodhound.ElasticSearch9.Requests qualified as RequestsES9
import TestsUtils.Common
import TestsUtils.Import
hasKey :: Key -> LBS.ByteString -> Bool
hasKey k bs = case decode bs of
Just (Object obj) -> k `KM.member` obj
_ -> False
-- | Order-insensitive comparison helper for query-parameter lists, since
-- 'withQueries' is order-insensitive and the two param renderers do not
-- guarantee a stable inter-key ordering. Mirrors the helper in
-- "Test.PointInTimeSpec".
sortPairs :: [(Text.Text, Maybe Text.Text)] -> [(Text.Text, Maybe Text.Text)]
sortPairs = sortOn fst
-- | Minimal request value carrying only the required 'eqlQuery' field.
minimalRequest :: ES7Types.EQLRequest
minimalRequest =
ES7Types.EQLRequest
{ ES7Types.eqlQuery = "process where process.name == \"cmd.exe\"",
ES7Types.eqlWaitForCompletionTimeout = Nothing,
ES7Types.eqlKeepOnCompletion = Nothing,
ES7Types.eqlKeepAlive = Nothing,
ES7Types.eqlSize = Nothing,
ES7Types.eqlFetchSize = Nothing,
ES7Types.eqlFields = Nothing,
ES7Types.eqlFilter = Nothing,
ES7Types.eqlResultPosition = Nothing,
ES7Types.eqlTiebreakerField = Nothing,
ES7Types.eqlEventCategoryField = Nothing,
ES7Types.eqlTimestampField = Nothing,
ES7Types.eqlRuntimeMappings = Nothing,
ES7Types.eqlAllowPartialSearchResults = Nothing,
ES7Types.eqlAllowPartialSequenceResults = Nothing,
ES7Types.eqlCaseSensitive = Nothing
}
-- | Fully-populated request value with every optional field set.
fullRequest :: ES7Types.EQLRequest
fullRequest =
ES7Types.EQLRequest
{ ES7Types.eqlQuery = "process where process.name == \"cmd.exe\"",
ES7Types.eqlWaitForCompletionTimeout = Just "5s",
ES7Types.eqlKeepOnCompletion = Just True,
ES7Types.eqlKeepAlive = Just "1m",
ES7Types.eqlSize = Just 100,
ES7Types.eqlFetchSize = Just 50,
ES7Types.eqlFields = Just [toJSON ("@timestamp" :: Text)],
ES7Types.eqlFilter = Just (Filter (TermQuery (Term "user" "alice") Nothing)),
ES7Types.eqlResultPosition = Just ES7Types.EQLTail,
ES7Types.eqlTiebreakerField = Just "tiebreaker",
ES7Types.eqlEventCategoryField = Just "event.category",
ES7Types.eqlTimestampField = Just "@timestamp",
ES7Types.eqlRuntimeMappings =
Just
( RuntimeMappings
( singleton
"day_of_week"
(RuntimeMapping RuntimeTypeKeyword Nothing)
)
),
ES7Types.eqlAllowPartialSearchResults = Just True,
ES7Types.eqlAllowPartialSequenceResults = Just False,
ES7Types.eqlCaseSensitive = Just True
}
-- | EQL async response body carrying an @id@: the search is still
-- running, so no @hits@/@total@ are present.
sampleAsyncResponseWithId :: LBS.ByteString
sampleAsyncResponseWithId =
LBS.pack
"{\"id\":\"FmRleEctRXJtaVNZQ1I2UkJoTDJfQkEAAAAAAAAUX0Q2UkRLNEVBYlhVZ19VUUZmeld3Zw==\",\"is_running\":true,\"timed_out\":false,\"took\":1}"
spec :: Spec
spec = describe "EQL Search API (POST /{index}/_eql/search)" $ do
describe "EQLRequest JSON" $ do
it "serializes the required query field" $ do
let json = encode minimalRequest
json `shouldSatisfy` hasKey "query"
it "omits all optional Nothing fields" $ do
let json = encode minimalRequest
json `shouldNotSatisfy` hasKey "wait_for_completion_timeout"
json `shouldNotSatisfy` hasKey "keep_on_completion"
json `shouldNotSatisfy` hasKey "keep_alive"
json `shouldNotSatisfy` hasKey "size"
json `shouldNotSatisfy` hasKey "fetch_size"
json `shouldNotSatisfy` hasKey "fields"
json `shouldNotSatisfy` hasKey "filter"
json `shouldNotSatisfy` hasKey "result_position"
json `shouldNotSatisfy` hasKey "tiebreaker_field"
json `shouldNotSatisfy` hasKey "event_category_field"
json `shouldNotSatisfy` hasKey "timestamp_field"
json `shouldNotSatisfy` hasKey "runtime_mappings"
json `shouldNotSatisfy` hasKey "allow_partial_search_results"
json `shouldNotSatisfy` hasKey "allow_partial_sequence_results"
json `shouldNotSatisfy` hasKey "case_sensitive"
it "includes optional fields when set" $ do
let json = encode fullRequest
json `shouldSatisfy` hasKey "wait_for_completion_timeout"
json `shouldSatisfy` hasKey "keep_on_completion"
json `shouldSatisfy` hasKey "keep_alive"
json `shouldSatisfy` hasKey "size"
json `shouldSatisfy` hasKey "fetch_size"
json `shouldSatisfy` hasKey "fields"
json `shouldSatisfy` hasKey "filter"
json `shouldSatisfy` hasKey "result_position"
json `shouldSatisfy` hasKey "tiebreaker_field"
json `shouldSatisfy` hasKey "event_category_field"
json `shouldSatisfy` hasKey "timestamp_field"
json `shouldSatisfy` hasKey "runtime_mappings"
json `shouldSatisfy` hasKey "allow_partial_search_results"
json `shouldSatisfy` hasKey "allow_partial_sequence_results"
json `shouldSatisfy` hasKey "case_sensitive"
it "encodes runtime_mappings as a bare object keyed by field name" $ do
let json = encode fullRequest
let Just (Object o) = decode json
-- runtime_mappings renders as an object whose keys are the runtime
-- field names; here we declared "day_of_week" with type keyword.
KM.lookup "runtime_mappings" o `shouldSatisfy` isJust
let Just (Object rm) = KM.lookup "runtime_mappings" o
KM.lookup "day_of_week" rm `shouldSatisfy` isJust
it "encodes the new boolean body fields verbatim" $ do
let json = encode fullRequest
let Just (Object o) = decode json
KM.lookup "allow_partial_search_results" o `shouldBe` Just (Bool True)
KM.lookup "allow_partial_sequence_results" o `shouldBe` Just (Bool False)
KM.lookup "case_sensitive" o `shouldBe` Just (Bool True)
it "encodes result_position as a bare string (\"head\"/\"tail\")" $ do
let json = encode fullRequest
let Just (Object obj) = decode json
KM.lookup "result_position" obj `shouldBe` Just "tail"
let headReq = fullRequest {ES7Types.eqlResultPosition = Just ES7Types.EQLHead}
let Just (Object objH) = decode (encode headReq)
KM.lookup "result_position" objH `shouldBe` Just "head"
it "round-trips a minimal request through ToJSON/FromJSON" $
decode (encode minimalRequest) `shouldBe` Just minimalRequest
it "round-trips a fully-populated request through ToJSON/FromJSON" $
decode (encode fullRequest) `shouldBe` Just fullRequest
describe "EQLRequest body version gating (EQLRequestBodyES7 / EQLRequestBodyES8)" $ do
-- The bare 'ToJSON' instance emits the full 8.x shape (every field,
-- including the three 8.x-only booleans). The two newtype wrappers
-- give the request builders version-specific wire shapes:
-- 'EQLRequestBodyES7' drops the 8.x-only keys so the body matches the
-- 7.17 spec; 'EQLRequestBodyES8' matches the bare instance so ES8/ES9
-- keep forwarding them. This mirrors the PIT precedent
-- ('pitOptionsParams' vs 'osPITOptionsParams').
it "EQLRequestBodyES7 omits the 8.x-only body keys even when set" $ do
let json = encode (ES7Types.EQLRequestBodyES7 fullRequest)
json `shouldNotSatisfy` hasKey "allow_partial_search_results"
json `shouldNotSatisfy` hasKey "allow_partial_sequence_results"
json `shouldNotSatisfy` hasKey "case_sensitive"
it "EQLRequestBodyES7 still emits every 7.17-documented body field" $ do
let json = encode (ES7Types.EQLRequestBodyES7 fullRequest)
json `shouldSatisfy` hasKey "query"
json `shouldSatisfy` hasKey "wait_for_completion_timeout"
json `shouldSatisfy` hasKey "keep_on_completion"
json `shouldSatisfy` hasKey "keep_alive"
json `shouldSatisfy` hasKey "size"
json `shouldSatisfy` hasKey "fetch_size"
json `shouldSatisfy` hasKey "fields"
json `shouldSatisfy` hasKey "filter"
json `shouldSatisfy` hasKey "result_position"
json `shouldSatisfy` hasKey "tiebreaker_field"
json `shouldSatisfy` hasKey "event_category_field"
json `shouldSatisfy` hasKey "timestamp_field"
json `shouldSatisfy` hasKey "runtime_mappings"
it "EQLRequestBodyES7 encodes runtime_mappings identically to the bare instance" $ do
let json = encode (ES7Types.EQLRequestBodyES7 fullRequest)
let Just (Object o) = decode json
KM.lookup "runtime_mappings" o `shouldSatisfy` isJust
let Just (Object rm) = KM.lookup "runtime_mappings" o
KM.lookup "day_of_week" rm `shouldSatisfy` isJust
it "EQLRequestBodyES7 on a minimal request is byte-identical to the bare instance" $
encode (ES7Types.EQLRequestBodyES7 minimalRequest) `shouldBe` encode minimalRequest
it "EQLRequestBodyES8 emits the 8.x-only body keys verbatim" $ do
let json = encode (ES7Types.EQLRequestBodyES8 fullRequest)
let Just (Object o) = decode json
KM.lookup "allow_partial_search_results" o `shouldBe` Just (Bool True)
KM.lookup "allow_partial_sequence_results" o `shouldBe` Just (Bool False)
KM.lookup "case_sensitive" o `shouldBe` Just (Bool True)
it "EQLRequestBodyES8 is byte-identical to the bare ToJSON instance" $
encode (ES7Types.EQLRequestBodyES8 fullRequest) `shouldBe` encode fullRequest
describe "eqlSearchOptionsParams7 (ES7 wire)" $ do
it "renders nothing for defaultEQLSearchOptions" $
ES7Types.eqlSearchOptionsParams7 ES7Types.defaultEQLSearchOptions `shouldBe` []
it "renders allow_no_indices as a boolean string" $
let opts = ES7Types.defaultEQLSearchOptions {ES7Types.esoAllowNoIndices = Just False}
in lookup "allow_no_indices" (ES7Types.eqlSearchOptionsParams7 opts) `shouldBe` Just (Just "false")
it "deliberately omits allow_partial_search_results even when set" $
let opts = ES7Types.defaultEQLSearchOptions {ES7Types.esoAllowPartialSearchResults = Just True}
in lookup "allow_partial_search_results" (ES7Types.eqlSearchOptionsParams7 opts) `shouldBe` Nothing
it "deliberately omits allow_partial_sequence_results even when set" $
let opts = ES7Types.defaultEQLSearchOptions {ES7Types.esoAllowPartialSequenceResults = Just True}
in lookup "allow_partial_sequence_results" (ES7Types.eqlSearchOptionsParams7 opts) `shouldBe` Nothing
it "renders ignore_unavailable as a boolean string" $
let opts = ES7Types.defaultEQLSearchOptions {ES7Types.esoIgnoreUnavailable = Just True}
in lookup "ignore_unavailable" (ES7Types.eqlSearchOptionsParams7 opts) `shouldBe` Just (Just "true")
it "renders ccs_minimize_roundtrips as a boolean string" $
let opts = ES7Types.defaultEQLSearchOptions {ES7Types.esoCcsMinimizeRoundtrips = Just False}
in lookup "ccs_minimize_roundtrips" (ES7Types.eqlSearchOptionsParams7 opts) `shouldBe` Just (Just "false")
it "renders keep_alive verbatim" $
let opts = ES7Types.defaultEQLSearchOptions {ES7Types.esoKeepAlive = Just "1m"}
in lookup "keep_alive" (ES7Types.eqlSearchOptionsParams7 opts) `shouldBe` Just (Just "1m")
it "renders keep_on_completion as a boolean string" $
let opts = ES7Types.defaultEQLSearchOptions {ES7Types.esoKeepOnCompletion = Just True}
in lookup "keep_on_completion" (ES7Types.eqlSearchOptionsParams7 opts) `shouldBe` Just (Just "true")
it "renders wait_for_completion_timeout verbatim" $
let opts = ES7Types.defaultEQLSearchOptions {ES7Types.esoWaitForCompletionTimeout = Just "5s"}
in lookup "wait_for_completion_timeout" (ES7Types.eqlSearchOptionsParams7 opts) `shouldBe` Just (Just "5s")
it "renders expand_wildcards comma-joined" $
let opts =
ES7Types.defaultEQLSearchOptions
{ ES7Types.esoExpandWildcards = Just [ExpandWildcardsOpen, ExpandWildcardsClosed]
}
in lookup "expand_wildcards" (ES7Types.eqlSearchOptionsParams7 opts)
`shouldBe` Just (Just "open,closed")
it "omits Nothing fields entirely (only sets render)" $
let opts = ES7Types.defaultEQLSearchOptions {ES7Types.esoIgnoreUnavailable = Just True}
in ES7Types.eqlSearchOptionsParams7 opts `shouldBe` [("ignore_unavailable", Just "true")]
describe "eqlSearchOptionsParams8 (ES8/ES9 wire)" $ do
it "renders nothing for defaultEQLSearchOptions" $
ES7Types.eqlSearchOptionsParams8 ES7Types.defaultEQLSearchOptions `shouldBe` []
it "renders allow_partial_search_results as a boolean string" $
let opts = ES7Types.defaultEQLSearchOptions {ES7Types.esoAllowPartialSearchResults = Just True}
in lookup "allow_partial_search_results" (ES7Types.eqlSearchOptionsParams8 opts) `shouldBe` Just (Just "true")
it "renders allow_partial_sequence_results as a boolean string" $
let opts = ES7Types.defaultEQLSearchOptions {ES7Types.esoAllowPartialSequenceResults = Just True}
in lookup "allow_partial_sequence_results" (ES7Types.eqlSearchOptionsParams8 opts) `shouldBe` Just (Just "true")
it "emits every parameter that params7 emits (superset)" $
let opts =
ES7Types.defaultEQLSearchOptions
{ ES7Types.esoIgnoreUnavailable = Just True,
ES7Types.esoAllowPartialSearchResults = Just False
}
in sortPairs (ES7Types.eqlSearchOptionsParams8 opts)
`shouldBe` sortPairs
[ ("ignore_unavailable", Just "true"),
("allow_partial_search_results", Just "false")
]
it "renders the 7.17-documented params identically to params7" $ do
let opts =
ES7Types.defaultEQLSearchOptions
{ ES7Types.esoAllowNoIndices = Just True,
ES7Types.esoExpandWildcards = Just [ExpandWildcardsOpen]
}
-- params8 is a superset of params7; when the 8.x-only fields are
-- unset the two renderers must agree byte-for-byte.
ES7Types.eqlSearchOptionsParams8 opts `shouldBe` ES7Types.eqlSearchOptionsParams7 opts
describe "EQLResult JSON" $ do
it "decodes a complete synchronous response with one event" $ do
let raw =
LBS.pack
"{\"is_running\":false,\"timed_out\":false,\"took\":3,\"hits\":{\"total\":{\"value\":1,\"relation\":\"eq\"},\"events\":[{\"_index\":\"logs-1\",\"_id\":\"1\",\"_source\":{\"event\":{\"category\":\"process\"}},\"_version\":1,\"_seq_no\":0,\"_primary_term\":1}]}}"
let Just (result :: ES7Types.EQLResult Value) = decode raw
ES7Types.eqlIsRunning result `shouldBe` Just False
ES7Types.eqlTimedOut result `shouldBe` Just False
ES7Types.eqlTook result `shouldBe` Just 3
let Just hits = ES7Types.eqlHits result
ES7Types.eqlHitsTotal hits
`shouldBe` Just (ES7Types.EQLTotal 1 ES7Types.EQLTotalEq)
length (ES7Types.eqlHitsEvents hits) `shouldBe` 1
let event = head (ES7Types.eqlHitsEvents hits)
ES7Types.eqlHitIndex event `shouldBe` Just "logs-1"
ES7Types.eqlHitDocId event `shouldBe` Just "1"
ES7Types.eqlHitVersion event `shouldBe` Just 1
ES7Types.eqlHitSeqNo event `shouldBe` Just 0
ES7Types.eqlHitPrimaryTerm event `shouldBe` Just 1
it "decodes a still-running async response (no hits)" $ do
let raw = LBS.pack "{\"is_running\":true,\"timed_out\":false,\"took\":1}"
let Just (result :: ES7Types.EQLResult Value) = decode raw
ES7Types.eqlIsRunning result `shouldBe` Just True
ES7Types.eqlHits result `shouldSatisfy` isNothing
-- When the search completes synchronously the server omits the
-- @id@ key entirely; the new 'eqlId' field must decode as Nothing
-- so existing eqlSearch callers keep their pre-async semantics.
ES7Types.eqlId result `shouldBe` Nothing
it "decodes an async-initiation response carrying the search id" $ do
-- When @wait_for_completion_timeout@ elapses, the server returns
-- @{"id": ..., "is_running": true}@ so the caller can poll the
-- deferred results via 'Requests.getEQLSearch'. The id is the
-- only piece of state that needs to round-trip to the follow-up
-- GET, so pin its decoder shape here.
let raw =
LBS.pack
"{\"id\":\"FmRleEctMW5TY2utc0MtOFk4AAAAAAAAAA==\",\"is_running\":true,\"timed_out\":false,\"took\":1}"
let Just (result :: ES7Types.EQLResult Value) = decode raw
ES7Types.eqlId result
`shouldBe` Just (ES7Types.EQLSearchId "FmRleEctMW5TY2utc0MtOFk4AAAAAAAAAA==")
ES7Types.eqlIsRunning result `shouldBe` Just True
ES7Types.eqlHits result `shouldSatisfy` isNothing
it "ignores undocumented keys (total/did_timeout/pits from later ES versions)" $ do
-- ES 7.17 does not document top-level @total@, @did_timeout@, or
-- @pits@; later ES versions emit them. They must be silently
-- ignored so the documented fields still decode.
let raw =
LBS.pack
"{\"is_running\":false,\"timed_out\":false,\"took\":2,\"did_timeout\":false,\"total\":{\"value\":1,\"relation\":\"eq\"},\"pits\":[{\"id\":\"x\",\"keep_alive\":\"1m\"}]}"
let Just (result :: ES7Types.EQLResult Value) = decode raw
ES7Types.eqlId result `shouldBe` Nothing
ES7Types.eqlIsRunning result `shouldBe` Just False
ES7Types.eqlIsPartial result `shouldBe` Nothing
ES7Types.eqlTimedOut result `shouldBe` Just False
ES7Types.eqlTook result `shouldBe` Just 2
ES7Types.eqlHits result `shouldSatisfy` isNothing
describe "small types JSON" $ do
it "round-trips EQLTotal through ToJSON/FromJSON" $ do
let total = ES7Types.EQLTotal 42 ES7Types.EQLTotalEq
decode (encode total) `shouldBe` Just total
let totalGte = ES7Types.EQLTotal 100 ES7Types.EQLTotalGte
decode (encode totalGte) `shouldBe` Just totalGte
it "round-trips EQLResultPosition through ToJSON/FromJSON" $ do
decode (encode ES7Types.EQLHead) `shouldBe` Just ES7Types.EQLHead
decode (encode ES7Types.EQLTail) `shouldBe` Just ES7Types.EQLTail
it "round-trips EQLSearchId through ToJSON/FromJSON as a bare string" $ do
let sid = ES7Types.EQLSearchId "FmRleEctMW5TY2utc0MtOFk4AAAAAAAAAA=="
encode sid `shouldBe` "\"FmRleEctMW5TY2utc0MtOFk4AAAAAAAAAA==\""
decode "\"FmRleEctMW5TY2utc0MtOFk4AAAAAAAAAA==\"" `shouldBe` Just sid
it "rejects a non-string EQLSearchId payload" $ do
decode "{}" `shouldBe` (Nothing :: Maybe ES7Types.EQLSearchId)
decode "42" `shouldBe` (Nothing :: Maybe ES7Types.EQLSearchId)
it "rejects unknown result_position strings" $ do
let raw = LBS.pack "\"sideways\""
let parsed = decode raw :: Maybe ES7Types.EQLResultPosition
parsed `shouldBe` Nothing
it "rejects unknown total relation strings" $ do
let raw = LBS.pack "{\"value\":1,\"relation\":\"around\"}"
let parsed = decode raw :: Maybe ES7Types.EQLTotal
parsed `shouldBe` Nothing
describe "endpoint shape" $ do
let index = [qqIndexName|eql-shape-test|]
it "ES7 POSTs to /{index}/_eql/search with no query string" $ do
let req = RequestsES7.eqlSearch @Value index minimalRequest
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` [unIndexName index, "_eql", "search"]
getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
it "ES7 embeds the query field in the body" $ do
let req = RequestsES7.eqlSearch @Value index minimalRequest
let body = bhRequestBody req
body `shouldSatisfy` isJust
let Just bodyBytes = body
hasKey "query" bodyBytes `shouldBe` True
it "ES8 builds the same endpoint shape (own builder, 8.x body)" $ do
let req8 = RequestsES8.eqlSearch @Value index minimalRequest
getRawEndpoint (bhRequestEndpoint req8)
`shouldBe` [unIndexName index, "_eql", "search"]
getRawEndpointQueries (bhRequestEndpoint req8) `shouldBe` []
it "ES9 reuses the ES8 request builder (identical endpoint)" $ do
let req9 = RequestsES9.eqlSearch @Value index minimalRequest
getRawEndpoint (bhRequestEndpoint req9)
`shouldBe` [unIndexName index, "_eql", "search"]
getRawEndpointQueries (bhRequestEndpoint req9) `shouldBe` []
it "ES7 eqlSearchWith renders no query string for default options" $ do
let req = RequestsES7.eqlSearchWith @Value index ES7Types.defaultEQLSearchOptions minimalRequest
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` [unIndexName index, "_eql", "search"]
getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
it "ES7 eqlSearchWith surfaces options as query parameters" $ do
let opts =
ES7Types.defaultEQLSearchOptions
{ ES7Types.esoAllowNoIndices = Just True,
ES7Types.esoExpandWildcards = Just [ExpandWildcardsOpen]
}
let req = RequestsES7.eqlSearchWith @Value index opts minimalRequest
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` [unIndexName index, "_eql", "search"]
getRawEndpointQueries (bhRequestEndpoint req)
`shouldBe` [("allow_no_indices", Just "true"), ("expand_wildcards", Just "open")]
it "ES7 eqlSearchWith still carries the body" $ do
let req = RequestsES7.eqlSearchWith @Value index ES7Types.defaultEQLSearchOptions minimalRequest
let Just bodyBytes = bhRequestBody req
hasKey "query" bodyBytes `shouldBe` True
it "ES7 eqlSearchWith body omits 8.x-only keys even when the request sets them" $ do
-- The whole point of bloodhound-jbe: the ES7 wire body must not
-- carry allow_partial_search_results,
-- allow_partial_sequence_results or case_sensitive, even when the
-- caller populated them on the EQLRequest.
let req = RequestsES7.eqlSearchWith @Value index ES7Types.defaultEQLSearchOptions fullRequest
let Just bodyBytes = bhRequestBody req
hasKey "query" bodyBytes `shouldBe` True
hasKey "allow_partial_search_results" bodyBytes `shouldBe` False
hasKey "allow_partial_sequence_results" bodyBytes `shouldBe` False
hasKey "case_sensitive" bodyBytes `shouldBe` False
-- 7.17-documented fields still go through.
hasKey "runtime_mappings" bodyBytes `shouldBe` True
it "ES8 eqlSearchWith body emits the 8.x-only keys when the request sets them" $ do
-- The ES8 builder keeps the 8.x shape, so the three keys survive
-- on the wire. This pins the complementary behaviour to the ES7
-- test above and guards against an accidental over-eager drop.
let req = RequestsES8.eqlSearchWith @Value index ES7Types.defaultEQLSearchOptions fullRequest
let Just bodyBytes = bhRequestBody req
hasKey "allow_partial_search_results" bodyBytes `shouldBe` True
hasKey "allow_partial_sequence_results" bodyBytes `shouldBe` True
hasKey "case_sensitive" bodyBytes `shouldBe` True
it "ES8 eqlSearchWith surfaces the 8.x-only URI params (unlike ES7)" $ do
-- Symmetric gating on the query-string side: ES8 emits
-- allow_partial_search_results / allow_partial_sequence_results
-- as URI parameters, ES7 does not.
let opts =
ES7Types.defaultEQLSearchOptions
{ ES7Types.esoAllowPartialSearchResults = Just True,
ES7Types.esoAllowPartialSequenceResults = Just False
}
let req7 = RequestsES7.eqlSearchWith @Value index opts minimalRequest
let req8 = RequestsES8.eqlSearchWith @Value index opts minimalRequest
sortPairs (getRawEndpointQueries (bhRequestEndpoint req7)) `shouldBe` []
sortPairs (getRawEndpointQueries (bhRequestEndpoint req8))
`shouldBe` [ ("allow_partial_search_results", Just "true"),
("allow_partial_sequence_results", Just "false")
]
it "ES8 eqlSearchWith uses its own builder but the 7.17 params still surface" $ do
let opts = ES7Types.defaultEQLSearchOptions {ES7Types.esoIgnoreUnavailable = Just True}
let req8 = RequestsES8.eqlSearchWith @Value index opts minimalRequest
getRawEndpoint (bhRequestEndpoint req8)
`shouldBe` [unIndexName index, "_eql", "search"]
getRawEndpointQueries (bhRequestEndpoint req8)
`shouldBe` [("ignore_unavailable", Just "true")]
it "ES9 eqlSearchWith reuses the ES8 request builder (identical query)" $ do
let opts = ES7Types.defaultEQLSearchOptions {ES7Types.esoCcsMinimizeRoundtrips = Just False}
let req9 = RequestsES9.eqlSearchWith @Value index opts minimalRequest
getRawEndpoint (bhRequestEndpoint req9)
`shouldBe` [unIndexName index, "_eql", "search"]
getRawEndpointQueries (bhRequestEndpoint req9)
`shouldBe` [("ccs_minimize_roundtrips", Just "false")]
describe "GET /_eql/search/{id} endpoint shape" $ do
-- Async follow-up: once an EQL search returns an 'EQLSearchId',
-- callers fetch the deferred results via @GET /_eql/search\/{id}@.
-- The wire path is index-less (the id encodes the originating
-- indices server-side), which is why the bead title's
-- @/{index}/_eql/search/{id}@ form would be a 404 -- pin the
-- index-less shape here.
let sid = ES7Types.EQLSearchId "FmRleEctMW5TY2utc0MtOFk4AAAAAAAAAA=="
it "ES7 GETs /_eql/search/{id} with no body; Nothing omits wait_for_completion_timeout" $ do
let req = RequestsES7.getEQLSearch @Value sid Nothing
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_eql", "search", unEQLSearchId sid]
getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
bhRequestBody req `shouldBe` Nothing
it "ES7 renders wait_for_completion_timeout as a query parameter when given" $ do
let req = RequestsES7.getEQLSearch @Value sid (Just "5s")
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_eql", "search", unEQLSearchId sid]
getRawEndpointQueries (bhRequestEndpoint req)
`shouldBe` [("wait_for_completion_timeout", Just "5s")]
it "ES8 reuses the ES7 request builder (identical endpoint and query)" $ do
let req8 = RequestsES8.getEQLSearch @Value sid (Just "5s")
getRawEndpoint (bhRequestEndpoint req8)
`shouldBe` ["_eql", "search", unEQLSearchId sid]
getRawEndpointQueries (bhRequestEndpoint req8)
`shouldBe` [("wait_for_completion_timeout", Just "5s")]
it "ES9 reuses the ES8 request builder (identical endpoint and query)" $ do
let req9 = RequestsES9.getEQLSearch @Value sid (Just "5s")
getRawEndpoint (bhRequestEndpoint req9)
`shouldBe` ["_eql", "search", unEQLSearchId sid]
getRawEndpointQueries (bhRequestEndpoint req9)
`shouldBe` [("wait_for_completion_timeout", Just "5s")]
describe "GET /_eql/search/status/{id} endpoint shape" $ do
let sid = ES7Types.EQLSearchId "FmRleEctMW5TY2utc0MtOFk4AAAAAAAAAA=="
it "ES7 GETs /_eql/search/status/{id} with no body and no query string" $ do
let req = RequestsES7.getEQLStatus sid
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_eql", "search", "status", unEQLSearchId sid]
getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
bhRequestBody req `shouldBe` Nothing
it "ES7 uses a distinct id in the path when given a different EQLSearchId" $ do
let req = RequestsES7.getEQLStatus (ES7Types.EQLSearchId "other-id")
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_eql", "search", "status", "other-id"]
it "ES7 keeps the id as a single opaque path segment" $ do
let req = RequestsES7.getEQLStatus (ES7Types.EQLSearchId "Fm_==_RxD_123")
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_eql", "search", "status", "Fm_==_RxD_123"]
it "ES8 reuses the ES7 request builder (identical endpoint)" $ do
let req8 = RequestsES8.getEQLStatus sid
getRawEndpoint (bhRequestEndpoint req8)
`shouldBe` ["_eql", "search", "status", unEQLSearchId sid]
getRawEndpointQueries (bhRequestEndpoint req8) `shouldBe` []
bhRequestBody req8 `shouldBe` Nothing
it "ES9 reuses the ES8 request builder (identical endpoint)" $ do
let req9 = RequestsES9.getEQLStatus sid
getRawEndpoint (bhRequestEndpoint req9)
`shouldBe` ["_eql", "search", "status", unEQLSearchId sid]
getRawEndpointQueries (bhRequestEndpoint req9) `shouldBe` []
bhRequestBody req9 `shouldBe` Nothing
describe "getEQLStatus response decode" $ do
let decodeSid = ES7Types.EQLSearchId "FmRleEctMW5TY2utc0MtOFk4AAAAAAAAAA=="
it "decodes a completed-search status response" $ do
let raw =
LBS.pack
"{\"id\":\"FmRleEctMW5TY2utc0MtOFk4AAAAAAAAAA==\",\"is_running\":false,\"is_partial\":false,\"expiration_time_in_millis\":1782109853290,\"completion_status\":200}"
let Just (status :: ES7Types.EQLStatus) = decode raw
ES7Types.eqlStatusId status `shouldBe` Just decodeSid
ES7Types.eqlStatusIsRunning status `shouldBe` Just False
ES7Types.eqlStatusIsPartial status `shouldBe` Just False
ES7Types.eqlStatusCompletionStatus status `shouldBe` Just 200
ES7Types.eqlStatusExpirationTimeInMillis status `shouldBe` Just 1782109853290
it "decodes a running-search status response (no completion_status)" $ do
let raw =
LBS.pack
"{\"id\":\"FmRleEctRXJtaVNZQ1I2UkJoTDJfQkEAAAAAAAAUX0Q2UkRLNEVBYlhVZ19VUUZmeld3Zw==\",\"is_running\":true,\"is_partial\":true}"
let Just (status :: ES7Types.EQLStatus) = decode raw
ES7Types.eqlStatusId status
`shouldBe` Just
( ES7Types.EQLSearchId
"FmRleEctRXJtaVNZQ1I2UkJoTDJfQkEAAAAAAAAUX0Q2UkRLNEVBYlhVZ19VUUZmeld3Zw=="
)
ES7Types.eqlStatusIsRunning status `shouldBe` Just True
ES7Types.eqlStatusIsPartial status `shouldBe` Just True
ES7Types.eqlStatusCompletionStatus status `shouldBe` Nothing
it "decodes a running-search status with start_time_in_millis" $ do
let raw =
LBS.pack
"{\"id\":\"abc\",\"is_running\":true,\"is_partial\":true,\"start_time_in_millis\":1782109853000}"
let Just (status :: ES7Types.EQLStatus) = decode raw
ES7Types.eqlStatusIsRunning status `shouldBe` Just True
ES7Types.eqlStatusStartTimeInMillis status `shouldBe` Just 1782109853000
it "tolerates unknown extra fields" $ do
let raw =
LBS.pack
"{\"id\":\"x\",\"is_running\":false,\"future_field\":\"value\"}"
let Just (status :: ES7Types.EQLStatus) = decode raw
ES7Types.eqlStatusId status `shouldBe` Just (ES7Types.EQLSearchId "x")
it "completion_status is Nothing when omitted" $ do
let raw = LBS.pack "{\"is_running\":true}"
let Just (status :: ES7Types.EQLStatus) = decode raw
ES7Types.eqlStatusCompletionStatus status `shouldBe` Nothing
describe "EQL sequence-query results" $ do
-- EQL @sequence ... with ...@ queries return matches under the
-- @hits.sequences@ array rather than @hits.events@. Without this
-- field modelled, a successful sequence query polled via
-- 'Requests.getEQLSearch' would silently look empty.
it "decodes a sequence query response (events + join_keys)" $ do
let raw =
LBS.pack
"{\"is_running\":false,\"took\":4,\"hits\":{\"total\":{\"value\":1,\"relation\":\"eq\"},\"sequences\":[{\"events\":[{\"_index\":\"logs-1\",\"_id\":\"a\",\"_source\":{\"event\":{\"category\":\"process\"}}},{\"_index\":\"logs-1\",\"_id\":\"b\",\"_source\":{\"event\":{\"category\":\"process\"}}}],\"join_keys\":[\"cmd.exe\"]}]}}"
let Just (result :: ES7Types.EQLResult Value) = decode raw
ES7Types.eqlIsPartial result `shouldBe` Nothing
let Just hits = ES7Types.eqlHits result
-- Event queries populate 'eqlHitsEvents' (defaults to []), but a
-- pure-sequence response leaves it empty; the matched events are
-- nested under each 'EQLSequence'.
ES7Types.eqlHitsEvents hits `shouldBe` []
let Just seqs = ES7Types.eqlHitsSequences hits
length seqs `shouldBe` 1
let s = head seqs
length (ES7Types.eqlSequenceEvents s) `shouldBe` 2
ES7Types.eqlSequenceJoinKeys s `shouldBe` Just [toJSON ("cmd.exe" :: Text)]
it "eqlHitsSequences is Nothing for a pure-event response" $ do
let raw =
LBS.pack
"{\"hits\":{\"total\":{\"value\":1,\"relation\":\"eq\"},\"events\":[{\"_index\":\"logs-1\",\"_id\":\"a\",\"_source\":{\"event\":{\"category\":\"process\"}}}]}}"
let Just (result :: ES7Types.EQLResult Value) = decode raw
let Just hits = ES7Types.eqlHits result
ES7Types.eqlHitsSequences hits `shouldBe` Nothing
length (ES7Types.eqlHitsEvents hits) `shouldBe` 1
it "decodes is_partial=true on a partial-result response" $ do
let raw =
LBS.pack
"{\"id\":\"FmRleEctMW5TY2utc0MtOFk4AAAAAAAAAA==\",\"is_running\":false,\"is_partial\":true,\"took\":1}"
let Just (result :: ES7Types.EQLResult Value) = decode raw
ES7Types.eqlIsPartial result `shouldBe` Just True
-- A partial response leaves hits absent; callers should treat
-- is_partial=true as terminal rather than retry.
ES7Types.eqlHits result `shouldSatisfy` isNothing
it "eqlIsPartial is Nothing when the server omits the key" $ do
let raw = LBS.pack "{\"is_running\":false,\"took\":3}"
let Just (result :: ES7Types.EQLResult Value) = decode raw
ES7Types.eqlIsPartial result `shouldBe` Nothing
describe "live integration (requires ES7.10+ backend)" $
backendSpecific [ElasticSearch7, ElasticSearch8, ElasticSearch9] $ do
it "returns events matching an EQL predicate" $
withTestEnv $ do
_ <- insertEqlData
result <- eqlSearchForBackend ("process where true" :: Text)
case result of
Left e ->
liftIO $
expectationFailure $
"eqlSearch failed: " <> show e
Right eqlResult -> liftIO $ do
ES7Types.eqlIsRunning eqlResult
`shouldSatisfy` (\x -> x == Just False || isNothing x)
let mbHits = ES7Types.eqlHits eqlResult
mbHits `shouldSatisfy` isJust
let Just hits = mbHits
length (ES7Types.eqlHitsEvents hits)
`shouldSatisfy` (\n -> n >= 1)
let total = ES7Types.eqlHitsTotal hits
total `shouldSatisfy` isJust
let Just (ES7Types.EQLTotal v _) = total
v `shouldSatisfy` (>= 1)
describe "EQLResult id field" $ do
it "decodes the top-level id when present" $ do
let Just (result :: ES7Types.EQLResult Value) = decode sampleAsyncResponseWithId
ES7Types.eqlId result `shouldBe` Just (ES7Types.EQLSearchId "FmRleEctRXJtaVNZQ1I2UkJoTDJfQkEAAAAAAAAUX0Q2UkRLNEVBYlhVZ19VUUZmeld3Zw==")
it "leaves eqlId at Nothing when the response has no id (synchronous)" $ do
let raw = LBS.pack "{\"is_running\":false,\"took\":3}"
let Just (result :: ES7Types.EQLResult Value) = decode raw
ES7Types.eqlId result `shouldBe` Nothing
it "tolerates unknown extra fields alongside id" $ do
let raw = LBS.pack "{\"id\":\"abc\",\"is_running\":true,\"future_field\":\"x\"}"
let Just (result :: ES7Types.EQLResult Value) = decode raw
ES7Types.eqlId result `shouldBe` Just (ES7Types.EQLSearchId "abc")
describe "EQLSearchId JSON" $ do
it "decodes a bare string" $ do
decode "\"FmRleEct==\"" `shouldBe` Just (ES7Types.EQLSearchId "FmRleEct==")
it "encodes back to a bare string" $ do
encode (ES7Types.EQLSearchId "FmRleEct==") `shouldBe` "\"FmRleEct==\""
it "round-trips through ToJSON/FromJSON" $ do
let original = ES7Types.EQLSearchId "Fm_==_RxD_123"
(decode . encode) original `shouldBe` Just original
describe "deleteEQLSearch endpoint shape" $ do
it "ES7 DELETEs /_eql/search/{id} when given an EQLSearchId" $ do
let req = RequestsES7.deleteEQLSearch (ES7Types.EQLSearchId "FmRleEct")
getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["_eql", "search", "FmRleEct"]
getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
it "ES7 uses DELETE and attaches no body" $ do
let req = RequestsES7.deleteEQLSearch (ES7Types.EQLSearchId "FmRleEct")
bhRequestMethod req `shouldBe` "DELETE"
bhRequestBody req `shouldBe` Nothing
it "ES7 uses a distinct id in the path when given a different EQLSearchId" $ do
let req = RequestsES7.deleteEQLSearch (ES7Types.EQLSearchId "other-id")
getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["_eql", "search", "other-id"]
it "ES7 keeps the id as a single opaque path segment" $ do
let req = RequestsES7.deleteEQLSearch (ES7Types.EQLSearchId "Fm_==_RxD_123")
getRawEndpoint (bhRequestEndpoint req) `shouldBe` ["_eql", "search", "Fm_==_RxD_123"]
it "ES8 reuses the ES7 request builder (identical endpoint)" $ do
let req8 = RequestsES8.deleteEQLSearch (ES7Types.EQLSearchId "FmRleEct")
getRawEndpoint (bhRequestEndpoint req8) `shouldBe` ["_eql", "search", "FmRleEct"]
getRawEndpointQueries (bhRequestEndpoint req8) `shouldBe` []
bhRequestBody req8 `shouldBe` Nothing
it "ES9 reuses the ES8 request builder (identical endpoint)" $ do
let req9 = RequestsES9.deleteEQLSearch (ES7Types.EQLSearchId "FmRleEct")
getRawEndpoint (bhRequestEndpoint req9) `shouldBe` ["_eql", "search", "FmRleEct"]
getRawEndpointQueries (bhRequestEndpoint req9) `shouldBe` []
bhRequestBody req9 `shouldBe` Nothing
describe "deleteEQLSearch response decode" $ do
it "decodes {\"acknowledged\": true} as Acknowledged True" $ do
decode "{\"acknowledged\":true}" `shouldBe` Just (Acknowledged True)
describe "deleteEQLSearch (live)" $
backendSpecific [ElasticSearch7, ElasticSearch8, ElasticSearch9] $ do
it "surfaces a server error for a non-existent EQL search id" $
withTestEnv $ do
-- ES rejects unknown ids with 404 and an error body that does
-- not parse as Acknowledged, which surfaces as an EsError.
result <-
tryEsError
( performBHRequest $
RequestsES7.deleteEQLSearch
(ES7Types.EQLSearchId "bloodhound-no-such-eql-search-04f-5-6-4")
)
case result of
Left _err -> pure ()
Right r ->
liftIO $
expectationFailure $
"expected EsError for missing EQL search, got: " <> show r
describe "getEQLStatus (live)" $
backendSpecific [ElasticSearch7, ElasticSearch8, ElasticSearch9] $ do
it "returns the status of an async EQL search" $
withTestEnv $ do
_ <- insertEqlData
-- Start an async EQL search: wait_for_completion_timeout="0ms"
-- forces an immediate return with an id; keep_on_completion=true
-- ensures the search context survives so status is available.
let asyncReq =
minimalRequest
{ ES7Types.eqlWaitForCompletionTimeout = Just "0ms",
ES7Types.eqlKeepOnCompletion = Just True,
ES7Types.eqlKeepAlive = Just "1m"
}
searchResult <- eqlSearchAsyncForBackend asyncReq
case searchResult of
Left e ->
liftIO $
expectationFailure $
"async eqlSearch failed: " <> show e
Right initialResult -> do
let mSid = ES7Types.eqlId initialResult
liftIO $ mSid `shouldSatisfy` isJust
let Just sid = mSid
statusResult <- getEQLStatusForBackend sid
case statusResult of
Left e ->
liftIO $
expectationFailure $
"getEQLStatus failed: " <> show e
Right status -> liftIO $ do
ES7Types.eqlStatusId status `shouldBe` Just sid
ES7Types.eqlStatusIsRunning status `shouldSatisfy` isJust
-- Cleanup: delete the async search context.
_ <- tryEsError (performBHRequest $ RequestsES7.deleteEQLSearch sid)
pure ()
-- ---------------------------------------------------------------------------
-- - EQL test fixtures
-- ---------------------------------------------------------------------------
eqlTestIndex :: IndexName
eqlTestIndex = [qqIndexName|bloodhound-tests-eql-1|]
-- | Sample event document. The @timestamp@ and @event.category@ fields
-- are required by EQL; @message@ is included so the decoded @_source@
-- has a recognisable value.
data EqlEvent = EqlEvent
{ eqlEventTimestamp :: Text,
eqlEventEvent :: EventCategory,
eqlEventMessage :: Text
}
deriving stock (Eq, Show)
newtype EventCategory = EventCategory {eventCategory :: Value}
deriving newtype (Eq, Show, ToJSON, FromJSON)
instance ToJSON EqlEvent where
toJSON e =
object
[ "@timestamp" .= eqlEventTimestamp e,
"event" .= eqlEventEvent e,
"message" .= eqlEventMessage e
]
instance FromJSON EqlEvent where
parseJSON (Object v) =
EqlEvent
<$> v .: "@timestamp"
<*> v .: "event"
<*> v .: "message"
parseJSON _ = empty
-- | Mapping declaring @\\@timestamp@ as a date and @event.category@ as a
-- keyword — the two fields EQL needs to order events.
eqlEventMapping :: Value
eqlEventMapping =
object
[ "properties"
.= object
[ "@timestamp" .= object ["type" .= ("date" :: Text)],
"event"
.= object
[ "properties"
.= object
["category" .= object ["type" .= ("keyword" :: Text)]]
],
"message" .= object ["type" .= ("keyword" :: Text)]
]
]
exampleEqlEventA :: EqlEvent
exampleEqlEventA =
EqlEvent
{ eqlEventTimestamp = "2020-12-06T21:00:00.000Z",
eqlEventEvent = EventCategory (object ["category" .= ("process" :: Text)]),
eqlEventMessage = "process A"
}
exampleEqlEventB :: EqlEvent
exampleEqlEventB =
EqlEvent
{ eqlEventTimestamp = "2020-12-06T21:00:01.000Z",
eqlEventEvent = EventCategory (object ["category" .= ("process" :: Text)]),
eqlEventMessage = "process B"
}
deleteEqlExampleIndex :: BH IO (BHResponse StatusDependant Acknowledged, Acknowledged)
deleteEqlExampleIndex =
performBHRequest $ keepBHResponse $ deleteIndex eqlTestIndex
createEqlExampleIndex :: BH IO (BHResponse StatusDependant Acknowledged, Acknowledged)
createEqlExampleIndex = do
result <-
tryPerformBHRequest
( keepBHResponse $
createIndex
(IndexSettings (ShardCount 1) (ReplicaCount 0) defaultIndexMappingsLimits)
eqlTestIndex
)
case result of
Left e
| "already exists" `T.isSuffixOf` errorMessage e ->
return (error "createEqlExampleIndex: already exists", Acknowledged False)
| otherwise -> throwEsError e
Right ack -> return ack
-- | Reset (delete + recreate + map + seed) the EQL test index.
insertEqlData :: BH IO ()
insertEqlData = do
_ <- tryEsError deleteEqlExampleIndex
_ <- createEqlExampleIndex
_ <- performBHRequest $ putMapping @Value eqlTestIndex eqlEventMapping
_ <- performBHRequest $ indexDocument eqlTestIndex defaultIndexDocumentSettings exampleEqlEventA (DocId "a")
_ <- performBHRequest $ indexDocument eqlTestIndex defaultIndexDocumentSettings exampleEqlEventB (DocId "b")
_ <- performBHRequest $ refreshIndex eqlTestIndex
pure ()
-- | Dispatch to the right eqlSearch client based on the detected backend.
-- The wire format is identical across ES 7.10+, 8.x and 9.x; the dispatch
-- exists only because each version has its own 'WithBackend' constraint.
eqlSearchForBackend :: Text -> BH IO (Either EsError (ES7Types.EQLResult EqlEvent))
eqlSearchForBackend queryText = do
backend <- liftIO detectBackendType
let req = minimalRequest {ES7Types.eqlQuery = queryText}
case backend of
Just ElasticSearch7 -> ClientES7.eqlSearch eqlTestIndex req
Just ElasticSearch8 -> ClientES8.eqlSearch eqlTestIndex req
Just ElasticSearch9 -> ClientES9.eqlSearch eqlTestIndex req
_ -> ClientES8.eqlSearch eqlTestIndex req
-- | Dispatch 'eqlSearch' with a caller-supplied 'EQLRequest' (used for
-- async-mode searches where @wait_for_completion_timeout@ and
-- @keep_on_completion@ differ from the minimal defaults).
eqlSearchAsyncForBackend :: ES7Types.EQLRequest -> BH IO (Either EsError (ES7Types.EQLResult EqlEvent))
eqlSearchAsyncForBackend req = do
backend <- liftIO detectBackendType
case backend of
Just ElasticSearch7 -> ClientES7.eqlSearch eqlTestIndex req
Just ElasticSearch8 -> ClientES8.eqlSearch eqlTestIndex req
Just ElasticSearch9 -> ClientES9.eqlSearch eqlTestIndex req
_ -> ClientES8.eqlSearch eqlTestIndex req
-- | Dispatch 'getEQLStatus' based on the detected backend. The wire
-- format is identical across ES 7.10+, 8.x and 9.x; the dispatch exists
-- only because each version has its own 'WithBackend' constraint.
getEQLStatusForBackend :: ES7Types.EQLSearchId -> BH IO (Either EsError ES7Types.EQLStatus)
getEQLStatusForBackend sid = do
backend <- liftIO detectBackendType
case backend of
Just ElasticSearch7 -> tryEsError (ClientES7.getEQLStatus sid)
Just ElasticSearch8 -> tryEsError (ClientES8.getEQLStatus sid)
Just ElasticSearch9 -> tryEsError (ClientES9.getEQLStatus sid)
_ -> tryEsError (ClientES8.getEQLStatus sid)