packages feed

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

-- | Resolve all runtime values for a validated workflow. Plain values and
--   secrets remain separate from discovery through serialization.
module HurlWorkbench.Parameter.Resolve
  ( BindingInput (..),
    BindingSource (..),
    BindingLayer (..),
    SecretValue,
    SecretValueError (..),
    ResolvedBindings (..),
    BindingIssue (..),
    BindingError (..),
    emptyBindingInput,
    mkSecretValue,
    resolveParameters,
    resolveParametersWithGenerator,
    resolveParametersWithLayersAndGenerator,
    resolveParameterSubset,
    resolveParameterSubsetInEnvironment,
    resolveParametersWithLayers,
    resolveWorkflowBindings,
    resolveWorkflowBindingsWithLayers,
    resolveWorkflowBindingSubsetWithLayers,
    renderBindingError,
    renderBindingIssue,
  )
where

import Control.Exception (IOException, try)
import Data.Bifunctor (first)
import Data.ByteString qualified as ByteString
import Data.Generics.Labels ()
import Data.Map.Strict qualified as Map
import Data.Set qualified as Set
import Data.Text qualified as Text
import HurlWorkbench.Parameter.Generate
import HurlWorkbench.Parameter.Properties
import HurlWorkbench.Parameter.Resolve.Internal
import HurlWorkbench.Prelude
import HurlWorkbench.Workspace.Context
import HurlWorkbench.Workspace.Types
import HurlWorkbench.Workspace.Validate (isValidEnvironmentName)
import System.Environment (lookupEnv)

emptyBindingInput :: BindingInput
emptyBindingInput =
  BindingInput
    { plainOverrides = Map.empty,
      secretEnvironmentOverrides = Map.empty,
      variableFiles = [],
      secretFiles = []
    }

