packages feed

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

-- | POSIX process-group lifecycle and readiness probes for one managed
--   service. Cleanup always completes before callback exceptions are rethrown.
module HurlWorkbench.Service.Run
  ( ServiceHandle,
    serviceProcessId,
    withService,
    freezeService,
    awaitService,
    prepareCommand,
  )
where

import Control.Concurrent (threadDelay)
import Control.Concurrent.Async (race)
import Control.Exception (SomeException, finally, mask, throwIO, try)
import Data.Generics.Labels ()
import Data.Text qualified as Text
import Data.Time.Clock (NominalDiffTime, UTCTime, addUTCTime, diffUTCTime, getCurrentTime)
import HurlWorkbench.Prelude
import HurlWorkbench.Process.Run
import HurlWorkbench.Service.Environment (selectEnvironment)
import HurlWorkbench.Service.Resolve
import HurlWorkbench.Workspace.Types (EnvironmentPolicy (..), ServiceName (..))
import Network.HTTP.Client
  ( HttpException,
    Manager,
    Request (responseTimeout),
    defaultManagerSettings,
    httpNoBody,
    newManager,
    parseRequest,
    responseStatus,
    responseTimeoutMicro,
  )
import Network.HTTP.Types.Status (statusCode)
import System.Environment (getEnvironment)
import System.Exit (ExitCode (..))
import System.Posix.Types (ProcessID)
import System.Timeout qualified as Timeout

data ServiceHandle = ServiceHandle
  { name :: !Text,
    processId :: !ProcessID
  }
  deriving stock (Generic, Eq, Show)

serviceProcessId :: ServiceHandle -> ProcessID
serviceProcessId = view #processId

data PreparedReadiness
  = PreparedHttpReadiness !Manager !Request !Int !Int !NominalDiffTime
  | PreparedCommandReadiness !ResolvedCommand !Int !NominalDiffTime

