packages feed

hercules-ci-agent-0.10.1: hercules-ci-agent/Hercules/Agent/Evaluate.hs

{-# LANGUAGE BlockArguments #-}
{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE ScopedTypeVariables #-}

module Hercules.Agent.Evaluate
  ( performEvaluation,
  )
where

import Conduit
import Control.Concurrent.Async (waitSTM)
import Control.Concurrent.Async.Lifted qualified as Async.Lifted
import Control.Concurrent.Chan.Lifted
import Control.Concurrent.STM.TVar (TVar, newTVarIO)
import Control.Exception.Safe qualified as Safe
import Control.Lens (at, (^?))
import Control.Monad.IO.Unlift (askUnliftIO, unliftIO)
import Data.Aeson qualified as A
import Data.Aeson.Lens (_String)
import Data.ByteString.Lazy qualified as BL
import Data.Char (isAsciiLower, isAsciiUpper)
import Data.Conduit.Process (sourceProcessWithStreams)
import Data.IORef
  ( atomicModifyIORef,
    newIORef,
  )
import Data.Map qualified as M
import Data.Text qualified as T
import Data.UUID (UUID)
import Data.Vector (Vector)
import Hercules.API (Id)
import Hercules.API.Agent.Evaluate
  ( getDerivationStatus2,
    tasksUpdateEvaluation,
  )
import Hercules.API.Agent.Evaluate.DerivationStatus qualified as DerivationStatus
import Hercules.API.Agent.Evaluate.EvaluateEvent qualified as EvaluateEvent
import Hercules.API.Agent.Evaluate.EvaluateEvent.AttributeEffectEvent qualified as AttributeEffectEvent
import Hercules.API.Agent.Evaluate.EvaluateEvent.AttributeErrorEvent qualified as AttributeErrorEvent
import Hercules.API.Agent.Evaluate.EvaluateEvent.AttributeEvent qualified as AttributeEvent
import Hercules.API.Agent.Evaluate.EvaluateEvent.AttributeIFDEvent qualified as AttributeIFDEvent
import Hercules.API.Agent.Evaluate.EvaluateEvent.BuildRequest qualified as BuildRequest
import Hercules.API.Agent.Evaluate.EvaluateEvent.BuildRequired qualified as BuildRequired
import Hercules.API.Agent.Evaluate.EvaluateEvent.DerivationInfo qualified as DerivationInfo
import Hercules.API.Agent.Evaluate.EvaluateEvent.JobConfig qualified as JobConfig
import Hercules.API.Agent.Evaluate.EvaluateEvent.Message qualified as Message
import Hercules.API.Agent.Evaluate.EvaluateEvent.SubstitutionQueryResult qualified as SubstitutionQueryResult
import Hercules.API.Agent.Evaluate.EvaluateTask qualified as EvaluateTask
import Hercules.API.Agent.Evaluate.ImmutableGitInput (ImmutableGitInput)
import Hercules.API.Agent.Evaluate.ImmutableGitInput qualified as ImmutableGitInput
import Hercules.API.Agent.Evaluate.ImmutableInput qualified as ImmutableInput
import Hercules.API.Logs.LogEntry (LogEntry)
import Hercules.API.Servant (noContent)
import Hercules.API.Task (Task)
import Hercules.Agent.Build (convertOutputInfo)
import Hercules.Agent.Cache (getConfiguredSubstituters)
import Hercules.Agent.Cache qualified as Agent.Cache
import Hercules.Agent.Client qualified
import Hercules.Agent.Config qualified as Config
import Hercules.Agent.Env
import Hercules.Agent.Env qualified as Env
import Hercules.Agent.Files
import Hercules.Agent.InitWorkerConfig qualified as WorkerConfig
import Hercules.Agent.Log
import Hercules.Agent.Memo (Memo, doOnce, newMemo)
import Hercules.Agent.Netrc qualified as Netrc
import Hercules.Agent.Nix qualified as Nix
import Hercules.Agent.Nix.RetrieveDerivationInfo
  ( retrieveDerivationInfo,
  )
import Hercules.Agent.NixFile (findNixFile)
import Hercules.Agent.NixFile.GitSource qualified as GitSource
import Hercules.Agent.NixPath
  ( renderSubPath,
  )
import Hercules.Agent.Producer
import Hercules.Agent.ResourceLimiter (ResourceLimiter, newResourceLimiter, withResource)
import Hercules.Agent.Store (toDrvInfo)
import Hercules.Agent.WorkerProcess qualified as WorkerProcess
import Hercules.Agent.WorkerProtocol.Command qualified as Command
import Hercules.Agent.WorkerProtocol.Command.BuildResult qualified as BuildResult
import Hercules.Agent.WorkerProtocol.Command.Eval qualified as Eval
import Hercules.Agent.WorkerProtocol.Event qualified as Event
import Hercules.Agent.WorkerProtocol.Event.Attribute qualified as WorkerAttribute
import Hercules.Agent.WorkerProtocol.Event.AttributeError qualified as WorkerAttributeError
import Hercules.Agent.WorkerProtocol.Event.AttributeIFD qualified as AttributeIFD
import Hercules.Agent.WorkerProtocol.ViaJSON (ViaJSON (ViaJSON))
import Hercules.Agent.WorkerProtocol.ViaJSON qualified
import Hercules.CNix.Store (Store, StorePath, getStorePathBaseName, parseStorePath)
import Hercules.CNix.Store qualified as CNix
import Hercules.Effect (parseDrvSecretsMap)
import Hercules.Error (defaultRetry, quickRetry)
import Network.HTTP.Client.Conduit qualified as HTTP.Conduit
import Network.HTTP.Simple qualified as HTTP.Simple
import Network.URI qualified
import Protolude hiding (async, atomically, concurrently, finally, newChan, writeChan)
import Servant.Client qualified
import Servant.Client.Core (showBaseUrl)
import System.Directory qualified as Dir
import System.FilePath
import System.Process
import UnliftIO (async, atomicModifyIORef', atomically, concurrently, concurrently_, forConcurrently, forConcurrently_, modifyTVar, readTVar, writeTVar)
import UnliftIO qualified

eventLimit :: Int
eventLimit = 50000

pushEvalWorkers :: Int
pushEvalWorkers = 16

performEvaluation :: (Vector LogEntry -> IO ()) -> Store -> EvaluateTask.EvaluateTask -> App ()
performEvaluation sendLogItems store task' =
  withProducer (produceEvaluationTaskEvents sendLogItems store task') $ \producer ->
    withBoundedDelayBatchProducer (1000 * 1000) 1000 producer $ \batchProducer ->
      fix $ \continue ->
        joinSTM $ listen batchProducer (\b -> withSync b (postBatch task' . catMaybes) *> continue) pure

getSrcInput :: (MonadIO m) => EvaluateTask.EvaluateTask -> m (Maybe ImmutableGitInput)
getSrcInput task = case M.lookup "src" (EvaluateTask.inputs task) of
  Just (ImmutableInput.Git x) ->
    purer x
  Nothing -> do
    throwIO $ FatalError $ "No src input provided" <> show task
  Just {} -> do
    pure Nothing

data AbortMessageAlreadySent = AbortMessageAlreadySent deriving (Show, Exception)

getWithStoreLimiter :: (MonadUnliftIO m) => (Env -> Memo Text ResourceLimiter) -> Text -> App (m a -> m a)
getWithStoreLimiter getLimiterMap store = do
  memo <- asks getLimiterMap
  limiter <- doOnce memo store do
    newResourceLimiter 32
  pure (withResource limiter)

-- | Apply concurreny limit
withStoreQuery :: Text -> App a -> App a
withStoreQuery storeURI m = do
  withLimit <- getWithStoreLimiter Env.concurrentStoreQueries storeURI
  withLimit m

-- | Apply concurreny limit
withStorePush :: Text -> App a -> App a
withStorePush storeURI m = do
  withLimit <- getWithStoreLimiter Env.concurrentStorePushes storeURI
  withLimit m

makeEventEmitter ::
  (Syncing EvaluateEvent.EvaluateEvent -> App ()) ->
  App (EvaluateEvent.EvaluateEvent -> App ())
makeEventEmitter writeToBatch = do
  let emitSingle = writeToBatch . Syncable

  eventCounter <- liftIO $ newIORef 0
  msgCounter <- liftIO $ newIORef 0

  let fixIndex ::
        (MonadIO m) =>
        EvaluateEvent.EvaluateEvent ->
        m EvaluateEvent.EvaluateEvent
      fixIndex (EvaluateEvent.Message m) = do
        i <- liftIO $ atomicModifyIORef msgCounter (\i0 -> (i0 + 1, i0))
        pure $ EvaluateEvent.Message m {Message.index = i}
      fixIndex other = pure other

      isLimited :: EvaluateEvent.EvaluateEvent -> Bool
      isLimited = \case
        EvaluateEvent.Attribute {} -> True
        EvaluateEvent.AttributeEffect {} -> True
        EvaluateEvent.AttributeError {} -> True
        EvaluateEvent.Message {} ->
          -- a simplistic solution against unexpected runaway Messages.
          -- we don't expect >1 message, especially since we have a separate eval log now,
          -- so realistically it doesn't impact the attribute limit at all.
          True
        _ -> False

      emit update = do
        when (isLimited update) do
          n <- liftIO $ atomicModifyIORef eventCounter $ \n -> dup (n + 1)
          when (n > eventLimit) do
            truncMsg <-
              fixIndex $
                EvaluateEvent.Message
                  Message.Message
                    { index = -1,
                      typ = Message.Error,
                      message =
                        "Evaluation limit reached. Does your nix expression produce infinite attributes? Please make sure that your project is finite. If it really does require more than "
                          <> show eventLimit
                          <> " attributes or messages, please contact info@hercules-ci.com."
                    }
            emitSingle truncMsg
            panic "Evaluation limit reached."
        emitSingle =<< fixIndex update
  pure emit

-- | @withDynamicBarrier (\addWait -> m ...)@ waits for any number of concurrent operations,
-- which are registered during the execution of @m@ using @addWait@.
--
-- The @addWait@ function enqueues an 'STM' transaction that will be dequeued only when the transaction completes successfully.
withDynamicBarrier :: (MonadUnliftIO m) => ((STM x -> STM ()) -> m a) -> m a
withDynamicBarrier driver = do
  -- Signals that `driver` is done. Prevents exiting before work builds up.
  driverDone :: TVar (STM ()) <- liftIO (newTVarIO retry)

  -- Collection of work that needs to complete before returning, including the `driver` itself.
  -- It's a list so that wait conditions that have completed can be removed; not checked again.
  done :: TVar [STM ()] <- liftIO (newTVarIO [join $ readTVar driverDone])

  let blockOn :: STM a -> STM ()
      blockOn stm = do
        modifyTVar done (void stm :)

      waitDone =
        atomically isDone >>= \case
          True -> pass
          False -> waitDone

      isDone :: STM Bool
      isDone = do
        readTVar done >>= \case
          [] -> pure True
          (c : cs) -> do
            c
            writeTVar done cs
            pure False

      claimDriverDone = writeTVar driverDone pass

  fst <$> concurrently (driver blockOn `UnliftIO.finally` atomically claimDriverDone) waitDone

withStores :: [Text] -> (Map Text Store -> App r) -> App r
withStores storeURIs f =
  let sorted = ordNub storeURIs
   in foldr
        ( \uri f2 stores -> do
            CNix.withStoreFromURI uri \store ->
              f2 (stores <> M.singleton uri store)
        )
        f
        sorted
        mempty

withSubstituters :: (Map Text Store -> App r) -> App r
withSubstituters f = do
  substituterURIs <- getConfiguredSubstituters
  withStores substituterURIs f

produceEvaluationTaskEvents ::
  (Vector LogEntry -> IO ()) ->
  Store ->
  EvaluateTask.EvaluateTask ->
  (Syncing EvaluateEvent.EvaluateEvent -> App ()) ->
  App ()
produceEvaluationTaskEvents sendLogItems store task writeToBatch = UnliftIO.handle (\AbortMessageAlreadySent -> pass) $ withSubstituters \substituters -> withWorkDir "eval" $ \tmpdir -> do
  let sync = syncer writeToBatch
  emit <- makeEventEmitter writeToBatch

  derivationInfoUpload :: Memo ByteString () <- newMemo
  derivationSubstitutable :: Memo (Text, ByteString) Bool <- newMemo
  derivationBuildRequest :: Memo Text () <- newMemo
  derivationCache :: Memo ByteString CNix.Derivation <- newMemo
  planBuild_ <- newMemo

  let emitDrvInfoRaw :: StorePath -> App ()
      emitDrvInfoRaw drvPath = do
        drvInfo <- retrieveDerivationInfo store drvPath
        forConcurrently_ (M.keys $ DerivationInfo.inputDerivations drvInfo) \inp -> do
          inputStorePath <- liftIO $ parseStorePath store (encodeUtf8 inp)
          emitDrvInfo inputStorePath
        emit $ EvaluateEvent.DerivationInfo drvInfo

      getDerivationCached :: StorePath -> App CNix.Derivation
      getDerivationCached drvPath = do
        bs <- liftIO do getStorePathBaseName drvPath
        doOnce derivationCache bs do
          liftIO $ CNix.getDerivation store drvPath

      emitDrvInfo :: StorePath -> App ()
      emitDrvInfo sp = do
        bs <- liftIO (CNix.getStorePathBaseName sp)
        doOnce derivationInfoUpload bs do
          emitDrvInfoRaw sp

      rawQuerySubstitutableOutput :: StorePath -> CNix.Derivation -> Text -> ByteString -> App Bool
      rawQuerySubstitutableOutput drvPath drv drvPathText outputName = do
        drvName <- liftIO $ CNix.getDerivationNameFromPath drvPath
        outputs <- liftIO $ CNix.getDerivationOutputs store drvName drv
        output <- case find (\o -> o.derivationOutputName == outputName) outputs of
          Nothing -> panic $ "derivation " <> drvPathText <> " does not have output " <> show outputName
          Just x -> pure x
        querySubstitutableOutput' drvPathText outputName output

      querySubstitutableOutput' drvPathText outputName output = do
        doOnce derivationSubstitutable (drvPathText, outputName) do
          rawQuerySubstitutableOutput' drvPathText outputName output

      rawQuerySubstitutableOutput' drvPathText outputName output = do
        outputPath <- case output.derivationOutputPath of
          Nothing -> panic $ "derivation " <> drvPathText <> " does not have a predetermined path. Content addressed derivations are not supported yet."
          Just x -> pure x
        alreadyPositive <-
          any (isJust . join) <$> for (M.toList substituters) \(_uri, substituter) -> liftIO do
            CNix.queryPathInfoFromClientCache substituter outputPath
        if alreadyPositive
          then pure True
          else do
            -- TODO waitAny
            any identity <$> forConcurrently (M.toList substituters) \(uri, substituter) -> do
              exists <- withStoreQuery uri do
                liftIO (CNix.isValidPath substituter outputPath)
              if exists
                then do
                  drvInfo <- liftIO (toDrvInfo substituter output)
                  emit $
                    EvaluateEvent.SubstitutionQueryResult
                      SubstitutionQueryResult.SubstitutionQueryResult
                        { storeURI = uri,
                          derivation = drvPathText,
                          outputName = decodeUtf8With lenientDecode outputName,
                          outputInfo = Just (convertOutputInfo drvPathText drvInfo)
                        }
                else do
                  emit $
                    EvaluateEvent.SubstitutionQueryResult
                      SubstitutionQueryResult.SubstitutionQueryResult
                        { storeURI = uri,
                          derivation = drvPathText,
                          outputName = decodeUtf8With lenientDecode outputName,
                          outputInfo = Nothing
                        }

              pure exists

      storePathToText :: StorePath -> App Text
      storePathToText sp = do
        liftIO (CNix.storePathToPath store sp) <&> decodeUtf8With lenientDecode

      planAllOutputs :: StorePath -> App ()
      planAllOutputs drvPath = do
        drvPathText <- storePathToText drvPath
        drv <- getDerivationCached drvPath
        drvName <- liftIO $ CNix.getDerivationNameFromPath drvPath
        outputs <- liftIO $ CNix.getDerivationOutputs store drvName drv
        outputsSubstitutable <-
          all identity <$> forConcurrently outputs \output ->
            querySubstitutableOutput' drvPathText output.derivationOutputName output

        when (not outputsSubstitutable) do
          planBuild drv drvPathText

      -- Like 'planInputs' but do assume we need to build all of them.
      planInputsForced drvPath = do
        drv <- getDerivationCached drvPath
        inputs <- liftIO (CNix.getDerivationInputs' drv)
        for_ inputs \(inputDrvPath, _outputs) -> do
          inputDrv <- getDerivationCached inputDrvPath
          planBuild inputDrv =<< storePathToText inputDrvPath

      planInputs drv = do
        inputs <- liftIO (CNix.getDerivationInputs' drv)
        for_ inputs \(inputDrvPath, outputs) -> do
          inputDrvPathText <- decodeUtf8With lenientDecode <$> liftIO (CNix.storePathToPath store inputDrvPath)
          inputDrv <- getDerivationCached inputDrvPath
          for outputs \outputName -> do
            planOutput inputDrvPath inputDrv outputName inputDrvPathText

      planOutput :: StorePath -> CNix.Derivation -> ByteString -> Text -> App ()
      planOutput drvPath drv outputName drvPathText = do
        isSubstitutable <- rawQuerySubstitutableOutput drvPath drv drvPathText outputName
        if isSubstitutable
          then pass
          else do
            planBuild drv drvPathText

      -- Plan a build, when it has been determined that a build task is necessary.
      planBuild drv drvPathText =
        doOnce planBuild_ drvPathText do
          planInputs drv
          requestBuild drvPathText

      requestBuild drvPathText =
        doOnce derivationBuildRequest drvPathText do
          emit $ EvaluateEvent.BuildRequest BuildRequest.BuildRequest {derivationPath = drvPathText, forceRebuild = False}

  let isFlakeJob = EvaluateTask.isFlakeJob task
  nonFlakeStuff <- for (guard (not isFlakeJob)) \() -> do
    inputLocations <-
      EvaluateTask.otherInputs task
        & M.traverseWithKey \k src -> do
          let fetchName = tmpdir </> ("fetch-" <> toS k)
              argName = tmpdir </> ("arg-" <> toS k)
              metaName = do
                meta <- EvaluateTask.inputMetadata task & M.lookup k
                nameValue <- meta & M.lookup "name"
                name <- case A.fromJSON nameValue of
                  A.Success a -> pure a
                  _ -> Nothing
                guard (isValidName name)
                pure name
              destName
                | Just sourceName <- metaName = argName </> sourceName
                | otherwise = argName
          fetched <- fetchSource fetchName src
          liftIO do
            Dir.createDirectoryIfMissing True (takeDirectory destName)
            renamePathTryHarder fetched destName
          pure destName
    projectDir <- case M.lookup "src" inputLocations of
      Nothing -> panic "No primary source provided"
      Just x -> pure x
    nixPath <-
      EvaluateTask.nixPath task
        & ( traverse
              . traverse
              . traverse
              $ \identifier -> case M.lookup identifier inputLocations of
                Just x -> pure x
                Nothing ->
                  throwIO $
                    FatalError $
                      "Nix path references undefined input "
                        <> identifier
          )
    autoArguments' <-
      EvaluateTask.autoArguments task
        & (traverse . traverse)
          ( \identifier -> case M.lookup identifier inputLocations of
              Just x | "/" `isPrefixOf` x -> pure x
              Just x ->
                throwIO $
                  FatalError $
                    "input "
                      <> identifier
                      <> " was not resolved to an absolute path: "
                      <> toS x
              Nothing ->
                throwIO $
                  FatalError $
                    "auto argument references undefined input "
                      <> identifier
          )
    let autoArguments =
          autoArguments'
            & M.mapWithKey \k sp ->
              let argPath = encodeUtf8 $ renderSubPath $ toS <$> sp
               in case do
                    inputId <- EvaluateTask.autoArguments task & M.lookup k
                    EvaluateTask.inputMetadata task & M.lookup (EvaluateTask.path inputId) of
                    Nothing -> Eval.ExprArg argPath
                    Just attrs ->
                      Eval.ExprArg $
                        -- TODO pass directly to avoid having to escape (or just escape properly)
                        "builtins.fromJSON ''" <> BL.toStrict (A.encode attrs) <> "'' // { outPath = " <> argPath <> "; }"
    adHocSystem <-
      readFileMaybe (projectDir </> "ci-default-system.txt")

    file <-
      liftIO (findNixFile projectDir) >>= \case
        Left e -> do
          emit $
            EvaluateEvent.Message
              Message.Message
                { Message.index = -1, -- will be set by emit
                  Message.typ = Message.Error,
                  Message.message = e
                }
          throwIO AbortMessageAlreadySent
        Right file ->
          pure file

    pure (nixPath, autoArguments, inputLocations, adHocSystem, projectDir, file)

  let nixPath = case nonFlakeStuff of
        Nothing -> mempty
        Just (np, _, _, _, _, _) -> np
      autoArgs = case nonFlakeStuff of
        Nothing -> mempty
        Just (_, aa, _, _, _, _) -> aa
      allowedPaths = case nonFlakeStuff of
        Nothing -> []
        Just (_, _, inputLocations, _, _, _) -> toList inputLocations <&> toS <&> encodeUtf8
      adHocSystem = case nonFlakeStuff of
        Nothing -> Nothing
        Just (_, _, _, a, _, _) -> a
      projectDir = case nonFlakeStuff of
        Nothing -> tmpdir -- unused
        Just (_, _, _, _, pd, _) -> pd
      file = case nonFlakeStuff of
        Nothing -> "" -- unused
        Just (_, _, _, _, _, file_) -> file_

  let uploadDrvs paths = do
        caches <- activePushCaches
        forM_ caches $ \cache -> do
          withNamedContext "cache" cache $ logLocM DebugS "Pushing derivations"

          -- TODO: Make it fine grained? withStorePush here and now is a limit on concurrent closures.
          withStorePush cache do
            Agent.Cache.push store cache (toList paths) pushEvalWorkers

  withDynamicBarrier \addToWait ->
    let addAsync = addToWait . waitSTM
        uploadDrvInfos drvPath = do
          emitDrvInfo drvPath
          uploadDrvs [drvPath]
        addTopDerivation drvPath = do
          atomically . addAsync =<< async do
            concurrently_
              (emitDrvInfo drvPath)
              (uploadDrvs [drvPath])
            planAllOutputs drvPath
            -- In theory we should be done now
            -- but we want derivation events for these for the backend, and
            -- potentially for inspecting the nix-support directory (TBD).
            -- FIXME: this realises build inputs even if output was substitutable
            --        (but only for top level derivations)
            planInputs =<< getDerivationCached drvPath
            requestBuild =<< storePathToText drvPath

        addTopDerivationInputs drvPath = do
          atomically . addAsync =<< async do
            concurrently_
              (emitDrvInfo drvPath)
              (uploadDrvs [drvPath])
            -- In theory we should be done (as in addTopDerivation) but we want
            -- derivation events for the inputs, so we force them.
            -- FIXME: this realises build inputs even if output was substitutable
            --        (but only for top level deps-only derivations' inputs)
            planInputsForced drvPath

        extraOpts = [("system", T.strip s) | Just s <- [adHocSystem]]
        evaluation = do
          let evalProc =
                Nix.withExtraOptions extraOpts do
                  runEvalProcess
                    sendLogItems
                    store
                    projectDir
                    file
                    autoArgs
                    nixPath
                    captureAttrDrvAndEmit
                    uploadDrvInfos
                    sync
                    task
                    allowedPaths
          evalProc
        captureAttrDrvAndEmit msg = do
          case msg of
            EvaluateEvent.Attribute ae -> do
              storePath <- liftIO $ parseStorePath store (encodeUtf8 $ AttributeEvent.derivationPath ae)
              case ae.typ of
                AttributeEvent.Regular -> addTopDerivation storePath
                AttributeEvent.MustFail -> addTopDerivation storePath
                AttributeEvent.MayFail -> addTopDerivation storePath
                AttributeEvent.DependenciesOnly -> addTopDerivationInputs storePath
                AttributeEvent.Effect -> addTopDerivationInputs storePath
            EvaluateEvent.AttributeEffect ae -> do
              storePath <- liftIO $ parseStorePath store (encodeUtf8 $ AttributeEffectEvent.derivationPath ae)
              addTopDerivationInputs storePath
            _ -> pass
          emit msg
     in evaluation

isValidName :: FilePath -> Bool
isValidName "" = False
isValidName cs@(c0 : _) = all isValidNameChar cs && c0 /= '.'
  where
    isValidNameChar c =
      isAsciiUpper c
        || isAsciiLower c
        || isDigit c
        || c `elem` ("+-._?=" :: [Char])

checkNonEmptyText :: Text -> Maybe Text
checkNonEmptyText "" = Nothing
checkNonEmptyText t = Just t

runEvalProcess ::
  (Vector LogEntry -> IO ()) ->
  CNix.Store ->
  FilePath ->
  FilePath ->
  Map Text Eval.Arg ->
  [ EvaluateTask.NixPathElement
      (EvaluateTask.SubPathOf FilePath)
  ] ->
  (EvaluateEvent.EvaluateEvent -> App ()) ->
  -- | Upload a derivation, return when done
  (StorePath -> App ()) ->
  App () ->
  EvaluateTask.EvaluateTask ->
  [ByteString] ->
  App ()
runEvalProcess sendLogItems store projectDir file autoArguments nixPath emit uploadDerivationInfos flush task allowedPaths = do
  extraOpts <- Nix.askExtraOptions
  apiBaseUrl <- asks (toS . showBaseUrl . Env.herculesBaseUrl)
  cfg <- asks Env.config
  srcInput <- getSrcInput task
  gitSource <-
    case srcInput of
      Just git ->
        pure $
          GitSource.GitSource
            { outPath = toS projectDir,
              ref = ImmutableGitInput.ref git,
              rev = ImmutableGitInput.rev git,
              shortRev = GitSource.shortRevFromRev (ImmutableGitInput.rev git),
              branch = GitSource.branchFromRef (ImmutableGitInput.ref git),
              tag = GitSource.tagFromRef (ImmutableGitInput.ref git),
              remoteHttpUrl = ImmutableGitInput.httpURL git & checkNonEmptyText,
              remoteSshUrl = ImmutableGitInput.sshURL git & checkNonEmptyText,
              webUrl = ImmutableGitInput.webURL git,
              forgeType = ImmutableGitInput.forgeType git,
              owner = ImmutableGitInput.owner git,
              name = ImmutableGitInput.name git
            }
      Nothing -> do
        (ref, rev) <- case M.lookup "src" (EvaluateTask.inputMetadata task) of
          Nothing -> do
            panic $ "No primary source metadata provided" <> show task
          Just meta -> pure $ fromMaybe (panic "no ref/rev in primary source metadata") do
            (,)
              <$> (meta ^? at "ref" . traverse . _String)
              <*> (meta ^? at "rev" . traverse . _String)
        pure $
          GitSource.GitSource
            { outPath = toS projectDir,
              ref = ref,
              rev = rev,
              shortRev = GitSource.shortRevFromRev rev,
              branch = GitSource.branchFromRef ref,
              tag = GitSource.tagFromRef ref,
              remoteHttpUrl = Nothing,
              remoteSshUrl = Nothing,
              webUrl = Nothing,
              forgeType = Nothing,
              owner = Nothing,
              name = Nothing
            }
  let eval =
        Eval.Eval
          { Eval.cwd = projectDir,
            Eval.file = toS file,
            Eval.autoArguments = autoArguments,
            Eval.extraNixOptions = extraOpts,
            Eval.gitSource = ViaJSON gitSource,
            Eval.srcInput = ViaJSON <$> srcInput,
            Eval.apiBaseUrl = apiBaseUrl,
            Eval.ciSystems = EvaluateTask.ciSystems task,
            Eval.selector = ViaJSON $ EvaluateTask.selector task,
            Eval.isFlakeJob = EvaluateTask.isFlakeJob task,
            Eval.allowInsecureBuiltinFetchers = Config.allowInsecureBuiltinFetchers cfg,
            Eval.allowedPaths = allowedPaths
          }
  buildRequiredIndex <- liftIO $ newIORef (0 :: Int)
  attributeIFDCounter <- liftIO $ newIORef (0 :: Int)
  commandChan <- newChan
  writeChan commandChan $ Just $ Command.Eval eval
  for_ (EvaluateTask.extraGitCredentials task) \creds ->
    Netrc.appendLines (credentialToLines =<< creds)
  netrcFile <- Netrc.getNetrcFile
  let decode = decodeUtf8With lenientDecode
      toGitConfigEnv items =
        M.fromList $
          ("GIT_CONFIG_COUNT", show (length items))
            : concatMap
              ( \(i, (k, v)) ->
                  [ ("GIT_CONFIG_KEY_" <> show i, k),
                    ("GIT_CONFIG_VALUE_" <> show i, v)
                  ]
              )
              (zip [0 :: Int ..] items)
      envSettings =
        WorkerProcess.WorkerEnvSettings
          { nixPath = nixPath,
            extraEnv =
              toGitConfigEnv
                [ ("credential.helper", "netrc --file " <> netrcFile),
                  -- Deny by default.
                  ("protocol.allow", "never"),
                  -- Safe protocols.
                  -- More protocols can be added as long as they can be shown
                  -- not to leak credentials from netrc or .git-credentials.
                  -- If a protocol is lacking in confidentiality, authenticity,
                  -- etc, it must be off by default with a Config item to
                  -- enable it.
                  ("protocol.https.allow", "always"),
                  ("protocol.ssh.allow", "always"),
                  ("protocol.file.allow", "always")
                ]
          }
  withProducer (produceWorkerEvents sendLogItems (EvaluateTask.id task) eval envSettings commandChan) $
    \workerEventsP -> fix $ \continue ->
      joinSTM $
        listen
          workerEventsP
          ( \case
              Event.Attribute a | WorkerAttribute.typ a == WorkerAttribute.Effect -> do
                let drvPath = WorkerAttribute.drv a
                secretsMay <- liftIO $ Safe.try do
                  drvStorePath <- CNix.parseStorePath store drvPath
                  derivation <- CNix.getDerivation store drvStorePath
                  drvEnv <- CNix.getDerivationEnv derivation
                  pure $ parseDrvSecretsMap drvEnv

                let emitError msg =
                      emit $
                        EvaluateEvent.AttributeError $
                          AttributeErrorEvent.AttributeErrorEvent
                            { AttributeErrorEvent.expressionPath = decode <$> WorkerAttribute.path a,
                              AttributeErrorEvent.errorMessage = msg,
                              AttributeErrorEvent.errorType = Nothing,
                              AttributeErrorEvent.errorDerivation = Just $ decodeUtf8With lenientDecode (WorkerAttribute.drv a),
                              AttributeErrorEvent.trace = Nothing
                            }
                case secretsMay of
                  Right (Right secrets) -> do
                    emit $
                      EvaluateEvent.AttributeEffect $
                        AttributeEffectEvent.AttributeEffectEvent
                          { expressionPath = decode <$> WorkerAttribute.path a,
                            derivationPath = decode $ WorkerAttribute.drv a,
                            secretsToUse = secrets
                          }
                  Right (Left userError) -> do
                    emitError userError
                  Left technicalError -> do
                    katipAddContext (sl "message" (displayException (technicalError :: SomeException))) do
                      logLocM ErrorS "An unexpected exception occurred while reading an effect."
                    emitError "An unexpected exception occurred while reading the effect. The error message has been logged locally on the agent."
                continue
              Event.Attribute a -> do
                emit $
                  EvaluateEvent.Attribute $
                    AttributeEvent.AttributeEvent
                      { AttributeEvent.expressionPath = decode <$> WorkerAttribute.path a,
                        AttributeEvent.derivationPath = decode $ WorkerAttribute.drv a,
                        AttributeEvent.typ = case WorkerAttribute.typ a of
                          WorkerAttribute.Regular -> AttributeEvent.Regular
                          WorkerAttribute.MustFail -> AttributeEvent.MustFail
                          WorkerAttribute.MayFail -> AttributeEvent.MayFail
                          WorkerAttribute.Effect -> AttributeEvent.Effect
                          WorkerAttribute.DependenciesOnly -> AttributeEvent.DependenciesOnly
                      }
                continue
              Event.AttributeError e -> do
                emit $
                  EvaluateEvent.AttributeError $
                    AttributeErrorEvent.AttributeErrorEvent
                      { AttributeErrorEvent.expressionPath = decode <$> WorkerAttributeError.path e,
                        AttributeErrorEvent.errorMessage = WorkerAttributeError.message e,
                        AttributeErrorEvent.errorType = WorkerAttributeError.errorType e,
                        AttributeErrorEvent.errorDerivation = WorkerAttributeError.errorDerivation e,
                        AttributeErrorEvent.trace = WorkerAttributeError.trace e
                      }
                continue
              Event.AttributeIFD e -> do
                index <- atomicModifyIORef' attributeIFDCounter \n -> (n + 1, n)
                emit $
                  EvaluateEvent.AttributeIFD $
                    AttributeIFDEvent.AttributeIFDEvent
                      { AttributeIFDEvent.expressionPath = decode <$> AttributeIFD.path e,
                        AttributeIFDEvent.derivationPath = decode $ AttributeIFD.derivationPath e,
                        AttributeIFDEvent.derivationOutput = decode $ AttributeIFD.derivationOutput e,
                        AttributeIFDEvent.done = AttributeIFD.done e,
                        AttributeIFDEvent.index = index
                      }
                continue
              Event.EvaluationDone ->
                writeChan commandChan Nothing
              Event.Error e -> do
                emit $
                  EvaluateEvent.Message
                    Message.Message
                      { Message.index = -1, -- will be set by emit
                        Message.typ = Message.Error,
                        Message.message = e
                      }
                continue
              Event.Build drv outputName notAttempt waitForStatus -> do
                storePath <- liftIO (parseStorePath store drv)
                let drvText = decode drv
                withNamedContext "derivation" (decode drv) $ do
                  currentIndex <- liftIO $ atomicModifyIORef buildRequiredIndex (\i -> (i + 1, i))
                  emit $
                    EvaluateEvent.BuildRequired
                      BuildRequired.BuildRequired
                        { BuildRequired.derivationPath = drvText,
                          BuildRequired.index = currentIndex,
                          BuildRequired.outputName = outputName
                        }
                  let pushDerivations = do
                        caches <- activePushCaches
                        forM_ caches $ \cache -> do
                          withNamedContext "cache" cache $ logLocM DebugS "Pushing derivations for import from derivation"
                          withStorePush cache do
                            Agent.Cache.push store cache [storePath] pushEvalWorkers
                  Async.Lifted.concurrently_
                    (uploadDerivationInfos storePath)
                    pushDerivations
                  emit $
                    EvaluateEvent.BuildRequest
                      BuildRequest.BuildRequest
                        { derivationPath = drvText,
                          forceRebuild = isJust notAttempt
                        }
                  let doPoll = do
                        status <- drvPoller notAttempt drvText
                        logLocM DebugS $ "Got derivation status " <> logStr (show status :: Text)
                        writeChan commandChan $ Just $ Command.BuildResult $ uncurry (BuildResult.BuildResult drvText) status
                  if waitForStatus
                    then do
                      flush
                      doPoll
                    else void $ do
                      uio <- askUnliftIO
                      liftIO $ forkIO $ unliftIO uio doPoll
                continue
              Event.LogItems (ViaJSON e) -> do
                liftIO (sendLogItems e)
                continue
              Event.OnPushHandler (ViaJSON e) -> do
                emit $ EvaluateEvent.OnPushHandlerEvent e
                continue
              Event.OnScheduleHandler (ViaJSON e) -> do
                emit $ EvaluateEvent.OnScheduleHandlerEvent e
                continue
              Event.JobConfig -> do
                emit $ EvaluateEvent.JobConfig JobConfig.JobConfig {sourceCaches = Nothing, binaryCaches = Nothing}
                continue
              Event.Exception e -> panic e
              -- Unused during eval
              Event.BuildResult {} -> pass
              Event.EffectResult {} -> pass
          )
          ( \case
              ExitSuccess -> logLocM DebugS "Clean worker exit"
              ExitFailure e -> do
                withNamedContext "exitStatus" e $ logLocM ErrorS "Worker failed"
                panic $ "Worker failed with exit status: " <> show e
          )

credentialToLines :: EvaluateTask.Credential -> [Text]
credentialToLines c =
  fromMaybe [] do
    host <- hostFromUrl (EvaluateTask.url c)
    pure
      [ "machine " <> host,
        "login " <> EvaluateTask.username c,
        "password " <> EvaluateTask.password c
      ]

hostFromUrl :: Text -> Maybe Text
hostFromUrl t = do
  uri <- Network.URI.parseURI (toS t)
  a <- Network.URI.uriAuthority uri
  pure $ toS $ Network.URI.uriRegName a

produceWorkerEvents ::
  (Vector LogEntry -> IO ()) ->
  Id (Task EvaluateTask.EvaluateTask) ->
  Eval.Eval ->
  WorkerProcess.WorkerEnvSettings ->
  Chan (Maybe Command.Command) ->
  (Event.Event -> App ()) ->
  App ExitCode
produceWorkerEvents sendLogEntries taskId eval envSettings commandChan writeEvent = do
  workerExe <- WorkerProcess.getWorkerExe
  let opts = ["eval", fromMaybe "" eval.gitSource.fromViaJSON.webUrl, eval.gitSource.fromViaJSON.rev] <&> T.unpack
  workerEnv <- liftIO $ WorkerProcess.prepareEnv envSettings
  let wps =
        (System.Process.proc workerExe opts)
          { env = Just workerEnv,
            close_fds = True, -- Disable on Windows?
            cwd = Just (Eval.cwd eval)
          }
      stderrHandler =
        stderrLineHandler
          sendLogEntries
          ( M.fromList
              [ ("taskId", A.toJSON taskId),
                ("evalRev", A.toJSON (eval.gitSource.fromViaJSON.rev))
              ]
          )
          "Effect worker"
  cfg <- WorkerConfig.getWorkerConfig
  WorkerProcess.runWorker cfg wps stderrHandler commandChan writeEvent

drvPoller :: Maybe UUID -> Text -> App (UUID, BuildResult.BuildStatus)
drvPoller notAttempt drvPath = do
  resp <-
    defaultRetry $
      runHerculesClient $
        getDerivationStatus2
          Hercules.Agent.Client.evalClient
          drvPath
  let oneSecond = 1000 * 1000
      again = do
        liftIO $ threadDelay oneSecond
        drvPoller notAttempt drvPath
  case resp of
    Nothing -> again
    Just (attempt, _) | Just attempt == notAttempt -> again
    Just (_attempt, DerivationStatus.Waiting) -> again
    Just (_attempt, DerivationStatus.Building) -> again
    Just (attempt, DerivationStatus.BuildFailure) -> pure (attempt, BuildResult.Failure)
    Just (attempt, DerivationStatus.DependencyFailure) -> pure (attempt, BuildResult.DependencyFailure)
    Just (attempt, DerivationStatus.BuildSuccess) -> pure (attempt, BuildResult.Success)

newtype SubprocessFailure = SubprocessFailure {message :: Text}
  deriving (Typeable, Show)
  deriving anyclass (Exception)

fetchSource :: FilePath -> Text -> App FilePath
fetchSource targetDir url = do
  clientEnv <- asks herculesClientEnv
  liftIO $ Dir.createDirectoryIfMissing True targetDir
  request <- HTTP.Simple.parseRequest $ toS url
  -- TODO: report stderr to service
  -- TODO: discard stdout
  -- Fewer retries in order to speed up the tests.
  quickRetry $ do
    (x, _, _) <-
      liftIO $
        (`runReaderT` Servant.Client.manager clientEnv) $
          HTTP.Conduit.withResponse request $
            \response -> do
              let tarball = HTTP.Conduit.responseBody response
                  procSpec =
                    (System.Process.proc "tar" ["-xz"]) {cwd = Just targetDir}
              sourceProcessWithStreams
                procSpec
                tarball
                Conduit.stderrC
                Conduit.stderrC
    case x of
      ExitSuccess -> pass
      ExitFailure {} -> throwIO $ SubprocessFailure "Extracting tarball"
    liftIO $ findTarballDir targetDir

dup :: a -> (a, a)
dup a = (a, a)

-- | Tarballs typically have a single directory at the root to cd into.
findTarballDir :: FilePath -> IO FilePath
findTarballDir fp = do
  nodes <- Dir.listDirectory fp
  case nodes of
    [x] ->
      Dir.doesDirectoryExist (fp </> x) >>= \case
        True -> pure $ fp </> x
        False -> pure fp
    _ -> pure fp

postBatch :: EvaluateTask.EvaluateTask -> [EvaluateEvent.EvaluateEvent] -> App ()
postBatch task events =
  noContent $
    defaultRetry $
      runHerculesClient
        ( tasksUpdateEvaluation
            Hercules.Agent.Client.evalClient
            (EvaluateTask.id task)
            events
        )