packages feed

hurl-workbench-core-0.1.0.0: src/HurlWorkbench/Suite/Run.hs

-- | Execute a fully preflighted suite, optionally inside one managed-service
--   lifecycle, and emit a redacted atomic summary.
module HurlWorkbench.Suite.Run
  ( ServiceOutcome (..),
    SuiteResult (..),
    StepOutcome (..),
    PhaseOutcome (..),
    CommandOutcome (..),
    LifecycleOutcome (..),
    runSuite,
    runSuiteWith,
    finalizeSuitePreflight,
  )
where

import Control.Applicative ((<|>))
import Control.Concurrent.STM (atomically, modifyTVar', newTVarIO, readTVarIO)
import Control.Exception (IOException, SomeAsyncException, SomeException, fromException, mask, onException, throwIO, try)
import Data.Aeson qualified as Aeson
import Data.Bits ((.&.))
import Data.ByteString.Lazy qualified as LazyByteString
import Data.Generics.Labels ()
import Data.Map.Strict qualified as Map
import Data.Text qualified as Text
import Data.Time.Clock (NominalDiffTime, addUTCTime, diffUTCTime, getCurrentTime)
import HurlWorkbench.Check.Run
import HurlWorkbench.Hurl.Format (HurlfmtCapabilities, HurlfmtError, validateRenderedWorkflow)
import HurlWorkbench.Hurl.Run
import HurlWorkbench.Prelude
import HurlWorkbench.Process.Run
import HurlWorkbench.Run.Batch
import HurlWorkbench.Service.Group
import HurlWorkbench.Service.Resolve
import HurlWorkbench.Suite.Resolve
import HurlWorkbench.Workflow.Render (RenderedWorkflow)
import HurlWorkbench.Workspace.Context (ValidatedWorkspace)
import HurlWorkbench.Workspace.Types (CheckName (..), IsolationPolicy (..), ServiceName (..), ServiceStep (..), SuiteName (..))
import System.Directory (createDirectoryIfMissing, removeFile, renameFile)
import System.Exit (ExitCode (..))
import System.FilePath (takeDirectory, (</>))
import System.IO (Handle, IOMode (WriteMode), hClose, withBinaryFile)
import System.IO.Temp (openBinaryTempFile, withSystemTempDirectory)
import System.Posix.Files
  ( accessModes,
    fileMode,
    getFileStatus,
    ownerExecuteMode,
    ownerReadMode,
    ownerWriteMode,
    setFileMode,
    unionFileModes,
  )
import System.Posix.Types (FileMode)
import System.Timeout qualified as Timeout

data ServiceOutcome
  = ServiceStopped
  | ServiceFailed !ServiceError
  deriving stock (Generic, Eq, Show)

data StepOutcome = HurlStep !CaseResult | LifecycleStep !LifecycleOutcome | CheckStep !CheckResult
  deriving stock (Generic, Eq, Show)

data PhaseOutcome = PhaseOutcome
  { phase :: !Text,
    ordinal :: !Int,
    status :: !CommandOutcome,
    elapsed :: !(Maybe NominalDiffTime),
    artifacts :: ![FilePath],
    cleanupFailure :: !(Maybe ServiceError)
  }
  deriving stock (Generic, Eq, Show)

data SuiteResult = SuiteResult
  { cases :: ![CaseResult],
    serviceOutcome :: !(Maybe ServiceOutcome),
    summaryPath :: !(Maybe FilePath),
    summaryError :: !(Maybe Text),
    selectedExitCode :: !ExitCode,
    steps :: ![StepOutcome],
    services :: ![LifecycleOutcome],
    phases :: ![PhaseOutcome],
    primaryFailure :: !(Maybe Text),
    cleanupDiagnostics :: ![ServiceError]
  }
  deriving stock (Generic, Eq, Show)

runSuite :: HurlRunner -> HurlfmtCapabilities -> ValidatedWorkspace -> SuiteRequest -> IO (Either SuitePreflightError SuiteResult)
runSuite = runSuiteWith validateRenderedWorkflow

runSuiteWith :: (HurlfmtCapabilities -> RenderedWorkflow -> IO (Either HurlfmtError ())) -> HurlRunner -> HurlfmtCapabilities -> ValidatedWorkspace -> SuiteRequest -> IO (Either SuitePreflightError SuiteResult)
runSuiteWith validateRendered runner capabilities validated request =
  preflightFileRoot validated request >>= \case
    Left err -> pure (Left err)
    Right root -> do
      let selected = request & #fileRoot .~ Just root
      prepareSuiteWith validateRendered capabilities validated selected >>= \case
        Left err -> Left <$> finalizeSuitePreflight validated selected err
        Right prepared -> Right <$> executePreparedSuite runner prepared

-- | One ownership boundary covers setup, every acquisition and barrier, active
-- batches, reverse service cleanup, and finally teardown. Observations survive
-- cancellation; only safe logical outcomes reach the summary.
executePreparedSuite :: HurlRunner -> PreparedSuite -> IO SuiteResult
executePreparedSuite runner prepared = withPhaseRoot $ \phaseRoot -> mask $ \restore -> do
  observed <- newTVarIO Map.empty
  lifecycle <- newTVarIO Map.empty
  checks <- newTVarIO Map.empty
  startups <- newTVarIO []
  phases <- newTVarIO Map.empty
  primary <- newTVarIO Nothing
  group <- newServiceGroup
  let remember reason = atomically (modifyTVar' primary (\current -> current <|> Just reason))
      phaseCommand phase ordinal budget command = do
        let relative = "phases" </> Text.unpack phase </> show ordinal
            directory = phaseRoot </> relative
            artifacts = if isJust (prepared ^. #metadata . #summaryPath) then [relative </> "stdout", relative </> "stderr"] else []
            pending = PhaseOutcome phase ordinal CommandCancelled Nothing artifacts Nothing
        atomically (modifyTVar' phases (Map.insert (phase, ordinal) pending))
        began <- getCurrentTime
        attempted <- try @IOException $ do
          createDirectoryIfMissing True directory
          traverse_ (`setFileMode` secureDirectoryMode) [takeDirectory (takeDirectory directory), takeDirectory directory, directory]
          withCapture (directory </> "stdout") $ \stdout ->
            withCapture (directory </> "stderr") $ \stderr ->
              runCommand (ServiceName phase) budget command stdout stderr
        let (outcome, cleanup) = either (const (CommandStartFailed, Nothing)) id attempted
        ended <- getCurrentTime
        atomically (modifyTVar' phases (Map.insert (phase, ordinal) (pending & #status .~ outcome & #elapsed .~ Just (diffUTCTime ended began) & #cleanupFailure .~ cleanup)))
        pure (outcome == CommandPassed && isNothing cleanup)
      setup = runSetup 0 (prepared ^. #setupCommands)
      runSetup _ [] = pure (Right ())
      runSetup ordinal (command : rest) = do
        passed <- phaseCommand "setup" ordinal (prepared ^. #commandTimeout) command
        if passed
          then runSetup (ordinal + 1) rest
          else do
            remember "setup-failed"
            pure (Left (ServicePhaseFailed "setup"))
      recordService event = atomically (modifyTVar' startups (<> [event]))
      startAll [] = pure (Right ())
      startAll (service : rest) = do
        result <- startNamedObserved group service recordService
        case result of
          Left err -> pure (Left err)
          Right () -> startAll rest
      executeSteps _ [] = pure (Right ())
      executeSteps ordinal (step : rest) = case step of
        HurlBatch offset cases -> do
          batch <-
            watchServices group $
              Right
                <$> runBatchObserved
                  runner
                  (prepared ^. #batchOptions)
                  cases
                  ( \caseIndex result -> do
                      atomically (modifyTVar' observed (Map.insert (offset + caseIndex) result))
                      when (result ^. #outcome /= CasePassed) (remember "hurl-failed")
                  )
          case batch of
            Left err -> pure (Left err)
            Right result
              | prepared ^. #batchOptions . #failFast, result ^. #selectedExitCode /= ExitSuccess -> pure (Right ())
              | otherwise -> executeSteps (ordinal + 1) rest
        CheckBarrier check -> do
          result <-
            watchServices group $
              Right
                <$> runCheckObserved
                  phaseRoot
                  check
                  ( \outcome -> do
                      atomically (modifyTVar' checks (Map.insert ordinal outcome))
                      unless (checkPassed outcome) (remember "check-failed")
                  )
          case result of
            Left err -> pure (Left err)
            Right outcome
              | not (null (outcome ^. #cleanupDiagnostics)) -> pure (Right ())
              | not (checkPassed outcome), prepared ^. #batchOptions . #failFast -> pure (Right ())
              | otherwise -> executeSteps (ordinal + 1) rest
        ServiceBarrier operation -> do
          let (name, action) = stepLabel operation
              pending = LifecycleOutcome name action "cancelled" 0 Nothing
          atomically (modifyTVar' lifecycle (Map.insert ordinal pending))
          began <- getCurrentTime
          result <- applyServiceStepObserved group (prepared ^. #managedServices) operation recordService
          ended <- getCurrentTime
          atomically (modifyTVar' lifecycle (Map.insert ordinal (pending & #status .~ either (const "failed") (const "passed") result & #elapsed .~ diffUTCTime ended began & #failure .~ either Just (const Nothing) result)))
          case result of
            Left err -> pure (Left err)
            Right () -> executeSteps (ordinal + 1) rest
      execute =
        setup >>= \case
          Left err -> pure (Left err)
          Right () ->
            startAll (prepared ^. #managedServices) >>= \case
              Left err -> pure (Left err)
              Right () -> executeSteps 0 (prepared ^. #steps)
      teardown = do
        began <- getCurrentTime
        let deadline = addUTCTime (prepared ^. #cleanupTimeout) began
            go _ [] = pure ()
            go ordinal (command : rest) = do
              now <- getCurrentTime
              if now >= deadline
                then pure ()
                else do
                  _passed <- phaseCommand "teardown" ordinal (min (prepared ^. #commandTimeout) (diffUTCTime deadline now)) command
                  outcomes <- readTVarIO phases
                  let timedOut = maybe False ((== CommandTimedOut) . view #status) (Map.lookup ("teardown", ordinal) outcomes)
                  unless (timedOut && diffUTCTime deadline now <= prepared ^. #commandTimeout) (go (ordinal + 1) rest)
        go 0 (prepared ^. #teardownCommands)
  execution <- try @SomeException (restore execute)
  case execution of
    Right (Left _) -> remember "service-failed"
    _ -> pure ()
  cleanup <- cleanupServices group
  teardown
  completed <- readTVarIO observed
  barrierResults <- readTVarIO lifecycle
  checkResults <- readTVarIO checks
  serviceStarts <- readTVarIO startups
  phaseResults <- readTVarIO phases
  primaryFailure <- readTVarIO primary
  let cancelled = case execution of Left err -> isJust (fromException err :: Maybe SomeAsyncException); _ -> False
      serviceError = case execution of Right (Left err) -> Just err; _ -> Nothing
      missingReason
        | cancelled = BatchCancelled
        | isJust serviceError || any (not . null . view #cleanupDiagnostics) checkResults = BatchPrerequisiteFailed
        | otherwise = FailFastTriggered
      caseResults = [Map.findWithDefault (prerequisiteFailed batchCase & #outcome .~ CaseSkipped missingReason) caseIndex completed | (caseIndex, batchCase) <- zip [0 ..] (toList (prepared ^. #cases))]
      phaseOutcomes = [Map.findWithDefault (PhaseOutcome phase ordinal CommandSkipped Nothing [] Nothing) (phase, ordinal) phaseResults | (phase, commands) <- [("setup", prepared ^. #setupCommands), ("teardown", prepared ^. #teardownCommands)], ordinal <- [0 .. length commands - 1]]
      cleanupErrors =
        mapMaybe (view #failure) cleanup
          <> mapMaybe (view #cleanupFailure) phaseOutcomes
          <> [ServicePhaseFailed "teardown" | phase <- phaseOutcomes, phase ^. #phase == "teardown", phase ^. #status /= CommandPassed]
      allServiceErrors = maybe [] pure serviceError <> cleanupErrors
      serviceOutcome
        | err : _ <- allServiceErrors = Just (ServiceFailed err)
        | null (prepared ^. #managedServices) = Nothing
        | otherwise = Just ServiceStopped
      exitCode
        | cancelled = ExitFailure 130
        | not (null allServiceErrors) = ExitFailure 4
        | any startFailed stepResults = ExitFailure 3
        | otherwise = selectStepExit stepResults
      stepResults =
        concat
          [ case step of
              HurlBatch offset cases -> map HurlStep (take (length cases) (drop offset (toList caseResults)))
              CheckBarrier check -> [CheckStep (Map.findWithDefault (skippedCheck (skipStatus missingReason) check) ordinal checkResults)]
              ServiceBarrier operation -> let (name, action) = stepLabel operation in [LifecycleStep (Map.findWithDefault (LifecycleOutcome name action "skipped" 0 Nothing) ordinal barrierResults)]
          | (ordinal, step) <- zip [0 :: Int ..] (prepared ^. #steps)
          ]
      provisional =
        SuiteResult
          caseResults
          serviceOutcome
          Nothing
          Nothing
          exitCode
          stepResults
          (serviceStarts <> [event | LifecycleStep event <- stepResults] <> cleanup)
          phaseOutcomes
          (primaryFailure <|> if null cleanupErrors then Nothing else Just "cleanup-failed")
          cleanupErrors
  summary <- case prepared ^. #metadata . #summaryPath of
    Nothing -> pure (Right Nothing)
    Just path -> do
      let write = writeSummary path (summaryValueWithStatus (if cancelled then "cancelled" else exitStatus exitCode) (prepared ^. #metadata) provisional)
      written <-
        if cancelled
          then fromMaybe (Left "suite summary write timed out") <$> Timeout.timeout 1000000 write
          else write
      pure (Just path <$ written)
  let finished = case summary of
        Right path -> provisional & #summaryPath .~ path
        Left message -> provisional & #summaryError .~ Just message & #selectedExitCode .~ (if null allServiceErrors then ExitFailure 3 else ExitFailure 4)
  case execution of
    Left err -> throwIO err
    Right _ -> pure finished
  where
    withPhaseRoot action = case prepared ^. #metadata . #summaryPath of
      Just path -> action (takeDirectory path)
      Nothing -> withSystemTempDirectory "hurl-workbench-phases" action

stepLabel :: ServiceStep -> (ServiceName, Text)
stepLabel = \case Stop name -> (name, "stop"); Start name -> (name, "start"); Restart name -> (name, "restart")

withCapture :: FilePath -> (Handle -> IO a) -> IO a
withCapture path action = withBinaryFile path WriteMode $ \handle -> do
  setFileMode path secureFileMode
  action handle

secureDirectoryMode :: FileMode
secureDirectoryMode = secureFileMode `unionFileModes` ownerExecuteMode

prerequisiteFailed :: BatchCase -> CaseResult
prerequisiteFailed batchCase =
  CaseResult
    { name = batchCase ^. #name,
      outcome = CaseSkipped BatchPrerequisiteFailed,
      elapsed = Nothing,
      outputPath = Nothing,
      capturedOutput = Nothing
    }

startFailed :: StepOutcome -> Bool
startFailed (HurlStep result) = case result ^. #outcome of CaseStartFailed _ -> True; _ -> False
startFailed (CheckStep result) = result ^. #outcome == CheckStartFailed
startFailed (LifecycleStep _) = False

selectStepExit :: [StepOutcome] -> ExitCode
selectStepExit results = fromMaybe fallback (firstFailure results)
  where
    fallback
      | all passed results = ExitSuccess
      | otherwise = ExitFailure 3
    passed (HurlStep result) = result ^. #outcome == CasePassed
    passed (CheckStep result) = checkPassed result
    passed (LifecycleStep result) = result ^. #status == "passed"
    firstFailure = \case
      [] -> Nothing
      HurlStep result : remaining -> case result ^. #outcome of
        CaseFailed code -> Just code
        CaseStartFailed _ -> Just (ExitFailure 3)
        _ -> firstFailure remaining
      CheckStep result : remaining | checkExitCode result /= ExitSuccess -> Just (checkExitCode result)
      _ : remaining -> firstFailure remaining

skipStatus :: SkipReason -> Text
skipStatus = \case
  FailFastTriggered -> "skipped-fail-fast"
  BatchPrerequisiteFailed -> "skipped-prerequisite-failed"
  BatchCancelled -> "cancelled"

writeSummary :: FilePath -> Aeson.Value -> IO (Either Text ())
writeSummary path value = do
  written <- try @IOException $ do
    let directory = takeDirectory path
    (temporaryPath, handle) <- openBinaryTempFile directory ".summary.json.tmp"
    let cleanup = do
          _ <- try @IOException (hClose handle)
          _ <- try @IOException (removeFile temporaryPath)
          pure ()
    ( do
        setFileMode temporaryPath secureFileMode
        verifyMode temporaryPath
        LazyByteString.hPut handle (Aeson.encode value)
        hClose handle
        renameFile temporaryPath path
        setFileMode path secureFileMode
        verifyMode path
      )
      `onException` cleanup
  pure $ case written of
    Left _ -> Left "could not write suite summary"
    Right () -> Right ()

summaryValueWithStatus :: Text -> SuiteMetadata -> SuiteResult -> Aeson.Value
summaryValueWithStatus status metadata result =
  Aeson.object
    [ "suite" Aeson..= unSuiteName (metadata ^. #name),
      "isolation" Aeson..= isolationLabel (metadata ^. #isolation),
      "status" Aeson..= status,
      "service" Aeson..= serviceStatus (result ^. #serviceOutcome),
      "primaryFailure" Aeson..= (result ^. #primaryFailure),
      "cleanupDiagnostics" Aeson..= (map (const ("cleanup-failed" :: Text)) (result ^. #cleanupDiagnostics) <> concat [check ^. #cleanupDiagnostics | CheckStep check <- result ^. #steps]),
      "services" Aeson..= map lifecycleValue (result ^. #services),
      "phases" Aeson..= map phaseValue (result ^. #phases),
      "steps" Aeson..= map stepValue (result ^. #steps),
      "cases" Aeson..= zipWith caseValue (toList (metadata ^. #scenarios)) (toList (result ^. #cases))
    ]
  where
    caseValue logical caseResult =
      Aeson.object
        [ "name" Aeson..= (logical ^. #name),
          "scenario" Aeson..= (logical ^. #scenario),
          "status" Aeson..= caseStatus (caseResult ^. #outcome),
          "elapsedSeconds" Aeson..= fmap (realToFrac @NominalDiffTime @Double) (caseResult ^. #elapsed),
          "reports" Aeson..= case (caseResult ^. #outcome) of
            CaseSkipped _ -> []
            CaseStartFailed _ -> []
            _ -> logical ^. #reports
        ]

lifecycleValue :: LifecycleOutcome -> Aeson.Value
lifecycleValue event =
  Aeson.object
    [ "kind" Aeson..= ("service" :: Text),
      "name" Aeson..= unServiceName (event ^. #name),
      "action" Aeson..= (event ^. #action),
      "status" Aeson..= (event ^. #status),
      "artifacts" Aeson..= ([] :: [FilePath]),
      "elapsedSeconds" Aeson..= realToFrac @NominalDiffTime @Double (event ^. #elapsed)
    ]

phaseValue :: PhaseOutcome -> Aeson.Value
phaseValue event =
  Aeson.object
    [ "phase" Aeson..= (event ^. #phase),
      "index" Aeson..= (event ^. #ordinal),
      "status" Aeson..= commandStatus (event ^. #status),
      "elapsedSeconds" Aeson..= fmap (realToFrac @NominalDiffTime @Double) (event ^. #elapsed),
      "artifacts" Aeson..= (event ^. #artifacts)
    ]

commandStatus :: CommandOutcome -> Text
commandStatus = \case
  CommandPassed -> "passed"
  CommandFailed -> "failed"
  CommandTimedOut -> "timed-out"
  CommandStartFailed -> "start-failed"
  CommandSkipped -> "skipped"
  CommandCancelled -> "cancelled"

stepValue :: StepOutcome -> Aeson.Value
stepValue (LifecycleStep event) = lifecycleValue event
stepValue (CheckStep result) =
  Aeson.object
    [ "kind" Aeson..= ("check" :: Text),
      "name" Aeson..= unCheckName (result ^. #name),
      "status" Aeson..= checkStatus result,
      "expectedStatus" Aeson..= (result ^. #expectedStatus),
      "actualStatus" Aeson..= (result ^. #actualStatus),
      "elapsedSeconds" Aeson..= fmap (realToFrac @NominalDiffTime @Double) (result ^. #elapsed),
      "stdout" Aeson..= (result ^. #stdoutPath),
      "stderr" Aeson..= (result ^. #stderrPath),
      "cleanupDiagnostics" Aeson..= (result ^. #cleanupDiagnostics)
    ]
stepValue (HurlStep result) =
  Aeson.object
    ["kind" Aeson..= ("hurl" :: Text), "name" Aeson..= (result ^. #name), "status" Aeson..= caseStatus (result ^. #outcome)]

isolationLabel :: IsolationPolicy -> Text
isolationLabel SharedState = "shared-state"
isolationLabel IndependentState = "independent-state"

-- | Report a refusal only when validated logical names and the ordinary safe
-- report reservation rules make a destination available. No runner is involved.
finalizeSuitePreflight :: ValidatedWorkspace -> SuiteRequest -> SuitePreflightError -> IO SuitePreflightError
finalizeSuitePreflight workspace request original
  | any isReportFailure (original ^. #issues) = pure original
  | otherwise = case suiteMetadata workspace request of
      Left _ -> pure original
      Right metadata -> case metadata ^. #summaryPath of
        Nothing -> pure original
        Just path ->
          reserveSuiteSummary workspace request metadata >>= \case
            Left issue -> pure (append issue)
            Right () ->
              writeSummary path (preflightSummary metadata) <&> \case
                Left _ -> append (SuiteReportIoError path "summary-write-failed")
                Right () -> original
  where
    append issue = original & #issues %~ (<> (issue :| []))
    isReportFailure = \case
      SuiteFileRootFailed _ -> True
      SuiteReportPathExists _ -> True
      SuiteReportPathIsSymbolicLink _ -> True
      SuiteReportPathIsNotDirectory _ -> True
      SuiteInvalidReportDirectory _ -> True
      SuiteReportIoError _ _ -> True
      _ -> False
    preflightSummary metadata =
      Aeson.object
        [ "suite" Aeson..= unSuiteName (metadata ^. #name),
          "isolation" Aeson..= isolationLabel (metadata ^. #isolation),
          "status" Aeson..= (if any suitePreflightSetupFailure (original ^. #issues) then "setup-failed" else "refused" :: Text),
          "reasonCodes" Aeson..= map suitePreflightReasonCode (toList (original ^. #issues)),
          "cases"
            Aeson..= map
              ( \logical ->
                  Aeson.object
                    [ "name" Aeson..= (logical ^. #name),
                      "scenario" Aeson..= (logical ^. #scenario),
                      "status" Aeson..= ("not-started" :: Text),
                      "reports" Aeson..= ([] :: [FilePath])
                    ]
              )
              (toList (metadata ^. #scenarios))
        ]

serviceStatus :: Maybe ServiceOutcome -> Text
serviceStatus = \case
  Nothing -> "external-or-not-configured"
  Just ServiceStopped -> "stopped"
  Just (ServiceFailed _) -> "failed"

caseStatus :: CaseOutcome -> Text
caseStatus = \case
  CasePassed -> "passed"
  CaseFailed _ -> "failed"
  CaseStartFailed _ -> "start-failed"
  CaseSkipped FailFastTriggered -> "skipped-fail-fast"
  CaseSkipped BatchPrerequisiteFailed -> "skipped-prerequisite-failed"
  CaseSkipped BatchCancelled -> "cancelled"

exitStatus :: ExitCode -> Text
exitStatus = \case
  ExitSuccess -> "passed"
  ExitFailure _ -> "failed"

verifyMode :: FilePath -> IO ()
verifyMode path = do
  actual <- (.&. accessModes) . fileMode <$> getFileStatus path
  unless (actual == secureFileMode) (ioError (userError ("insecure permissions on " <> path)))

secureFileMode :: FileMode
secureFileMode = ownerReadMode `unionFileModes` ownerWriteMode