packages feed

language-puppet-1.0.1: progs/PuppetResources.hs

{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE  NamedFieldPuns #-}
module Main where

import           Control.Concurrent.ParallelIO (parallel)
import           Control.Lens
import           Control.Monad
import           Data.Aeson (encode)
import qualified Data.ByteString.Lazy.Char8 as BSL
import           Data.Either (partitionEithers)
import qualified Data.Either.Strict as S
import qualified Data.HashMap.Strict as HM
import qualified Data.HashSet as HS
import           Data.List (isInfixOf)
import           Data.Maybe (mapMaybe)
import           Data.Monoid hiding (First)
import qualified Data.Set as Set
import qualified Data.Text as T
import qualified Data.Text.IO as T
import           Data.Text.Strict.Lens
import           Data.Tuple (swap)
import qualified Data.Vector as V
import           Data.Yaml (decodeFileEither)
import           Options.Applicative
import           System.Exit (exitFailure, exitSuccess)
import qualified System.FilePath.Glob as G
import           System.IO
import qualified System.Log.Logger as LOG
import qualified Test.Hspec.Runner as H
import qualified Text.Parsec as P
import           Text.Regex.PCRE.String

import           Facter

import           Puppet.PP hiding ((<$>))
import           Puppet.Preferences
import           Puppet.Daemon
import           Puppet.Interpreter.Types
import           Puppet.Parser.Types
import           Puppet.Parser
import           Puppet.Parser.PrettyPrinter(ppStatements)
import           Puppet.Interpreter.PrettyPrinter()
import           PuppetDB.Remote
import           PuppetDB.Dummy
import           PuppetDB.TestDB
import           PuppetDB.Common
import           Puppet.Testing
import           Puppet.Stats


type QueryFunc = T.Text -> IO (S.Either PrettyError (FinalCatalog, EdgeMap, FinalCatalog, [Resource]))

data Options = Options
    { _pdb :: Maybe String
    , _showjson :: Bool
    , _showContent :: Bool
    , _resourceType :: Maybe T.Text
    , _resourceName :: Maybe T.Text
    , _puppetdir :: FilePath
    , _nodename :: Maybe T.Text
    , _pdbfile :: Maybe FilePath
    , _loglevel :: LOG.Priority
    , _hieraFile :: Maybe FilePath
    , _factsOverr :: Maybe FilePath
    , _factsDefault :: Maybe FilePath
    , _commitDB :: Bool
    , _checkExport :: Bool
    , _nousergrouptest :: Bool
    , _ignoredMods :: HS.HashSet T.Text
    } deriving (Show)

options :: Parser Options
options = Options
    <$> optional (strOption
       (  long "pdburl"
       <> help "URL of the puppetdb (ie. http://localhost:8080/)."))
   <*> switch
       (  long "JSON"
       <> short 'j'
       <> help "Shows the output as a JSON document (useful for full catalog views)")
   <*> switch
       (  long "showcontent"
       <> short 'c'
       <> help "When specifying a file resource, only output its content (useful for testing templates)")
   <*> optional (T.pack <$> strOption
       (  long "type"
       <> short 't'
       <> help "Filter the output by resource type (accepts a regular expression, ie '^file$')"))
   <*> optional (T.pack <$> strOption
       (  long "name"
       <> short 'n'
       <> help "Filter the output by resource name (accepts a regular expression)"))
   <*> strOption
       (  long "puppetdir"
       <> short 'p'
       <> help "Puppet directory")
   <*> optional (T.pack <$> strOption
       (  long "node"
       <> short 'o'
       <> help "Node name. Using 'allnodes' enables a special mode where all nodes present in site.pp are tried. \
              \ Run with +RTS -N. Using 'deadcode' will do the same, but will print warnings about code that's not being used."))
   <*> optional (strOption
       (  long "pdbfile"
       <> help "Path to the testing PuppetDB file."))
   <*> option auto
       (  long "loglevel"
       <> short 'v'
       <> help "Values are : DEBUG, INFO, NOTICE, WARNING, ERROR, CRITICAL, ALERT, EMERGENCY"
       <> value LOG.WARNING)
   <*> optional (strOption
       (  long "hiera"
       <> help "Path to the Hiera configuration file (default hiera.yaml)"
       <> value "hiera.yaml"))
   <*> optional (strOption
       (  long "facts-override"
       <> help "Path to a Yaml file containing a list of 'facts' that will override locally resolved facts"))
   <*> optional (strOption
       (  long "facts-defaults"
       <> help "Path to a Yaml file containing a list of 'facts' that will be used as defaults"))
   <*> switch
       (  long "commitdb"
       <> help "Commit the computed catalogs in the puppetDB")
   <*> switch
       (  long "checkExported"
       <> help "Save exported resources in the puppetDB")
   <*>  switch
       (  long "nousergrouptest"
       <> help "Disable the user and group tests")
   <*> (HS.fromList . T.splitOn "," . T.pack <$>
       strOption
       (  long "ignoremodules"
       <> help "Specify a comma-separated list of modules to ignore"
       <> value ""))

checkError :: Doc -> S.Either PrettyError a -> IO a
checkError r (S.Left rr) = error (show (red r <> ": " <+> getError rr))
checkError _ (S.Right x) = return x

{-| Does all the work of initializing a daemon for querying.
Returns the final catalog when given a node name. Note that this is pretty
hackish as it will generate facts from the local computer !
-}
initializedaemonWithPuppet :: LOG.Priority -> PuppetDBAPI IO -> FilePath -> Maybe FilePath -> (Facts -> Facts) -> HS.HashSet T.Text -> IO (QueryFunc, MStats, MStats, MStats)
initializedaemonWithPuppet loglevel pdbapi puppetdir hiera overrideFacts ignoremod = do
    LOG.updateGlobalLogger "Puppet.Daemon" (LOG.setLevel loglevel)
    q <- initDaemon =<< setupPreferences puppetdir ((prefPDB.~ pdbapi) . (hieraPath.~ hiera) . (ignoredmodules.~ ignoremod))
    let f node = fmap overrideFacts (puppetDBFacts node pdbapi) >>= _dGetCatalog q node
    return (f, _dParserStats q, _dCatalogStats q, _dTemplateStats q)

parseFile :: FilePath -> IO (Either P.ParseError (V.Vector Statement))
parseFile = fmap . runPParser puppetParser <*> T.readFile

printContent :: T.Text -> FinalCatalog -> IO ()
printContent filename catalog =
        case HM.lookup (RIdentifier "file" filename) catalog of
            Nothing -> error "File not found"
            Just r  -> case HM.lookup "content" (_rattributes r) of
                           Nothing -> error "This file has no content"
                           Just (PString c)  -> T.putStrLn c
                           Just x -> print x

prepareForPuppetApply :: WireCatalog -> WireCatalog
prepareForPuppetApply w =
    let res = V.filter (\r -> r ^. rvirtuality == Normal) (w ^. wResources) :: V.Vector Resource
        -- step 1 : capitalize resources types (and names in case of
        -- classes), and filter out exported stuff
        capi :: RIdentifier -> RIdentifier
        capi r = r & itype %~ capitalizeRT & if r ^. itype == "class"
                                                then iname %~ capitalizeRT
                                                else id
        aliasMap :: HM.HashMap RIdentifier T.Text
        aliasMap = HM.fromList $ concatMap genAliasList (res ^.. folded)
        genAliasList r = map (\n -> (RIdentifier (r ^. rid . itype) n, r ^. rid . iname)) (r ^. rid . iname : r ^.. ralias . folded)
        nr = V.map (rid %~ capi) res
        ne = V.map capEdge (w ^. wEdges)
        capEdge (PuppetEdge a b x) = PuppetEdge (capi a) (capi b) x
        -- step 2 : replace all references with the resource title in case
        -- of aliases - yes this sucks
        knownEdge :: PuppetEdge -> Bool
        knownEdge (PuppetEdge s d _) = (aliasMap & has (ix s)) && (aliasMap & has (ix d))
        correctEdges = V.map correctEdge $ V.filter knownEdge ne
        correctResources = V.map correctResource nr
        correct :: RIdentifier -> RIdentifier
        correct ri = ri & iname %~ \n -> HM.lookupDefault n ri aliasMap
        correctEdge :: PuppetEdge -> PuppetEdge
        correctEdge (PuppetEdge s d x) = PuppetEdge (correct s) (correct d) x
        correctResource :: Resource -> Resource
        correctResource r = r & rrelations %~ HM.fromList . filter (\(x,_) -> aliasMap & has (ix x)) . map (_1 %~ correct) . HM.toList
    in   (wResources .~ correctResources)
       . (wEdges     .~ correctEdges)
       $ w


loadFactsOverrides :: FilePath -> IO Facts
loadFactsOverrides fp = decodeFileEither fp >>= \case
    Left rr -> error ("Error when parsing " ++ fp ++ ": " ++ show rr)
    Right x -> return x

-- this finds the dead code
findDeadCode :: String -> [Resource] -> Set.Set FilePath -> IO ()
findDeadCode puppetdir catalogs allfiles = do
    -- first collect all files / positions from all the catalogs
    let allpositions = Set.fromList $ catalogs ^.. traverse . rpos
    -- now find all haskell files
    puppetfiles <- Set.fromList . concat . fst <$> G.globDir [G.compile "**/*.pp"] (puppetdir <> "/modules")
    let deadfiles = Set.filter ("/manifests/" `isInfixOf`) $ puppetfiles `Set.difference`   allfiles
        usedfiles = puppetfiles `Set.intersection` allfiles
    unless (Set.null deadfiles) $ do
        putDoc ("The following files" <+> int (Set.size deadfiles) <+> "are not used: " <> list (map string $ Set.toList deadfiles))
        putStrLn ""
    allparses <- parallel (map parseFile (Set.toList usedfiles))
    let (parseFailed, parseSucceeded) = partitionEithers allparses
    unless (null parseFailed) $ do
        putDoc ("The following" <+> int (length parseFailed) <+> "files could not be parsed:" </> indent 4 (vcat (map (string . show) parseFailed)))
        putStrLn ""
    let getSubStatements s@(ResourceDeclaration{}) = [s]
        getSubStatements (ConditionalStatement (CondStatement conds _)) = conds ^.. traverse . _2 . tgt
        getSubStatements s@(ClassDeclaration{}) = extractPrism s
        getSubStatements s@(DefineDeclaration{}) = extractPrism s
        getSubStatements s@(Node{}) = extractPrism s
        getSubStatements s@(SHFunctionCall{}) = extractPrism s
        getSubStatements (TopContainer v s) = getSubStatements s ++ v ^.. tgt
        getSubStatements _ = []
        tgt = folded . to getSubStatements . folded
        extractPrism = toListOf (_Statements . traverse . to getSubStatements . traverse)
        allResources = parseSucceeded ^.. folded . folded . to getSubStatements . folded
        deadResources = filter isDead allResources
        isDead (ResourceDeclaration (ResDec _ _ _ _ pp)) = not $ Set.member pp allpositions
        isDead _ = True
    unless (null deadResources) $ do
        putDoc ("The following" <+> int (length deadResources) <+> "resource declarations are not used:" </> indent 4 (vcat (map pretty deadResources)))
        putStrLn ""

newtype Maximum a = Maximum { getMaximum :: Maybe a }

instance (Ord a) => Monoid (Maximum a) where
    mempty = Maximum Nothing
    mappend (Maximum Nothing) m2 = m2
    mappend m1 (Maximum Nothing) = m1
    mappend (Maximum (Just a1)) (Maximum (Just a2)) = Maximum (Just (max a1 a2))


run :: Options -> IO ()
run (Options {_nodename = Nothing, _puppetdir}) = parseFile _puppetdir >>= \case
            Left rr -> error ("parse error:" ++ show rr)
            Right s -> putDoc $ ppStatements s

run cmd@(Options {_nodename = Just node, _pdb, _puppetdir, _pdbfile, _loglevel, _hieraFile, _factsOverr, _factsDefault, _commitDB, _ignoredMods}) = do
    pdbapi <- case (_pdb, _pdbfile) of
                  (Nothing, Nothing) -> return dummyPuppetDB
                  (Just _, Just _)   -> error "You must choose between a testing PuppetDB and a remote one"
                  (Just url, _)      -> pdbConnect (T.pack url) >>= checkError "Error when connecting to the remote PuppetDB"
                  (_, Just file)     -> loadTestDB file >>= checkError "Error when initializing the PuppetDB API"
    !factsOverrides <- case (_factsOverr, _factsDefault) of
                           (Just _, Just _) -> error "You can't use --facts-override and --facts-defaults at the same time"
                           (Just p, Nothing) -> HM.union `fmap` loadFactsOverrides p
                           (Nothing, Just p) -> (flip HM.union) `fmap` loadFactsOverrides p
                           (Nothing, Nothing) -> return id
    (queryfunc,mPStats,mCStats,mTStats) <- initializedaemonWithPuppet _loglevel pdbapi _puppetdir _hieraFile factsOverrides _ignoredMods
    printFunc <- hIsTerminalDevice stdout >>= \isterm -> return $ \x ->
        if isterm
            then putDoc x >> putStrLn ""
            else displayIO stdout (renderCompact x) >> putStrLn ""
    let allnodes = node == "allnodes" || deadcode
        deadcode = node == "deadcode"
    exit <- if allnodes
        then do
            allstmts <- parseFile (_puppetdir <> "/manifests/site.pp") >>= \presult -> case presult of
                                                                                          Left rr -> error (show rr)
                                                                                          Right x -> return x
            let topnodes = mapMaybe getNodeName (V.toList allstmts)
                getNodeName (Node (Nd (NodeName n) _ _ _)) = Just n
                getNodeName _ = Nothing
            cats <- parallel (map (computeCatalogs True queryfunc pdbapi printFunc cmd) topnodes)
            -- the the parsing statistics, so that we known which files
            -- were parsed
            pStats <- getStats mPStats
            cStats <- getStats mCStats
            tStats <- getStats mTStats
            -- merge all the resources together
            let cc = mapMaybe fst cats
                testFailures = getSum (cats ^. traverse . _2 . _Just . to (Sum . H.summaryFailures))
                allres = (cc ^.. folded . _1 . folded) ++ (cc ^.. folded . _2 . folded)
                allfiles = Set.fromList $ map T.unpack $ HM.keys pStats
            when deadcode $ findDeadCode _puppetdir allres allfiles
            -- compute statistics
            let (parsing,    Just (wPName, wPMean)) = worstAndSum pStats
                (cataloging, Just (wCName, wCMean)) = worstAndSum cStats
                (templating, Just (wTName, wTMean)) = worstAndSum tStats
                parserShare = 100 * parsing / cataloging
                templateShare = 100 * templating / cataloging
                formatDouble = take 5 . show -- yeah, well ...
                nbnodes = length topnodes
                worstAndSum = (_1 %~ getSum)
                                    . (_2 %~ fmap swap . getMaximum)
                                    . ifoldMap (\k (StatsPoint cnt total _ _) -> (Sum total, Maximum $ Just (total / fromIntegral cnt, k)))
            putStr ("Tested " ++ show nbnodes ++ " nodes. ")
            unless (nbnodes == 0) $ do
                putStrLn (formatDouble parserShare <> "% of total CPU time spent parsing, " <> formatDouble templateShare <> "% spent computing templates")
                when (_loglevel <= LOG.INFO) $ do
                    putStrLn ("Slowest template:           " <> T.unpack wTName <> ", taking " <> formatDouble wTMean <> "s on average")
                    putStrLn ("Slowest file to parse:      " <> T.unpack wPName <> ", taking " <> formatDouble wPMean <> "s on average")
                    putStrLn ("Slowest catalog to compute: " <> T.unpack wCName <> ", taking " <> formatDouble wCMean <> "s on average")
            return $ if testFailures > 0
                         then exitFailure
                         else exitSuccess
        else do
            r <- computeCatalogs False queryfunc pdbapi printFunc cmd node
            return $ case snd r of
                         Just s  -> if (H.summaryFailures s > 0)
                                       then exitFailure
                                       else exitSuccess
                         Nothing -> exitSuccess
    when _commitDB $ void $ commitDB pdbapi
    exit

computeCatalogs :: Bool -> QueryFunc -> PuppetDBAPI IO -> (Doc -> IO ()) -> Options -> T.Text -> IO (Maybe (FinalCatalog, [Resource]), Maybe H.Summary)
computeCatalogs testOnly queryfunc pdbapi printFunc (Options {_showjson, _showContent, _resourceType, _resourceName, _puppetdir, _checkExport, _nousergrouptest}) node =
    queryfunc node >>= \case
      S.Left rr -> do
          if testOnly
              then putDoc ("Problem with" <+> ttext node <+> ":" <+> getError rr </> mempty)
              else putDoc (getError rr) >> putStrLn "" >> error "error!"
          return (Nothing, Just (H.Summary 1 1))
      S.Right (rawcatalog, m, rawexported, knownRes) -> do
          let cmpMatch Nothing _ curcat = return curcat
              cmpMatch (Just rg) lns curcat = compile compBlank execBlank (T.unpack rg) >>= \case
                  Left rr   -> error ("Error compiling regexp 're': "  ++ show rr)
                  Right rec -> fmap HM.fromList $ filterM (filterResource lns rec) (HM.toList curcat)
              filterResource lns rec v = execute rec (v ^. lns) >>= \case
                                              Left rr -> error ("Error when applying regexp: " ++ show rr)
                                              Right Nothing -> return False
                                              _ -> return True
              filterCatalog = cmpMatch _resourceType (_1 . itype . unpacked) >=> cmpMatch _resourceName (_1 . iname . unpacked)
          catalog  <- filterCatalog rawcatalog
          exported <- filterCatalog rawexported
          let wireCatalog    = generateWireCatalog node (catalog    <> exported   ) m
              rawWireCatalog = generateWireCatalog node (rawcatalog <> rawexported) m
          when _checkExport $ void $ replaceCatalog pdbapi rawWireCatalog
          testResult <- case (testOnly, _showContent, _showjson) of
              (True, _, _) -> Just `fmap` testCatalog node _puppetdir rawcatalog basicTest
              (_, _, True) -> BSL.putStrLn (encode (prepareForPuppetApply wireCatalog)) >> return Nothing
              (_, True, _) -> do
                  unless (_resourceType == Just "file" || _resourceType == Nothing) (error $ "Show content only works for file, not for " ++ show _resourceType)
                  case _resourceName of
                      Just f  -> printContent f catalog
                      Nothing -> error "You should supply a resource name when using showcontent"
                  return Nothing
              _ -> do
                  r <- testCatalog node _puppetdir rawcatalog (basicTest >> unless _nousergrouptest usersGroupsDefined)
                  printFunc (pretty (HM.elems catalog))
                  unless (HM.null exported) $ do
                      printFunc (mempty <+> dullyellow "Exported:" <+> mempty)
                      printFunc (pretty (HM.elems exported))
                  return (Just r)
          return (Just (rawcatalog <> rawexported, knownRes), testResult)

main :: IO ()
main = execParser opts >>= run
    where
        opts :: ParserInfo Options
        opts = info (helper <*> options)
                (fullDesc
                 <> progDesc "A program for parsing puppet files, generating and inspecting catalogs"
                 <> header "puppetresources - a development tool for Puppet"
                 <> failureCode 3)