packages feed

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

-- | Resolve a validated service definition against suite-level runtime
--   bindings. Per-recipe and per-case binding layers never enter this module.
module HurlWorkbench.Service.Resolve
  ( ResolvedCommand (..),
    ResolvedReadiness (..),
    ResolvedService (..),
    ServiceError (..),
    requiredServiceParameters,
    resolveService,
    resolveCommand,
    renderServiceError,
  )
where

import Data.Generics.Labels ()
import Data.Map.Strict qualified as Map
import Data.Set qualified as Set
import Data.Text qualified as Text
import Data.Time.Clock (NominalDiffTime)
import HurlWorkbench.Parameter.Resolve (ResolvedBindings (..))
import HurlWorkbench.Parameter.Resolve.Internal (secretValueText)
import HurlWorkbench.Prelude
import HurlWorkbench.Workspace.Context
import HurlWorkbench.Workspace.Types
import Numeric.Natural (Natural)
import System.Exit (ExitCode)
import System.FilePath ((</>))
import System.Posix.Types (ProcessID)

data ResolvedCommand = ResolvedCommand
  { executable :: !FilePath,
    arguments :: ![String],
    workingDirectory :: !FilePath,
    environmentPolicy :: !EnvironmentPolicy,
    environment :: ![(String, String)]
  }
  deriving stock (Generic, Eq)

instance Show ResolvedCommand where
  show _ = "ResolvedCommand <redacted>"

data ResolvedReadiness
  = ResolvedHttpReadiness !Text !Int !Int !NominalDiffTime
  | ResolvedCommandReadiness !ResolvedCommand !Int !NominalDiffTime
  deriving stock (Generic, Eq, Show)

data ResolvedService = ResolvedService
  { name :: !ServiceName,
    processConfig :: !ResolvedCommand,
    readiness :: !ResolvedReadiness,
    shutdownTimeout :: !NominalDiffTime
  }
  deriving stock (Generic, Eq, Show)

data ServiceError
  = ServicePhaseFailed !Text
  | ServiceBindingMissing !ParameterName
  | ServiceBindingUnknown !ParameterName
  | ServiceReadinessSecretPlaceholder !ParameterName
  | ServiceReadinessMalformedPlaceholder !Text
  | ServiceReadinessInvalidUrl !Text !Text
  | ServiceEnvironmentMissing !ServiceName !Text ![Text]
  | ServiceSpawnFailed !FilePath !Text
  | ServiceProcessIdUnavailable !ServiceName
  | ServiceExitedBeforeReady !ServiceName !ExitCode
  | ServiceReadinessTimedOut !ServiceName
  | ServiceReadinessProbeStartFailed !FilePath !Text
  | ServiceExitedDuringCallback !ServiceName !ExitCode
  | ServiceShutdownFailed !ServiceName !(Maybe ProcessID) !Text
  deriving stock (Generic, Eq, Show)

data UrlPart
  = UrlText !Text
  | UrlParameter !ParameterName

