packages feed

batchd-0.1.1.0: src/Batchd/Daemon/Hosts.hs

{-# LANGUAGE CPP #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE ExistentialQuantification #-}
{-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE ScopedTypeVariables #-}

module Batchd.Daemon.Hosts where

import Control.Monad
import Control.Concurrent
import Control.Exception
import Control.Monad.Trans
import qualified Control.Monad.Catch as MC
import qualified Data.Map as M
import qualified Data.HashMap.Strict as H
import Data.Time
import Data.Aeson as Aeson
import qualified Data.Text as T
import qualified Data.Text.Lazy as TL
import Data.Text.Format.Heavy
import Data.Text.Format.Heavy.Time () -- import instances only
import Data.Text.Format.Heavy.Parse.Shell
import System.Log.Heavy
import System.Exit (ExitCode (..))

import Batchd.Core.Common.Types
import Batchd.Core.Common.Config
import Batchd.Daemon.Types
import Batchd.Core.Daemon.Hosts
import Batchd.Core.Daemon.Logging
import Batchd.Common.Types
import Batchd.Daemon.Monitoring as Monitoring
import Batchd.Daemon.SSH (execCommandsOnHost, accountForDefaultHostKeys)
import Batchd.Daemon.Local (execLocalCommands)

#ifdef LIBVIRT
import Batchd.Ext.LibVirt
#endif
#ifdef DOCKER
import Batchd.Ext.Docker
#endif
#ifdef AWSEC2
import Batchd.Ext.AWS
#endif
#ifdef LINODE
import Batchd.Ext.Linode
#endif

supportedDrivers :: [HostDriver]
supportedDrivers =
  [
#ifdef LIBVIRT
   libVirtDriver,
#endif
#ifdef DOCKER
   dockerDriver,
#endif
#ifdef AWSEC2
   awsEc2Driver,
#endif
#ifdef LINODE
    linodeDriver,
#endif
   localDriver
  ]

selectDriver :: String -> Maybe HostDriver
selectDriver name = go supportedDrivers
  where
    go [] = Nothing
    go (driver : rest)
      | driverName driver == name = Just driver
      | otherwise = go rest

loadHostController :: LoggingTState -> T.Text -> IO HostController
loadHostController lts "local" = do
  case initController localDriver lts undefined of
    Right controller -> return controller
    Left err -> throw $ UnknownError $ "Can't initialize local host controller: " ++ show err
loadHostController lts name = do
    cfgr <- loadHostControllerConfig name
    case cfgr of
      Left err -> throw err
      Right value@(Object cfg) -> do
          case H.lookup "driver" cfg of
            Nothing -> throw $ UnknownError $ "Invalid host controller config: driver not specified"
            Just (Aeson.String dname) -> do
              case selectDriver (T.unpack dname) of
                Nothing -> throw $ UnknownError $ "Invalid host controller config: unsupported driver name: " ++ T.unpack dname
                Just driver -> do
                  case initController driver lts value of
                    Right controller -> return controller
                    Left err -> throw err
      Right _ -> throw $ UnknownError "Invalid host controller config: it must be a dictionary"

getMaxJobs :: Host -> JobType -> Maybe Int
getMaxJobs host jtype =
  case (hMaxJobs host, jtMaxJobs jtype) of
    (Nothing, Nothing) -> Nothing
    (Just m, Nothing) -> Just m
    (Nothing, Just m) -> Just m
    (Just x, Just y) -> Just $ max x y

waitForStatus :: MVar HostState -> [HostStatus] -> (HostState -> Daemon HostState) -> Daemon ()
waitForStatus mvar targetStatuses actions = do
  connInfo <- askConnectionInfo
  lts <- askLoggingStateM
  ok <- liftIO $ modifyMVar mvar $ \st ->
          if hsStatus st `elem` targetStatuses
            then do
              result <- runDaemonIO connInfo lts $ actions st
              return (result, True)
            else do
              debugIO lts $(here) "Host `{}' has status {}, waiting for one of {}..."
                                  (hName $ hsHostConfig st, show (hsStatus st), show targetStatuses)
              return (st, False)

  when (not ok) $ do
    liftIO $ threadDelay $ 10 * 1000 * 1000
    waitForStatus mvar targetStatuses actions

increaseJobCount :: HostState -> Maybe Int -> Daemon HostState
increaseJobCount st mbMaxJobs = do
    $debug "Host `{}' had {} jobs, got new one, set status to {}"
                        (hName $ hsHostConfig st, hsJobCount st, show activeOrBusy)
    return $ st {hsStatus = activeOrBusy, hsJobCount = hsJobCount st + 1}
  where
    activeOrBusy =
      case mbMaxJobs of
        Nothing -> Active
        Just maxJobs -> if hsJobCount st + 1 >= maxJobs 
                          then Busy
                          else Active

decreaseJobCount :: HostState -> Daemon HostState
decreaseJobCount st = do
    let newStatus = if hsJobCount st - 1 <= 0
                      then Released
                      else Active
    released <- if newStatus == Released
                  then Just <$> liftIO getCurrentTime
                  else return Nothing
    $debug "Host `{}' had {} jobs, one done, set status to {}"
                        (hName $ hsHostConfig st, hsJobCount st, show newStatus)
    return $ st {hsStatus = newStatus, hsJobCount = hsJobCount st - 1, hsReleaseTime = released}

setHostStatus :: MVar HostState -> HostStatus -> IO ()
setHostStatus mvar status =
  modifyMVar_ mvar $ \st -> return $ st {hsStatus = status}

formatHostCommand :: GlobalConfig -> Host -> T.Text -> T.Text
formatHostCommand cfg host template =
    TL.toStrict $ format (parseShellFormat' $ TL.fromStrict template) context
  where
    context = optional $ hVariables host `ThenCheck` hostParams `ThenCheck` dbcVariables cfg
    hostParams :: Variables
    hostParams = M.fromList $ [
                    ("name", hName host),
                    ("hostname", hHostName host),
                    ("controllerId", hControllerId host),
                    -- ("publicKey", hPublicKey host),
                    -- ("privateKey", hPrivateKey host),
                    ("passphrase", hPassphrase host),
                    ("user", hUserName host),
                    ("port", T.pack $ show $ hPort host),
                    ("inputDirectory", T.pack $ hInputDirectory host),
                    ("outputDirectory", T.pack $ hOutputDirectory host)
                  ]

ensureHostStarted :: Host -> Daemon ()
ensureHostStarted host = do
    lts <- askLoggingStateM
    cfg <- askConfig
    host' <- liftIO $ accountForDefaultHostKeys host
    controller <- liftIO $ loadHostController lts (hController host')
    let name = hName host'
    $debug "Host `{}' is controlled by `{}'" (name, show controller)
    if doesSupportStartStop controller
      then do
        $debug "Starting host `{}'" (Single name)
        r <- liftIO $ startHost controller host'
        case r of
          Right _ -> do
              $debug "Waiting for host `{}' to initialize for {} seconds..." 
                                  (name, hStartupTime host')
              liftIO $ threadDelay $ 1000 * 1000 * hStartupTime host'
              let localCommands = map (formatHostCommand cfg host') (hStartupDispatcherCommands host')
              ec <- liftIO $ execLocalCommands lts localCommands
              case ec of
                ExitFailure rc -> throw $ HostInitializationError rc
                ExitSuccess -> do
                  let commandsOnHost = map (formatHostCommand cfg host') (hStartupHostCommands host')
                  ec <- execCommandsOnHost controller host' commandsOnHost
                  case ec of
                    ExitSuccess -> return ()
                    ExitFailure rc -> throw $ HostInitializationError rc
          Left err -> throw err
      else $debug "Controller does not support starting hosts" ()

ensureHostStopped :: LoggingTState -> Host -> IO ()
ensureHostStopped lts host = do
    controller <- loadHostController lts (hController host)
    debugIO lts $(here) "Host `{}' is controlled by `{}'" (hName host, show controller)
    let name = hName host
    if doesSupportStartStop controller
      then do
        debugIO lts $(here) "Stopping host `{}'" (Single name)
        r <- stopHost controller (hControllerId host)
        case r of
          Right _ -> return ()
          Left err -> throw err
      else debugIO lts $(here) "Controller does not support stopping hosts" ()

acquireHost :: HostsPool -> Host -> JobType -> Daemon ()
acquireHost mvar host jtype = do
    check (hName host)
    return ()
  where
    check :: T.Text -> Daemon()
    check name = do
      counter <- liftIO $ modifyMVar mvar $ \m -> do
                   case M.lookup name m of
                     Just c -> do
                       return (m, c)
                     Nothing -> do
                       c <- newMVar $ HostState Free Nothing 0 host
                       let m' = M.insert name c m
                       return (m', c)

      waitForStatus counter [Free, Active, Released] $ \st -> do
        when (hsStatus st == Free) $ do
          ensureHostStarted host
        increaseJobCount st $ getMaxJobs host jtype

releaseHost :: HostsPool -> Host -> Daemon ()
releaseHost mvar host = do
  let name = hName host
  counter <- liftIO $ withMVar mvar $ \m -> do
               case M.lookup name m of
                 Nothing -> fail $ "Can't release host: " <> T.unpack name <> "; map: " <> show (M.keys m)
                 Just c -> return c
  wrapDaemon_ (modifyMVar_ counter) $ \st -> decreaseJobCount st
  return ()

withHost :: HostsPool -> Host -> JobType -> Daemon a -> Daemon (Either SomeException a)
withHost mvar host jtype actions = withLogVariable "host" (hName host) $ do
  MC.try $ MC.bracket_ (acquireHost mvar host jtype) (releaseHost mvar host) actions

hostsMetricDumper :: HostsPool -> Daemon ()
hostsMetricDumper pool = forever $ do
  liftIO $ threadDelay $ 10 * 1000 * 1000
  hosts <- liftIO $ readMVar pool
  statuses <- forM (M.assocs hosts) $ \(name, stVar) -> do
                st <- liftIO $ readMVar stVar
                Monitoring.gauge ("batchd.host." <> name <> ".jobs") $ hsJobCount st
                Monitoring.label ("batchd.host." <> name <> ".status") $ T.pack $ show $ hsStatus st
                return $ hsStatus st
  let active = length $ filter (== Active) statuses
  let busy = length $ filter (== Busy) statuses
  Monitoring.gauge "batchd.hosts.active" active
  Monitoring.gauge "batchd.hosts.busy" busy

hostCleaner :: LoggingTState -> HostsPool -> IO ()
hostCleaner lts mvar = forever $ do
  threadDelay $ 60 * 1000 * 1000
  hosts <- readMVar mvar
  forM_ (M.assocs hosts) $ \(name, counter) -> do
    modifyMVar_ counter $ \st -> do
      if hsStatus st == Released
        then case hsReleaseTime st of
               Nothing -> do
                 reportErrorIO lts $(here) "Host `{}' status is Released, but it's release time is not set"
                                           (Single name)
                 return st
               Just releaseTime -> do
                 now <- getCurrentTime
                 if ceiling (diffUTCTime now releaseTime) >= hShutdownTimeout (hsHostConfig st)
                   then do
                        debugIO lts $(here) "Host `{}' was released at {}, it's time to shut it down"
                                            (name, releaseTime)
                        ensureHostStopped lts (hsHostConfig st)
                        return $ st {hsStatus = Free, hsReleaseTime = Nothing}
                   else do
                        debugIO lts $(here) "Host `{}' was relesed at {}, it's not time to shut it down yet"
                                            (name, releaseTime)
                        return st
        else return st