packages feed

prodapi-0.1.0.0: src/Prod/Health.hs

{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DerivingVia #-}
{-# LANGUAGE StandaloneDeriving #-}

module Prod.Health (
    HealthApi,
    GetReadinessApi,
    handleHealth,
    Liveness (..),
    Reason (..),
    Readiness (..),
    completeReadiness,
    Runtime (..),
    alwaysReadyRuntime,
    withLiveness,
    withReadiness,
    Track (..),
)
where

import Control.Monad.IO.Class (MonadIO, liftIO)
import Data.Aeson (FromJSON, ToJSON (..), Value (String))
import Data.IORef (IORef, atomicModifyIORef', newIORef, readIORef)
import Data.Set (Set)
import qualified Data.Set as Set
import Data.Text (Text)
import GHC.Generics (Generic)
import GHC.Stack (CallStack, HasCallStack, callStack)
import Prod.Tracer
import Servant

data Runtime
    = Runtime
    { liveness :: IO Liveness
    , readiness :: IO Readiness
    , conditions :: IORef (Set Reason)
    , tracer :: Tracer IO Track
    }

alwaysReadyRuntime :: Tracer IO Track -> IO Runtime
alwaysReadyRuntime tracer = Runtime (pure Alive) (pure Ready) <$> (newIORef mempty) <*> pure tracer

withReadiness :: IO Readiness -> Runtime -> Runtime
withReadiness io rt = rt{readiness = io}

withLiveness :: IO Liveness -> Runtime -> Runtime
withLiveness io rt = rt{liveness = io}

data Liveness = Alive

instance ToJSON Liveness where
    toJSON = const $ String "alive"

newtype Reason = Reason Text
    deriving stock (Eq, Ord, Show)
    deriving
        (ToJSON, FromJSON)
        via Text

data Readiness = Ready | Ill (Set Reason)
    deriving stock (Eq, Ord, Show)
    deriving (Generic)

instance ToJSON Readiness
instance FromJSON Readiness

data Track = Afflict CallStack Reason | Cure CallStack Reason
    deriving (Show)

trace :: (HasCallStack, MonadIO m) => Runtime -> Track -> m ()
trace rt = liftIO . runTracer (tracer rt)

combineReasons :: Readiness -> Set Reason -> Readiness
combineReasons Ready rs
    | Set.null rs = Ready
    | otherwise = Ill rs
combineReasons (Ill rs1) rs2 = Ill (rs1 <> rs2)

completeReadiness :: Runtime -> IO Readiness
completeReadiness rt =
    combineReasons <$> readiness rt <*> readIORef (conditions rt)

-- | Add some illness reason.
afflict :: (MonadIO m, HasCallStack) => Runtime -> Reason -> m ()
afflict rt r = do
    trace rt $ Afflict callStack r
    liftIO $ atomicModifyIORef' (conditions rt) (\rs -> (Set.insert r rs, ()))

-- | Remove some illness reason.
cure :: (MonadIO m, HasCallStack) => Runtime -> Reason -> m ()
cure rt r = do
    trace rt $ Cure callStack r
    liftIO $ atomicModifyIORef' (conditions rt) (\rs -> (Set.delete r rs, ()))

type GetLivenessApi =
    Summary "Health liveness probe."
        :> "health"
        :> "alive"
        :> Get '[JSON] Liveness

type GetReadinessApi =
    Summary "Health readiness probe."
        :> "health"
        :> "ready"
        :> Get '[JSON] Readiness

type DrainApi =
    Summary "Set a specific 'drained' condition."
        :> "health"
        :> "drain"
        :> Post '[JSON] Readiness

type HealthApi =
    GetLivenessApi
        :<|> GetReadinessApi
        :<|> DrainApi

handleHealth :: Runtime -> Server HealthApi
handleHealth runtime =
    handleLiveness runtime
        :<|> handleReadiness runtime
        :<|> handleDrain runtime
  where
    handleLiveness :: Runtime -> Handler Liveness
    handleLiveness = liftIO . liveness
    handleReadiness :: Runtime -> Handler Readiness
    handleReadiness rt = liftIO $ do
        completeReadiness rt
    handleDrain :: Runtime -> Handler Readiness
    handleDrain rt = do
        afflict rt (Reason "drained")
        handleReadiness rt