requiredServiceParameters :: Service -> Either ServiceError (Set ParameterName)
requiredServiceParameters service = do
  readinessParameters <- case service ^. #readiness of
    HttpReadinessCheck http ->
      Set.fromList . mapMaybe partParameter <$> parseUrlTemplate (http ^. #url)
    CommandReadinessCheck probe -> pure (commandParameters (probe ^. #command))
  pure (commandParameters (service ^. #command) <> readinessParameters)
  where
    commandParameters command = Set.fromList (map (view #parameter) (command ^. #environment))
    partParameter = \case
      UrlText _ -> Nothing
      UrlParameter name -> Just name

resolveService :: ValidatedWorkspace -> ResolvedBindings -> Service -> Either ServiceError ResolvedService
resolveService validated bindings service = do
  processConfig <- resolveCommand validated bindings (service ^. #command)
  readiness <- case service ^. #readiness of
    HttpReadinessCheck http ->
      ResolvedHttpReadiness
        <$> substituteReadinessUrl validated bindings (http ^. #url)
        <*> pure (fromIntegral (http ^. #expectedStatus))
        <*> pure (milliseconds (http ^. #intervalMilliseconds))
        <*> pure (seconds (http ^. #timeoutSeconds))
    CommandReadinessCheck probe ->
      ResolvedCommandReadiness
        <$> resolveCommand validated bindings (probe ^. #command)
        <*> pure (milliseconds (probe ^. #intervalMilliseconds))
        <*> pure (seconds (probe ^. #timeoutSeconds))
  pure
    ResolvedService
      { name = service ^. #name,
        processConfig,
        readiness,
        shutdownTimeout = seconds (service ^. #shutdownTimeoutSeconds)
      }

resolveCommand :: ValidatedWorkspace -> ResolvedBindings -> CommandSpec -> Either ServiceError ResolvedCommand
resolveCommand validated bindings command = do
  environment <- traverse resolveEnvironmentBinding (command ^. #environment)
  let WorkspaceRoot root = validatedRoot validated
      workingDirectory = maybe root (root </>) (command ^. #workingDirectory)
  pure
    ResolvedCommand
      { executable = Text.unpack (command ^. #executable),
        arguments = map Text.unpack (command ^. #arguments),
        workingDirectory,
        environmentPolicy = command ^. #environmentPolicy,
        environment
      }
  where
    resolveEnvironmentBinding binding = do
      value <- resolveValue (binding ^. #parameter)
      pure (Text.unpack (binding ^. #variable), Text.unpack value)

    resolveValue name =
      case Map.lookup name (bindings ^. #variables) of
        Just value -> Right (hurlValueLiteralText value)
        Nothing -> case Map.lookup name (bindings ^. #secrets) of
          Just value -> Right (secretValueText value)
          Nothing -> Left (ServiceBindingMissing name)

substituteReadinessUrl :: ValidatedWorkspace -> ResolvedBindings -> Text -> Either ServiceError Text
substituteReadinessUrl validated bindings template = do
  urlParts <- parseUrlTemplate template
  Text.concat <$> traverse renderPart urlParts
  where
    renderPart = \case
      UrlText value -> Right value
      UrlParameter name -> readinessValue name

    readinessValue name = case lookupParameter name validated of
      Nothing -> Left (ServiceBindingUnknown name)
      Just parameter
        | parameter ^. #kind == Secret -> Left (ServiceReadinessSecretPlaceholder name)
        | otherwise -> case Map.lookup name (bindings ^. #variables) of
            Nothing -> Left (ServiceBindingMissing name)
            Just value -> Right (hurlValueLiteralText value)

parseUrlTemplate :: Text -> Either ServiceError [UrlPart]
parseUrlTemplate template = go [] template
  where
    go accumulated remaining =
      case Text.breakOn "{{" remaining of
        (prefix, rest)
          | Text.null rest ->
              if "}}" `Text.isInfixOf` prefix
                then malformed
                else Right (reverse (prependText prefix accumulated))
          | otherwise ->
              let afterOpen = Text.drop 2 rest
                  (rawName, close) = Text.breakOn "}}" afterOpen
               in if Text.null close || Text.null rawName || "{{" `Text.isInfixOf` rawName
                    then malformed
                    else go (UrlParameter (ParameterName rawName) : prependText prefix accumulated) (Text.drop 2 close)

    prependText value accumulated
      | Text.null value = accumulated
      | otherwise = UrlText value : accumulated
    malformed = Left (ServiceReadinessMalformedPlaceholder template)

milliseconds :: Natural -> Int
milliseconds value = fromIntegral value * 1000

seconds :: Natural -> NominalDiffTime
seconds = fromIntegral

renderServiceError :: ServiceError -> Text
renderServiceError = \case
  ServicePhaseFailed phase -> "suite " <> phase <> " command failed"
  ServiceBindingMissing name -> "service parameter " <> quotedParameter name <> " has no suite-level runtime value"
  ServiceBindingUnknown name -> "readiness URL refers to unknown parameter " <> quotedParameter name
  ServiceReadinessSecretPlaceholder name -> "readiness URL must not contain secret parameter " <> quotedParameter name
  ServiceReadinessMalformedPlaceholder value -> "readiness URL contains a malformed placeholder near " <> quote value
  ServiceReadinessInvalidUrl url message -> "invalid readiness URL " <> quote url <> ": " <> message
  ServiceEnvironmentMissing name phase names -> "service " <> quotedService name <> " " <> phase <> " requires missing environment variables: " <> Text.intercalate ", " names
  ServiceSpawnFailed executable message -> "service executable " <> quote (Text.pack executable) <> " could not be started: " <> message
  ServiceProcessIdUnavailable name -> "service " <> quotedService name <> " started without an observable process id"
  ServiceExitedBeforeReady name exitCode -> "service " <> quotedService name <> " exited before readiness with " <> Text.pack (show exitCode)
  ServiceReadinessTimedOut name -> "service " <> quotedService name <> " did not become ready before its timeout"
  ServiceReadinessProbeStartFailed executable message -> "readiness executable " <> quote (Text.pack executable) <> " could not be started: " <> message
  ServiceExitedDuringCallback name exitCode -> "service " <> quotedService name <> " exited while its suite was running with " <> Text.pack (show exitCode)
  ServiceShutdownFailed name processId message ->
    "service "
      <> quotedService name
      <> maybe "" ((" (pid " <>) . (<> ")") . Text.pack . show) processId
      <> " could not be shut down cleanly: "
      <> message
  where
    quotedParameter = quote . unParameterName
    quotedService = quote . unServiceName
    quote value = "\"" <> value <> "\""