erebos-tester-0.3.6: src/Script/Shell.hs
module Script.Shell (
ShellScript(..),
ShellStatement(ShellStatement),
ShellPipeline(ShellPipeline),
ShellCommand(ShellCommand),
ShellArguments(..), ShellArgument(..),
withShellProcess,
) where
import Control.Concurrent
import Control.Concurrent.STM
import Control.Monad
import Control.Monad.Except
import Control.Monad.IO.Class
import Control.Monad.Reader
import Data.Maybe
import Data.Scientific
import Data.Text (Text)
import Data.Text qualified as T
import Data.Typeable
import Foreign.C.Types
import Foreign.Ptr
import Foreign.Marshal.Array
import Foreign.Storable
import System.Directory
import System.Exit
import System.FilePath
import System.IO
import System.Posix.IO qualified as P
import System.Posix.Process
import System.Posix.Types
import System.Process hiding (ShellCommand)
import Asset
import Network
import Network.Ip
import Output
import Process
import Run.Monad
import Script.Expr.Class
import Script.Var
newtype ShellScript = ShellScript [ ShellStatement ]
data ShellState = ShellState
{ shellWorkingDirectory :: FilePath
, shellOldWorkingDirectory :: FilePath
, shellExitOnError :: Bool
, shellLastExitCode :: ExitCode
}
data ShellStatement = ShellStatement
{ shellPipeline :: ShellPipeline
, shellSourceLine :: SourceLine
}
data ShellPipeline = ShellPipeline
{ pipeCommand :: ShellCommand
, pipeUpstream :: Maybe ShellPipeline
}
data ShellCommand = ShellCommand
{ cmdCommand :: Text
, cmdExtArguments :: ShellArguments
, cmdSourceLine :: SourceLine
}
newtype ShellArguments = ShellArguments { fromShellArguments :: [ ShellArgument ] }
deriving (Semigroup, Monoid)
data ShellArgument
= ShellArgument Text
| ShellRedirectStdin Text
| ShellRedirectStdout Bool Text
| ShellRedirectStderr Bool Text
cmdArguments :: ShellCommand -> [ Text ]
cmdArguments = catMaybes . map (\case ShellArgument x -> Just x; _ -> Nothing) . fromShellArguments . cmdExtArguments
instance ExprType ShellScript where
textExprType _ = T.pack "ShellScript"
textExprValue _ = "<shell-script>"
instance ExprType ShellStatement where
textExprType _ = T.pack "ShellStatement"
textExprValue _ = "<shell-statement>"
instance ExprType ShellPipeline where
textExprType _ = T.pack "ShellPipeline"
textExprValue _ = "<shell-pipeline>"
instance ExprType ShellCommand where
textExprType _ = T.pack "ShellCommand"
textExprValue _ = "<shell-command>"
instance ExprType ShellArguments where
textExprType _ = T.pack "ShellArguments"
textExprValue _ = "<shell-arguments>"
exprExpansionConvFrom = shellExpansionTemplate
(Just (ShellArguments . (: []) . ShellArgument))
(Just (ShellArguments . map ShellArgument))
instance ExprType ShellArgument where
textExprType _ = T.pack "ShellArgument"
textExprValue _ = "<shell-argument>"
exprExpansionConvFrom = shellExpansionTemplate (Just ShellArgument) Nothing
shellExpansionTemplate :: forall a b. (Typeable a, ExprType b) => Maybe (Text -> a) -> Maybe ([ Text ] -> a) -> Maybe (b -> a)
shellExpansionTemplate fromSingle fromList = listToMaybe $ catMaybes
[ single id
, single (T.pack . show @Integer)
, single (T.pack . show @Scientific)
, single textAssetPath
]
where
single :: forall c. (ExprType c) => (c -> Text) -> Maybe (b -> a)
single conv = listToMaybe $ catMaybes
[ fromSingle >>= \f -> cast (f . conv)
, fromList >>= \f -> cast (f . map conv)
]
data ShellExecInfo = ShellExecInfo
{ seiNode :: Node
, seiProcName :: ProcName
, seiStatusVar :: MVar ExitCode
}
data HandleHandling
= CloseHandle Handle
| KeepHandle Handle
closeIfRequested :: MonadIO m => HandleHandling -> m ()
closeIfRequested (CloseHandle h) = liftIO $ hClose h
closeIfRequested (KeepHandle _) = return ()
handledHandle :: HandleHandling -> Handle
handledHandle (CloseHandle h) = h
handledHandle (KeepHandle h) = h
executeCommand :: ShellExecInfo -> ShellState -> HandleHandling -> HandleHandling -> HandleHandling -> ShellCommand -> TestRun ShellState
executeCommand sei@ShellExecInfo {..} st pstdin pstdout pstderr scmd@ShellCommand {..} = do
let args = cmdArguments scmd
( pstdin', pstdout', pstderr' ) <- (\f -> foldM f ( pstdin, pstdout, pstderr ) (fromShellArguments cmdExtArguments)) $ \cur@( cin, cout, cerr ) -> \case
ShellRedirectStdin path -> do
closeIfRequested cin
h <- liftIO $ openBinaryFile (nodeDir seiNode </> T.unpack path) ReadMode
return ( CloseHandle h, cout, cerr )
ShellRedirectStdout append path -> do
closeIfRequested cout
h <- liftIO $ openBinaryFile (nodeDir seiNode </> T.unpack path) $ if append then AppendMode else WriteMode
return ( cin, CloseHandle h, cerr )
ShellRedirectStderr append path -> do
closeIfRequested cerr
h <- liftIO $ openBinaryFile (nodeDir seiNode </> T.unpack path) $ if append then AppendMode else WriteMode
return ( cin, cout, CloseHandle h )
_ -> do
return cur
( getExitStatus, st' ) <- executeCommandProcess sei st (handledHandle pstdin') (handledHandle pstdout') (handledHandle pstderr') args cmdCommand
let failedWithStatus status = do
when (shellExitOnError st) $ do
liftIO $ putMVar seiStatusVar status
throwError Failed
return st' { shellLastExitCode = status }
mapM_ closeIfRequested [ pstdin', pstdout', pstderr' ]
getExitStatus >>= \case
Exited ExitSuccess -> do
return st' { shellLastExitCode = ExitSuccess }
Exited status -> do
outLine OutputChildFail (Just $ textProcName seiProcName) $ "failed at: " <> textSourceLine cmdSourceLine
failedWithStatus status
Terminated sig _ -> do
outLine OutputChildFail (Just $ textProcName seiProcName) $ "killed with " <> T.pack (show sig) <> " at: " <> textSourceLine cmdSourceLine
failedWithStatus (ExitFailure (- fromIntegral sig))
Stopped sig -> do
outLine OutputChildFail (Just $ textProcName seiProcName) $ "stopped with " <> T.pack (show sig) <> " at: " <> textSourceLine cmdSourceLine
failedWithStatus (ExitFailure (- fromIntegral sig))
executeCommandProcess :: ShellExecInfo -> ShellState -> Handle -> Handle -> Handle -> [ Text ] -> Text -> TestRun ( TestRun ProcessStatus, ShellState )
executeCommandProcess sei@ShellExecInfo {..} st@ShellState {..} pstdin pstdout pstderr args = \case
"!"
| (cmd : args') <- args -> do
( exit, st' ) <- executeCommandProcess sei st pstdin pstdout pstderr args' cmd
let exit' = exit >>= \case Exited ExitSuccess -> return (Exited (ExitFailure (-1)))
_ -> return (Exited ExitSuccess)
return ( exit', st' )
| [] <- args -> do
return ( return (Exited (ExitFailure (-1))), st )
"cd"
| [] <- args -> liftIO $ do
hPutStrLn pstdout (nodeDir seiNode)
return ( return (Exited ExitSuccess), st
{ shellWorkingDirectory = nodeDir seiNode
, shellOldWorkingDirectory = shellWorkingDirectory
} )
| [ "-" ] <- args -> liftIO $ do
hPutStrLn pstdout shellOldWorkingDirectory
return ( return (Exited ExitSuccess), st
{ shellWorkingDirectory = shellOldWorkingDirectory
, shellOldWorkingDirectory = shellWorkingDirectory
} )
| [ dir ] <- args -> liftIO $ do
cd <- canonicalizePath $ shellWorkingDirectory </> T.unpack dir
doesDirectoryExist cd >>= \case
True -> return ( return (Exited ExitSuccess), st
{ shellWorkingDirectory = cd
, shellOldWorkingDirectory = shellWorkingDirectory
} )
False -> do
hPutStrLn pstderr $ "cd: no such directory: " <> T.unpack dir
return ( return (Exited (ExitFailure (-1))), st )
| otherwise -> do
liftIO $ hPutStrLn pstderr $ "cd: too many arguments"
return ( return (Exited (ExitFailure (-1))), st )
"pwd"
| [] <- args -> do
liftIO $ hPutStrLn pstdout shellWorkingDirectory
return ( return (Exited ExitSuccess), st )
| otherwise -> do
liftIO $ hPutStrLn pstderr $ "pwd: too many arguments"
return ( return (Exited (ExitFailure (-1))), st )
"set"
| [ "+e" ] <- args -> do
return ( return (Exited ExitSuccess), st { shellExitOnError = False } )
| [ "-e" ] <- args -> do
return ( return (Exited ExitSuccess), st { shellExitOnError = True } )
| otherwise -> do
liftIO $ hPutStrLn pstderr $ "set: " <> T.unpack (T.unwords args) <> ": not implemented"
return ( return (Exited (ExitFailure (-1))), st )
cmd -> liftIO $ do
(_, _, _, phandle) <- createProcess_ "shell"
(proc (T.unpack cmd) (map T.unpack args))
{ std_in = UseHandle pstdin
, std_out = UseHandle pstdout
, std_err = UseHandle pstderr
, cwd = Just shellWorkingDirectory
, env = Just []
}
Just pid <- getPid phandle
let getProcessStatus' =
liftIO (getProcessStatus True False pid) >>= \case
Just status -> return status
Nothing -> do
outLine OutputChildFail (Just $ textProcName seiProcName) $ "no exit status"
return (Exited (ExitFailure (-1)))
return ( getProcessStatus', st )
executePipeline :: ShellExecInfo -> ShellState -> HandleHandling -> HandleHandling -> HandleHandling -> ShellPipeline -> TestRun ShellState
executePipeline sei st pstdin pstdout pstderr ShellPipeline {..} = do
case pipeUpstream of
Nothing -> do
executeCommand sei st pstdin pstdout pstderr pipeCommand
Just upstream -> do
( pipeRead, pipeWrite ) <- createPipeCloexec
void $ forkTestUsing forkOS $ do
void $ executePipeline sei st pstdin (CloseHandle pipeWrite) (KeepHandle $ handledHandle pstderr) upstream
state' <- executeCommand sei st (CloseHandle pipeRead) pstdout (KeepHandle $ handledHandle pstderr) pipeCommand
closeIfRequested pstderr
return state'
executeScript :: ShellExecInfo -> Handle -> Handle -> Handle -> ShellScript -> TestRun ()
executeScript sei@ShellExecInfo {..} pstdin pstdout pstderr (ShellScript statements) = do
setNetworkNamespace $ getNetns seiNode
let initialState = ShellState
{ shellWorkingDirectory = nodeDir seiNode
, shellOldWorkingDirectory = nodeDir seiNode
, shellExitOnError = True
, shellLastExitCode = ExitSuccess
}
finalState <- (\f -> foldM f initialState statements) $ \st ShellStatement {..} -> do
executePipeline sei st (KeepHandle pstdin) (KeepHandle pstdout) (KeepHandle pstderr) shellPipeline
liftIO $ putMVar seiStatusVar (shellLastExitCode finalState)
spawnShell :: Node -> ProcName -> ShellScript -> TestRun Process
spawnShell procNode procName script = do
idVar <- asks $ teNextProcId . fst
procId <- liftIO $ modifyMVar idVar (\x -> return ( x + 1, ProcessId x ))
procOutput <- liftIO $ newTVarIO []
procIgnore <- liftIO $ newTVarIO ( 0, [] )
seiStatusVar <- liftIO $ newEmptyMVar
( pstdin, procStdin ) <- createPipeCloexec
( hout, pstdout ) <- createPipeCloexec
( herr, pstderr ) <- createPipeCloexec
procHandle <- fmap (Right . (, seiStatusVar)) $ forkTestUsing forkOS $ do
let seiNode = procNode
seiProcName = procName
executeScript ShellExecInfo {..} pstdin pstdout pstderr script
liftIO $ do
hClose pstdin
hClose pstdout
hClose pstderr
let procKillWith = Nothing
let procPid = Nothing
let process = Process {..}
startProcessIOLoops process hout herr
return process
withShellProcess :: Node -> ProcName -> ShellScript -> (Process -> TestRun a) -> TestRun a
withShellProcess node pname script inner = do
procVar <- asks $ teProcesses . fst
process <- spawnShell node pname script
liftIO $ modifyMVar_ procVar $ return . (process:)
inner process `finally` do
ps <- liftIO $ takeMVar procVar
closeTestProcess process `finally` do
liftIO $ putMVar procVar $ filter (/=process) ps
foreign import ccall "shell_pipe_cloexec" c_pipe_cloexec :: Ptr Fd -> IO CInt
createPipeCloexec :: (MonadIO m, MonadFail m) => m ( Handle, Handle )
createPipeCloexec = liftIO $ do
allocaArray 2 $ \ptr -> do
c_pipe_cloexec ptr >>= \case
0 -> do
rh <- P.fdToHandle =<< peekElemOff ptr 0
wh <- P.fdToHandle =<< peekElemOff ptr 1
return ( rh, wh )
_ -> do
fail $ "failed to create pipe"