packages feed

keter-2.1.10: src/Keter/Main.hs

{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE TypeSynonymInstances #-}

module Keter.Main
    ( keter
    ) where

import Control.Concurrent.Async (waitAny, withAsync)
import Control.Exception (SomeException, bracket, throwIO, try)
import Control.Monad (forM, forM_, unless, void, when)
import Control.Monad.IO.Class (MonadIO, liftIO)
import Control.Monad.IO.Unlift (MonadUnliftIO, withRunInIO)
import Control.Monad.Logger (LoggingT, logInfo, runLoggingT)
import Control.Monad.Logger qualified as L
import Control.Monad.Reader (MonadReader, ReaderT, ask, asks, runReaderT)
import Data.Map qualified as Map
import Data.String (fromString)
import Data.Text qualified as T
import Data.Text.Read qualified
import Data.Time (getCurrentTime)
import Data.Vector qualified as V
import Keter.AppManager qualified as AppMan
import Keter.Cli
import Keter.Common
import Keter.Conduit.Process.Unix (initProcessTracker)
import Keter.Config
import Keter.Config.V10
import Keter.Context
import Keter.HostManager qualified as HostMan
import Keter.Logger qualified as Log
import Keter.PortPool qualified as PortPool
import Keter.Proxy qualified as Proxy
import Keter.SharedData.App (AppStartConfig(..))
import Keter.SharedData.AppManager qualified as AppMan
import Keter.TempTarball qualified as TempFolder
import Keter.Yaml.FilePath
import Prelude hiding (FilePath, log)
import System.Directory
       ( createDirectoryIfMissing
       , doesDirectoryExist
       , doesFileExist
       , getDirectoryContents
       )
import System.FilePath (FilePath, takeExtension, (</>))
import System.FSNotify qualified as FSN
import System.Log.FastLogger qualified as FL
import System.Posix.Files (getFileStatus, modificationTime)
import System.Posix.Signals (Handler(Catch), installHandler, sigHUP)
import System.Posix.User
       (getUserEntryForID, getUserEntryForName, userGroupID, userID, userName)

keter :: FilePath -- ^ root directory or config file
      -> [FilePath -> IO Plugin]
      -> IO ()
keter input mkPlugins =
    runKeterConfigReader input . runKeterLogger . runKeterM $
        withManagers mkPlugins $ \hostman appMan -> do
            KeterConfig{..} <- ask
            $logInfo "Launching cli"
            forM_ kconfigCliPort $ \port ->
              withMappedConfig
                  (const $ MkCliStates
                      { csAppManager = appMan
                      , csPort       = port
                      })
                  launchCli
            $logInfo "Launching initial"
            launchInitial appMan
            $logInfo "Started watching"
            startWatching appMan
            $logInfo "Started listening"
            startListening hostman

-- | Load up Keter config and evaluate a ReaderT context with it
runKeterConfigReader :: MonadIO m
                     => FilePath
                     -> ReaderT KeterConfig m a
                     -> m a
runKeterConfigReader input ctx = do
    exists <- liftIO $ doesFileExist input
    config <- liftIO $
        if exists
            then do
                eres <- decodeFileRelative input
                case eres of
                    Left e -> throwIO $ InvalidKeterConfigFile input e
                    Right x -> return x
            else return defaultKeterConfig { kconfigDir = input }
    runReaderT ctx config

-- | Running the Keter logger requires a context with access to a KeterConfig, hence the
-- MonadReader constraint. This is versatile: 'runKeterConfigReader', or use the free
-- ((->) KeterConfig) instance.
runKeterLogger :: (MonadReader KeterConfig m, MonadIO m, MonadUnliftIO m)
               => LoggingT m a
               -> m a
runKeterLogger ctx = do
    cfg <- ask
    withRunInIO $ \rio -> bracket (Log.createLoggerViaConfig cfg "keter") Log.loggerClose $
        rio . runLoggingT ctx . formatLog
    where
        formatLog logger loc _ lvl msg = do
            now <- liftIO getCurrentTime
            -- Format: "{keter|}$time|$module$:$line_num|$log_level> $msg"
            let tag = case Log.loggerType logger of { FL.LogStderr _ -> "keter|"; _ -> mempty }
            let bs = mconcat
                    [ tag
                    , L.toLogStr $ take 22 $ show now
                    , "|"
                    , L.toLogStr (L.loc_module loc)
                    , ":"
                    , L.toLogStr (fst $ L.loc_start loc)
                    , "|"
                    , L.toLogStr $ drop 5 $ show lvl
                    , "> "
                    , msg
                    , "\n"
                    ]
            Log.loggerLog logger bs

withManagers :: [FilePath -> IO Plugin]
             -> (HostMan.HostManager -> AppMan.AppManager -> KeterM KeterConfig a)
             -> KeterM KeterConfig a
withManagers mkPlugins f = do
    cfg@KeterConfig{..} <- ask
    processTracker <- liftIO initProcessTracker
    hostman <- liftIO HostMan.start
    portpool <- liftIO $ PortPool.start kconfigPortPool
    tf <- liftIO $ TempFolder.setup $ kconfigDir </> "temp"
    plugins <- mapM (liftIO . ($ kconfigDir)) mkPlugins
    muid <-
        case kconfigSetuid of
            Nothing -> return Nothing
            Just t -> do
                x <- liftIO $ try $
                    case Data.Text.Read.decimal t of
                        Right (i, "") -> getUserEntryForID i
                        _ -> getUserEntryForName $ T.unpack t
                case x of
                    Left (_ :: SomeException) -> error $ "Invalid user ID: " ++ T.unpack t
                    Right ue -> return $ Just (T.pack $ userName ue, (userID ue, userGroupID ue))

    let appStartConfig = AppStartConfig
            { ascTempFolder = tf
            , ascSetuid = muid
            , ascProcessTracker = processTracker
            , ascHostManager = hostman
            , ascPortPool = portpool
            , ascPlugins = plugins
            , ascKeterConfig = cfg
            }
    appMan <- withMappedConfig (const appStartConfig) AppMan.initialize
    f hostman appMan

launchInitial :: AppMan.AppManager -> KeterM KeterConfig ()
launchInitial appMan = do
    kc@KeterConfig{..} <- ask
    let incoming = getIncoming kc
    liftIO $ createDirectoryIfMissing True incoming
    bundles0 <- liftIO $ filter isKeter <$> listDirectoryTree incoming
    withMappedConfig (const appMan) $ do
        mapM_ AppMan.addApp bundles0
        unless (V.null kconfigBuiltinStanzas) $ AppMan.perform
            AIBuiltin
            (AppMan.Reload $ AIData $ BundleConfig kconfigBuiltinStanzas mempty)

getIncoming :: KeterConfig -> FilePath
getIncoming kc = kconfigDir kc </> "incoming"

isKeter :: FilePath -> Bool
isKeter fp = takeExtension fp == ".keter"

startWatching :: AppMan.AppManager -> KeterM KeterConfig ()
startWatching appMan = do
    incoming <- asks getIncoming
    -- File system watching
    wm <- liftIO FSN.startManager
    withMappedConfig (const appMan) $ withRunInIO $ \rio -> do
        _ <- FSN.watchTree wm (fromString incoming) (const True) $ \e -> do
                e' <-
                    case e of
                        FSN.Removed fp _ _ -> do
                            rio $ $logInfo $ "Watched file removed: " <> T.pack fp
                            return $ Left fp
                        FSN.Added fp _ _ -> do
                            rio $ $logInfo $ "Watched file added: " <> T.pack fp
                            return $ Right fp
                        FSN.Modified fp _ _ -> do
                            rio $ $logInfo $ "Watched file modified: " <> T.pack fp
                            return $ Right fp
                        _ -> do
                            rio $ $logInfo $ "Watched file unknown" <> T.pack mempty
                            return $ Left []
                rio $ case e' of
                    Left fp -> when (isKeter fp) $ AppMan.terminateApp $ getAppname fp
                    Right fp -> when (isKeter fp) $ AppMan.addApp $ incoming </> fp
        -- Install HUP handler for cases when inotify cannot be used.
        void $ flip (installHandler sigHUP) Nothing $ Catch $ do
            bundles <- filter isKeter <$> listDirectoryTree incoming
            newMap <- fmap Map.fromList $ forM bundles $ \bundle -> do
                time <- modificationTime <$> getFileStatus bundle
                return (getAppname bundle, (bundle, time))
            rio $ AppMan.reloadAppList newMap

listDirectoryTree :: FilePath -> IO [FilePath]
listDirectoryTree fp = do
       dir <- getDirectoryContents fp
       concat <$> mapM (\fpRel -> do
          let fp1 = fp </> fpRel
          isDir <- doesDirectoryExist fp1
          if isDir
           then
             listDirectoryTree fp1
           else
             return [fp1]
           ) (filter (\x -> x /= "." && x /= "..") dir)

startListening :: HostMan.HostManager -> KeterM KeterConfig ()
startListening hostman = do
    KeterConfig{..} <- ask
    settings <- Proxy.makeSettings hostman
    withMappedConfig (const settings) $ withRunInIO $ \rio ->
        liftIO $ runAndBlock kconfigListeners $ \ls ->
            rio $ Proxy.reverseProxy ls

runAndBlock :: NonEmptyVector a
            -> (a -> IO ())
            -> IO ()
runAndBlock (NonEmptyVector x0 v) f =
    loop l0 []
  where
    l0 = x0 : V.toList v

    loop (x:xs) asyncs = withAsync (f x) $ \async -> loop xs $ async : asyncs
    -- Once we have all of our asyncs, we wait for /any/ of them to exit. If
    -- any listener thread exits, we kill the whole process.
    loop [] asyncs = void $ waitAny asyncs