packages feed

cachix-1.7.3: src/Cachix/Client/Daemon.hs

module Cachix.Client.Daemon
  ( Types.Daemon,
    Types.runDaemon,
    new,
    start,
    run,
    stop,
    stopIO,
    subscribe,
  )
where

import qualified Cachix.Client.Commands.Push as Commands.Push
import qualified Cachix.Client.Config as Config
import Cachix.Client.Config.Orphans ()
import qualified Cachix.Client.Daemon.EventLoop as EventLoop
import Cachix.Client.Daemon.Listen as Daemon
import qualified Cachix.Client.Daemon.Log as Log
import Cachix.Client.Daemon.Protocol as Protocol
import Cachix.Client.Daemon.Push as Push
import qualified Cachix.Client.Daemon.PushManager as PushManager
import Cachix.Client.Daemon.ShutdownLatch
import qualified Cachix.Client.Daemon.SocketStore as SocketStore
import Cachix.Client.Daemon.Subscription as Subscription
import Cachix.Client.Daemon.Types as Types
import Cachix.Client.Daemon.Types.EventLoop (DaemonEvent (ShutdownGracefully))
import qualified Cachix.Client.Daemon.Types.PushManager as PushManager
import qualified Cachix.Client.Daemon.Worker as Worker
import Cachix.Client.Env as Env
import Cachix.Client.OptionsParser (DaemonOptions, PushOptions)
import qualified Cachix.Client.OptionsParser as Options
import Cachix.Client.Push
import Cachix.Types.BinaryCache (BinaryCacheName)
import qualified Cachix.Types.BinaryCache as BinaryCache
import Control.Concurrent.STM.TMChan
import Control.Exception.Safe (catchAny)
import qualified Control.Monad.Catch as E
import qualified Data.Text as T
import qualified Hercules.CNix.Util as CNix.Util
import qualified Katip
import qualified Network.Socket as Socket
import qualified Network.Socket.ByteString as Socket.BS
import Protolude
import System.IO.Error (isResourceVanishedError)
import System.Posix.Process (getProcessID)
import qualified System.Posix.Signals as Signal
import qualified UnliftIO.Async as Async

-- | Configure a new daemon. Use 'run' to start it.
new ::
  -- | The Cachix environment.
  Env ->
  -- | Daemon-specific options.
  DaemonOptions ->
  -- | An optional handle to output logs to.
  Maybe Handle ->
  -- | Push options, like compression settings and number of jobs.
  PushOptions ->
  -- | The name of the binary cache to push to.
  BinaryCacheName ->
  -- | The configured daemon environment.
  IO DaemonEnv
new daemonEnv daemonOptions daemonLogHandle daemonPushOptions daemonCacheName = do
  let daemonLogLevel =
        if Config.verbose (Env.cachixoptions daemonEnv)
          then Debug
          else Info
  daemonLogger <- Log.new "cachix.daemon" daemonLogHandle daemonLogLevel

  daemonSocketPath <- maybe getSocketPath pure (Options.daemonSocketPath daemonOptions)
  daemonSocketThread <- newEmptyMVar
  daemonEventLoop <- EventLoop.new
  daemonClients <- SocketStore.newSocketStore
  daemonShutdownLatch <- newShutdownLatch
  daemonPid <- getProcessID

  daemonPushSecret <- Commands.Push.getPushSecretRequired (config daemonEnv) daemonCacheName
  let authToken = getAuthTokenFromPushSecret daemonPushSecret
  daemonBinaryCache <- Push.getBinaryCache daemonEnv authToken daemonCacheName

  daemonSubscriptionManager <- Subscription.newSubscriptionManager
  let onPushEvent = Subscription.pushEvent daemonSubscriptionManager
  daemonPushManager <- PushManager.newPushManagerEnv daemonPushOptions daemonLogger onPushEvent

  return $ DaemonEnv {..}

-- | Configure and run the daemon. Equivalent to running 'new' and 'run' together with some signal handling.
start :: Env -> DaemonOptions -> PushOptions -> BinaryCacheName -> IO ()
start daemonEnv daemonOptions daemonPushOptions daemonCacheName = do
  daemon <- new daemonEnv daemonOptions Nothing daemonPushOptions daemonCacheName
  installSignalHandlers daemon
  void $ run daemon

-- | Run a daemon from a given configuration.
run :: DaemonEnv -> IO ExitCode
run daemon = runDaemon daemon $ flip E.onError (return $ ExitFailure 1) $ do
  Katip.logFM Katip.InfoS "Starting Cachix Daemon"
  DaemonEnv {..} <- ask

  printConfiguration

  Push.withPushParams $ \pushParams -> do
    subscriptionManagerThread <-
      Async.async $ runSubscriptionManager daemonSubscriptionManager

    let runWorkerTask =
          liftIO . PushManager.runPushManager daemonPushManager . PushManager.handleTask pushParams
    workersThreads <-
      Worker.startWorkers
        (Options.numJobs daemonPushOptions)
        (PushManager.pmTaskQueue daemonPushManager)
        runWorkerTask

    listenThread <- Async.async (Daemon.listen daemonEventLoop daemonSocketPath)
    liftIO $ putMVar daemonSocketThread listenThread

    EventLoop.run daemonEventLoop $ \case
      EventLoop.AddSocketClient conn ->
        SocketStore.addSocket conn (Daemon.handleClient daemonEventLoop) daemonClients
      EventLoop.RemoveSocketClient socketId ->
        SocketStore.removeSocket socketId daemonClients
      EventLoop.ReconnectSocket ->
        -- TODO: implement reconnection logic
        EventLoop.exitLoopWith (ExitFailure 1) daemonEventLoop
      EventLoop.ReceivedMessage clientMsg ->
        case clientMsg of
          ClientPushRequest pushRequest -> queueJob pushRequest
          ClientStop -> EventLoop.send daemonEventLoop EventLoop.ShutdownGracefully
          _ -> return ()
      EventLoop.ShutdownGracefully -> do
        Katip.logFM Katip.InfoS "Shutting down daemon..."
        shutdownGracefully subscriptionManagerThread workersThreads
        Katip.logFM Katip.InfoS "Daemon shut down. Exiting."
        EventLoop.exitLoopWith ExitSuccess daemonEventLoop

