packages feed

morley-client-0.3.2: src/Morley/Client/TezosClient/Helpers.hs

-- SPDX-FileCopyrightText: 2023 Oxhead Alpha
-- SPDX-License-Identifier: LicenseRef-MIT-OA

-- | Helpers used to call @octez-client@.
module Morley.Client.TezosClient.Helpers
  ( CallMode(..)
  , callTezosClient
  , callTezosClientStrict
  , readProcessWithExitCode'
  ) where

import Unsafe qualified ((!!))

import Colourista (formatWith, red)
import Control.Exception (IOException, throwIO)
import Data.ByteArray (ScrubbedBytes)
import Data.Text qualified as T
import System.Exit (ExitCode(..))
import System.Process (readProcessWithExitCode)

import Morley.Client.Logging
import Morley.Client.TezosClient.Types
import Morley.Client.TezosClient.Types.Errors
import Morley.Client.Util (scrubbedBytesToString)

-- | Datatype that represents modes for calling node from @octez-client@.
data CallMode
  = MockupMode
  -- ^ Mode in which @octez-client@ doesn't perform any actual RPC calls to the node
  -- and use mock instead.
  | ClientMode
  -- ^ Normal mode in which @octez-client@ performs all necessary RPC calls to the node.

-- | Call @octez-client@ with given arguments. Arguments defined by
-- config are added automatically. The second argument specifies what
-- should be done in failure case. It takes stdout and stderr
-- output. Possible handling:
--
-- 1. Parse a specific error and throw it.
-- 2. Parse an expected error that shouldn't cause a failure.
-- Return @True@ in this case.
-- 3. Detect an unexpected error, return @False@.
-- In this case 'UnexpectedClientFailure' will be throw.
callTezosClient
  :: forall env m. (WithClientLog env m, HasTezosClientEnv env, MonadIO m, MonadCatch m)
  => (Text -> Text -> IO Bool) -> [String] -> CallMode -> Maybe ScrubbedBytes -> m Text
callTezosClient errHandler args mode mbInput = retryEConnreset mode $ do
  TezosClientEnv {..} <- view tezosClientEnvL
  let
    extraArgs :: [String]
    extraArgs = mconcat
      [ ["-E", toCmdArg tceEndpointUrl]
      , maybe [] (\dir -> ["-d", dir]) tceMbTezosClientDataDir
      , ["--mode", case mode of
            MockupMode -> "mockup"
            ClientMode -> "client"
        ]
      ]

    allArgs = extraArgs ++ args
  logDebug $ "Running: " <> unwords (toText <$> tceTezosClientPath:allArgs)
  let
    ifNotEmpty prefix output
      | null output = ""
      | otherwise = prefix <> ":\n" <> output
    logOutput :: Text -> Text -> m ()
    logOutput output errOutput = logDebug $
      ifNotEmpty "stdout" output <>
      ifNotEmpty "stderr" errOutput

  liftIO (readProcessWithExitCode' tceTezosClientPath allArgs
          (maybe "" scrubbedBytesToString mbInput)) >>= \case
    (ExitSuccess, toText -> output, toText -> errOutput) ->
      output <$ logOutput output errOutput
    (ExitFailure errCode, toText -> output, toText -> errOutput) -> do
      checkCounterError errOutput
      checkEConnreset errOutput
      liftIO $ unlessM (errHandler output errOutput) $
        throwM $ UnexpectedClientFailure errCode output errOutput

      output <$ logOutput output errOutput
  where
    checkCounterError
      :: Text -> m ()
    checkCounterError errOutput |
      "Counter" `T.isPrefixOf` errOutput && "already used for contract" `T.isInfixOf` errOutput = do
        let splittedErrOutput = words errOutput
        liftIO $ throwM $
          CounterIsAlreadyUsed (splittedErrOutput Unsafe.!! 1) (splittedErrOutput Unsafe.!! 5)
    checkCounterError _ = pass
    checkEConnreset :: Text -> m ()
    checkEConnreset errOutput
      | "Unix.ECONNRESET" `T.isInfixOf` errOutput = throwM EConnreset
    checkEConnreset _ = pass

    -- Helper function that retries @octez-client@ call action in case of @ECONNRESET@.
    -- Note that this error cannot appear in case of 'MockupMode' call.
    retryEConnreset :: CallMode -> m a -> m a
    retryEConnreset MockupMode action = action
    retryEConnreset ClientMode action = retryEConnresetImpl 0 action

    retryEConnresetImpl :: Integer -> m a -> m a
    retryEConnresetImpl attempt action = action `catch` \err -> do
      case err of
        EConnreset ->
          if attempt >= maxRetryAmount then throwM err
          else retryEConnresetImpl (attempt + 1) action
        anotherErr -> throwM anotherErr

    maxRetryAmount = 5

-- | Call @octez-client@ and expect success.
callTezosClientStrict
  :: (WithClientLog env m, HasTezosClientEnv env, MonadIO m, MonadCatch m)
  => [String] -> CallMode -> Maybe ScrubbedBytes -> m Text
callTezosClientStrict = callTezosClient errHandler
  where
    errHandler _ _ = pure False

-- | Variant of @readProcessWithExitCode@ that prints a better error in case of
-- an exception in the inner @readProcessWithExitCode@ call.
readProcessWithExitCode'
  :: FilePath
  -> [String]
  -> String
  -> IO (ExitCode, String, String)
readProcessWithExitCode' fp args inp =
  catch
    (readProcessWithExitCode fp args inp) handler
  where
    handler :: IOException -> IO (ExitCode, String, String)
    handler e = do
      hPutStrLn @Text stderr $ formatWith [red] errorMsg
      throwIO e

    errorMsg =
      "ERROR!! There was an error in executing `" <> toText fp <> "` program. Is the \
      \ executable available in PATH ?"