sensu-run-0.0.0: sensu-run.hs
{-# LANGUAGE ApplicativeDo #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE MultiWayIf #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE PatternSynonyms #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE StrictData #-}
{-# LANGUAGE TypeApplications #-}
{-# LANGUAGE ViewPatterns #-}
module Main where
import Control.Exception
import Data.Foldable
import Data.List.NonEmpty (NonEmpty)
import Data.Maybe
import System.Exit
import System.IO
import qualified Data.List.NonEmpty as NE
import qualified System.Timeout as Timeout
import Control.Lens hiding ((.=))
import Data.Aeson
import Data.Time
import Data.Time.Clock.POSIX
import Network.HTTP.Client (HttpException)
import Network.Socket
import System.FilePath ((</>))
import System.IO.Temp
import System.Process
import qualified Data.ByteString.Lazy as BL
import qualified Data.ByteString.Lazy.Char8 as BL8
import qualified Data.Text as T
import qualified Data.Text.Encoding.Error as TE
import qualified Data.Text.Lazy as TL
import qualified Data.Text.Lazy.Encoding as TL
import qualified Network.HTTP.Types.Status as HT
import qualified Network.Socket.ByteString.Lazy as Socket
import qualified Network.Wreq as W
import qualified Options.Applicative as O
main :: IO ()
main = do
issued <- getCurrentTime
Options {..} <- O.execParser $ O.info (O.helper <*> options) O.fullDesc
withSystemTempFile "sensu-run.XXX" $ \path hdl -> do
executed <- getCurrentTime
rawStatus <- bracket
(startProcess cmdspec hdl)
terminateProcess
(withTimeout timeout . waitForProcess)
exited <- getCurrentTime
rawOutput <- BL.readFile path
let
encoded = encode $ CheckResult
{ command = cmdspec
, output = TL.decodeUtf8With TE.lenientDecode rawOutput
, status = case rawStatus of
Nothing -> UNKNOWN
Just ExitSuccess -> OK
Just (ExitFailure {}) -> CRITICAL
, duration = diffUTCTime exited executed
, ..
}
if dryRun
then BL8.putStrLn encoded
else case endpoint of
ClientSocketInput port -> sendToClientSocketInput port encoded
SensuServer urls -> sendToSensuServer urls encoded
case rawStatus of
Just ExitSuccess -> exitSuccess
Nothing -> do
hPutStrLn stderr $ showCmdSpec cmdspec ++ " timed out"
exitFailure
Just (ExitFailure {}) -> exitFailure
sendToClientSocketInput
:: PortNumber -- ^ Listening port of Sensu client socket
-> BL8.ByteString -- ^ Payload
-> IO ()
sendToClientSocketInput port payload = bracket open close $ \sock -> do
localhost <- inet_addr "127.0.0.1"
connect sock $ SockAddrInet port localhost
Socket.sendAll sock payload
`catch` \(ioe :: IOException) -> do
hPutStrLn stderr $
"Failed to write results to localhost:3030 (" ++ show ioe ++ ")"
exitFailure
where
open = socket AF_INET Stream defaultProtocol
sendToSensuServer
:: NonEmpty String -- ^ Sensu server base URLs
-> BL8.ByteString -- ^ Payload
-> IO ()
sendToSensuServer urls payload =
foldr go (handleError "no more retry") urls
`catch` \(e :: HttpException) -> handleError (show e)
where
go url retry = do
resp <- W.postWith params (url </> "results") payload
let status = resp ^. W.responseStatus
if
| HT.statusIsClientError status -> handleError $ show status
| HT.statusIsServerError status -> retry
| HT.statusIsSuccessful status -> return ()
| otherwise ->
fail $ "sendToSensuServer: unexpected status " ++ show status
params = W.defaults &
W.header "Content-Type" .~ ["application/json"]
handleError reason = do
hPutStrLn stderr $
"Failed to POST results to Sensu server (" ++ reason ++ ")"
exitFailure
startProcess :: CmdSpec -> Handle -> IO ProcessHandle
startProcess cmdspec hdl = do
(_, _, _, ph) <- createProcess CreateProcess
{ cmdspec
, cwd = Nothing
, env = Nothing
, std_in = Inherit
, std_out = UseHandle hdl
, std_err = UseHandle hdl
, close_fds = False
, create_group = False
, delegate_ctlc = False
, detach_console = False
, create_new_console = False
, new_session = False
, child_group = Nothing
, child_user = Nothing
}
return ph
withTimeout :: Maybe NominalDiffTime -> IO a -> IO (Maybe a)
withTimeout time io = case time of
Just n -> Timeout.timeout (round $ n * 10^(6 :: Int)) io
Nothing -> Just <$> io
data Options = Options
{ name :: T.Text
, cmdspec :: CmdSpec
, source :: Maybe T.Text
, ttl :: Maybe NominalDiffTime
, timeout :: Maybe NominalDiffTime
, handlers :: [T.Text]
, endpoint :: Endpoint
, dryRun :: Bool
}
data Endpoint
= ClientSocketInput PortNumber
-- ^ Local client socket input
| SensuServer (NonEmpty String)
-- ^ Sensu server API
--
-- Multiple servers can be specified. sensu-run retries sequentially until it
-- succeeds.
options :: O.Parser Options
options = do
name <- textOption $ mconcat
[ O.short 'n'
, O.long "name"
, O.metavar "NAME"
, O.help "The name of the check"
]
source <- O.optional $ textOption $ mconcat
[ O.long "source"
, O.metavar "SOURCE"
, O.help $ unlines
[ "The check source, used to create a JIT Sensu client for an"
, "external resource" ]
]
ttl <- durationOption $ mconcat
[ O.long "ttl"
, O.metavar "SECONDS"
, O.help "The time to live in seconds until check results are considered stale"
]
timeout <- durationOption $ mconcat
[ O.long "timeout"
, O.metavar "SECONDS"
, O.help "The check executaion duration timeout in seconds"
]
handlers <- O.some $ textOption $ mconcat
[ O.long "handler"
, O.metavar "HANDLER"
, O.help "Sensu event handler(s) to use for events created by the check"
]
endpoint <- asum
[ ClientSocketInput <$> portOption
, SensuServer . NE.fromList <$> O.some serverOption
]
dryRun <- O.switch $ mconcat
[ O.long "dry-run"
, O.long "dry"
]
cmdspec <- cmdSpecOption
return Options {..}
where
textOption m = T.pack <$> O.strOption m
durationOption m =
fmap (realToFrac @Double) <$> O.optional (O.option O.auto m)
cmdSpecOption = cmdSpec
<$> O.optional
(O.switch $ mconcat
[ O.short 's'
, O.long "shell"
, O.help "Execute the command using the shell"
])
<*> O.some (O.strArgument $ O.metavar "COMMAND")
where
cmdSpec (fromMaybe False -> isShell) args
| isShell = ShellCommand (unwords args)
| otherwise = RawCommand (head args) (tail args)
portOption = O.option O.auto $ mconcat
[ O.long "port"
, O.metavar "PORT"
, O.help
"Send results to the local sensu-client listening on the specified port"
, O.showDefault
, O.value 3030
]
serverOption = O.strOption $ mconcat
[ O.long "server"
, O.metavar "URL"
, O.help "Send results to the specified Sensu server"
]
data CheckResult = CheckResult
{ name :: T.Text
, command :: CmdSpec
, status :: ExitCode
, source :: Maybe T.Text
, issued :: UTCTime
, executed :: UTCTime
, duration :: NominalDiffTime
, output :: TL.Text
}
pattern OK :: ExitCode
pattern OK = ExitSuccess
pattern WARNING :: ExitCode
pattern WARNING = ExitFailure 1
pattern CRITICAL :: ExitCode
pattern CRITICAL = ExitFailure 2
pattern UNKNOWN :: ExitCode
pattern UNKNOWN = ExitFailure 3
instance ToJSON CheckResult where
toJSON = object . checkResultKeyValue
toEncoding = pairs . mconcat . checkResultKeyValue
checkResultKeyValue :: KeyValue a => CheckResult -> [a]
checkResultKeyValue CheckResult {..} =
addOptional "source" source
[ "name" .= name
, "command" .= showCmdSpec command
, "issued" .= (floor (utcTimeToPOSIXSeconds issued) :: Int)
, "executed" .= (floor (utcTimeToPOSIXSeconds executed) :: Int)
, "duration" .= (round duration :: Int)
, "status" .= statusToInt status
, "output" .= output
]
where
addOptional key val ps = maybe ps (\val' -> key .= val' : ps) val
statusToInt ExitSuccess = 0
statusToInt (ExitFailure n) = n
showCmdSpec :: CmdSpec -> String
showCmdSpec = \case
ShellCommand cmd -> cmd
RawCommand cmd args -> unwords $ cmd:args