packages feed

language-puppet-1.1.1: Hiera/Server.hs

{-# LANGUAGE FlexibleInstances      #-}
{-# LANGUAGE FunctionalDependencies #-}
{-# LANGUAGE LambdaCase             #-}
{-# LANGUAGE MultiParamTypeClasses  #-}
{-# LANGUAGE NamedFieldPuns         #-}
{-# LANGUAGE TemplateHaskell        #-}

{- | This module runs a Hiera server that caches Hiera data. There is
a huge caveat : only the data files are watched for changes, not the main configuration file.

A minor bug is that interpolation will not work for inputs containing the % character when it isn't used for interpolation.
-}
module Hiera.Server (
    startHiera
  , dummyHiera
    -- * Re-export (query API)
  , HieraQueryFunc
) where

import           Control.Applicative
import           Control.Exception
import           Control.Lens
import           Control.Monad.Writer.Strict
import           Data.Aeson                  (FromJSON, Value (..), (.!=), (.:?))
import qualified Data.Aeson                  as A
import           Data.Aeson.Lens
import qualified Data.Attoparsec.Text        as AT
import qualified Data.ByteString.Lazy        as BS
import qualified Data.Either.Strict          as S
import qualified Data.FileCache              as F
import qualified Data.HashMap.Strict         as HM
import qualified Data.List                   as L
import           Data.Maybe                  (fromMaybe)
import qualified Data.Text                   as T
import qualified Data.Vector                 as V
import qualified Data.Yaml                   as Y
import           System.FilePath.Lens        (directory)
import qualified System.Log.Logger           as LOG

import           Puppet.Interpreter.Types
import           Puppet.PP
import           Puppet.Utils                (strictifyEither)

loggerName :: String
loggerName = "Hiera.Server"

data HieraConfigFile = HieraConfigFile
    { _backends  :: [Backend]
    , _hierarchy :: [InterpolableHieraString]
    } deriving (Show)

data Backend = YamlBackend FilePath
             | JsonBackend FilePath
             deriving Show

newtype InterpolableHieraString = InterpolableHieraString [HieraStringPart]
                                  deriving Show

data HieraStringPart = HString T.Text
                     | HVariable T.Text
                     deriving Show

instance Pretty HieraStringPart where
    pretty (HString t) = ttext t
    pretty (HVariable v) = dullred (string "%{" <> ttext v <> string "}")
    prettyList = mconcat . map pretty

type Cache = F.FileCacheR String Y.Value

makeClassy ''HieraConfigFile

instance FromJSON InterpolableHieraString where
    parseJSON (String s) = case parseInterpolableString s of
                               Right x -> return (InterpolableHieraString x)
                               Left rr -> fail rr
    parseJSON _ = fail "Invalid value type"

instance FromJSON HieraConfigFile where
    parseJSON (Object v) = do
        let genBackend :: T.Text -> Y.Parser Backend
            genBackend name = do
                (backendConstructor, skey) <- case name of
                                                  "yaml" -> return (YamlBackend, ":yaml")
                                                  "json" -> return (JsonBackend, ":json")
                                                  _      -> fail ("Unknown backend " ++ T.unpack name)
                datadir <- case Object v ^? key skey . key ":datadir" of
                                  Just (String dir)   -> return dir
                                  Just _              -> fail ":datadir should be a string"
                                  Nothing             -> return "/etc/puppet/hieradata"
                return (backendConstructor (T.unpack datadir))
        HieraConfigFile
            <$> (v .:? ":backends" .!= ["yaml"] >>= mapM genBackend)
            <*> (v .:? ":hierarchy" .!= [InterpolableHieraString [HString "common"]])
    parseJSON _ = fail "Not a valid Hiera configuration"

-- | An attoparsec parser that turns text into parts that are ready for interpolation
interpolableString :: AT.Parser [HieraStringPart]
interpolableString = AT.many1 (fmap HString rawPart <|> fmap HVariable interpPart)
    where
        rawPart = AT.takeWhile1 (/= '%')
        interpPart = AT.string "%{" *> AT.takeWhile1 (/= '}') <* AT.char '}'

parseInterpolableString :: T.Text -> Either String [HieraStringPart]
parseInterpolableString = AT.parseOnly interpolableString

-- | The only method you'll ever need. It runs a Hiera server and gives you
-- a querying function. The 'Nil' output is explicitely given as a Maybe
-- type.
startHiera :: FilePath -> IO (Either String (HieraQueryFunc IO))
startHiera fp = Y.decodeFileEither fp >>= \case
    Left (Y.InvalidYaml (Just (Y.YamlException "Yaml file not found: hiera.yaml"))) -> return (Right dummyHiera)
    Left ex   -> return (Left (show ex))
    Right cfg -> do
        cache <- F.newFileCache
        return (Right (query cfg fp cache))

-- | A dummy hiera function that will be used when hiera is not detected
dummyHiera :: Monad m => HieraQueryFunc m
dummyHiera _ _ _ = return $ S.Right Nothing

-- | The combinator for "normal" queries
queryCombinator :: HieraQueryType -> [IO (Maybe PValue)] -> IO (Maybe PValue)
queryCombinator Priority = foldr (liftA2 mplus) (pure mzero)
queryCombinator ArrayMerge  = fmap rejoin . sequence
    where
        rejoin = Just . PArray . V.concat . map toA
        toA Nothing = V.empty
        toA (Just (PArray r)) = r
        toA (Just a) = V.singleton a
queryCombinator HashMerge = fmap (Just . PHash . mconcat . map toH) . sequence
    where
        toH Nothing = mempty
        toH (Just (PHash h)) = h
        toH _ = error "The hiera value was not a hash"

interpolateText :: Container T.Text -> T.Text -> T.Text
interpolateText vars t = fromMaybe t ((parseInterpolableString t ^? _Right) >>= resolveInterpolable vars)

resolveInterpolable :: Container T.Text -> [HieraStringPart] -> Maybe T.Text
resolveInterpolable vars = fmap T.concat . mapM resolvePart
  where
    resolvePart :: HieraStringPart -> Maybe T.Text
    resolvePart (HString x) = Just x
    resolvePart (HVariable v) = vars ^. at v

interpolatePValue :: Container T.Text -> PValue -> PValue
interpolatePValue v (PHash h) = PHash . HM.fromList . map ( (_1 %~ interpolateText v) . (_2 %~ interpolatePValue v) ) . HM.toList $ h
interpolatePValue v (PArray r) = PArray (fmap (interpolatePValue v) r)
interpolatePValue v (PString t) = PString (interpolateText v t)
interpolatePValue _ x = x

query :: HieraConfigFile -> FilePath -> Cache -> HieraQueryFunc IO
query (HieraConfigFile {_backends, _hierarchy}) fp cache vars hquery qtype =
    fmap S.Right (queryCombinator qtype (map query' _hierarchy)) `catch` (\e -> return . S.Left . PrettyError . string . show $ (e :: SomeException))
    where
        varlist = hcat (L.intersperse comma (map (dullblue . ttext) (L.sort (HM.keys vars))))
        query' :: InterpolableHieraString -> IO (Maybe PValue)
        query' (InterpolableHieraString strs) =
            case resolveInterpolable vars strs of
                Just s  -> queryCombinator qtype (map (query'' s) _backends)
                Nothing -> do
                    LOG.infoM loggerName (show $ "Hiera lookup: skipping using hierarchy level" <+> pretty strs
                                            <$$> "It couldn't be interpolated with known variables:" <+> varlist)
                    return Nothing
        query'' :: T.Text -> Backend -> IO (Maybe PValue)
        query'' hierastring backend = do
            let (decodefunction, datadir, extension) = case backend of
                                                (JsonBackend d) -> (fmap (strictifyEither . A.eitherDecode') . BS.readFile       , d, ".json")
                                                (YamlBackend d) -> (fmap (strictifyEither . (_Left %~ show)) . Y.decodeFileEither, d, ".yaml")
                filename = basedir <> datadir <> "/" <> T.unpack hierastring <> extension
                    where
                      basedir = case datadir of
                                  '/' : _ -> mempty
                                  _       -> fp^.directory <> "/"
                mfromJSON :: Maybe Value -> IO (Maybe PValue)
                mfromJSON Nothing = return Nothing
                mfromJSON (Just v) = case A.fromJSON v of
                                         A.Success a -> return (Just (interpolatePValue vars a))
                                         _           -> do
                                             LOG.warningM loggerName (show $ "Hiera:" <+> dullred "could not convert this Value to a Puppet type" <> ":" <+> string (show v))
                                             return Nothing
            v <- liftIO (F.query cache filename (decodefunction filename))
            case v of
                S.Left r -> do
                    let errs = "Hiera: error when reading file " <> string filename <+> string r
                    if "Yaml file not found: " `L.isInfixOf` r
                        then LOG.debugM loggerName (show errs)
                        else LOG.warningM loggerName (show errs)
                    return Nothing
                S.Right x -> mfromJSON (x ^? key hquery)