withService :: ResolvedService -> (ServiceHandle -> IO a) -> IO (Either ServiceError a)
withService service callback = mask $ \restore -> do
  ambient <- getEnvironment
  case prepareCommand ambient (service ^. #name) "command" (service ^. #processConfig) of
    Left err -> pure (Left err)
    Right command -> do
      preparedReadiness <- prepareReadiness ambient (service ^. #name) (service ^. #readiness)
      case preparedReadiness of
        Left err -> pure (Left err)
        Right readiness -> launch restore command readiness
  where
    launch restore command readiness = do
      spawnGroup (service ^. #name) (commandConfig command) >>= \case
        Left err -> pure (Left err)
        Right owned -> do
          execution <- try @SomeException (restore (runLifecycle service readiness owned (owned ^. #processId) callback))
          shutdown <- terminateGroup (service ^. #name) (service ^. #shutdownTimeout) owned
          case execution of
            Left exception -> throwIO exception
            Right (Left err) -> pure (Left err)
            Right (Right value) -> pure (value <$ shutdown)

awaitService :: ResolvedService -> OwnedProcess -> IO (Either ServiceError ())
awaitService service owned = do
  prepared <- prepareReadiness [] (service ^. #name) (service ^. #readiness)
  case prepared of
    Left err -> pure (Left err)
    Right readiness -> awaitReadiness service readiness owned

runLifecycle :: ResolvedService -> PreparedReadiness -> OwnedProcess -> ProcessID -> (ServiceHandle -> IO a) -> IO (Either ServiceError a)
runLifecycle service readiness process pid callback =
  awaitReadiness service readiness process >>= \case
    Left err -> pure (Left err)
    Right () ->
      race
        (callback ServiceHandle {name = unServiceName (service ^. #name), processId = pid})
        (waitExitCode process)
        <&> \case
          Left value -> Right value
          Right exitCode -> Left (ServiceExitedDuringCallback (service ^. #name) exitCode)

prepareReadiness :: [(String, String)] -> ServiceName -> ResolvedReadiness -> IO (Either ServiceError PreparedReadiness)
prepareReadiness _ambient _name (ResolvedHttpReadiness url expectedStatus interval timeout) = do
  parsed <- try @HttpException (parseRequest (Text.unpack url))
  case parsed of
    Left _ -> pure (Left (ServiceReadinessInvalidUrl "<redacted>" "URL could not be parsed"))
    Right request -> do
      manager <- newManager defaultManagerSettings
      let withTimeout = request {responseTimeout = responseTimeoutMicro 1000000}
      pure
        ( Right
            ( PreparedHttpReadiness
                manager
                withTimeout
                expectedStatus
                interval
                timeout
            )
        )
prepareReadiness ambient name (ResolvedCommandReadiness command interval timeout) =
  pure ((\prepared -> PreparedCommandReadiness prepared interval timeout) <$> prepareCommand ambient name "readiness command" command)

prepareCommand :: [(String, String)] -> ServiceName -> Text -> ResolvedCommand -> Either ServiceError ResolvedCommand
prepareCommand ambient name phase command =
  case selectEnvironment (command ^. #environmentPolicy) ambient (command ^. #environment) of
    Left missing -> Left (ServiceEnvironmentMissing name phase missing)
    Right selected -> Right (command & #environment .~ selected & #environmentPolicy .~ Clean [])

freezeService :: [(String, String)] -> ResolvedService -> IO (Either ServiceError ResolvedService)
freezeService ambient service = case prepareCommand ambient (service ^. #name) "command" (service ^. #processConfig) of
  Left err -> pure (Left err)
  Right command ->
    prepareReadiness ambient (service ^. #name) (service ^. #readiness)
      <&> fmap
        ( \prepared ->
            service & #processConfig .~ command & #readiness .~ case prepared of
              PreparedCommandReadiness probe interval timeout -> ResolvedCommandReadiness probe interval timeout
              PreparedHttpReadiness {} -> service ^. #readiness
        )

awaitReadiness :: ResolvedService -> PreparedReadiness -> OwnedProcess -> IO (Either ServiceError ())
awaitReadiness service readiness process = do
  began <- getCurrentTime
  let deadline = addUTCTime (readinessTimeout readiness) began
  loop deadline
  where
    loop deadline = do
      getExitCode process >>= \case
        Just exitCode -> pure (Left (ServiceExitedBeforeReady (service ^. #name) exitCode))
        Nothing -> do
          now <- getCurrentTime
          if now >= deadline
            then pure (Left (ServiceReadinessTimedOut (service ^. #name)))
            else do
              attempted <- Timeout.timeout (remainingMicroseconds now deadline) (probeReadiness readiness)
              case attempted of
                Nothing -> pure (Left (ServiceReadinessTimedOut (service ^. #name)))
                Just (Left err) -> pure (Left err)
                Just (Right True) ->
                  getExitCode process <&> \case
                    Just exitCode -> Left (ServiceExitedBeforeReady (service ^. #name) exitCode)
                    Nothing -> Right ()
                Just (Right False) -> threadDelay (readinessInterval readiness) >> loop deadline

probeReadiness :: PreparedReadiness -> IO (Either ServiceError Bool)
probeReadiness (PreparedHttpReadiness manager request expected _interval _timeout) = do
  attempted <- try @HttpException (httpNoBody request manager)
  pure (Right (either (const False) ((== expected) . statusCode . responseStatus) attempted))
probeReadiness (PreparedCommandReadiness command _interval timeout) = mask $ \restore -> do
  spawnGroup (ServiceName "readiness") (commandConfig command) >>= \case
    Left _ -> pure (Left (ServiceReadinessProbeStartFailed "<redacted>" "process launch failed"))
    Right owned -> do
      exitCode <- restore (waitExitCode owned) `finally` void (terminateGroup (ServiceName "readiness") (min 1 timeout) owned)
      pure (Right (exitCode == ExitSuccess))

readinessInterval :: PreparedReadiness -> Int
readinessInterval = \case
  PreparedHttpReadiness _ _ _ interval _ -> interval
  PreparedCommandReadiness _ interval _ -> interval

readinessTimeout :: PreparedReadiness -> NominalDiffTime
readinessTimeout = \case
  PreparedHttpReadiness _ _ _ _ timeout -> timeout
  PreparedCommandReadiness _ _ timeout -> timeout

remainingMicroseconds :: UTCTime -> UTCTime -> Int
remainingMicroseconds now deadline = max 1 (floor (diffUTCTime deadline now * 1000000))