stop :: Daemon ()
stop = do
  eventloop <- asks daemonEventLoop
  EventLoop.send eventloop ShutdownGracefully

stopIO :: DaemonEnv -> IO ()
stopIO DaemonEnv {daemonEventLoop} =
  EventLoop.sendIO daemonEventLoop ShutdownGracefully

installSignalHandlers :: DaemonEnv -> IO ()
installSignalHandlers daemon = do
  for_ [Signal.sigTERM, Signal.sigINT] $ \signal ->
    Signal.installHandler signal (Signal.CatchOnce handler) Nothing
  where
    handler = do
      CNix.Util.triggerInterrupt
      stopIO daemon

queueJob :: Protocol.PushRequest -> Daemon ()
queueJob pushRequest = do
  daemonPushManager <- asks daemonPushManager
  -- TODO: subscribe the socket to updates if available

  -- Queue the job
  void $
    PushManager.runPushManager daemonPushManager (PushManager.addPushJob pushRequest)

subscribe :: DaemonEnv -> IO (TMChan PushEvent)
subscribe DaemonEnv {..} = do
  chan <- liftIO newBroadcastTMChanIO
  liftIO $ atomically $ do
    subscribeToAllSTM daemonSubscriptionManager (SubChannel chan)
    dupTMChan chan

-- | Print the daemon configuration to the log.
printConfiguration :: Daemon ()
printConfiguration = do
  config <- showConfiguration
  Katip.logFM Katip.InfoS $ Katip.ls $ "Configuration:\n" <> config

-- | Fetch debug information about the daemon configuration.
showConfiguration :: Daemon Text
showConfiguration = do
  DaemonEnv {..} <- ask
  pure $
    T.intercalate
      "\n"
      [ "PID: " <> show daemonPid,
        "Socket: " <> toS daemonSocketPath,
        "Workers: " <> show (Options.numJobs daemonPushOptions),
        "Cache name: " <> toS daemonCacheName,
        "Cache URI: " <> BinaryCache.uri daemonBinaryCache,
        "Cache public keys: " <> show (BinaryCache.publicSigningKeys daemonBinaryCache),
        "Cache is public: " <> show (BinaryCache.isPublic daemonBinaryCache),
        "Compression: " <> show (Push.getCompressionMethod daemonPushOptions daemonBinaryCache)
      ]

shutdownGracefully :: Async () -> [Worker.Thread] -> Daemon ()
shutdownGracefully subscriptionManagerThread workersThreads = do
  DaemonEnv {..} <- ask

  initiateShutdown daemonShutdownLatch
  PushManager.runPushManager daemonPushManager $ do
    queuedStorePathCount <- PushManager.queuedStorePathCount
    when (queuedStorePathCount > 0) $
      Katip.logFM Katip.InfoS $
        Katip.logStr $
          "Remaining store paths: " <> (show queuedStorePathCount :: Text)

  Katip.logFM Katip.DebugS "Waiting for push manager to clear remaining jobs..."
  -- Finish processing remaining push jobs
  let timeoutOptions =
        PushManager.TimeoutOptions
          { PushManager.toTimeout = 60.0,
            PushManager.toPollingInterval = 1.0
          }
  liftIO $ PushManager.stopPushManager timeoutOptions daemonPushManager
  Katip.logFM Katip.DebugS "Push manager shut down."

  -- Gracefully shut down the worker before closing the socket
  Worker.stopWorkers workersThreads

  -- Close all event subscriptions
  Katip.logFM Katip.DebugS "Shutting down event manager..."
  liftIO $ stopSubscriptionManager daemonSubscriptionManager
  Async.wait subscriptionManagerThread
  Katip.logFM Katip.DebugS "Event manager shut down."

  let sayGoodbye socket = do
        let clientSock = SocketStore.socket socket
        let clientThread = SocketStore.handlerThread socket
        Async.cancel clientThread

        -- Wave goodbye to the client that requested the shutdown
        liftIO $ Daemon.serverBye clientSock
        liftIO $ Socket.shutdown clientSock Socket.ShutdownBoth `catchAny` (\_ -> return ())
        -- Wait for the other end to disconnect
        ebs <- liftIO $ try $ Socket.BS.recv clientSock 4096
        case ebs of
          Left err | isResourceVanishedError err -> Katip.logFM Katip.DebugS "Client did not disconnect cleanly."
          Left err -> Katip.logFM Katip.DebugS $ Katip.ls $ "Client socket threw an error: " <> displayException err
          Right _ -> Katip.logFM Katip.DebugS "Client disconnected."

  Async.mapConcurrently_ sayGoodbye =<< SocketStore.toList daemonClients