{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE MultiWayIf #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TupleSections #-}
module Cli.Extras.Process
( AsProcessFailure (..)
, ProcessFailure (..)
, ProcessSpec (..)
, callCommand
, callProcess
, callProcessAndLogOutput
, createProcess
, createProcess_
, overCreateProcess
, proc
, readCreateProcessWithExitCode
, readProcessAndLogOutput
, readProcessAndLogStderr
, readProcessJSONAndLogStderr
, reconstructCommand
, setCwd
, setDelegateCtlc
, setEnvOverride
, shell
, waitForProcess
, prettyProcessFailure
) where
import Control.Monad ((<=<), join, void)
import Control.Monad.Except (throwError)
import Control.Monad.Fail
import Control.Monad.IO.Class (MonadIO, liftIO)
import Control.Lens (Prism', review)
import qualified Data.ByteString as BS
import qualified Data.ByteString.Char8 as BSC
import Data.Function (fix)
import Data.Map (Map)
import qualified Data.Map as Map
import Data.Monoid ((<>))
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.Encoding as T
import Data.Text.Encoding.Error (lenientDecode)
import System.Environment (getEnvironment)
import System.Exit (ExitCode (..))
import System.IO (Handle)
import System.IO.Streams (InputStream, handleToInputStream)
import qualified System.IO.Streams as Streams
import System.IO.Streams.Concurrent (concurrentMerge)
import System.Process (CreateProcess, ProcessHandle, StdStream (CreatePipe), std_err, std_out)
import qualified System.Process as Process
import qualified Data.Aeson as Aeson
import Control.Monad.Log (Severity (..))
import Cli.Extras.Logging (putLog, putLogRaw)
import Cli.Extras.Types (CliLog, CliThrow)
data ProcessSpec = ProcessSpec
{ _processSpec_createProcess :: !CreateProcess
, _processSpec_overrideEnv :: !(Maybe (Map String String -> Map String String))
}
proc :: FilePath -> [String] -> ProcessSpec
proc cmd args = ProcessSpec (Process.proc cmd args) Nothing
shell :: String -> ProcessSpec
shell cmd = ProcessSpec (Process.shell cmd) Nothing
setEnvOverride :: (Map String String -> Map String String) -> ProcessSpec -> ProcessSpec
setEnvOverride f p = p { _processSpec_overrideEnv = Just f }
overCreateProcess :: (CreateProcess -> CreateProcess) -> ProcessSpec -> ProcessSpec
overCreateProcess f (ProcessSpec p x) = ProcessSpec (f p) x
setDelegateCtlc :: Bool -> ProcessSpec -> ProcessSpec
setDelegateCtlc b = overCreateProcess (\p -> p { Process.delegate_ctlc = b })
setCwd :: Maybe FilePath -> ProcessSpec -> ProcessSpec
setCwd fp = overCreateProcess (\p -> p { Process.cwd = fp })
data ProcessFailure = ProcessFailure Process.CmdSpec Int
deriving Show
prettyProcessFailure :: ProcessFailure -> Text
prettyProcessFailure (ProcessFailure p code) = "Process exited with code " <> T.pack (show code) <> "; " <> reconstructCommand p
class AsProcessFailure e where
asProcessFailure :: Prism' e ProcessFailure
instance AsProcessFailure ProcessFailure where
asProcessFailure = id
readProcessAndLogStderr
:: (MonadIO m, CliLog m, CliThrow e m, AsProcessFailure e, MonadFail m)
=> Severity -> ProcessSpec -> m Text
readProcessAndLogStderr sev process = do
(out, _err) <- withProcess process $ \_out err -> do
streamToLog =<< liftIO (streamHandle sev err)
liftIO $ T.decodeUtf8With lenientDecode <$> BS.hGetContents out
readProcessJSONAndLogStderr
:: (Aeson.FromJSON a, MonadIO m, CliLog m, CliThrow e m, AsProcessFailure e, MonadFail m)
=> Severity -> ProcessSpec -> m a
readProcessJSONAndLogStderr sev process = do
(out, _err) <- withProcess process $ \_out err -> do
streamToLog =<< liftIO (streamHandle sev err)
json <- liftIO $ BS.hGetContents out
case Aeson.eitherDecodeStrict json of
Right a -> pure a
Left err -> do
putLog Error $ "Could not decode process output as JSON: " <> T.pack err
throwError $ review asProcessFailure $ ProcessFailure (Process.cmdspec $ _processSpec_createProcess process) 0
readCreateProcessWithExitCode
:: (MonadIO m, CliLog m, CliThrow e m, AsProcessFailure e)
=> ProcessSpec -> m (ExitCode, String, String)
readCreateProcessWithExitCode procSpec = do
process <- mkCreateProcess procSpec
putLog Debug $ "Creating process: " <> reconstructProcSpec procSpec
liftIO $ Process.readCreateProcessWithExitCode process ""
readProcessAndLogOutput
:: (MonadIO m, CliLog m, CliThrow e m, AsProcessFailure e, MonadFail m)
=> (Severity, Severity) -> ProcessSpec -> m Text
readProcessAndLogOutput (sev_out, sev_err) process = do
(_, Just out, Just err, p) <- createProcess $ overCreateProcess
(\p -> p { std_out = CreatePipe , std_err = CreatePipe }) process
streamToLog =<< liftIO (streamHandle sev_err err)
outText <- liftIO $ T.decodeUtf8With lenientDecode <$> BS.hGetContents out
putLogRaw sev_out outText
waitForProcess p >>= \case
ExitSuccess -> pure outText
ExitFailure code -> throwError $ review asProcessFailure $ ProcessFailure (Process.cmdspec $ _processSpec_createProcess process) code
callProcessAndLogOutput
:: (MonadIO m, CliLog m, CliThrow e m, AsProcessFailure e, MonadFail m)
=> (Severity, Severity) -> ProcessSpec -> m ()
callProcessAndLogOutput (sev_out, sev_err) process =
void $ withProcess process $ \out err -> do
stream <- liftIO $ join $ combineStream
<$> streamHandle sev_out out
<*> streamHandle sev_err err
streamToLog stream
where
combineStream s1 s2 = concurrentMerge [s1, s2]
createProcess
:: (MonadIO m, CliLog m)
=> ProcessSpec -> m (Maybe Handle, Maybe Handle, Maybe Handle, ProcessHandle)
createProcess procSpec = do
p <- mkCreateProcess procSpec
putLog Debug $ "Creating process: " <> reconstructProcSpec procSpec
liftIO $ Process.createProcess p
createProcess_
:: (MonadIO m, CliLog m)
=> String -> ProcessSpec -> m (Maybe Handle, Maybe Handle, Maybe Handle, ProcessHandle)
createProcess_ name procSpec = do
p <- mkCreateProcess procSpec
putLog Debug $ "Creating process " <> T.pack name <> ": " <> reconstructProcSpec procSpec
liftIO $ Process.createProcess_ name p
mkCreateProcess :: MonadIO m => ProcessSpec -> m Process.CreateProcess
mkCreateProcess (ProcessSpec p override') = do
case override' of
Nothing -> pure p
Just override -> do
procEnv <- Map.fromList <$> maybe (liftIO getEnvironment) pure (Process.env p)
pure $ p { Process.env = Just $ Map.toAscList (override procEnv) }
callProcess
:: (MonadIO m, CliLog m)
=> String -> [String] -> m ()
callProcess exe args = do
putLog Debug $ "Calling process " <> T.pack exe <> " with args: " <> T.pack (show args)
liftIO $ Process.callProcess exe args
callCommand
:: (MonadIO m, CliLog m)
=> String -> m ()
callCommand cmd = do
putLog Debug $ "Calling command " <> T.pack cmd
liftIO $ Process.callCommand cmd
withProcess
:: (MonadIO m, CliLog m, CliThrow e m, AsProcessFailure e, MonadFail m)
=> ProcessSpec -> (Handle -> Handle -> m ()) -> m (Handle, Handle)
withProcess process f = do
(_, Just out, Just err, p) <- createProcess $ overCreateProcess
(\x -> x { std_out = CreatePipe , std_err = CreatePipe }) process
f out err
waitForProcess p >>= \case
ExitSuccess -> return (out, err)
ExitFailure code -> throwError $ review asProcessFailure $ ProcessFailure (Process.cmdspec $ _processSpec_createProcess process) code
streamHandle :: Severity -> Handle -> IO (InputStream (Severity, BSC.ByteString))
streamHandle sev = Streams.map (sev,) <=< handleToInputStream
streamToLog
:: (MonadIO m, CliLog m)
=> InputStream (Severity, BSC.ByteString) -> m ()
streamToLog stream = fix $ \loop -> do
liftIO (Streams.read stream) >>= \case
Nothing -> return ()
Just (sev, line) -> putLogRaw sev (T.decodeUtf8With lenientDecode line) >> loop
waitForProcess :: MonadIO m => ProcessHandle -> m ExitCode
waitForProcess = liftIO . Process.waitForProcess
reconstructCommand :: Process.CmdSpec -> Text
reconstructCommand p = case p of
Process.ShellCommand str -> T.pack str
Process.RawCommand c as -> processToShellString c as
where
processToShellString cmd args = T.unwords $ map quoteAndEscape (cmd : args)
quoteAndEscape x = "'" <> T.replace "'" "'\''" (T.pack x) <> "'"
reconstructProcSpec :: ProcessSpec -> Text
reconstructProcSpec = reconstructCommand . Process.cmdspec . _processSpec_createProcess