packages feed

keiro-ops-http-0.22.0.0: src/Keiro/Ops/Http/Application.hs

module Keiro.Ops.Http.Application
  ( opsApplication,
    opsApplicationWithBudget,
    OpsHttpError (..),
    errorResponse,
    OpsHttpOperations (..),
    opsApplicationWithOperations,
  )
where

import Control.Exception (SomeAsyncException, SomeException, displayException, evaluate, fromException, throwIO, try)
import Data.Aeson (Value (..), object, (.=))
import Data.Aeson qualified as Aeson
import Data.ByteString qualified as ByteString
import Data.ByteString.Char8 qualified as Char8
import Data.ByteString.Lazy qualified as LazyByteString
import Data.Char (toLower)
import Data.IORef
import Data.Map.Strict qualified as Map
import Data.Sequence qualified as Seq
import Data.Text (Text)
import Data.Text qualified as Text
import Data.Text.Encoding qualified as Text
import Hasql.Pool qualified as Pool
import Keiro.Migrations.SchemaCheck (renderSchemaDrift, verifyExpectedSchemaSession)
import Keiro.Ops (AppHooks, Command, commandParser, isMutation, runCommandWithReadEncoding, validateCommand)
import Keiro.Ops.Env (OpsEnv (..), OutputMode (Json))
import Keiro.Ops.Http.Budget
import Keiro.Ops.Http.Config
import Keiro.Ops.Http.Cors
import Keiro.Ops.Http.Route
import Keiro.Ops.Render
import Keiro.Ops.WorkflowHeaderCache (newWorkflowHeaderCache)
import Kiroku.Store.Connection (KirokuStore (..))
import Network.HTTP.Types
import Network.Wai
import System.Exit (ExitCode (..))

data ResponsePlan = Immediate Response | Inspected Bool (IO Response)

data OpsHttpError = OpsHttpError
  { status :: !Status,
    code :: !Text,
    message :: !Text,
    details :: !(Maybe Value)
  }

jsonHeaders :: ResponseHeaders
jsonHeaders = [(hContentType, "application/json; charset=utf-8")]

errorResponse :: OpsHttpError -> Response
errorResponse err = responseLBS err.status jsonHeaders (Aeson.encode (errorValue err))

errorValue :: OpsHttpError -> Value
errorValue err = object ["error" .= object (["code" .= err.code, "message" .= err.message] <> maybe [] (\value -> ["details" .= value]) err.details)]

failure :: Status -> Text -> Text -> Response
failure status code message = errorResponse (OpsHttpError status code message Nothing)

-- | A host/test seam for supported verification and execution, never a route
-- registry. Production mounting uses 'opsApplication'.
data OpsHttpOperations = OpsHttpOperations
  { verifySchema :: IO (Either Text [Text]),
    executeCommand :: OpsEnv -> Command -> IO OpsOutcome
  }

-- | Allocate once per mount. The supplied store must have statement_timeout
-- configured on EVERY connection, at or below inspectionLimits. Use a dedicated
-- pool; changing a borrowed connection here cannot constrain another handler's
-- borrow and would change writer policy on a shared pool.
opsApplication :: OpsHttpConfig -> AppHooks -> KirokuStore -> IO Application
opsApplication config hooks store = do
  budget <- newInspectionBudget config.inspectionLimits
  opsApplicationWithBudget config budget hooks store

-- | Internal feed integration: HTTP and refresh work consume the same budget.
opsApplicationWithBudget :: OpsHttpConfig -> InspectionBudget -> AppHooks -> KirokuStore -> IO Application
opsApplicationWithBudget config budget hooks store = do
  cache <- newWorkflowHeaderCache config.inspectionLimits.maxResponseBytes $ \value ->
    fmap LazyByteString.toStrict <$> encodeBounded config.inspectionLimits.maxResponseBytes value
  let verify = do
        result <- Pool.use store.pool verifyExpectedSchemaSession
        pure $ case result of
          Left err -> Left (Text.pack (show err))
          Right (Left err) -> Left (Text.pack (show err))
          Right (Right drifts) -> Right (map renderSchemaDrift drifts)
  let encodeDirect encoding = fmap LazyByteString.toStrict <$> encodeBoundedEncoding config.inspectionLimits.maxResponseBytes encoding
  opsApplicationPrepared config budget hooks store verify (runCommandWithReadEncoding encodeDirect cache hooks)

opsApplicationWithOperations :: OpsHttpConfig -> InspectionBudget -> AppHooks -> KirokuStore -> OpsHttpOperations -> IO Application
opsApplicationWithOperations config budget hooks store operations =
  opsApplicationPrepared config budget hooks store operations.verifySchema $ \env command -> do
    outcome <- operations.executeCommand env command
    pure (outcome, Nothing)

