hurl-workbench-core-0.1.0.0: src/HurlWorkbench/Hurl/Run.hs
-- | Secure execution of one rendered workflow through Hurl. This is the only
-- production module which constructs Hurl argv.
module HurlWorkbench.Hurl.Run
( HurlRunMode (..),
RunOutputPolicy (..),
HurlReportTarget (..),
HurlVerbosity (..),
AllowedHurlArgument,
HurlOptionError (..),
HurlOptions (..),
defaultHurlOptions,
mkAllowedHurlArgument,
RunRequest (..),
RunStartError (..),
CapturedRunOutput (..),
RunResult (..),
HurlRunner (..),
mkHurlRunner,
renderRunStartError,
)
where
import Control.Exception (IOException, SomeException, displayException, mask, onException, throwIO, try)
import Data.Bits ((.&.))
import Data.ByteString qualified as ByteString
import Data.Generics.Labels ()
import Data.List (nub)
import Data.Map.Strict qualified as Map
import Data.Text qualified as Text
import Data.Text.Encoding qualified as Text.Encoding
import Data.Time.Clock (NominalDiffTime, diffUTCTime, getCurrentTime)
import HurlWorkbench.Hurl.Capabilities
import HurlWorkbench.Parameter.Resolve (ResolvedBindings (..))
import HurlWorkbench.Parameter.Resolve.Internal (secretValueText)
import HurlWorkbench.Prelude
import HurlWorkbench.Process.Run
import HurlWorkbench.Run.FileRoot
import HurlWorkbench.Workflow.Render
import HurlWorkbench.Workspace.Types
import System.Directory (createDirectoryIfMissing, doesDirectoryExist, removeFile)
import System.Environment (getEnvironment)
import System.Exit (ExitCode)
import System.FilePath (takeDirectory)
import System.IO (IOMode (WriteMode), hClose, hFlush, withBinaryFile)
import System.IO.Temp (openBinaryTempFile, withSystemTempDirectory)
import System.Posix.Files
( accessModes,
fileMode,
getFileStatus,
ownerExecuteMode,
ownerReadMode,
ownerWriteMode,
setFileMode,
unionFileModes,
)
import System.Posix.IO (OpenFileFlags (..), OpenMode (WriteOnly), defaultFileFlags, fdToHandle, openFd)
import System.Posix.Types (FileMode)
import System.Process qualified as Process
data HurlRunMode = ClientMode | TestMode
deriving stock (Generic, Eq, Show)
data RunOutputPolicy
= InheritRunOutput
| CaptureRunOutput
| ResponseFile !FilePath
deriving stock (Generic, Eq, Show)
data HurlReportTarget
= JUnitReport !FilePath
| HtmlReport !FilePath
| JsonReport !FilePath
| TapReport !FilePath
deriving stock (Generic, Eq, Show)
data HurlVerbosity = Verbose | VeryVerbose
deriving stock (Generic, Eq, Show)
newtype AllowedHurlArgument = AllowedHurlArgument Text
deriving stock (Generic, Eq, Ord, Show)
data HurlOptionError
= NegativeOption !Text !Int
| UnsupportedHurlArgument !Text
| DuplicateReportFormat !Text
deriving stock (Generic, Eq, Show)
data HurlOptions = HurlOptions
{ connectTimeoutSeconds :: !(Maybe Int),
maxTimeSeconds :: !(Maybe Int),
retryCount :: !(Maybe Int),
retryIntervalMilliseconds :: !(Maybe Int),
insecureTls :: !Bool,
includeHeaders :: !Bool,
jsonOutput :: !Bool,
verbosity :: !(Maybe HurlVerbosity),
curlExportPath :: !(Maybe FilePath),
additionalArguments :: ![AllowedHurlArgument]
}
deriving stock (Generic, Eq, Show)
defaultHurlOptions :: HurlOptions
defaultHurlOptions =
HurlOptions
{ connectTimeoutSeconds = Nothing,
maxTimeSeconds = Nothing,
retryCount = Nothing,
retryIntervalMilliseconds = Nothing,
insecureTls = False,
includeHeaders = False,
jsonOutput = False,
verbosity = Nothing,
curlExportPath = Nothing,
additionalArguments = []
}
mkAllowedHurlArgument :: Text -> Either HurlOptionError AllowedHurlArgument
mkAllowedHurlArgument rawArgument
| rawArgument `elem` allowedArguments = Right (AllowedHurlArgument rawArgument)
| otherwise = Left (UnsupportedHurlArgument rawArgument)
where
allowedArguments = ["--compressed", "--location", "--location-trusted", "--no-color", "--path-as-is"]
data RunRequest = RunRequest
{ renderedWorkflow :: !RenderedWorkflow,
fileRoot :: !FileRoot,
mode :: !HurlRunMode,
bindings :: !ResolvedBindings,
options :: !HurlOptions,
outputPolicy :: !RunOutputPolicy,
reportTargets :: ![HurlReportTarget]
}
deriving stock (Generic, Eq)
instance Show RunRequest where
show request =
"RunRequest {workflow="
<> show (request ^. #renderedWorkflow . #workflowName)
<> ", mode="
<> show (request ^. #mode)
<> ", bindings=<redacted>}"
data RunStartError
= InvalidFileRootLayout !FileRootError
| InvalidRunOptions !(NonEmpty HurlOptionError)
| SecureRunFileError !FilePath !Text
| HurlSpawnFailed !FilePath !Text
deriving stock (Generic, Eq, Show)
data CapturedRunOutput = CapturedRunOutput
{ stdout :: !ByteString.ByteString,
stderr :: !ByteString.ByteString
}
deriving stock (Generic, Eq, Show)
data RunResult = RunResult
{ exitCode :: !ExitCode,
elapsed :: !NominalDiffTime,
capturedOutput :: !(Maybe CapturedRunOutput)
}
deriving stock (Generic, Eq, Show)
newtype HurlRunner = HurlRunner
{ runHurl :: RunRequest -> IO (Either RunStartError RunResult)
}
mkHurlRunner :: HurlCapabilities -> HurlRunner
mkHurlRunner capabilities = HurlRunner (runRequest capabilities)
runRequest :: HurlCapabilities -> RunRequest -> IO (Either RunStartError RunResult)
runRequest capabilities request = case validateRequest request of
Just issues -> pure (Left (InvalidRunOptions issues))
Nothing ->
checkFileRootOutputs (request ^. #fileRoot) outputPaths >>= \case
Left err -> pure (Left (InvalidFileRootLayout err))
Right () -> do
attempted <- try @IOException (withSystemTempDirectory "hurl-workbench-run" (prepareAndRun capabilities request))
pure $ case attempted of
Left err -> Left (SecureRunFileError "temporary execution directory" (Text.pack (displayException err)))
Right result -> result
where
outputPaths = maybe [] pure (responseTarget (request ^. #outputPolicy)) <> maybe [] pure (request ^. #options . #curlExportPath) <> map (\case JUnitReport p -> p; HtmlReport p -> p; JsonReport p -> p; TapReport p -> p) (request ^. #reportTargets)
prepareAndRun :: HurlCapabilities -> RunRequest -> FilePath -> IO (Either RunStartError RunResult)
prepareAndRun capabilities request directory = do
renderedPath <- writeSecureTemporary directory "workflow.hurl" (Text.Encoding.encodeUtf8 (request ^. #renderedWorkflow . #contents))
variablesPath <- writeBindings directory "variables.env" renderVariable (request ^. #bindings . #variables)
secretsPath <- writeBindings directory "secrets.env" renderSecret (request ^. #bindings . #secrets)
traverse_ prepareReportTarget (request ^. #reportTargets)
traverse_ prepareResponseTarget (responseTarget (request ^. #outputPolicy))
traverse_ prepareSecureFile (request ^. #options . #curlExportPath)
environment <- filter (not . Text.isPrefixOf "HURL_" . Text.pack . fst) <$> getEnvironment
let arguments = buildArguments request renderedPath variablesPath secretsPath
spawn capabilities request environment arguments directory
where
renderVariable name value = unParameterName name <> "=" <> hurlValueLiteralText value
renderSecret name value = unParameterName name <> "=" <> secretValueText value
writeBindings :: FilePath -> FilePath -> (ParameterName -> value -> Text) -> Map ParameterName value -> IO (Maybe FilePath)
writeBindings _directory _template _render values | Map.null values = pure Nothing
writeBindings directory template render values =
Just <$> writeSecureTemporary directory template (Text.Encoding.encodeUtf8 (Text.unlines [render name value | (name, value) <- Map.toAscList values]))
writeSecureTemporary :: FilePath -> FilePath -> ByteString.ByteString -> IO FilePath
writeSecureTemporary directory template bytes = do
(path, handle) <- openBinaryTempFile directory template
let cleanup = do
_ <- try @IOException (hClose handle)
_ <- try @IOException (removeFile path)
pure ()
( do
verifyMode secureFileMode path
ByteString.hPut handle bytes
hFlush handle
hClose handle
pure path
)
`onException` cleanup
prepareResponseTarget :: FilePath -> IO ()
prepareResponseTarget = prepareSecureFile
prepareReportTarget :: HurlReportTarget -> IO ()
prepareReportTarget = \case
JUnitReport path -> prepareSecureFileWith path "<?xml version=\"1.0\"?>\n<testsuites/>\n"
TapReport path -> prepareSecureFileWith path "TAP version 13\n1..0\n"
HtmlReport path -> prepareSecureDirectory path
JsonReport path -> prepareSecureDirectory path
prepareSecureFile :: FilePath -> IO ()
prepareSecureFile path = prepareSecureFileWith path ""
-- Hurl 8 appends to JUnit and TAP files by parsing any existing target. Seed
-- those owner-only files with valid empty documents rather than zero bytes;
-- File.create then preserves the secure mode when Hurl replaces the content.
prepareSecureFileWith :: FilePath -> ByteString.ByteString -> IO ()
prepareSecureFileWith path initialContent = do
createDirectoryIfMissing True (takeDirectory path)
handle <- fdToHandle =<< openFd path WriteOnly defaultFileFlags {creat = Just secureFileMode, trunc = True, nofollow = True}
ByteString.hPut handle initialContent
hClose handle
setFileMode path secureFileMode
verifyMode secureFileMode path
prepareSecureDirectory :: FilePath -> IO ()
prepareSecureDirectory path = do
exists <- doesDirectoryExist path
unless exists (createDirectoryIfMissing True path)
setFileMode path secureDirectoryMode
verifyMode secureDirectoryMode path
verifyMode :: FileMode -> FilePath -> IO ()
verifyMode expected path = do
actual <- (.&. accessModes) . fileMode <$> getFileStatus path
unless (actual == expected) (ioError (userError ("insecure permissions on " <> path)))
secureFileMode :: FileMode
secureFileMode = ownerReadMode `unionFileModes` ownerWriteMode
secureDirectoryMode :: FileMode
secureDirectoryMode = secureFileMode `unionFileModes` ownerExecuteMode
-- Private files avoid closing a live output pipe before signalling its writer.
-- That pipe-close ordering can block cancellation inside typed-process cleanup.
spawn :: HurlCapabilities -> RunRequest -> [(String, String)] -> [String] -> FilePath -> IO (Either RunStartError RunResult)
spawn capabilities request environment arguments directory = case request ^. #outputPolicy of
InheritRunOutput -> runOwned config
CaptureRunOutput -> capture
ResponseFile _ -> capture
where
config = (Process.proc (capabilities ^. #hurlExecutable) arguments) {Process.env = Just environment, Process.create_group = True}
runOwned processConfig = mask $ \restore -> do
began <- getCurrentTime
spawnGroup (ServiceName "hurl") processConfig >>= \case
Left _ -> pure (Left (HurlSpawnFailed (capabilities ^. #hurlExecutable) "process launch failed"))
Right owned -> do
execution <- try @SomeException (restore (waitExitCode owned))
cleanup <- terminateGroup (ServiceName "hurl") 2 owned
ended <- getCurrentTime
case execution of
Left err -> throwIO err
Right exit -> pure $ case cleanup of
Left _ -> Left (SecureRunFileError "Hurl process group" "cleanup failed")
Right () -> Right (RunResult exit (diffUTCTime ended began) Nothing)
capture = do
stdoutPath <- writeSecureTemporary directory "stdout" ""
stderrPath <- writeSecureTemporary directory "stderr" ""
result <- withBinaryFile stdoutPath WriteMode $ \stdout ->
withBinaryFile stderrPath WriteMode $ \stderr ->
runOwned config {Process.std_in = Process.NoStream, Process.std_out = Process.UseHandle stdout, Process.std_err = Process.UseHandle stderr}
case result of
Left err -> pure (Left err)
Right finished -> do
stdout <- ByteString.readFile stdoutPath
stderr <- ByteString.readFile stderrPath
pure (Right (finished & #capturedOutput .~ Just (CapturedRunOutput stdout stderr)))
buildArguments :: RunRequest -> FilePath -> Maybe FilePath -> Maybe FilePath -> [String]
buildArguments request renderedPath variablesPath secretsPath =
["--file-root", root]
<> maybe [] (\path -> ["--variables-file", path]) variablesPath
<> maybe [] (\path -> ["--secrets-file", path]) secretsPath
<> ["--test" | request ^. #mode == TestMode]
<> renderOptions (request ^. #options)
<> concatMap renderReportTarget (request ^. #reportTargets)
<> maybe [] (\path -> ["--output", path]) (responseTarget (request ^. #outputPolicy))
<> [renderedPath]
where
root = fileRootPath (request ^. #fileRoot)
renderOptions :: HurlOptions -> [String]
renderOptions options =
valued "--connect-timeout" (options ^. #connectTimeoutSeconds)
<> valued "--max-time" (options ^. #maxTimeSeconds)
<> valued "--retry" (options ^. #retryCount)
<> valued "--retry-interval" (options ^. #retryIntervalMilliseconds)
<> ["--insecure" | options ^. #insecureTls]
<> ["--include" | options ^. #includeHeaders]
<> ["--json" | options ^. #jsonOutput]
<> case options ^. #verbosity of
Just Verbose -> ["--verbose"]
Just VeryVerbose -> ["--very-verbose"]
Nothing -> []
<> maybe [] (\path -> ["--curl", path]) (options ^. #curlExportPath)
<> map (Text.unpack . allowedArgumentText) (options ^. #additionalArguments)
where
valued flag = maybe [] (\value -> [flag, show value])
allowedArgumentText :: AllowedHurlArgument -> Text
allowedArgumentText (AllowedHurlArgument value) = value
renderReportTarget :: HurlReportTarget -> [String]
renderReportTarget = \case
JUnitReport path -> ["--report-junit", path]
HtmlReport path -> ["--report-html", path]
JsonReport path -> ["--report-json", path]
TapReport path -> ["--report-tap", path]
responseTarget :: RunOutputPolicy -> Maybe FilePath
responseTarget = \case
ResponseFile path -> Just path
_ -> Nothing
validateRequest :: RunRequest -> Maybe (NonEmpty HurlOptionError)
validateRequest request = nonEmpty (numericIssues <> reportIssues)
where
options = request ^. #options
numericIssues =
concat
[ nonNegative "connect timeout" (options ^. #connectTimeoutSeconds),
nonNegative "max time" (options ^. #maxTimeSeconds),
nonNegative "retry count" (options ^. #retryCount),
nonNegative "retry interval" (options ^. #retryIntervalMilliseconds)
]
formats = map reportFormat (request ^. #reportTargets)
reportIssues = [DuplicateReportFormat format | format <- nub formats, length (filter (== format) formats) > 1]
nonNegative name = maybe [] (\value -> [NegativeOption name value | value < 0])
reportFormat :: HurlReportTarget -> Text
reportFormat = \case
JUnitReport _ -> "JUnit"
HtmlReport _ -> "HTML"
JsonReport _ -> "JSON"
TapReport _ -> "TAP"
renderRunStartError :: RunStartError -> Text
renderRunStartError = \case
InvalidFileRootLayout err -> renderFileRootError err
InvalidRunOptions issues -> Text.intercalate "\n" (map renderOptionError (toList issues))
SecureRunFileError path message -> "could not prepare secure run file " <> Text.pack path <> ": " <> message
HurlSpawnFailed path message -> "Hurl could not be started at " <> Text.pack path <> ": " <> message
where
renderOptionError = \case
NegativeOption name value -> name <> " must not be negative, got " <> Text.pack (show value)
UnsupportedHurlArgument rawArgument -> "unsupported --hurl-arg " <> rawArgument
DuplicateReportFormat format -> "at most one " <> format <> " report target is allowed"