packages feed

clickhouse-migrate-0.1.0.0: src/ClickHouseMigrate/Internal/LedgerCodec.hs

-- | Strict bounded version-one event codec; values are JSON data, never SQL.
module ClickHouseMigrate.Internal.LedgerCodec
  ( maxEventBytes,
    encodeEvent,
    decodeEvent,
    eventValue,
    definitionValue,
    definitions,
    validateEvent,
    eventKind,
    encodeValue,
    definitionsDigest,
    contextFor,
  )
where

import ClickHouseMigrate.Definition qualified as Def
import ClickHouseMigrate.Internal.Checksum qualified as Checksum
import ClickHouseMigrate.Internal.LedgerTypes
import ClickHouseMigrate.Internal.LedgerValidation (auditContext, ledgerPositionText, parseLedgerPosition)
import ClickHouseMigrate.Plan qualified as Plan
import ClickHouseMigrate.Prelude
import ClickHouseMigrate.Target qualified as Target
import Data.Aeson (Value (..), eitherDecodeStrict', object, (.=))
import Data.Aeson qualified as Aeson
import Data.Aeson.Key qualified as Key
import Data.Aeson.KeyMap qualified as KM
import Data.Aeson.Types (Parser, parseEither, withObject, (.:))
import Data.ByteString qualified as BS
import Data.ByteString.Lazy qualified as Lazy
import Data.List (sortOn)
import Data.List.NonEmpty qualified as NE
import Data.Set qualified as Set
import Data.Text qualified as Text
import Data.Text.Encoding qualified as Encoding
import Data.Time (UTCTime, defaultTimeLocale, parseTimeM)

encodeValue :: Value -> ByteString
encodeValue = \case
  Object members -> "{" <> BS.intercalate "," [encodeValue (String (Key.toText key)) <> ":" <> encodeValue value | (key, value) <- sortOn (Key.toText . fst) (KM.toList members)] <> "}"
  Array values -> "[" <> BS.intercalate "," (foldr (\value rest -> encodeValue value : rest) [] values) <> "]"
  scalar -> Lazy.toStrict (Aeson.encode scalar)

maxEventBytes :: Int
maxEventBytes = 65536

encodeEvent :: LedgerEvent -> ByteString
encodeEvent = encodeValue . eventValue

eventValue :: LedgerEvent -> Value
eventValue (LedgerEvent sequenceNumber event ledger run attempt time audit runner context body) =
  object
    [ "version" .= (1 :: Int),
      "sequence" .= sequenceNumber,
      "eventId" .= event,
      "ledgerId" .= Target.ledgerIdText ledger,
      "runId" .= run,
      "attemptId" .= attempt,
      "occurredAt" .= time,
      "audit" .= auditValue audit,
      "runnerVersion" .= runner,
      "context" .= fmap contextValue context,
      "kind" .= eventKind body,
      "body" .= bodyValue body
    ]

eventKind :: EventBody -> Text
eventKind = \case
  Initialize _ -> "initialized"
  StartMigration _ -> "migration-started"
  StartStep _ _ -> "step-started"
  SucceedStep _ _ -> "step-succeeded"
  FailStep _ _ _ -> "execution-failed"
  CompleteMigration -> "migration-completed"
  AuthorizeRecovery _ -> "recovery-authorized"
  AcceptCompletion _ _ -> "step-accepted"
  StartAdoption _ -> "adoption-started"
  AdoptEntry _ _ -> "adoption-entry"
  CompleteAdoption _ -> "adoption-completed"

bodyValue :: EventBody -> Value
bodyValue = \case
  Initialize target -> object ["target" .= Target.targetKeyText target, "schemaVersion" .= (1 :: Int), "checksumFormat" .= ("sha256-v1" :: Text), "policy" .= Encoding.decodeUtf8 Plan.completionPolicy]
  StartMigration definition -> definitionValue definition
  StartStep step query -> stepValue step query
  SucceedStep step query -> stepValue step query
  FailStep step query (FailureEvidence code status server) -> object ["step" .= step, "queryId" .= query, "code" .= code, "httpStatus" .= status, "serverCode" .= server]
  CompleteMigration -> object []
  AuthorizeRecovery (Recovery action expected previous next (QuiescenceEvidence target audit)) ->
    object
      [ "action" .= recoveryName action,
        "step" .= recoveryStep action,
        "expected" .= ledgerPositionText expected,
        "previousAttempt" .= previous,
        "nextAttempt" .= next,
        "target" .= Target.targetKeyText target,
        "quiescence" .= auditValue audit
      ]
  AcceptCompletion step evidence -> object ["step" .= step, "evidence" .= evidenceValue evidence]
  StartAdoption (AdoptionBatch source snapshot mapping definitionsHash count) ->
    object
      ["source" .= source, "snapshot" .= snapshot, "mapping" .= mapping, "definitions" .= definitionsHash, "count" .= count]
  AdoptEntry definition evidence -> object ["definition" .= definitionValue definition, "evidence" .= evidenceValue evidence]
  CompleteAdoption digest -> object ["digest" .= digest]

stepValue :: Int -> Text -> Value
stepValue step query = object ["step" .= step, "queryId" .= query]

auditValue :: AuditContext -> Value
auditValue (AuditContext actor reason references) = object ["actor" .= actor, "reason" .= reason, "references" .= references]

evidenceValue :: Evidence -> Value
evidenceValue (Evidence level references) = object ["level" .= (case level of OperatorAttestation -> "operator-attestation" :: Text; PredecessorChecksum -> "predecessor-checksum"), "references" .= references]

definitionValue :: Definition -> Value
definitionValue (Definition ordinal componentIndex component dependencies migration checksum steps) =
  object
    [ "ordinal" .= ordinal,
      "componentIndex" .= componentIndex,
      "component" .= component,
      "dependencies" .= dependencies,
      "migration" .= migration,
      "checksum" .= checksum,
      "steps" .= [object ["name" .= name, "checksum" .= digest] | StepDefinition name digest <- steps]
    ]

contextValue :: EventContext -> Value
contextValue (EventContext component migration checksum step) =
  object
    [ "component" .= component,
      "migration" .= migration,
      "checksum" .= checksum,
      "step" .= fmap (\(name, digest) -> object ["name" .= name, "checksum" .= digest]) step
    ]

contextFor :: Definition -> Maybe Int -> Either LedgerError EventContext
contextFor definition index =
  EventContext (component definition) (migration definition) (checksum definition) <$> case index of
    Nothing -> Right Nothing
    Just i | i >= 0 -> case drop i (steps definition) of
      StepDefinition name digest : _ -> Right (Just (name, digest))
      _ -> Left InvalidEvent
    _ -> Left InvalidEvent

parseContext :: Value -> Parser EventContext
parseContext = exact ["component", "migration", "checksum", "step"] $ \o ->
  EventContext <$> o .: "component" <*> o .: "migration" <*> o .: "checksum" <*> (o .: "step" >>= traverse (exact ["name", "checksum"] (\s -> (,) <$> s .: "name" <*> s .: "checksum")))

recoveryName :: RecoveryAction -> Text
recoveryName = \case Resume -> "resume"; RetryStep _ -> "retry-step"; AcceptStep _ -> "accept-step"; Finalize -> "finalize"

recoveryStep :: RecoveryAction -> Maybe Int
recoveryStep = \case RetryStep step -> Just step; AcceptStep step -> Just step; _ -> Nothing

definitions :: Plan.MigrationPlan -> [Definition]
definitions plan =
  zipWith
    bind
    [0 ..]
    [ (index, component, migration)
    | migration <- NE.toList (Plan.inspectSchedule plan),
      (index, component) <- zip [0 ..] (NE.toList (Plan.inspectPlan plan)),
      Plan.componentViewName component == Def.migrationIdComponent (Plan.migrationId migration)
    ]
  where
    bind ordinal (index, component, migration) =
      Definition
        ordinal
        index
        (Def.componentNameText (Plan.componentViewName component))
        (map Def.componentNameText (Set.toAscList (Plan.componentDependencies component)))
        (Def.migrationNameText (Def.migrationIdName (Plan.migrationId migration)))
        (Plan.migrationChecksumHex (Plan.migrationChecksum migration))
        [StepDefinition (Def.stepNameText (Plan.stepViewName step)) (Plan.stepChecksumHex (Plan.stepChecksum step)) | step <- NE.toList (Plan.migrationSteps migration)]

definitionsDigest :: [Definition] -> Text
definitionsDigest values = Checksum.digestHex (Checksum.digest (encodeValue (Aeson.toJSON (map definitionValue values))))

decodeEvent :: ByteString -> Either LedgerError LedgerEvent
decodeEvent bytes
  | BS.length bytes > maxEventBytes = Left LedgerLimitExceeded
  | otherwise = do
      value <- either (const (Left InvalidEvent)) Right (eitherDecodeStrict' bytes)
      event <- either (const (Left InvalidEvent)) Right (parseEither parseEvent value)
      validateEvent event
      -- Our encoder supplies a canonical wire spelling. This also rejects duplicate
      -- keys (including escaped aliases), alternate number/string encodings and
      -- whitespace changes instead of discarding evidence during object decoding.
      if encodeEvent event == bytes then Right event else Left InvalidEvent

parseEvent :: Value -> Parser LedgerEvent
parseEvent = exact ["version", "sequence", "eventId", "ledgerId", "runId", "attemptId", "occurredAt", "audit", "runnerVersion", "context", "kind", "body"] $ \o -> do
  version <- o .: "version" :: Parser Int
  runner <- o .: "runnerVersion" :: Parser Text
  unless (version == 1 && not (Text.null runner) && Text.length runner <= 128) (fail "unsupported version")
  ledger <- o .: "ledgerId" >>= validated Target.ledgerId
  kind <- o .: "kind"
  body <- o .: "body" >>= parseBody kind
  LedgerEvent <$> o .: "sequence" <*> o .: "eventId" <*> pure ledger <*> o .: "runId" <*> o .: "attemptId" <*> o .: "occurredAt" <*> (o .: "audit" >>= parseAudit) <*> pure runner <*> (o .: "context" >>= traverse parseContext) <*> pure body

parseBody :: Text -> Value -> Parser EventBody
parseBody = \case
  "initialized" -> exact ["target", "schemaVersion", "checksumFormat", "policy"] $ \o -> do
    version <- o .: "schemaVersion" :: Parser Int
    format <- o .: "checksumFormat" :: Parser Text
    policy <- o .: "policy" :: Parser Text
    unless (version == 1 && format == "sha256-v1" && policy == Encoding.decodeUtf8 Plan.completionPolicy) (fail "unsupported format")
    Initialize <$> (o .: "target" >>= validated Target.targetKey)
  "migration-started" -> fmap StartMigration . parseDefinition
  "step-started" -> parseStep StartStep
  "step-succeeded" -> parseStep SucceedStep
  "execution-failed" -> exact ["step", "queryId", "code", "httpStatus", "serverCode"] $ \o -> FailStep <$> o .: "step" <*> o .: "queryId" <*> (FailureEvidence <$> o .: "code" <*> o .: "httpStatus" <*> o .: "serverCode")
  "migration-completed" -> exact [] (const (pure CompleteMigration))
  "recovery-authorized" -> exact ["action", "step", "expected", "previousAttempt", "nextAttempt", "target", "quiescence"] $ \o -> do
    name <- o .: "action" :: Parser Text
    step <- o .: "step" :: Parser (Maybe Int)
    action <- case (name, step) of
      ("resume", Nothing) -> pure Resume
      ("finalize", Nothing) -> pure Finalize
      ("retry-step", Just i) -> pure (RetryStep i)
      ("accept-step", Just i) -> pure (AcceptStep i)
      _ -> fail "invalid recovery"
    expected <- o .: "expected" >>= validated parseLedgerPosition
    target <- o .: "target" >>= validated Target.targetKey
    audit <- o .: "quiescence" >>= parseAudit
    AuthorizeRecovery <$> (Recovery action expected <$> o .: "previousAttempt" <*> o .: "nextAttempt" <*> pure (QuiescenceEvidence target audit))
  "step-accepted" -> exact ["step", "evidence"] $ \o -> AcceptCompletion <$> o .: "step" <*> (o .: "evidence" >>= parseEvidence)
  "adoption-started" -> exact ["source", "snapshot", "mapping", "definitions", "count"] $ \o -> StartAdoption <$> (AdoptionBatch <$> o .: "source" <*> o .: "snapshot" <*> o .: "mapping" <*> o .: "definitions" <*> o .: "count")
  "adoption-entry" -> exact ["definition", "evidence"] $ \o -> AdoptEntry <$> (o .: "definition" >>= parseDefinition) <*> (o .: "evidence" >>= parseEvidence)
  "adoption-completed" -> exact ["digest"] $ \o -> CompleteAdoption <$> o .: "digest"
  _ -> const (fail "unknown kind")

parseStep :: (Int -> Text -> EventBody) -> Value -> Parser EventBody
parseStep construct = exact ["step", "queryId"] $ \o -> construct <$> o .: "step" <*> o .: "queryId"

parseAudit :: Value -> Parser AuditContext
parseAudit = exact ["actor", "reason", "references"] $ \o -> do
  actor <- o .: "actor"
  reason <- o .: "reason"
  references <- o .: "references"
  validated (\() -> auditContext actor reason references) ()

parseEvidence :: Value -> Parser Evidence
parseEvidence = exact ["level", "references"] $ \o -> do
  level <- o .: "level" :: Parser Text
  Evidence <$> (case level of "operator-attestation" -> pure OperatorAttestation; "predecessor-checksum" -> pure PredecessorChecksum; _ -> fail "unknown evidence") <*> o .: "references"

parseDefinition :: Value -> Parser Definition
parseDefinition = exact ["ordinal", "componentIndex", "component", "dependencies", "migration", "checksum", "steps"] $ \o ->
  Definition <$> o .: "ordinal" <*> o .: "componentIndex" <*> o .: "component" <*> o .: "dependencies" <*> o .: "migration" <*> o .: "checksum" <*> (o .: "steps" >>= mapM (exact ["name", "checksum"] (\s -> StepDefinition <$> s .: "name" <*> s .: "checksum")))

exact :: [Text] -> (Aeson.Object -> Parser a) -> Value -> Parser a
exact keys action = withObject "ledger object" $ \o -> do
  unless (Set.fromList (map Key.toText (KM.keys o)) == Set.fromList keys) (fail "invalid fields")
  action o

validated :: (a -> Either e b) -> a -> Parser b
validated f = either (const (fail "invalid value")) pure . f

validateEvent :: LedgerEvent -> Either LedgerError ()
validateEvent event@(LedgerEvent sequenceNumber eventId ledger run attempt occurred audit runner context body) = do
  require (sequenceNumber > 0 && BS.length (encodeEvent event) <= maxEventBytes && not (Text.null runner) && Text.length runner <= 128)
  uuid eventId
  uuid run
  mapM_ uuid attempt
  _ <- auditContext (actor audit) (reason audit) (references audit)
  require (isJust (parseTimeM True defaultTimeLocale "%Y-%m-%dT%H:%M:%S%QZ" (Text.unpack occurred) :: Maybe UTCTime))
  mapM_
    ( \(EventContext component migration checksum step) -> do
        void (either (const (Left InvalidEvent)) Right (Def.componentName component))
        void (either (const (Left InvalidEvent)) Right (Def.migrationName migration))
        checkHash checksum
        mapM_ (\(name, digest) -> void (either (const (Left InvalidEvent)) Right (Def.stepName name)) >> checkHash digest) step
    )
    context
  case body of
    Initialize _ -> require (isNothing attempt && sequenceNumber == 1 && isNothing context)
    StartMigration definition -> needAttempt >> checkDefinition definition >> (contextFor definition Nothing >>= require . (== context) . Just)
    StartStep step query -> needAttempt >> checkStep step query
    SucceedStep step query -> needAttempt >> checkStep step query
    FailStep step query (FailureEvidence code status server) -> needAttempt >> checkStep step query >> require (code `elem` ["ServerRejected", "UnexpectedResponse", "RedirectRejected"] && maybe True (\n -> n >= 100 && n <= 599) status && maybe True (>= 0) server)
    CompleteMigration -> needAttempt
    AuthorizeRecovery (Recovery action (LedgerPosition expected _ previousEvent) previous next (QuiescenceEvidence _ evidenceAudit)) -> do
      needAttempt
      uuid previous
      uuid next
      uuid previousEvent
      require (ledger == expected && previous /= next && all (>= 0) (recoveryStep action) && not (null (references evidenceAudit)))
      _ <- auditContext (actor evidenceAudit) (reason evidenceAudit) (references evidenceAudit)
      pure ()
    AcceptCompletion step evidence -> needAttempt >> require (step >= 0) >> checkEvidence evidence
    StartAdoption (AdoptionBatch source snapshot mapping definition count) -> do
      require (isNothing attempt && isNothing context && not (Text.null (Text.strip source)) && Text.length source <= 2048 && count > 0)
      mapM_ checkHash [snapshot, mapping, definition]
    AdoptEntry definition evidence -> require (isNothing attempt) >> checkDefinition definition >> checkEvidence evidence >> (contextFor definition Nothing >>= require . (== context) . Just)
    CompleteAdoption digest -> require (isNothing attempt && isNothing context) >> checkHash digest
  where
    require True = Right (); require False = Left InvalidEvent
    uuid = either (const (Left InvalidEvent)) (const (Right ())) . Target.ledgerId
    needAttempt = require (isJust attempt && isJust context)
    checkStep step query = require (step >= 0) >> either (const (Left InvalidEvent)) (const (Right ())) (Target.queryId query)
    checkHash digest = require (Text.length digest == 64 && Text.all (\c -> (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) digest)
    checkEvidence (Evidence _ refs) = do
      require (not (null refs))
      void (auditContext "evidence" "evidence" refs)
    checkDefinition (Definition ordinal index component dependencies migration checksum steps) = do
      require (ordinal >= 0 && index >= 0 && not (null steps) && dependencies == Set.toAscList (Set.fromList dependencies) && component `notElem` dependencies)
      void (either (const (Left InvalidEvent)) Right (Def.componentName component))
      void (either (const (Left InvalidEvent)) Right (Def.migrationName migration))
      mapM_ (\dependency -> void (either (const (Left InvalidEvent)) Right (Def.componentName dependency))) dependencies
      checkHash checksum
      require (length steps == Set.size (Set.fromList [name | StepDefinition name _ <- steps]))
      mapM_ (\(StepDefinition name digest) -> void (either (const (Left InvalidEvent)) Right (Def.stepName name)) >> checkHash digest) steps
    actor (AuditContext value _ _) = value
    reason (AuditContext _ value _) = value
    references (AuditContext _ _ value) = value