packages feed

hix-0.7.2: lib/Hix/Managed/Handlers/Build/Prod.hs

module Hix.Managed.Handlers.Build.Prod where

import Control.Monad.Catch (catch)
import Control.Monad.Trans.Class (lift)
import Control.Monad.Trans.Reader (ask, asks)
import Control.Monad.Trans.State.Strict (StateT (runStateT), gets, modify')
import qualified Data.ByteString.Char8 as ByteString
import Data.List.Extra (firstJust)
import qualified Data.Set as Set
import Exon (exon)
import Network.HTTP.Client (newManager)
import Network.HTTP.Client.TLS (tlsManagerSettings)
import Path (Abs, Dir, Path, toFilePath)
import Path.IO (copyDirRecur')
import System.IO (BufferMode (LineBuffering), Handle, hSetBuffering)
import System.IO.Error (IOError, tryIOError)
import System.Process.Typed (
  ExitCode (ExitFailure, ExitSuccess),
  ProcessConfig,
  createPipe,
  getStderr,
  inherit,
  nullStream,
  proc,
  setStderr,
  setStdout,
  setWorkingDir,
  waitExitCode,
  withProcessTerm,
  )
import System.Timeout (timeout)

import Hix.Class.Map (nInsert, nMember)
import Hix.Data.EnvName (EnvName)
import Hix.Data.Error (Error (Fatal))
import qualified Hix.Data.Monad
import Hix.Data.Monad (M (M))
import Hix.Data.Overrides (Overrides)
import qualified Hix.Data.PackageId
import Hix.Data.PackageId (PackageId)
import Hix.Data.PackageName (LocalPackage)
import Hix.Data.Version (Versions)
import Hix.Error (pathText)
import Hix.Hackage (latestVersionHackage, versionsHackage)
import qualified Hix.Log as Log
import Hix.Managed.Build.NixOutput (OutputResult (OutputResult), PackageDerivation (..), outputParse, runOutputState)
import Hix.Managed.Build.NixOutput.Analysis (analyzeLog)
import Hix.Managed.Cabal.Data.Config (CabalConfig)
import qualified Hix.Managed.Data.BuildConfig
import Hix.Managed.Data.BuildConfig (BuildConfig)
import Hix.Managed.Data.EnvConfig (EnvConfig)
import qualified Hix.Managed.Data.EnvContext
import Hix.Managed.Data.EnvContext (EnvContext)
import Hix.Managed.Data.EnvState (EnvState)
import Hix.Managed.Data.Envs (Envs)
import Hix.Managed.Data.Initial (Initial)
import Hix.Managed.Data.StageState (BuildFailure (..), BuildResult (..), buildUnsuccessful)
import qualified Hix.Managed.Data.StateFileConfig
import Hix.Managed.Data.StateFileConfig (StateFileConfig)
import Hix.Managed.Data.Targets (firstMTargets)
import qualified Hix.Managed.Handlers.Build
import Hix.Managed.Handlers.Build (BuildHandlers (..), BuildOutputsPrefix, Builder (Builder), EnvBuilder (EnvBuilder))
import Hix.Managed.Handlers.Cabal (CabalHandlers)
import qualified Hix.Managed.Handlers.Cabal.Prod as CabalHandlers
import Hix.Managed.Handlers.Hackage (HackageHandlers)
import qualified Hix.Managed.Handlers.Hackage.Prod as HackageHandlers
import qualified Hix.Managed.Handlers.Report.Prod as ReportHandlers
import Hix.Managed.Handlers.StateFile (StateFileHandlers)
import qualified Hix.Managed.Handlers.StateFile.Prod as StateFileHandlers
import Hix.Managed.Overrides (packageOverride, packageOverrides)
import Hix.Managed.Path (rootOrCwd)
import Hix.Managed.StateFile (writeBuildStateFor, writeInitialEnvState)
import Hix.Monad (runMUsing, throwM, tryIOM, withTempDir)
import Hix.Pretty (showP)

data BuilderResources =
  BuilderResources {
    hackage :: HackageHandlers,
    stateFileHandlers :: StateFileHandlers,
    envsConf :: Envs EnvConfig,
    buildOutputsPrefix :: Maybe BuildOutputsPrefix,
    root :: Path Abs Dir,
    buildConfig :: BuildConfig
  }

data EnvBuilderResources =
  EnvBuilderResources {
    global :: BuilderResources,
    context :: EnvContext
  }

withTempProject ::
  Maybe (Path Abs Dir) ->
  (Path Abs Dir -> M a) ->
  M a
withTempProject rootOverride use = do
  projectRoot <- rootOrCwd rootOverride
  catch
    do
      withTempDir "managed-build" \ tmpRoot -> do
        copyDirRecur' projectRoot tmpRoot
        use tmpRoot
    \ (err :: IOError) -> throwM (Fatal (show err))

data OutputConfig =
  OutputDebug
  |
  OutputParse
  |
  OutputIgnore

data NixProcResult =
  NixProcSuccess [Text]
  |
  NixProcFailure Text
  deriving stock (Eq, Show, Generic)

outputLines ::
  MonadIO m =>
  (ByteString -> m ()) ->
  Handle ->
  m (Maybe Text)
outputLines parse handle = do
  liftIO (hSetBuffering handle LineBuffering)
  spin mempty
  where
    spin buf = do
      liftIO (tryIOError (ByteString.hGet handle 4096)) >>= \case
        Left err -> pure (Just (show err))
        Right new ->
          join <$> for (completeLines buf new) \ (lns, newBuf) -> do
            for_ lns parse
            spin newBuf

    completeLines = \cases
      -- Empty output means the handle was closed, empty buffer means we can terminate
      "" "" ->
        Nothing
      -- First iteration after handle was closed, need to emit the last line
      buf "" ->
        Just ([buf], "")
      buf new ->
        Just (breakNl [] (buf <> new))

    breakNl acc s =
      case ByteString.break (== '\n') s of
        (rest, "") -> (reverse acc, rest)
        (ln, rest) -> breakNl (ln : acc) (ByteString.drop 1 rest)

nixProc ::
  OutputConfig ->
  Path Abs Dir ->
  [Text] ->
  Text ->
  [Text] ->
  M (ProcessConfig () () (Maybe Handle))
nixProc output root cmd installable extra = do
  Log.debug [exon|Running nix at '#{pathText root}' with args #{show args}|]
  pure conf
  where
    conf = err (setWorkingDir (toFilePath root) (proc "nix" args))

    err = case output of
      OutputParse -> setStderr (Just <$> createPipe)
      OutputDebug -> setStdout inherit . setStderr (Nothing <$ inherit)
      OutputIgnore -> setStderr (Nothing <$ nullStream)

    args = toString <$> cmd ++ [exon|path:#{".#"}#{installable}|] : extra ++ logArgs

    logArgs = case output of
      OutputParse -> ["--log-format", "internal-json"]
      OutputDebug -> ["-L"]
      OutputIgnore -> []

runProc ::
  BuildConfig ->
  (Handle -> IO a) ->
  ProcessConfig () () (Maybe Handle) ->
  M (Maybe (Maybe a, ExitCode))
runProc buildConf pipeHandler conf =
  tryIOM (withProcessTerm conf interact)
  where
    interact prc =
      limit do
        output <- traverse pipeHandler (getStderr prc)
        res <- waitExitCode prc
        pure (output, res)

    limit | Just t <- buildConf.timeout
          , t > 0
          = timeout (coerce t * 1_000_000)
          | otherwise = fmap Just

outputResult ::
  Maybe (Either Error (Maybe Text, OutputResult)) ->
  ExitCode ->
  M BuildResult
outputResult result = \case
  ExitSuccess -> pure BuildSuccess
  ExitFailure _ -> pure (BuildFailure (maybe UnknownFailure PackageFailure failedPackage))
  where
    failedPackage =
      result >>= \case
        Right (_, OutputResult pkg) -> pkg
        Left _ -> Nothing

buildTarget ::
  BuildConfig ->
  Path Abs Dir ->
  EnvName ->
  LocalPackage ->
  M BuildResult
buildTarget buildConf root env target = do
  appRes <- M ask
  debug <- M (asks (.debug))
  conf <- nixProc (outputHandler debug) root ["build"] [exon|env.##{env}.##{target}|] []
  runProc buildConf (runOutput appRes) conf >>= \case
    Just (output, code) ->
      outputResult output code
    Nothing -> pure (BuildFailure (TimeoutFailure []))
  where
    runOutput appRes handle =
      runMUsing appRes (runOutputState (outputLines outputParse handle))

    outputHandler debug
      | buildConf.buildOutput || (buildConf.disableNixMonitor && debug)
      = OutputDebug
      | buildConf.disableNixMonitor
      = OutputIgnore
      | otherwise
      = OutputParse

buildAdaptive ::
  EnvBuilderResources ->
  Bool ->
  LocalPackage ->
  StateT (Overrides, Set PackageId) M BuildResult
buildAdaptive EnvBuilderResources {global, context} allowRevisions target = do
  build
  where
    build = do
      overrides <- gets fst
      result <- lift do
        writeBuildStateFor "current build" global.stateFileHandlers global.root context overrides
        buildTarget global.buildConfig global.root context.env target
      checkResult overrides result

    checkResult overrides result
      | allowRevisions
      , BuildFailure (PackageFailure pkgs) <- result
      , Just package <- firstJust logFailure (toList pkgs)
      , not (nMember package.name overrides)
      = retry package
      | otherwise
      = pure result

    logFailure PackageDerivation {package, log} =
      package <$ analyzeLog log

    retry broken = do
      lift $ Log.verbose [exon|Installed package failed with bounds error, retrying with override: #{showP broken}|]
      (package, newOverride) <- lift $ packageOverride global.hackage broken
      modify' \ (overrides, revisions) -> (nInsert package newOverride overrides, Set.insert broken revisions)
      build

buildWithState ::
  EnvBuilderResources ->
  Bool ->
  Versions ->
  [PackageId] ->
  M (BuildResult, (Overrides, Set PackageId))
buildWithState builder allowRevisions _ overrideVersions = do
  overrides <- packageOverrides builder.global.hackage overrideVersions
  let build = buildAdaptive builder allowRevisions
      s0 = (overrides, Set.empty)
  runStateT (firstMTargets BuildSuccess buildUnsuccessful build builder.context.targets) s0

-- | This used to have the purpose of reading an updated GHC package db using the current managed state, but this has
-- become obsolete.
--
-- TODO Decide whether to keep this for abstraction purposes.
withEnvBuilder ::
  ∀ a .
  BuilderResources ->
  CabalHandlers ->
  EnvContext ->
  Initial EnvState ->
  (EnvBuilder -> M a) ->
  M a
withEnvBuilder global cabal context initialState use = do
  writeInitialEnvState global.stateFileHandlers global.root context initialState
  use builder
  where
    builder =
      EnvBuilder {
        cabal,
        buildWithState = buildWithState resources
      }

    resources = EnvBuilderResources {..}

withBuilder ::
  HackageHandlers ->
  StateFileHandlers ->
  StateFileConfig ->
  Envs EnvConfig ->
  Maybe BuildOutputsPrefix ->
  BuildConfig ->
  (Builder -> M a) ->
  M a
withBuilder hackage stateFileHandlers stateFileConf envsConf buildOutputsPrefix buildConfig use =
  withTempProject stateFileConf.projectRoot \ root -> do
    let resources = BuilderResources {..}
    use Builder {withEnvBuilder = withEnvBuilder resources}

handlersProd ::
  MonadIO m =>
  StateFileConfig ->
  Envs EnvConfig ->
  Maybe BuildOutputsPrefix ->
  BuildConfig ->
  CabalConfig ->
  Bool ->
  m BuildHandlers
handlersProd stateFileConf envsConf buildOutputsPrefix buildConfig cabalConf oldest = do
  manager <- liftIO (newManager tlsManagerSettings)
  hackage <- HackageHandlers.handlersProd
  let stateFile = StateFileHandlers.handlersProd stateFileConf
  pure BuildHandlers {
    stateFile,
    report = ReportHandlers.handlersProd,
    cabal = CabalHandlers.handlersProd cabalConf oldest,
    withBuilder = withBuilder hackage stateFile stateFileConf envsConf buildOutputsPrefix buildConfig,
    versions = versionsHackage manager,
    latestVersion = latestVersionHackage manager
  }