resolveWorkflowBindings :: ValidatedWorkspace -> Workflow -> BindingInput -> IO (Either BindingError ResolvedBindings)
resolveWorkflowBindings validated workflow input =
  resolveParameters validated (Set.fromList (workflow ^. #parameters)) input

resolveWorkflowBindingsWithLayers :: ValidatedWorkspace -> Workflow -> [BindingLayer] -> BindingInput -> IO (Either BindingError ResolvedBindings)
resolveWorkflowBindingsWithLayers validated workflow layers input =
  resolveParametersWithLayers validated (Set.fromList (workflow ^. #parameters)) layers input

resolveWorkflowBindingSubsetWithLayers :: ValidatedWorkspace -> Workflow -> [BindingLayer] -> BindingInput -> IO (Either BindingError ResolvedBindings)
resolveWorkflowBindingSubsetWithLayers validated workflow layers input =
  resolveParametersWithMode False validated (Set.fromList (workflow ^. #parameters)) layers input

resolveParameters :: ValidatedWorkspace -> Set ParameterName -> BindingInput -> IO (Either BindingError ResolvedBindings)
resolveParameters validated selected = resolveParametersWithLayers validated selected []

-- | Resolve one consumer's parameter subset from a shared runtime input.
--   Bindings for other declared consumers are ignored, while selected names
--   retain the same kind and precedence validation as normal resolution.
resolveParameterSubset :: ValidatedWorkspace -> Set ParameterName -> BindingInput -> IO (Either BindingError ResolvedBindings)
resolveParameterSubset validated selected = resolveParametersWithMode False validated selected []

-- | Resolve runtime inputs over committed plain-value layers ordered from
--   highest to lowest precedence. Runtime overrides and variable files remain
--   above these layers; declared environments and defaults remain below them.
resolveParametersWithLayers :: ValidatedWorkspace -> Set ParameterName -> [BindingLayer] -> BindingInput -> IO (Either BindingError ResolvedBindings)
resolveParametersWithLayers = resolveParametersWithLayersAndGenerator defaultGeneratorEnvironment

-- | Inject clock/entropy while preserving the committed binding layers.
resolveParametersWithLayersAndGenerator :: GeneratorEnvironment -> ValidatedWorkspace -> Set ParameterName -> [BindingLayer] -> BindingInput -> IO (Either BindingError ResolvedBindings)
resolveParametersWithLayersAndGenerator env = resolveParametersWithLookup env lookupEnv True

resolveParametersWithMode :: Bool -> ValidatedWorkspace -> Set ParameterName -> [BindingLayer] -> BindingInput -> IO (Either BindingError ResolvedBindings)
resolveParametersWithMode = resolveParametersWithLookup defaultGeneratorEnvironment lookupEnv

-- | Resolve managed consumers from the invocation snapshot, including declared
-- parameter environments and explicit secret-environment channels.
resolveParameterSubsetInEnvironment :: [(String, String)] -> ValidatedWorkspace -> Set ParameterName -> BindingInput -> IO (Either BindingError ResolvedBindings)
resolveParameterSubsetInEnvironment ambient validated selected = resolveParametersWithLookup defaultGeneratorEnvironment (pure . (`Map.lookup` Map.fromList ambient)) False validated selected []

resolveParametersWithGenerator :: GeneratorEnvironment -> ValidatedWorkspace -> Set ParameterName -> BindingInput -> IO (Either BindingError ResolvedBindings)
resolveParametersWithGenerator env validated selected = resolveParametersWithLookup env lookupEnv True validated selected []

resolveParametersWithLookup :: GeneratorEnvironment -> (String -> IO (Maybe String)) -> Bool -> ValidatedWorkspace -> Set ParameterName -> [BindingLayer] -> BindingInput -> IO (Either BindingError ResolvedBindings)
resolveParametersWithLookup generatorEnvironment lookupEnvironment rejectUnselected validated selected layers input = do
  loadedVariables <- loadVariableFiles (input ^. #variableFiles)
  loadedSecrets <- loadSecretFiles (input ^. #secretFiles)
  case (loadedVariables, loadedSecrets) of
    (Left err, _) -> pure (Left err)
    (_, Left err) -> pure (Left err)
    (Right variableMaps, Right secretMaps) -> do
      let fileVariables = laterWins variableMaps
          fileSecrets = laterWins secretMaps
          explicitVariables = Map.map (,ExplicitVariable) (input ^. #plainOverrides)
          explicitSecrets = Map.mapWithKey (\_ env -> (env, ExplicitSecretEnvironment env)) (input ^. #secretEnvironmentOverrides)
          committedVariables = map sourcedLayer layers
          parameters = validatedParameters validated
          staticIssues =
            unknownSelected parameters selected
              <> [ issue
                 | rejectUnselected,
                   issue <-
                     unexpected selected explicitVariables
                       <> unexpected selected explicitSecrets
                       <> unexpected selected fileVariables
                       <> unexpected selected fileSecrets
                       <> concatMap (unexpected selected) committedVariables
                 ]
              <> kindMismatches parameters selected explicitVariables explicitSecrets fileVariables fileSecrets committedVariables
      resolved <- traverse (resolveOne generatorEnvironment lookupEnvironment parameters explicitVariables explicitSecrets fileVariables fileSecrets committedVariables) (Set.toAscList selected)
      let dynamicIssues = concatMap (\(issues, _, _) -> issues) resolved
          allIssues = staticIssues <> dynamicIssues
      pure $ case nonEmpty allIssues of
        Just issues -> Left (BindingIssues issues)
        Nothing ->
          Right
            ResolvedBindings
              { variables = Map.fromList (mapMaybe (\(_, variable, _) -> variable) resolved),
                secrets = Map.fromList (mapMaybe (\(_, _, secret) -> secret) resolved)
              }
  where
    sourcedLayer layer = Map.map (,(layer ^. #source)) (layer ^. #values)

type Sourced value = Map ParameterName (value, BindingSource)

loadVariableFiles :: [FilePath] -> IO (Either BindingError [Sourced HurlValueLiteral])
loadVariableFiles = loadFiles VariableProperties parseVariableProperties

loadSecretFiles :: [FilePath] -> IO (Either BindingError [Sourced Text])
loadSecretFiles = loadFiles SecretProperties parseSecretProperties

loadFiles :: PropertyFileKind -> (FilePath -> ByteString.ByteString -> Either PropertyError (Map ParameterName value)) -> [FilePath] -> IO (Either BindingError [Sourced value])
loadFiles kind parser = go []
  where
    go loaded [] = pure (Right (reverse loaded))
    go loaded (path : remaining) = do
      bytes <- try @IOException (ByteString.readFile path)
      case bytes of
        Left _err -> pure (Left (BindingFileReadError kind path))
        Right contents -> case parser path contents of
          Left err -> pure (Left (BindingPropertyError err))
          Right values ->
            let source = case kind of
                  VariableProperties -> VariableFile path
                  SecretProperties -> SecretFile path
             in go (Map.map (,source) values : loaded) remaining

laterWins :: [Sourced value] -> Sourced value
laterWins = foldl' (flip Map.union) Map.empty

unknownSelected :: Map ParameterName Parameter -> Set ParameterName -> [BindingIssue]
unknownSelected parameters selected =
  [UnknownDeclaredParameter name | name <- Set.toAscList selected, Map.notMember name parameters]

unexpected :: Set ParameterName -> Sourced value -> [BindingIssue]
unexpected selected values =
  [ UnexpectedBinding name source
  | (name, (_, source)) <- Map.toAscList values,
    name `Set.notMember` selected
  ]

kindMismatches :: Map ParameterName Parameter -> Set ParameterName -> Sourced HurlValueLiteral -> Sourced Text -> Sourced HurlValueLiteral -> Sourced Text -> [Sourced HurlValueLiteral] -> [BindingIssue]
kindMismatches parameters selected explicitVariables explicitSecrets fileVariables fileSecrets committedVariables =
  concatMap one (Set.toAscList selected)
  where
    one name = case Map.lookup name parameters of
      Nothing -> []
      Just parameter -> case parameter ^. #kind of
        Generated _ -> [BindingKindMismatch name Plain source | source <- sourcesFor name explicitSecrets <> sourcesFor name fileSecrets]
        Plain -> [BindingKindMismatch name Secret source | source <- sourcesFor name explicitSecrets <> sourcesFor name fileSecrets]
        Secret -> [BindingKindMismatch name Plain source | source <- sourcesFor name explicitVariables <> sourcesFor name fileVariables <> concatMap (sourcesFor name) committedVariables]

sourcesFor :: ParameterName -> Sourced value -> [BindingSource]
sourcesFor name values = maybe [] (pure . snd) (Map.lookup name values)

resolveOne :: GeneratorEnvironment -> (String -> IO (Maybe String)) -> Map ParameterName Parameter -> Sourced HurlValueLiteral -> Sourced Text -> Sourced HurlValueLiteral -> Sourced Text -> [Sourced HurlValueLiteral] -> ParameterName -> IO ([BindingIssue], Maybe (ParameterName, HurlValueLiteral), Maybe (ParameterName, SecretValue))
resolveOne generatorEnvironment lookupEnvironment parameters explicitVariables explicitSecrets fileVariables fileSecrets committedVariables name =
  case Map.lookup name parameters of
    Nothing -> pure ([], Nothing, Nothing)
    Just parameter -> case parameter ^. #kind of
      Generated generator -> do
        environmentValue <- traverse (lookupPlainEnvironment lookupEnvironment name) (parameter ^. #environment)
        case firstJust [Right <$> Map.lookup name explicitVariables, Right <$> Map.lookup name fileVariables, environmentValue] of
          Just (Left issue) -> pure ([issue], Nothing, Nothing)
          Just (Right (value, _)) -> pure ([], Just (name, value), Nothing)
          Nothing ->
            generateValue generatorEnvironment generator >>= \case
              Left _ -> pure ([GeneratorFailure name generator], Nothing, Nothing)
              Right value -> pure ([], Just (name, value), Nothing)
      Plain -> do
        environmentValue <- traverse (lookupPlainEnvironment lookupEnvironment name) (parameter ^. #environment)
        let selectedValue =
              firstJust
                [ Right <$> Map.lookup name explicitVariables,
                  Right <$> Map.lookup name fileVariables,
                  firstLayerValue name committedVariables,
                  environmentValue,
                  Right . (,DefaultValue) <$> parameter ^. #defaultValue
                ]
        pure $ case selectedValue of
          Nothing -> ([MissingBinding name], Nothing, Nothing)
          Just (Left issue) -> ([issue], Nothing, Nothing)
          Just (Right (value, _source)) -> ([], Just (name, value), Nothing)
      Secret -> do
        explicitValue <- traverse (lookupSecretEnvironment lookupEnvironment name) (Map.lookup name explicitSecrets)
        declaredValue <- traverse (\env -> lookupSecretEnvironment lookupEnvironment name (env, DeclaredEnvironment env)) (parameter ^. #environment)
        let fileValue = case Map.lookup name fileSecrets of
              Nothing -> Nothing
              Just (value, source) -> Just (first (InvalidSecretBinding name source) (mkSecretValue value) <&> (,source))
            selectedValue = firstJust [explicitValue, fileValue, declaredValue]
        pure $ case selectedValue of
          Nothing -> ([MissingBinding name], Nothing, Nothing)
          Just (Left issue) -> ([issue], Nothing, Nothing)
          Just (Right (value, _source)) -> ([], Nothing, Just (name, value))

lookupPlainEnvironment :: (String -> IO (Maybe String)) -> ParameterName -> Text -> IO (Either BindingIssue (HurlValueLiteral, BindingSource))
lookupPlainEnvironment lookupEnvironment name environmentName
  | not (isValidEnvironmentName environmentName) =
      pure (Left (InvalidBindingEnvironmentName name environmentName (DeclaredEnvironment environmentName)))
  | otherwise = do
      value <- lookupEnvironment (Text.unpack environmentName)
      pure $ case value of
        Nothing -> Left (MissingBindingEnvironment name environmentName (DeclaredEnvironment environmentName))
        Just raw -> first (InvalidPlainBinding name (DeclaredEnvironment environmentName)) (mkHurlValueLiteral (Text.pack raw)) <&> (,DeclaredEnvironment environmentName)

lookupSecretEnvironment :: (String -> IO (Maybe String)) -> ParameterName -> (Text, BindingSource) -> IO (Either BindingIssue (SecretValue, BindingSource))
lookupSecretEnvironment lookupEnvironment name (environmentName, source)
  | not (isValidEnvironmentName environmentName) =
      pure (Left (InvalidBindingEnvironmentName name environmentName source))
  | otherwise = do
      value <- lookupEnvironment (Text.unpack environmentName)
      pure $ case value of
        Nothing -> Left (MissingBindingEnvironment name environmentName source)
        Just raw -> first (InvalidSecretBinding name source) (mkSecretValue (Text.pack raw)) <&> (,source)

firstJust :: [Maybe a] -> Maybe a
firstJust = \case
  [] -> Nothing
  Nothing : rest -> firstJust rest
  Just value : _ -> Just value

firstLayerValue :: ParameterName -> [Sourced value] -> Maybe (Either issue (value, BindingSource))
firstLayerValue name = firstJust . map (fmap Right . Map.lookup name)

renderBindingError :: BindingError -> Text
renderBindingError = \case
  BindingFileReadError kind path -> label kind <> " file could not be read: " <> Text.pack path
  BindingPropertyError err -> renderPropertyError err
  BindingIssues issues -> Text.intercalate "\n" (map renderBindingIssue (toList issues))
  where
    label VariableProperties = "variables"
    label SecretProperties = "secrets"

renderBindingIssue :: BindingIssue -> Text
renderBindingIssue = \case
  UnexpectedBinding name source -> "parameter " <> quoted name <> " from " <> renderSource source <> " is not declared by the selected workflow"
  BindingKindMismatch name expected source -> "parameter " <> quoted name <> " from " <> renderSource source <> " must be supplied as " <> kindName expected
  MissingBinding name -> "required parameter " <> quoted name <> " has no value"
  MissingBindingEnvironment name environmentName source -> "environment variable " <> quote environmentName <> " for parameter " <> quoted name <> " from " <> renderSource source <> " is not set"
  InvalidBindingEnvironmentName name environmentName source -> "environment variable name " <> quote environmentName <> " for parameter " <> quoted name <> " from " <> renderSource source <> " is invalid"
  InvalidPlainBinding name source err -> "plain parameter " <> quoted name <> " from " <> renderSource source <> " is invalid: " <> renderHurlValueLiteralError err
  InvalidSecretBinding name source err -> "secret parameter " <> quoted name <> " from " <> renderSource source <> " is invalid: " <> secretError err
  GeneratorFailure name generator -> "generation failed for parameter " <> quoted name <> " (" <> generatorName generator <> ")"
  UnknownDeclaredParameter name -> "selected parameter " <> quoted name <> " does not exist in the validated workspace"
  where
    quoted = quote . unParameterName
    quote value = "\"" <> value <> "\""
    kindName (Generated _) = "a plain value"
    kindName Plain = "a plain value"
    kindName Secret = "a secret value"
    secretError SecretContainsLineBreakOrNul = "value contains a line break or NUL character, which Hurl secret files cannot carry"
    secretError SecretHasSurroundingWhitespace = "value has leading or trailing whitespace, which Hurl secret files would trim"

renderSource :: BindingSource -> Text
renderSource = \case
  ExplicitVariable -> "--variable"
  ExplicitSecretEnvironment env -> "--secret-env " <> env
  VariableFile path -> "variables file " <> Text.pack path
  SecretFile path -> "secrets file " <> Text.pack path
  CommittedBinding label -> "committed " <> label
  DeclaredEnvironment env -> "declared environment " <> env
  DefaultValue -> "the workspace default"