packages feed

angel-0.5.2: src/Angel/Config.hs

{-# LANGUAGE BangPatterns #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE OverloadedStrings #-}
module Angel.Config ( monitorConfig
                    , modifyProg
                    , expandByCount
                    -- for testing
                    , processConfig) where

import Control.Exception ( try
                         , SomeException )
import qualified Data.Map as M
import Control.Monad ( when
                     , (>=>) )
import Control.Monad.IO.Class
import Control.Concurrent.STM ( STM
                              , TVar
                              , writeTVar
                              , readTVar
                              , atomically )
import Data.Configurator ( load
                         , getMap
                         , Worth(Required) )
import Data.Configurator.Types ( Value(Number, String, Bool)
                               , Name )
import qualified Data.Traversable as T
import qualified Data.HashMap.Lazy as HM
import Data.List ( nub
                 , foldl' )
import Data.Maybe ( isNothing
                  , isJust )
import Data.Monoid ( (<>) )
import qualified Data.Text as T
import Angel.Job ( syncSupervisors )
import Angel.Data ( Program( user
                           , exec
                           , delay
                           , stdout
                           , stderr
                           , logExec
                           , pidFile
                           , workingDir
                           , name
                           , termGrace
                           , env )
                  , SpecKey
                  , AngelM
                  , GroupConfig
                  , Verbosity(..)
                  , spec
                  , defaultProgram )
import Angel.Log ( logger )
import Angel.Util ( waitForWake
                  , nnull
                  , expandPath )

-- |produce a mapping of name -> program for every program
buildConfigMap :: HM.HashMap Name Value -> SpecKey
buildConfigMap = HM.foldlWithKey' addToMap M.empty . addDefaults
  where
    addToMap :: SpecKey -> Name -> Value -> SpecKey
    addToMap m n value
      | nnull basekey && nnull localkey =
        let !newprog = case M.lookup basekey m of
                          Just prog -> modifyProg prog localkey value
                          Nothing   -> modifyProg defaultProgram {name = basekey, env = []} localkey value
            in
        M.insert basekey newprog m
      | otherwise = m
      where (basekey, '.':localkey) = break (== '.') $ T.unpack n

addDefaults :: HM.HashMap Name Value -> HM.HashMap Name Value
addDefaults conf = foldl' addDefault conf progs
  where
    graceKey prog = prog <> ".grace"
    progs = programNames conf
    addDefault conf' prog
      | HM.member (graceKey prog) conf' = conf'
      | otherwise = HM.insert (graceKey prog) defaultGrace conf
    defaultGrace = Bool False

programNames :: HM.HashMap Name a -> [Name]
programNames = nub . filter nnullN . map extractName . HM.keys
  where
    extractName = T.takeWhile (/= '.')

checkConfigValues :: SpecKey -> IO SpecKey
checkConfigValues progs = mapM_ checkProgram (M.elems progs) >> return progs
  where
    checkProgram p = do
        when (isNothing $ exec p) $ error $ name p ++ " does not have an 'exec' specification"
        when (isJust (logExec p) &&
            (isJust (stdout p) || isJust (stderr p) )) $ error $ name p ++ " cannot have both a logger process and stderr/stdout"

modifyProg :: Program -> String -> Value -> Program
modifyProg prog "exec" (String s) = prog {exec = Just (T.unpack s)}
modifyProg _ "exec" _ = error "wrong type for field 'exec'; string required"

modifyProg prog "user" (String s) = prog{user = Just (T.unpack s)}
modifyProg _ "user" _ = error "wrong type for field 'user'; string required"

modifyProg prog "delay" (Number n) | n < 0     = error "delay value must be >= 0"
                                   | otherwise = prog{delay = Just $ round n}
modifyProg _ "delay" _ = error "wrong type for field 'delay'; integer"

modifyProg prog "stdout" (String s) = prog{stdout = Just (T.unpack s)}
modifyProg _ "stdout" _ = error "wrong type for field 'stdout'; string required"

modifyProg prog "stderr" (String s) = prog{stderr = Just (T.unpack s)}
modifyProg _ "stderr" _ = error "wrong type for field 'stderr'; string required"

modifyProg prog "directory" (String s) = prog{workingDir = Just (T.unpack s)}
modifyProg _ "directory" _ = error "wrong type for field 'directory'; string required"

modifyProg prog "logger" (String s) = prog{logExec = Just (T.unpack s)}
modifyProg _ "logger" _ = error "wrong type for field 'logger'; string required"

modifyProg prog "pidfile" (String s) = prog{pidFile = Just (T.unpack s)}
modifyProg _ "pidfile" _ = error "wrong type for field 'pidfile'; string required"

modifyProg prog ('e':'n':'v':'.':envVar) (String s) = prog{env = envVar'}
  where envVar' = (envVar, T.unpack s):env prog
modifyProg _ ('e':'n':'v':'.':_) _ = error "wrong type for env field; string required"

modifyProg prog "termgrace" (Bool False) = prog{termGrace = Nothing}
modifyProg prog "termgrace" (Number n) | n < 0 = error "termgrace if it is a number must be >= 1"
                                       | n == 0 = prog{termGrace = Nothing}
                                       | otherwise = prog { termGrace = Just $ round n}
modifyProg _ "termgrace" _ = error "wrong type for field 'termgrace'; number or boolean false required"

modifyProg prog _ _ = prog


-- |invoke the parser to process the file at configPath
-- |produce a SpecKey
processConfig :: String -> IO (Either String SpecKey)
processConfig configPath = do
    mconf <- try $ process =<< load [Required configPath]

    case mconf of
        Right config -> return $ Right config
        Left (e :: SomeException) -> return $ Left $ show e
  where process = getMap                  >=>
                  return . expandByCount  >=>
                  return . buildConfigMap >=>
                  expandPaths             >=>
                  checkConfigValues

-- |preprocess config into multiple programs if "count" is specified
expandByCount :: HM.HashMap Name Value -> HM.HashMap Name Value
expandByCount cfg = HM.unions expanded
  where expanded :: [HM.HashMap Name Value]
        expanded         = concat $ HM.foldlWithKey' expand' [] groupedByProgram
        expand'  :: [[HM.HashMap Name Value]] -> Name -> HM.HashMap Name Value -> [[HM.HashMap Name Value]]
        expand' acc      = fmap (:acc) . expand
        groupedByProgram :: HM.HashMap Name (HM.HashMap Name Value)
        groupedByProgram = HM.foldlWithKey' binByProg HM.empty cfg
        binByProg h fullKey v
          | prog /= "" && localKey /= "" = HM.insertWith HM.union prog (HM.singleton localKey v) h
          | otherwise                    = h
          where (prog, localKeyWithLeadingDot) = T.breakOn "." fullKey
                localKey                 = T.drop 1 localKeyWithLeadingDot
        expand :: Name -> HM.HashMap Name Value -> [HM.HashMap Name Value]
        expand prog pcfg = maybe [reflatten prog pcfg]
                                 expandWithCount
                                 (HM.lookup "count" pcfg)
          where expandWithCount (Number n)
                  | n >= 0    = [ reflatten (genProgName i) (rewriteConfig i pcfg) | i <- [1..n] ]
                  | otherwise = error "count must be >= 0"
                expandWithCount _ = error "count must be a number or not specified"
                genProgName i     = prog <> "-" <> textNumber i

rewriteConfig :: Rational -> HM.HashMap Name Value -> HM.HashMap Name Value
rewriteConfig n = HM.insert "env.ANGEL_PROCESS_NUMBER" procNumber . HM.adjust rewritePidfile "pidfile"
  where procNumber                   = String n'
        n'                           = textNumber n
        rewritePidfile (String path) = String $ rewrittenFilename <> extension
          where rewrittenFilename     = filename <> "-" <> n'
                (filename, extension) = T.breakOn "." path
        rewritePidfile x              = x

textNumber :: Rational -> T.Text
textNumber = T.pack . show . (truncate :: Rational -> Integer)

reflatten :: Name -> HM.HashMap Name Value -> HM.HashMap Name Value
reflatten prog pcfg = HM.fromList asList
  where asList            = map prependKey $ filter notCount $ HM.toList pcfg
        prependKey (k, v) = (prog <> "." <> k, v)
        notCount          = not . (== "count") . fst

-- |given a new SpecKey just parsed from the file, update the
-- |shared state TVar
updateSpecConfig :: TVar GroupConfig -> SpecKey -> STM ()
updateSpecConfig sharedGroupConfig newSpec = do
    cfg <- readTVar sharedGroupConfig
    writeTVar sharedGroupConfig cfg{spec=newSpec}

-- |read the config file, update shared state with current spec,
-- |re-sync running supervisors, wait for the HUP TVar, then repeat!
monitorConfig :: String -> TVar GroupConfig -> TVar (Maybe Int) -> AngelM ()
monitorConfig configPath sharedGroupConfig wakeSig = do
    let logger' = logger "config-monitor"
    mspec <- liftIO $ processConfig configPath
    case mspec of
        Left e     -> do
            logger' V1 $ " <<<< Config Error >>>>\n" ++ e
            logger' V2 " <<<< Config Error: Skipping reload >>>>"
        Right newSpec -> do
            liftIO $ atomically $ updateSpecConfig sharedGroupConfig newSpec
            syncSupervisors sharedGroupConfig
    liftIO $ waitForWake wakeSig
    logger' V2 "HUP caught, reloading config"

expandPaths :: SpecKey -> IO SpecKey
expandPaths = T.mapM expandProgramPaths

expandProgramPaths :: Program -> IO Program
expandProgramPaths prog = do exec'       <- maybeExpand $ exec prog
                             stdout'     <- maybeExpand $ stdout prog
                             stderr'     <- maybeExpand $ stderr prog
                             workingDir' <- maybeExpand $ workingDir prog
                             pidFile'    <- maybeExpand $ pidFile prog
                             return prog { exec       = exec',
                                           stdout     = stdout',
                                           stderr     = stderr',
                                           workingDir = workingDir',
                                           pidFile    = pidFile' }
    where maybeExpand = T.traverse expandPath

nnullN :: Name -> Bool
nnullN = not . T.null