bloodhound-1.0.0.0: tests/Test/SnapshotManagementSpec.hs
{-# LANGUAGE OverloadedStrings #-}
module Test.SnapshotManagementSpec (spec) where
import Data.Aeson (Value (..), decode, encode, object, (.=))
import Data.ByteString.Lazy.Char8 qualified as LBS
import Data.List (sort)
import Data.Map.Strict qualified as Map
import Database.Bloodhound.OpenSearch2.Requests qualified as OS2Requests
import Database.Bloodhound.OpenSearch3.Requests qualified as OS3Requests
import Database.Bloodhound.OpenSearch3.Types qualified as OS3
import TestsUtils.Import
import Prelude
-- | Pure endpoint-shape and JSON-decoding tests for the OpenSearch Snapshot
-- Management (SM) plugin (@\/_plugins\/_sm\/policies@). No live backend
-- needed: every assertion runs against the in-memory 'BHRequest' produced
-- by the OS3 'Requests' module, or against JSON fixtures quoted verbatim
-- from the SM API docs.
--
-- The OS2 and OS3 'Requests' modules produce byte-identical 'BHRequest's
-- at runtime (the SM type modules are byte-identical mirrors of each
-- other). The SM plugin was introduced in OpenSearch 2.1, so OS1 does not
-- carry this surface; following the 'Test.OSAsyncSearchSpec' precedent we
-- exercise OS3 for the detailed shape assertions and add a cross-version
-- equivalence check on one representative endpoint ('getSMPolicies') to
-- guard against refactors that silently diverge the two modules.
--
-- See <https://docs.opensearch.org/latest/tuning-your-cluster/availability-and-recovery/snapshots/sm-api/>
spec :: Spec
spec = describe "OpenSearch Snapshot Management (SM) API" $ do
-- ===============================================================
-- Endpoint-shape tests (no live backend)
-- ===============================================================
describe "postSMPolicy endpoint shape" $ do
let body =
OS3.SMPolicyBody
{ OS3.smPolicyBodyDescription = Just "Daily snapshot policy",
OS3.smPolicyBodyEnabled = Just True,
OS3.smPolicyBodyCreation = Nothing,
OS3.smPolicyBodyDeletion = Nothing,
OS3.smPolicyBodySnapshotConfig = Nothing,
OS3.smPolicyBodyNotification = Nothing
}
req = OS3Requests.postSMPolicy (OS3.SMPolicyName "daily-policy") body
it "POSTs to /_plugins/_sm/policies/{policy_name} with no query string" $ do
bhRequestMethod req `shouldBe` "POST"
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_plugins", "_sm", "policies", "daily-policy"]
getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
it "attaches the encoded SMPolicyBody as the JSON body" $ do
let Just bytes = bhRequestBody req
LBS.unpack bytes `shouldContain` "\"description\""
LBS.unpack bytes `shouldContain` "Daily snapshot policy"
describe "putSMPolicyWith endpoint shape" $ do
let body = OS3.SMPolicyBody Nothing Nothing Nothing Nothing Nothing Nothing
req = OS3Requests.putSMPolicyWith (OS3.SMPolicyName "p") body 7 3
it "PUTs to /_plugins/_sm/policies/{policy_name} with if_seq_no / if_primary_term" $ do
bhRequestMethod req `shouldBe` "PUT"
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_plugins", "_sm", "policies", "p"]
sort (getRawEndpointQueries (bhRequestEndpoint req))
`shouldBe` sort
[ ("if_seq_no", Just "7"),
("if_primary_term", Just "3")
]
describe "getSMPolicy endpoint shape" $ do
it "GETs /_plugins/_sm/policies/{policy_name} with no body / query" $ do
let req = OS3Requests.getSMPolicy (OS3.SMPolicyName "daily-policy")
bhRequestMethod req `shouldBe` "GET"
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_plugins", "_sm", "policies", "daily-policy"]
getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
bhRequestBody req `shouldBe` Nothing
describe "getSMPolicies endpoint shape" $ do
it "GETs /_plugins/_sm/policies with no query string by default" $ do
let req = OS3Requests.getSMPolicies Nothing
bhRequestMethod req `shouldBe` "GET"
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_plugins", "_sm", "policies"]
getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
it "forwards the documented pagination/sort/queryString parameters" $ do
let q =
OS3.GetSMPoliciesQuery
{ OS3.getSMPoliciesQueryFrom = Just 0,
OS3.getSMPoliciesQuerySize = Just 20,
OS3.getSMPoliciesQuerySortField = Just "sm_policy.name",
OS3.getSMPoliciesQuerySortOrder = Just "desc",
OS3.getSMPoliciesQueryQueryString = Just "*"
}
req = OS3Requests.getSMPolicies (Just q)
sort (getRawEndpointQueries (bhRequestEndpoint req))
`shouldBe` sort
[ ("from", Just "0"),
("size", Just "20"),
("sortField", Just "sm_policy.name"),
("sortOrder", Just "desc"),
("queryString", Just "*")
]
it "defaultGetSMPoliciesQuery emits no parameters" $ do
let req = OS3Requests.getSMPolicies (Just OS3.defaultGetSMPoliciesQuery)
getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
it "produces the same path across OS2 and OS3 (path equivalence)" $ do
let path2 = getRawEndpoint (bhRequestEndpoint (OS2Requests.getSMPolicies Nothing))
path3 = getRawEndpoint (bhRequestEndpoint (OS3Requests.getSMPolicies Nothing))
path2 `shouldBe` ["_plugins", "_sm", "policies"]
path2 `shouldBe` path3
describe "deleteSMPolicy endpoint shape" $ do
it "DELETEs /_plugins/_sm/policies/{policy_name} with no body / query" $ do
let req = OS3Requests.deleteSMPolicy (OS3.SMPolicyName "daily-policy")
bhRequestMethod req `shouldBe` "DELETE"
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_plugins", "_sm", "policies", "daily-policy"]
bhRequestBody req `shouldBe` Nothing
describe "startSMPolicy endpoint shape" $ do
it "POSTs (no body) to /_plugins/_sm/policies/{policy_name}/_start" $ do
let req = OS3Requests.startSMPolicy (OS3.SMPolicyName "daily-policy")
bhRequestMethod req `shouldBe` "POST"
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_plugins", "_sm", "policies", "daily-policy", "_start"]
bhRequestBody req `shouldBe` Nothing
getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []
describe "stopSMPolicy endpoint shape" $ do
it "POSTs (no body) to /_plugins/_sm/policies/{policy_name}/_stop" $ do
let req = OS3Requests.stopSMPolicy (OS3.SMPolicyName "daily-policy")
bhRequestMethod req `shouldBe` "POST"
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_plugins", "_sm", "policies", "daily-policy", "_stop"]
bhRequestBody req `shouldBe` Nothing
describe "explainSMPolicies endpoint shape" $ do
it "GETs /_plugins/_sm/policies/{pattern}/_explain" $ do
let req = OS3Requests.explainSMPolicies "daily*"
bhRequestMethod req `shouldBe` "GET"
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_plugins", "_sm", "policies", "daily*", "_explain"]
bhRequestBody req `shouldBe` Nothing
it "forwards the comma-separated list verbatim as one path segment" $ do
let req = OS3Requests.explainSMPolicies "policy-a,policy-b"
getRawEndpoint (bhRequestEndpoint req)
`shouldBe` ["_plugins", "_sm", "policies", "policy-a,policy-b", "_explain"]
-- ===============================================================
-- JSON-decoding tests (verbatim doc fixtures)
-- ===============================================================
describe "SMPolicyResponse decoding (Create/Get fixture)" $ do
-- Verbatim from the SM API docs: POST /_plugins/_sm/policies/daily-policy
let fixture =
LBS.pack
"{\"_id\":\"daily-policy-sm-policy\",\"_version\":5,\"_seq_no\":54983,\
\\"_primary_term\":21,\"sm_policy\":{\"name\":\"daily-policy\",\
\\"description\":\"Daily snapshot policy\",\"schema_version\":15,\
\\"creation\":{\"schedule\":{\"cron\":{\"expression\":\"0 8 * * *\",\
\\"timezone\":\"UTC\"}},\"time_limit\":\"1h\"},\"deletion\":{\"schedule\":\
\{\"cron\":{\"expression\":\"0 1 * * *\",\"timezone\":\
\\"America/Los_Angeles\"}},\"condition\":{\"max_age\":\"7d\",\
\\"min_count\":7,\"max_count\":21},\"time_limit\":\"1h\",\
\\"snapshot_pattern\":\"external-backup-*\"},\"snapshot_config\":\
\{\"indices\":\"*\",\"metadata\":{\"any_key\":\"any_value\"},\
\\"ignore_unavailable\":\"true\",\"timezone\":\
\\"America/Los_Angeles\",\"include_global_state\":\"false\",\
\\"date_format\":\"yyyy-MM-dd-HH:mm\",\"repository\":\"s3-repo\",\
\\"partial\":\"true\"},\"schedule\":{\"interval\":{\"start_time\":\
\1656425122909,\"period\":1,\"unit\":\"Minutes\"}},\"enabled\":true,\
\\"last_updated_time\":1656425122909,\"enabled_time\":1656425122909,\
\\"notification\":{\"channel\":{\"id\":\"NC3OpoEBzEoHMX183R3f\"},\
\\"conditions\":{\"creation\":true,\"deletion\":false,\"failure\":false,\
\\"time_limit_exceeded\":false}}}}"
it "decodes the documented Create/Get fixture" $ do
let Just decoded = decode fixture :: Maybe OS3.SMPolicyResponse
OS3.smPolicyResponseId decoded `shouldBe` Just "daily-policy-sm-policy"
OS3.smPolicyResponseVersion decoded `shouldBe` Just 5
OS3.smPolicyResponseSeqNo decoded `shouldBe` Just 54983
OS3.smPolicyResponsePrimaryTerm decoded `shouldBe` Just 21
let Just policy = OS3.smPolicyResponseSMPolicy decoded
OS3.smPolicyName policy `shouldBe` Just "daily-policy"
OS3.smPolicySchemaVersion policy `shouldBe` Just 15
OS3.smPolicyLastUpdatedTime policy `shouldBe` Just 1656425122909
it "parses lenient booleans in snapshot_config (string \"true\"/\"false\")" $ do
let Just decoded = decode fixture :: Maybe OS3.SMPolicyResponse
Just policy = OS3.smPolicyResponseSMPolicy decoded
Just cfg = OS3.smPolicyBodySnapshotConfig (OS3.smPolicyBody policy)
OS3.smSnapshotConfigIgnoreUnavailable cfg `shouldBe` Just True
OS3.smSnapshotConfigIncludeGlobalState cfg `shouldBe` Just False
OS3.smSnapshotConfigPartial cfg `shouldBe` Just True
it "parses snapshot_config.repository / indices / metadata verbatim" $ do
let Just decoded = decode fixture :: Maybe OS3.SMPolicyResponse
Just policy = OS3.smPolicyResponseSMPolicy decoded
Just cfg = OS3.smPolicyBodySnapshotConfig (OS3.smPolicyBody policy)
OS3.smSnapshotConfigRepository cfg `shouldBe` Just "s3-repo"
OS3.smSnapshotConfigIndices cfg `shouldBe` Just "*"
OS3.smSnapshotConfigMetadata cfg `shouldBe` Just (Map.fromList [("any_key", "any_value")])
it "parses the notification channel + conditions" $ do
let Just decoded = decode fixture :: Maybe OS3.SMPolicyResponse
Just policy = OS3.smPolicyResponseSMPolicy decoded
Just notif = OS3.smPolicyBodyNotification (OS3.smPolicyBody policy)
Just chan = OS3.smNotificationChannel notif
Just conds = OS3.smNotificationConditions notif
OS3.smNotificationChannelId chan `shouldBe` "NC3OpoEBzEoHMX183R3f"
OS3.smNotificationConditionsCreation conds `shouldBe` Just True
OS3.smNotificationConditionsDeletion conds `shouldBe` Just False
it "parses creation/deletion cron schedules and time_limits" $ do
let Just decoded = decode fixture :: Maybe OS3.SMPolicyResponse
Just policy = OS3.smPolicyResponseSMPolicy decoded
Just creation = OS3.smPolicyBodyCreation (OS3.smPolicyBody policy)
Just deletion = OS3.smPolicyBodyDeletion (OS3.smPolicyBody policy)
OS3.smCreationTimeLimit creation `shouldBe` Just "1h"
OS3.smDeletionTimeLimit deletion `shouldBe` Just "1h"
OS3.smDeletionSnapshotPattern deletion `shouldBe` Just "external-backup-*"
describe "SMPolicyResponse decoding (Get fixture without notification)" $ do
-- Verbatim from the docs: GET /_plugins/_sm/policies/daily-policy,
-- demonstrating that notification is genuinely optional.
let fixture =
LBS.pack
"{\"_id\":\"daily-policy-sm-policy\",\"_version\":6,\"_seq_no\":44696,\
\\"_primary_term\":19,\"sm_policy\":{\"name\":\"daily-policy\",\
\\"description\":\"Daily snapshot policy\",\"schema_version\":15,\
\\"creation\":{\"schedule\":{\"cron\":{\"expression\":\"0 8 * * *\",\
\\"timezone\":\"UTC\"}},\"time_limit\":\"1h\"},\"deletion\":{\"schedule\":\
\{\"cron\":{\"expression\":\"0 1 * * *\",\"timezone\":\
\\"America/Los_Angeles\"}},\"condition\":{\"max_age\":\"7d\",\
\\"min_count\":7,\"max_count\":21},\"time_limit\":\"1h\",\
\\"snapshot_pattern\":\"external-backup-*\"},\"snapshot_config\":\
\{\"metadata\":{\"any_key\":\"any_value\"},\"ignore_unavailable\":\
\\"true\",\"include_global_state\":\"false\",\"date_format\":\
\\"yyyy-MM-dd-HH:mm\",\"repository\":\"s3-repo\",\"partial\":\
\\"true\"},\"schedule\":{\"interval\":{\"start_time\":1656341042874,\
\\"period\":1,\"unit\":\"Minutes\"}},\"enabled\":true,\
\\"last_updated_time\":1656341042874,\"enabled_time\":1656341042874}}"
it "decodes cleanly when notification is absent" $ do
let Just decoded = decode fixture :: Maybe OS3.SMPolicyResponse
Just policy = OS3.smPolicyResponseSMPolicy decoded
OS3.smPolicyBodyNotification (OS3.smPolicyBody policy) `shouldBe` Nothing
describe "DeleteSMPolicyResponse decoding (fixture)" $ do
-- Verbatim from the docs: DELETE /_plugins/_sm/policies/daily-policy
let fixture =
LBS.pack
"{\"_index\":\".opendistro-ism-config\",\"_id\":\
\\"daily-policy-sm-policy\",\"_version\":8,\"result\":\"deleted\",\
\\"forced_refresh\":true,\"_shards\":{\"total\":2,\"successful\":2,\
\\"failed\":0},\"_seq_no\":45366,\"_primary_term\":20}"
it "decodes the documented delete envelope" $ do
let Just decoded = decode fixture :: Maybe OS3.DeleteSMPolicyResponse
OS3.deleteSMPolicyResponseIndex decoded `shouldBe` Just ".opendistro-ism-config"
OS3.deleteSMPolicyResponseId decoded `shouldBe` Just "daily-policy-sm-policy"
OS3.deleteSMPolicyResponseVersion decoded `shouldBe` Just 8
OS3.deleteSMPolicyResponseResult decoded `shouldBe` Just "deleted"
OS3.deleteSMPolicyResponseForcedRefresh decoded `shouldBe` Just True
OS3.deleteSMPolicyResponseSeqNo decoded `shouldBe` Just 45366
describe "SMExplainResponse decoding (fixture)" $ do
-- Verbatim from the docs: GET /_plugins/_sm/policies/daily*/_explain
let fixture =
LBS.pack
"{\"policies\":[{\"name\":\"daily-policy\",\"creation\":\
\{\"current_state\":\"CREATION_START\",\"trigger\":{\"time\":\
\1656403200000}},\"deletion\":{\"current_state\":\
\\"DELETION_START\",\"trigger\":{\"time\":1656403200000}},\
\\"policy_seq_no\":44696,\"policy_primary_term\":19,\"enabled\":true}]}"
it "decodes the documented explain fixture" $ do
let Just decoded = decode fixture :: Maybe OS3.SMExplainResponse
let entry = head (OS3.smExplainResponsePolicies decoded)
OS3.smExplainEntryName entry `shouldBe` Just "daily-policy"
OS3.smExplainEntryPolicySeqNo entry `shouldBe` Just 44696
OS3.smExplainEntryPolicyPrimaryTerm entry `shouldBe` Just 19
OS3.smExplainEntryEnabled entry `shouldBe` Just True
let Just creation = OS3.smExplainEntryCreation entry
OS3.smExplainWorkflowCurrentState creation `shouldBe` Just "CREATION_START"
let Just trigger = OS3.smExplainWorkflowTrigger creation
OS3.smExplainTriggerTime trigger `shouldBe` Just 1656403200000
-- ===============================================================
-- Lenient / inconsistent-input tests
-- ===============================================================
describe "SMSnapshotConfig lenient bool parsing" $ do
it "accepts native Bool for ignore_unavailable / include_global_state / partial" $ do
let fixture =
LBS.pack
"{\"repository\":\"r\",\"ignore_unavailable\":true,\
\\"include_global_state\":false,\"partial\":true}"
Just cfg = decode fixture :: Maybe OS3.SMSnapshotConfig
OS3.smSnapshotConfigIgnoreUnavailable cfg `shouldBe` Just True
OS3.smSnapshotConfigIncludeGlobalState cfg `shouldBe` Just False
OS3.smSnapshotConfigPartial cfg `shouldBe` Just True
it "accepts string \"true\"/\"false\" for the same fields (case-insensitive)" $ do
let fixture =
LBS.pack
"{\"repository\":\"r\",\"ignore_unavailable\":\"TRUE\",\
\\"include_global_state\":\"False\",\"partial\":\"true\"}"
Just cfg = decode fixture :: Maybe OS3.SMSnapshotConfig
OS3.smSnapshotConfigIgnoreUnavailable cfg `shouldBe` Just True
OS3.smSnapshotConfigIncludeGlobalState cfg `shouldBe` Just False
OS3.smSnapshotConfigPartial cfg `shouldBe` Just True
it "forwards unknown snapshot_config keys through Extras" $ do
let fixture =
LBS.pack
"{\"repository\":\"r\",\"custom_key\":\"custom_value\"}"
Just cfg = decode fixture :: Maybe OS3.SMSnapshotConfig
OS3.smSnapshotConfigRepository cfg `shouldBe` Just "r"
describe "SMDeletion condition / delete_condition key acceptance" $ do
it "decodes the JSON-example key \"condition\"" $ do
let fixture =
LBS.pack
"{\"condition\":{\"max_age\":\"7d\",\"max_count\":21,\"min_count\":7}}"
Just decoded = decode fixture :: Maybe OS3.SMDeletion
Just cond = OS3.smDeletionCondition decoded
OS3.smDeleteConditionMaxAge cond `shouldBe` Just "7d"
OS3.smDeleteConditionMaxCount cond `shouldBe` Just 21
it "also accepts the parameter-table key \"delete_condition\"" $ do
let fixture =
LBS.pack
"{\"delete_condition\":{\"max_age\":\"14d\",\"max_count\":50}}"
Just decoded = decode fixture :: Maybe OS3.SMDeletion
Just cond = OS3.smDeletionCondition decoded
OS3.smDeleteConditionMaxAge cond `shouldBe` Just "14d"
OS3.smDeleteConditionMaxCount cond `shouldBe` Just 50
describe "getSMPoliciesQueryParams rendering" $ do
it "renders nothing for the default query" $ do
OS3.getSMPoliciesQueryParams OS3.defaultGetSMPoliciesQuery `shouldBe` []
it "renders every set field, skipping Nothing" $ do
let q =
OS3.defaultGetSMPoliciesQuery
{ OS3.getSMPoliciesQuerySize = Just 50,
OS3.getSMPoliciesQuerySortOrder = Just "asc"
}
sort (OS3.getSMPoliciesQueryParams q)
`shouldBe` sort [("size", Just "50"), ("sortOrder", Just "asc")]
describe "SMPolicyBody round-trip" $ do
it "encodes a minimal body without the optional keys" $ do
let body =
OS3.SMPolicyBody
{ OS3.smPolicyBodyDescription = Nothing,
OS3.smPolicyBodyEnabled = Nothing,
OS3.smPolicyBodyCreation = Nothing,
OS3.smPolicyBodyDeletion = Nothing,
OS3.smPolicyBodySnapshotConfig = Nothing,
OS3.smPolicyBodyNotification = Nothing
}
bytes = encode body
LBS.unpack bytes `shouldBe` "{}"
it "encodes a notification block and decodes it back" $ do
let notif =
OS3.SMNotification
{ OS3.smNotificationChannel =
Just (OS3.SMNotificationChannel "ch-1"),
OS3.smNotificationConditions =
Just
( OS3.SMNotificationConditions
{ OS3.smNotificationConditionsCreation = Just True,
OS3.smNotificationConditionsDeletion = Nothing,
OS3.smNotificationConditionsFailure = Nothing,
OS3.smNotificationConditionsTimeLimitExceeded = Nothing
}
)
}
body =
OS3.SMPolicyBody
{ OS3.smPolicyBodyDescription = Just "rt",
OS3.smPolicyBodyEnabled = Just True,
OS3.smPolicyBodyCreation = Nothing,
OS3.smPolicyBodyDeletion = Nothing,
OS3.smPolicyBodySnapshotConfig = Nothing,
OS3.smPolicyBodyNotification = Just notif
}
Just decoded = decode (encode body) :: Maybe OS3.SMPolicyBody
OS3.smPolicyBodyDescription decoded `shouldBe` Just "rt"
OS3.smPolicyBodyEnabled decoded `shouldBe` Just True
OS3.smNotificationChannelId
<$> (OS3.smNotificationChannel =<< OS3.smPolicyBodyNotification decoded)
`shouldBe` Just "ch-1"