-- The prepared bytes are paired with the exact successful read outcome by the
-- owning runner. They never change the public OpsResult or mutation protocol.
opsApplicationPrepared :: OpsHttpConfig -> InspectionBudget -> AppHooks -> KirokuStore -> IO (Either Text [Text]) -> (OpsEnv -> Command -> IO (OpsOutcome, Maybe ByteString.ByteString)) -> IO Application
opsApplicationPrepared config budget hooks store verify execute = do
  parsedReads <- newIORef (Map.empty, Seq.empty)
  let parser = commandParser hooks
      flags = flagNames parser
      parse arguments = do
        (known, _) <- readIORef parsedReads
        case Map.lookup arguments known of
          Just command -> pure (Parsed command)
          Nothing -> case parseArgumentsWithParser parser arguments of
            Parsed command
              | not (isMutation command),
                Right () <- validateCommand command -> do
                  -- Cache only pure parsing, never observations or authorization.
                  -- FIFO capacity and the request-byte cap bound retained inputs.
                  atomicModifyIORef' parsedReads $ \(commands, order) ->
                    if Map.member arguments commands
                      then ((commands, order), ())
                      else
                        let (kept, queue) = case Seq.viewl order of
                              oldest Seq.:< rest | Map.size commands >= 64 -> (Map.delete oldest commands, rest)
                              _ -> (commands, order)
                         in ((Map.insert arguments command kept, queue Seq.|> arguments), ())
                  pure (Parsed command)
            outcome -> pure outcome
  -- Initialize static command metadata during mounting, before requests arrive.
  _ <- evaluate (flags [])
  pure $ corsMiddleware config.allowedOrigins $ \request respond -> do
    result <- trySync (dispatch parse flags request)
    case result of
      Left _ -> respond internalError
      Right (Immediate response) -> respond response
      Right (Inspected mutation action) -> do
        let deadline = if mutation then limits.mutationDeadlineSeconds else limits.readDeadlineSeconds
        delivered <- withInspectionAdmission budget $ do
          computed <- withInspectionDeadline deadline (trySync action)
          -- Network exceptions must propagate; never attempt a second response.
          withInspectionDelivery limits.deliveryDeadlineSeconds $ respond $ case computed of
            Left problem -> budgetFailure mutation problem
            Right (Left _) -> internalError
            Right (Right response) -> response
        case delivered of
          Left problem -> respond (budgetFailure mutation problem)
          Right sent -> pure sent
  where
    limits = config.inspectionLimits
    dispatch parse flags request
      | inputBytes request > limits.maxRequestBytes = immediate (failure status413 "inspection_request_too_large" "request path and query exceed the host limit")
      | pathInfo request == [] = immediate (failure status404 "not_found" "no inspection command at this path")
      | pathInfo request == ["health"] =
          if requestMethod request == methodGet
            then pure (Inspected False health)
            else immediate (methodFailure methodGet)
      | otherwise = case translate flags request of
          Left message -> immediate (failure status400 "invalid_request" message)
          Right arguments -> do
            parsed <- parse arguments
            case parsed of
              Rejected message -> immediate (errorResponse (OpsHttpError status400 "invalid_request" message (Just (object ["arguments" .= arguments]))))
              Parsed command | Left (code, message) <- validateCommand command -> immediate (failure status400 code message)
              Parsed command -> do
                let mutation = isMutation command
                    method = if mutation then methodPost else methodGet
                if requestMethod request /= method
                  then immediate (methodFailure method)
                  else
                    if mutation && (any (\(key, value) -> key == "Origin" && ByteString.length value > limits.maxRequestBytes) (requestHeaders request) || not (originAllowed config.allowedOrigins (isSecure request) (requestHeaders request)))
                      then immediate (failure status403 "origin_not_allowed" "mutation origin is not allowed")
                      else
                        if mutation && config.mutations == MutationsDisabled
                          then immediate (failure status403 "mutations_disabled" "this surface was mounted without mutations")
                          else budgetedCommand request command mutation
    translate flags request = do
      query <- either (Left . Text.pack . displayException) Right (traverse decodeQuery (queryString request))
      either (Left . renderRouteError) Right (translateRequest (flags (pathInfo request)) (pathInfo request) query)
    decodeQuery (key, value) = (,) <$> Text.decodeUtf8' key <*> traverse Text.decodeUtf8' value
    inputBytes request = ByteString.length (rawPathInfo request) + ByteString.length (rawQueryString request)
    methodFailure method = mapResponseHeaders ((hAllow, method) :) (failure status405 "method_not_allowed" "method does not match the command classification")
    budgetedCommand request command mutation =
      pure $ Inspected mutation $ do
        confirmation <- if mutation then readConfirmation request else pure (Right False)
        case confirmation of
          Left err -> pure err
          Right confirmed -> do
            verified <- verify
            case verified of
              Left message -> pure (failure status500 "schema_verification_failed" message)
              Right drift
                | not (null drift) && config.schemaDrift == RefuseOnDrift ->
                    boundedJson status409 [] (errorValue (OpsHttpError status409 "schema_drift" "the live schema differs from this binary; refusing to serve until they agree" (Just (object ["drift" .= drift]))))
                | otherwise -> do
                    let env = OpsEnv store Json (mutation && confirmed) drift (config.schemaDrift == AllowDrift)
                    (outcome, prepared) <- execute env command
                    renderOutcome mutation drift prepared outcome
    immediate = pure . Immediate
    internalError = failure status500 "internal_error" "inspection failed"
    budgetFailure mutation = \case
      InspectionBusy -> failure status503 "inspection_busy" "all inspection slots are in use; retry later"
      InspectionTimedOut
        | mutation -> failure status504 "mutation_outcome_unknown" "deadline exceeded; the mutation may have committed; inspect state before deciding whether to retry"
        | otherwise -> failure status504 "inspection_timeout" "inspection exceeded the host deadline"
    health = do
      result <- verify
      case result of
        Left message -> boundedJson status503 [] (object ["status" .= ("unavailable" :: Text), "message" .= message])
        Right drift ->
          boundedJson
            (if null drift || config.schemaDrift == AllowDrift then status200 else status503)
            (driftHeaders drift)
            (object ["status" .= (if null drift then "ok" else "schema_drift" :: Text), "schema_drift" .= drift, "mutations_enabled" .= (config.mutations == MutationsEnabled)])
    driftHeaders drift = [("Keiro-Ops-Schema-Drift", Char8.pack (show (length drift))) | not (null drift) && config.schemaDrift == AllowDrift]
    boundedJson status headers value = do
      encoded <- encodeBounded limits.maxResponseBytes value
      pure $ case encoded of
        Nothing -> failure status413 "inspection_result_too_large" "encoded result exceeds the host limit; narrow the read"
        Just body -> responseLBS status (jsonHeaders <> headers) body
    renderOutcome mutation drift prepared = \case
      InspectionFailed code message ->
        let status
              | code == "cursor_generation_mismatch" = status409
              | code == "inspection_result_too_large" = status413
              | otherwise = status400
         in boundedJson status [] (errorValue (OpsHttpError status code message Nothing))
      Failed message -> boundedJson status422 [] (errorValue (OpsHttpError status422 "command_failed" message Nothing))
      PreviewRequired result invocation -> boundedJson status200 (driftHeaders drift) (object ["outcome" .= ("preview" :: Text), "result" .= result.jsonValue, "cli_reinvocation" .= invocation])
      Succeeded result -> success result ExitSuccess
      SucceededWithExit result exitCode -> success result exitCode
      where
        success result exitCode =
          let exitFields = case exitCode of ExitSuccess -> []; ExitFailure n -> ["exit_code" .= n]
              exitHeaders = case exitCode of ExitSuccess -> []; ExitFailure n -> [("Keiro-Ops-Exit-Code", Char8.pack (show n))]
              value = if mutation then object (["outcome" .= ("executed" :: Text), "result" .= result.jsonValue] <> exitFields) else result.jsonValue
           in case (mutation, prepared) of
                (False, Just body)
                  | ByteString.length body <= limits.maxResponseBytes ->
                      pure (responseLBS status200 (jsonHeaders <> driftHeaders drift <> exitHeaders) (LazyByteString.fromStrict body))
                  | otherwise ->
                      pure (failure status413 "inspection_result_too_large" "encoded result exceeds the host limit; narrow the read")
                _ -> boundedJson status200 (driftHeaders drift <> if mutation then [] else exitHeaders) value

