bloodhound-1.0.0.0: tests/Test/JobSchedulerSpec.hs
{-# LANGUAGE OverloadedStrings #-}
module Test.JobSchedulerSpec (spec) where
import Data.Aeson.KeyMap qualified as KeyMap
import Data.ByteString.Lazy.Char8 qualified as LBS
import Data.Map.Strict qualified as Map
import Database.Bloodhound.OpenSearch3.Requests qualified as OS3Requests
import Database.Bloodhound.OpenSearch3.Types qualified as OS3Types
import TestsUtils.Import
import Prelude
-- ---------------------------------------------------------------------------
-- Pure endpoint-shape and JSON-decoding tests for the OpenSearch Job
-- Scheduler plugin (@\/_plugins\/_job_scheduler\/api@). No live backend
-- is needed: every assertion runs against the in-memory 'BHRequest'
-- produced by the OS3 'Requests' module, or against JSON fixtures quoted
-- verbatim from a live OS 3.7.0 cluster.
--
-- The @\/api\/jobs@ and @\/api\/locks@ REST handlers are only registered
-- on OpenSearch 3.x (a 1.x or 2.x cluster answers @404 no handler found@
-- even when the @opensearch-job-scheduler@ plugin is installed), so this
-- surface lives solely in the OS3 module — see the type module Haddock
-- for the availability caveat.
--
-- See <https://docs.opensearch.org/latest/monitoring-your-cluster/job-scheduler/>
spec :: Spec
spec = describe "OpenSearch Job Scheduler API" $ do
-- ===============================================================
-- Endpoint-shape tests (no live backend)
-- ===============================================================
describe "getJobSchedulerJobs endpoint shape" $
it "GETs /_plugins/_job_scheduler/api/jobs with no body or query" $ do
let req = OS3Requests.getJobSchedulerJobs
bhRequestMethod req `shouldBe` "GET"
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_plugins", "_job_scheduler", "api", "jobs"]
getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
bhRequestBody req `shouldBe` Nothing
describe "getJobSchedulerLocks endpoint shape" $
it "GETs /_plugins/_job_scheduler/api/locks with no body or query" $ do
let req = OS3Requests.getJobSchedulerLocks
bhRequestMethod req `shouldBe` "GET"
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_plugins", "_job_scheduler", "api", "locks"]
getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
bhRequestBody req `shouldBe` Nothing
describe "getJobSchedulerLock endpoint shape" $
it "GETs /_plugins/_job_scheduler/api/locks/{lock_id} with no body or query" $ do
let req = OS3Requests.getJobSchedulerLock (OS3Types.LockId ".opendistro-ism-config-myjob_123")
bhRequestMethod req `shouldBe` "GET"
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_plugins", "_job_scheduler", "api", "locks", ".opendistro-ism-config-myjob_123"]
getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
bhRequestBody req `shouldBe` Nothing
-- ===============================================================
-- LockId JSON
-- ===============================================================
describe "LockId JSON" $ do
it "encodes LockId as a bare JSON string" $
encode (OS3Types.LockId "index-jobid_42")
`shouldBe` "\"index-jobid_42\""
it "decodes a bare JSON string into LockId" $
decode "\"index-jobid_42\"" `shouldBe` Just (OS3Types.LockId "index-jobid_42")
it "round-trips LockId through encode -> decode" $ do
let original = OS3Types.LockId "round-trip-id"
(decode . encode) original `shouldBe` Just original
-- ===============================================================
-- LocksResponse / LockEntry JSON (live-verified OS 3.7.0 shapes)
-- ===============================================================
describe "LocksResponse JSON" $ do
it "decodes the verbatim two-lock fixture" $ do
let Just resp = decode sampleLocksJson :: Maybe OS3Types.LocksResponse
OS3Types.locksResponseTotalLocks resp `shouldBe` 2
Map.size (OS3Types.locksResponseLocks resp) `shouldBe` 2
it "exposes each lock's typed fields" $ do
let Just resp = decode sampleLocksJson :: Maybe OS3Types.LocksResponse
Just entry = OS3Types.lookupLockEntry ".opendistro-ism-config-delprobe_tid_1859609" resp
OS3Types.lockEntryJobIndexName entry `shouldBe` Just ".opendistro-ism-config"
OS3Types.lockEntryJobId entry `shouldBe` Just "delprobe_tid_1859609"
OS3Types.lockEntryLockTime entry `shouldBe` Just 1782421825
OS3Types.lockEntryLockDurationSeconds entry `shouldBe` Just 1800
OS3Types.lockEntryReleased entry `shouldBe` Just True
OS3Types.lockEntryExtras entry `shouldBe` Nothing
it "lookupLockEntry returns Nothing for an absent id" $ do
let Just resp = decode sampleLocksJson :: Maybe OS3Types.LocksResponse
OS3Types.lookupLockEntry "nope" resp `shouldBe` Nothing
it "decodes an empty envelope (valid-format but unknown id)" $ do
let Just resp = decode "{ \"total_locks\": 0, \"locks\": {} }" :: Maybe OS3Types.LocksResponse
OS3Types.locksResponseTotalLocks resp `shouldBe` 0
OS3Types.locksResponseLocks resp `shouldBe` Map.empty
it "defaults total_locks/locks when the keys are absent" $ do
let Just resp = decode "{}" :: Maybe OS3Types.LocksResponse
OS3Types.locksResponseTotalLocks resp `shouldBe` 0
OS3Types.locksResponseLocks resp `shouldBe` Map.empty
describe "LockEntry lenient decoding" $ do
it "captures unrecognised fields in lockEntryExtras" $ do
let Just (e :: OS3Types.LockEntry) =
decode
"{ \"job_id\": \"j1\", \
\\"released\": false, \
\\"custom_meta\": 7 }"
OS3Types.lockEntryJobId e `shouldBe` Just "j1"
OS3Types.lockEntryReleased e `shouldBe` Just False
OS3Types.lockEntryExtras e `shouldSatisfy` isJust
it "reads Nothing for every field of a minimal object" $ do
let Just (e :: OS3Types.LockEntry) = decode "{}"
OS3Types.lockEntryJobIndexName e `shouldBe` Nothing
OS3Types.lockEntryLockTime e `shouldBe` Nothing
-- ===============================================================
-- JobsResponse / JobEntry JSON
-- ===============================================================
describe "JobsResponse JSON" $ do
it "decodes the empty envelope" $ do
let Just resp = decode sampleJobsEmptyJson :: Maybe OS3Types.JobsResponse
OS3Types.jobsResponseTotalJobs resp `shouldBe` 0
OS3Types.jobsResponseJobs resp `shouldBe` []
OS3Types.jobsResponseFailures resp `shouldBe` []
it "decodes a populated job, routing plugin-specific fields to extras" $ do
let Just resp = decode sampleJobsPopulatedJson :: Maybe OS3Types.JobsResponse
OS3Types.jobsResponseTotalJobs resp `shouldBe` 1
length (OS3Types.jobsResponseJobs resp) `shouldBe` 1
let job = head (OS3Types.jobsResponseJobs resp)
OS3Types.jobEntryName job `shouldBe` Just "Snx1-J4BQHJI9IqNYJBj"
OS3Types.jobEntryEnabled job `shouldBe` Just False
OS3Types.jobEntryLockDurationSeconds job `shouldBe` Just 60
OS3Types.jobEntryJobType job `shouldBe` Just "AD"
OS3Types.jobEntryJobId job `shouldBe` Nothing
-- window_delay / disabled_time are not known keys -> land in extras.
let Just extras = OS3Types.jobEntryExtras job
KeyMap.lookup "window_delay" extras `shouldSatisfy` isJust
KeyMap.lookup "disabled_time" extras `shouldSatisfy` isJust
it "defaults total_jobs/jobs/failures when the keys are absent" $ do
let Just resp = decode "{}" :: Maybe OS3Types.JobsResponse
OS3Types.jobsResponseTotalJobs resp `shouldBe` 0
OS3Types.jobsResponseJobs resp `shouldBe` []
describe "JobEntry _source wrapper tolerance" $
it "decodes a job wrapped in a {_source: ...} search hit" $ do
let wrapped =
"{ \"_source\": { \"name\": \"wrapped\", \"enabled\": true } }"
Just (e :: OS3Types.JobEntry) = decode wrapped
OS3Types.jobEntryName e `shouldBe` Just "wrapped"
OS3Types.jobEntryEnabled e `shouldBe` Just True
-- ===============================================================
-- JobSchedulerSchedule JSON
-- ===============================================================
describe "JobSchedulerSchedule JSON" $ do
it "decodes the interval form" $ do
let Just (s :: OS3Types.JobSchedulerSchedule) =
decode "{ \"interval\": { \"period\": 1, \"unit\": \"Minutes\", \"start_time\": 42 } }"
Just (OS3Types.JobSchedulerScheduleInterval i) = Just s
OS3Types.jobSchedulerIntervalPeriod i `shouldBe` Just 1
OS3Types.jobSchedulerIntervalUnit i `shouldBe` Just "Minutes"
OS3Types.jobSchedulerIntervalStartTime i `shouldBe` Just 42
it "decodes the cron form" $ do
let Just (s :: OS3Types.JobSchedulerSchedule) =
decode "{ \"cron\": { \"expression\": \"0 * * * *\", \"timezone\": \"UTC\" } }"
Just (OS3Types.JobSchedulerScheduleCron c) = Just s
OS3Types.jobSchedulerCronExpression c `shouldBe` Just "0 * * * *"
OS3Types.jobSchedulerCronTimezone c `shouldBe` Just "UTC"
it "decodes an unknown schedule kind via the Custom escape hatch" $ do
let Just (s :: OS3Types.JobSchedulerSchedule) =
decode "{ \"daily\": { \"at\": \"12:00\" } }"
s `shouldSatisfy` (\case OS3Types.JobSchedulerScheduleCustom _ -> True; _ -> False)
-- ===============================================================
-- Round-trip symmetry
-- ===============================================================
describe "Round-trip symmetry" $ do
it "JobsResponse round-trips through encode -> decode" $ do
let Just original = decode sampleJobsPopulatedJson :: Maybe OS3Types.JobsResponse
(decode . encode) original `shouldBe` Just original
it "LocksResponse round-trips through encode -> decode" $ do
let Just original = decode sampleLocksJson :: Maybe OS3Types.LocksResponse
(decode . encode) original `shouldBe` Just original
-- ---------------------------------------------------------------------------
-- Fixtures, quoted verbatim from a live OS 3.7.0 cluster.
-- ---------------------------------------------------------------------------
-- | @GET /_plugins/_job_scheduler/api/locks@ on a cluster running an ISM
-- probe job and an Anomaly Detection detector.
sampleLocksJson :: LBS.ByteString
sampleLocksJson =
"{\
\ \"total_locks\": 2,\
\ \"locks\": {\
\ \".opendistro-ism-config-delprobe_tid_1859609\": {\
\ \"job_index_name\": \".opendistro-ism-config\",\
\ \"job_id\": \"delprobe_tid_1859609\",\
\ \"lock_time\": 1782421825,\
\ \"lock_duration_seconds\": 1800,\
\ \"released\": true\
\ },\
\ \".opendistro-anomaly-detector-jobs-Snx1-J4BQHJI9IqNYJBj\": {\
\ \"job_index_name\": \".opendistro-anomaly-detector-jobs\",\
\ \"job_id\": \"Snx1-J4BQHJI9IqNYJBj\",\
\ \"lock_time\": 1782285322,\
\ \"lock_duration_seconds\": 60,\
\ \"released\": true\
\ }\
\ }\
\}"
-- | @GET /_plugins/_job_scheduler/api/jobs@ on an idle scheduler (the
-- endpoint lists no jobs even though locks exist — see the type module
-- Haddock for the availability caveat).
sampleJobsEmptyJson :: LBS.ByteString
sampleJobsEmptyJson =
"{\
\ \"total_jobs\": 0,\
\ \"jobs\": [],\
\ \"failures\": []\
\}"
-- | A populated jobs response. The @jobs@ element is reconstructed from
-- the @ScheduledJobParameter@ document stored in
-- @.opendistro-anomaly-detector-jobs@ (its @_source@, fetched via a
-- direct index search): the @window_delay@ / @disabled_time@ keys are
-- plugin-specific and exercise the @extras@ catch-all.
sampleJobsPopulatedJson :: LBS.ByteString
sampleJobsPopulatedJson =
"{\
\ \"total_jobs\": 1,\
\ \"jobs\": [\
\ {\
\ \"name\": \"Snx1-J4BQHJI9IqNYJBj\",\
\ \"schedule\": {\
\ \"interval\": { \"start_time\": 1782284902663, \"period\": 1, \"unit\": \"Minutes\" }\
\ },\
\ \"enabled\": false,\
\ \"enabled_time\": 1782284902663,\
\ \"last_update_time\": 1782285322682,\
\ \"lock_duration_seconds\": 60,\
\ \"type\": \"AD\",\
\ \"window_delay\": { \"period\": { \"interval\": 0, \"unit\": \"Seconds\" } },\
\ \"disabled_time\": 1782285322682\
\ }\
\ ],\
\ \"failures\": []\
\}"