A small exec wrapper which will query the lorri daemon for the last few events, and if it sees a build running for the current project (searching upwards for shell.nix), it will wait for the build to finish before executing the command (in the new direnv environment). TODO: should patch lorri so that it can provide this information in a better digestive format; right now it might have a later evaluation running, so it’s hard to know which completion to wait for … Change-Id: I8fa4a10484830a731fe3ec58f2694498f46a496c Reviewed-on: https://cl.tvl.fyi/c/depot/+/5903 Tested-by: BuildkiteCI Reviewed-by: Profpatsch <mail@profpatsch.de>
		
			
				
	
	
		
			189 lines
		
	
	
	
		
			6.4 KiB
		
	
	
	
		
			Haskell
		
	
	
	
	
	
			
		
		
	
	
			189 lines
		
	
	
	
		
			6.4 KiB
		
	
	
	
		
			Haskell
		
	
	
	
	
	
{-# LANGUAGE DerivingStrategies #-}
 | 
						|
{-# LANGUAGE FlexibleContexts #-}
 | 
						|
{-# LANGUAGE LambdaCase #-}
 | 
						|
{-# LANGUAGE NamedFieldPuns #-}
 | 
						|
{-# LANGUAGE NumericUnderscores #-}
 | 
						|
{-# LANGUAGE OverloadedStrings #-}
 | 
						|
{-# LANGUAGE QuasiQuotes #-}
 | 
						|
{-# LANGUAGE ScopedTypeVariables #-}
 | 
						|
{-# OPTIONS_GHC -Wall #-}
 | 
						|
 | 
						|
module Main where
 | 
						|
 | 
						|
import Conduit
 | 
						|
import qualified Conduit as Cond
 | 
						|
import Control.Concurrent
 | 
						|
import qualified Control.Concurrent.Async as Async
 | 
						|
import Control.Monad
 | 
						|
import qualified Data.Aeson.BetterErrors as Json
 | 
						|
import Data.Bifunctor
 | 
						|
import Data.ByteString (ByteString)
 | 
						|
import qualified Data.Conduit.Binary as Conduit.Binary
 | 
						|
import qualified Data.Conduit.Combinators as Cond
 | 
						|
import Data.Conduit.Process
 | 
						|
import Data.Error
 | 
						|
import Data.Function
 | 
						|
import Data.Functor
 | 
						|
import Data.List.NonEmpty (NonEmpty ((:|)), nonEmpty)
 | 
						|
import Data.Text (Text)
 | 
						|
import qualified Data.Text as Text
 | 
						|
import qualified Data.Text.Encoding
 | 
						|
import qualified Data.Text.Encoding.Error
 | 
						|
import Data.Text.IO (hPutStrLn)
 | 
						|
import PyF
 | 
						|
import qualified System.Directory as Dir
 | 
						|
import qualified System.Environment as Env
 | 
						|
import qualified System.Exit as Exit
 | 
						|
import System.FilePath (takeDirectory)
 | 
						|
import qualified System.FilePath.Posix as FilePath
 | 
						|
import System.IO (stderr)
 | 
						|
import qualified System.Posix as Posix
 | 
						|
import Prelude hiding (log)
 | 
						|
 | 
						|
data LorriEvent = LorriEvent
 | 
						|
  { nixFile :: Text,
 | 
						|
    eventType :: LorriEventType
 | 
						|
  }
 | 
						|
  deriving stock (Show)
 | 
						|
 | 
						|
data ChanToken a
 | 
						|
  = -- | so we can see that the lorri thread has been initialized
 | 
						|
    NoEventYet
 | 
						|
  | ChanEvent a
 | 
						|
 | 
						|
data LorriEventType
 | 
						|
  = Completed
 | 
						|
  | Started
 | 
						|
  | Failure
 | 
						|
  deriving stock (Show)
 | 
						|
 | 
						|
main :: IO ()
 | 
						|
main = do
 | 
						|
  argv <- Env.getArgs <&> nonEmpty
 | 
						|
 | 
						|
  dir <- Dir.getCurrentDirectory
 | 
						|
  shellNix <-
 | 
						|
    findShellNix dir >>= \case
 | 
						|
      Nothing -> Exit.die [fmt|could not find any shell.nix in or above the directory {dir}|]
 | 
						|
      Just s -> pure s
 | 
						|
  getEventChan :: MVar (Chan LorriEvent) <- newEmptyMVar
 | 
						|
  Async.race_
 | 
						|
    ( do
 | 
						|
        sendEventChan :: Chan LorriEvent <- newChan
 | 
						|
        (exitCode, ()) <-
 | 
						|
          sourceProcessWithConsumer
 | 
						|
            (proc "lorri" ["internal", "stream-events"])
 | 
						|
            $
 | 
						|
            -- first, we want to send a message over the chan that the process is running (for timeout)
 | 
						|
            liftIO (putMVar getEventChan sendEventChan)
 | 
						|
              *> Conduit.Binary.lines
 | 
						|
              .| Cond.mapC
 | 
						|
                ( \jsonBytes ->
 | 
						|
                    (jsonBytes :: ByteString)
 | 
						|
                      & Json.parseStrict
 | 
						|
                        ( Json.key
 | 
						|
                            "Completed"
 | 
						|
                            ( do
 | 
						|
                                nixFile <- Json.key "nix_file" Json.asText
 | 
						|
                                pure LorriEvent {nixFile, eventType = Completed}
 | 
						|
                            )
 | 
						|
                            Json.<|> Json.key
 | 
						|
                              "Started"
 | 
						|
                              ( do
 | 
						|
                                  nixFile <- Json.key "nix_file" Json.asText
 | 
						|
                                  pure LorriEvent {nixFile, eventType = Started}
 | 
						|
                              )
 | 
						|
                            Json.<|> Json.key
 | 
						|
                              "Failure"
 | 
						|
                              ( do
 | 
						|
                                  nixFile <- Json.key "nix_file" Json.asText
 | 
						|
                                  pure LorriEvent {nixFile, eventType = Failure}
 | 
						|
                              )
 | 
						|
                        )
 | 
						|
                      & first Json.displayError'
 | 
						|
                      & first (map newError)
 | 
						|
                      & first (smushErrors [fmt|Cannot parse line returned by lorri: {jsonBytes & bytesToTextUtf8Lenient}|])
 | 
						|
                      & unwrapError
 | 
						|
                )
 | 
						|
              .| (Cond.mapM_ (\ev -> writeChan sendEventChan ev))
 | 
						|
 | 
						|
        log [fmt|lorri internal stream-events exited {show exitCode}|]
 | 
						|
    )
 | 
						|
    ( do
 | 
						|
        let waitMs ms = threadDelay (ms * 1000)
 | 
						|
 | 
						|
        -- log [fmt|Waiting for lorri event for {shellNix}|]
 | 
						|
 | 
						|
        eventChan <- takeMVar getEventChan
 | 
						|
 | 
						|
        let isOurEvent ev = FilePath.normalise (ev & nixFile & textToString) == FilePath.normalise shellNix
 | 
						|
 | 
						|
        let handleEvent ev =
 | 
						|
              case ev & eventType of
 | 
						|
                Started ->
 | 
						|
                  log [fmt|waiting for lorri build to finish|]
 | 
						|
                Completed -> do
 | 
						|
                  log [fmt|build completed|]
 | 
						|
                  exec (inDirenvDir (takeDirectory shellNix) <$> argv)
 | 
						|
                Failure -> do
 | 
						|
                  log [fmt|evaluation failed! for path {ev & nixFile}|]
 | 
						|
                  Exit.exitWith (Exit.ExitFailure 111)
 | 
						|
 | 
						|
        -- wait for 100ms for the first message from lorri,
 | 
						|
        -- or else assume lorri is not building the project yet
 | 
						|
        Async.race
 | 
						|
          (waitMs 100)
 | 
						|
          ( do
 | 
						|
              -- find the first event that we can use
 | 
						|
              let go = do
 | 
						|
                    ev <- readChan eventChan
 | 
						|
                    if isOurEvent ev then pure ev else go
 | 
						|
              go
 | 
						|
          )
 | 
						|
          >>= \case
 | 
						|
            Left () -> do
 | 
						|
              log [fmt|No event received from lorri, assuming this is the first evaluation|]
 | 
						|
              exec argv
 | 
						|
            Right ch -> handleEvent ch
 | 
						|
 | 
						|
        runConduit $
 | 
						|
          repeatMC (readChan eventChan)
 | 
						|
            .| filterC isOurEvent
 | 
						|
            .| mapM_C handleEvent
 | 
						|
    )
 | 
						|
  where
 | 
						|
    inDirenvDir dir' argv' = ("direnv" :| ["exec", dir']) <> argv'
 | 
						|
    exec = \case
 | 
						|
      Just (exe :| args') -> Posix.executeFile exe True args' Nothing
 | 
						|
      Nothing -> Exit.exitSuccess
 | 
						|
 | 
						|
log :: Text -> IO ()
 | 
						|
log msg = hPutStrLn stderr [fmt|lorri-wait-for-eval: {msg}|]
 | 
						|
 | 
						|
-- | Searches from the current directory upwards, until it finds the `shell.nix`.
 | 
						|
findShellNix :: FilePath -> IO (Maybe FilePath)
 | 
						|
findShellNix curDir = do
 | 
						|
  let go :: (FilePath -> IO (Maybe FilePath))
 | 
						|
      go dir = do
 | 
						|
        let file = dir FilePath.</> "shell.nix"
 | 
						|
        Dir.doesFileExist file >>= \case
 | 
						|
          True -> pure (Just file)
 | 
						|
          False -> pure Nothing
 | 
						|
  go curDir
 | 
						|
 | 
						|
textToString :: Text -> String
 | 
						|
textToString = Text.unpack
 | 
						|
 | 
						|
smushErrors :: Foldable t => Text -> t Error -> Error
 | 
						|
smushErrors msg errs =
 | 
						|
  errs
 | 
						|
    -- hrm, pretty printing and then creating a new error is kinda shady
 | 
						|
    & foldMap (\err -> "\n- " <> prettyError err)
 | 
						|
    & newError
 | 
						|
    & errorContext msg
 | 
						|
 | 
						|
-- | decode a Text from a ByteString that is assumed to be UTF-8,
 | 
						|
-- replace non-UTF-8 characters with the replacment char U+FFFD.
 | 
						|
bytesToTextUtf8Lenient :: Data.ByteString.ByteString -> Data.Text.Text
 | 
						|
bytesToTextUtf8Lenient =
 | 
						|
  Data.Text.Encoding.decodeUtf8With Data.Text.Encoding.Error.lenientDecode
 |