readConfirmation :: Request -> IO (Either Response Bool)
readConfirmation request = go 0 []
  where
    go size chunks = do
      chunk <- getRequestBodyChunk request
      let nextSize = size + ByteString.length chunk
      if nextSize > 1024
        then pure (Left (failure status413 "inspection_request_too_large" "confirmation body exceeds 1 KiB"))
        else
          if ByteString.null chunk
            then pure (parse (LazyByteString.fromChunks (reverse chunks)))
            else go nextSize (chunk : chunks)
    jsonContentType = case [value | (key, value) <- requestHeaders request, key == hContentType] of
      [value] -> Char8.map toLower (Char8.strip (Char8.takeWhile (/= ';') value)) == "application/json"
      _ -> False
    parse body
      | LazyByteString.null body = Right False
      | not jsonContentType = Left (failure status415 "unsupported_media_type" "a nonempty confirmation body requires application/json")
      | Aeson.decode body == Just (object ["confirm" .= ("execute" :: Text)]) = Right True
      | otherwise = Left (failure status400 "invalid_confirmation" "expected an empty body or exactly {\"confirm\":\"execute\"}")

trySync :: IO a -> IO (Either SomeException a)
trySync action = do
  result <- try action
  case result of
    Left exception | Just (_ :: SomeAsyncException) <- fromException exception -> throwIO exception
    _ -> pure result