packages feed

grapesy-1.0.0: test-stress/Test/Stress/Cmdline.hs

{-# LANGUAGE CPP #-}
{-# OPTIONS_GHC -Wno-orphans #-}

module Test.Stress.Cmdline
  ( -- * Types
    Cmdline(..)
  , Role(..)
  , GlobalOpts(..)

    -- ** Client-specific
  , Connect(..)
  , Exec(..)
  , Call(..)

    -- ** Server-specific
  , Security(..)
  , TlsOpts(..)

    -- ** Auxiliary
  , NotPretty(..)

    -- * Parser
  , getCmdline
  ) where

import Control.Applicative ((<|>))
import Data.Foldable (asum, toList)
import Data.Maybe (fromMaybe)
import GHC.Generics (Generic)
import Network.Socket (HostName, PortNumber)
import Options.Applicative qualified as Opt
import Text.Show.Pretty (PrettyVal(..), parseValue)

import Network.GRPC.Client qualified as Client
import Network.GRPC.Common
import Network.GRPC.Common.Compression (Compression)
import Network.GRPC.Common.Compression qualified as Compr
import Network.GRPC.Server.Run

import Paths_grapesy

{-------------------------------------------------------------------------------
  Definitions
-------------------------------------------------------------------------------}

-- | Command specification
data Cmdline = Cmdline {
      cmdRole       :: Role
    , cmdGlobalOpts :: GlobalOpts
    }
  deriving stock (Show, Generic)
  deriving anyclass (PrettyVal)

-- | Should we run the client, servers, or both?
data Role =
      -- | Run the clients
      Client {
          -- | Connect over TLS?
          clientSecurity   :: Maybe (NotPretty Client.ServerValidation)
        , clientServerPort :: PortNumber
        , clientConnects   :: [Connect]

          -- | Insist on this compression scheme for all messages
        , clientCompression :: Maybe (NotPretty Compression)
        }

      -- | Run the server
    | Server {
          serverConfig :: ServerConfig
        }

      -- | Run the automatic stress test suite
    | Driver {
          driverWorkingDir :: Maybe FilePath
        , driverDuration   :: Int
        , driverGenCharts  :: Bool
        }
  deriving stock (Show, Generic)
  deriving anyclass (PrettyVal)

-- | Connections to execute
data Connect = Connect {
      -- | Execute the connections concurrently or sequentially?
      connectExec :: Exec

      -- | Number of connections
    , connectNum :: Int

      -- | Number of times to repeat the calls on the connection
    , callNum :: Int

      -- | Calls to make on the connections
    , connectCalls :: [Call]
    }
  deriving stock (Show, Generic)
  deriving anyclass (PrettyVal)

-- | Concurrent or sequential execution
data Exec = Concurrent | Sequential
  deriving stock (Show, Generic)
  deriving anyclass (PrettyVal)

-- | Types of RPCs
data Call =
      -- | A single message back and forth
      NonStreaming

      -- | Client sends @N@ messages to the server
    | ClientStreaming Int

      -- | Server sends @N@ messages to the client
    | ServerStreaming Int

      -- | Client and server send @N@ messages to each other
    | BiDiStreaming Int
  deriving stock (Show, Generic)
  deriving anyclass (PrettyVal)

data Security =
      Insecure
    | Secure
  deriving stock (Show, Generic)
  deriving anyclass (PrettyVal)

data TlsOpts = TlsOpts {
      tlsPubCert    :: FilePath
    , tlsChainCerts :: [FilePath]
    , tlsPrivKey    :: FilePath
    }
  deriving stock (Show, Generic)
  deriving anyclass (PrettyVal)

mkConfig :: Maybe TlsOpts -> HostName -> PortNumber -> ServerConfig
mkConfig mtls host port =
    case mtls of
      Just TlsOpts{..} ->
        ServerConfig Nothing $ Just SecureConfig {
            secureHost       = host
          , securePort       = port
          , securePubCert    = tlsPubCert
          , secureChainCerts = tlsChainCerts
          , securePrivKey    = tlsPrivKey
          , secureSslKeyLog  = SslKeyLogNone
          }
      Nothing ->
        (`ServerConfig` Nothing) $ Just InsecureConfig {
            insecureHost = Just host
          , insecurePort = port
          }

data GlobalOpts = GlobalOpts {
      optsTracing :: Bool
    }
  deriving stock (Show, Generic)
  deriving anyclass (PrettyVal)

-------------------------------------------------------------------------------
-- Top-level parsers
-------------------------------------------------------------------------------

getCmdline :: IO Cmdline
getCmdline = do
    defaultPub  <- getDataFileName "grpc-demo.pem"
    defaultPriv <- getDataFileName "grpc-demo.key"

    let info  :: Opt.ParserInfo Cmdline
        info = Opt.info
                 (          parseCmdline defaultPub defaultPriv
                   Opt.<**> Opt.helper
                 )
                 Opt.fullDesc

    Opt.execParser info

parseCmdline ::
     FilePath
  -> FilePath
  -> Opt.Parser Cmdline
parseCmdline defaultPub defaultPriv =
    Cmdline
      <$> (parseRole defaultPub defaultPriv <|> pure (Driver Nothing 60 False))
      <*> parseGlobalOpts

parseRole :: FilePath -> FilePath -> Opt.Parser Role
parseRole defaultPub defaultPriv = Opt.subparser $ mconcat [
      sub "client" "Run the client" $
        parseClientRole defaultPub
    , sub "server" "Run the server" $
        parseServerRole defaultPub defaultPriv
    , sub "driver" "Run the stress test driver" $
        parseDriverRole
    ]

-------------------------------------------------------------------------------
-- Client option parsers
-------------------------------------------------------------------------------

parseClientRole :: FilePath -> Opt.Parser Role
parseClientRole defaultPub =
    Client
      <$> (fmap WrapNotPretty <$> parseClientSecurity defaultPub)
      <*> parseClientPort
      <*> parseClientConnects
      <*> Opt.optional (WrapNotPretty <$> parseCompression)

parseClientSecurity :: FilePath -> Opt.Parser (Maybe Client.ServerValidation)
parseClientSecurity defaultPub =
    Opt.optional (
        Opt.flag' () (mconcat [
            Opt.long "secure"
          , Opt.help "Connect over TLS"
          ])
        *> parseServerValidation
      )
  where
    parseServerValidation :: Opt.Parser Client.ServerValidation
    parseServerValidation =
        aux
          <$> (Opt.switch $ mconcat [
                  Opt.long "no-server-validation"
                , Opt.help "Skip server (certificate) validation"
                ])
          <*> (Opt.switch $ mconcat [
                  Opt.long "cert-store-from-system"
                , Opt.help "Enable the system certificate store"
                ])
          <*> (Opt.option Opt.str $ mconcat [
                  Opt.long "cert-store-from-path"
                , Opt.help "Load certificate store from file or directory (set to empty to disable)"
                , Opt.metavar "PATH"
                , Opt.value defaultPub
                , Opt.showDefault
                ])
      where
        aux :: Bool -> Bool -> FilePath -> Client.ServerValidation
        aux noServerValidation certStoreFromSystem certStoreFromPath =
            if noServerValidation then
              Client.NoServerValidation
            else
              Client.ValidateServer $ mconcat . concat $ [
                  [ Client.certStoreFromSystem
                  | certStoreFromSystem
                  ]

                , [ Client.certStoreFromPath certStoreFromPath
                  | not (null certStoreFromPath)
                  ]
                ]

parseClientPort :: Opt.Parser PortNumber
parseClientPort =
    Opt.option Opt.auto (mconcat [
        Opt.long "port"
      , Opt.help "Connect to the server at PORT"
      , Opt.metavar "PORT"
      ])

parseClientConnects :: Opt.Parser [Connect]
parseClientConnects =
    Opt.some $ Connect
      <$> Opt.flag Sequential Concurrent (mconcat [
              Opt.long "concurrent"
            , Opt.help "Open connections concurrently"
            ])
      <*> Opt.option Opt.auto (mconcat [
              Opt.long "num-connections"
            , Opt.help "Open N connections"
            , Opt.metavar "N"
            , Opt.value 1
            , Opt.showDefault
            ])
      <*> Opt.option Opt.auto (mconcat [
              Opt.long "num-calls"
            , Opt.help "Repeat the calls N times on each connection"
            , Opt.metavar "N"
            , Opt.value 1
            , Opt.showDefault
            ])
      <*> Opt.some parseCall

parseCall :: Opt.Parser Call
parseCall =
        nonStreaming
    <|> clientStreaming
    <|> serverStreaming
    <|> bidiStreaming
  where
    nonStreaming :: Opt.Parser Call
    nonStreaming =
        Opt.flag' NonStreaming $ mconcat [
            Opt.long "non-streaming"
          , Opt.help "Make a single non-streaming call"
          ]

    clientStreaming :: Opt.Parser Call
    clientStreaming =
        ClientStreaming
          <$> Opt.option Opt.auto (mconcat [
                  Opt.long "client-streaming"
                , Opt.help "Stream N messages from the client"
                , Opt.metavar "N"
                ])

    serverStreaming :: Opt.Parser Call
    serverStreaming =
        ServerStreaming
          <$> Opt.option Opt.auto (mconcat [
                  Opt.long "server-streaming"
                , Opt.help "Stream N messages from the server"
                , Opt.metavar "N"
                ])

    bidiStreaming :: Opt.Parser Call
    bidiStreaming =
        BiDiStreaming
          <$> Opt.option Opt.auto (mconcat [
                  Opt.long "bidi-streaming"
                , Opt.help "Stream N messages from both the client and server"
                , Opt.metavar "N"
                ])

parseCompression :: Opt.Parser Compression
parseCompression = asum $ map go (toList Compr.allSupportedCompression)
  where
    go :: Compression -> Opt.Parser Compression
    go compr = Opt.flag' compr $ mconcat [
          Opt.long comprId
        , Opt.help $ "Insist on " ++ comprId ++ " compression "
        ]
      where
        comprId :: String
        comprId = show (Compr.compressionId compr)

-------------------------------------------------------------------------------
-- Server option parsers
-------------------------------------------------------------------------------

parseServerRole :: FilePath -> FilePath -> Opt.Parser Role
parseServerRole defaultPub defaultPriv =
    aux
      <$> parseServerPort
      <*> parseServerSecurity defaultPub defaultPriv
  where
    aux :: PortNumber -> Maybe TlsOpts -> Role
    aux port mtls = Server $ mkConfig mtls "127.0.0.1" port

parseServerPort :: Opt.Parser PortNumber
parseServerPort =
    Opt.option Opt.auto (mconcat [
        Opt.long "port"
      , Opt.help "Bind the server to port PORT"
      , Opt.metavar "PORT"
      ])

parseServerSecurity :: FilePath -> FilePath -> Opt.Parser (Maybe TlsOpts)
parseServerSecurity defaultPub defaultPriv =
    Opt.optional $
      Opt.flag' () (mconcat [
          Opt.long "secure"
        , Opt.help "Enable TLS on the servers"
        ])
      *> parseTlsOpts defaultPub defaultPriv

parseTlsOpts :: FilePath -> FilePath -> Opt.Parser TlsOpts
parseTlsOpts defaultPub defaultPriv =
    TlsOpts
      <$> Opt.strOption (mconcat [
              Opt.long "tls-pub"
            , Opt.help "TLS public certificate (X.509 format)"
            , Opt.metavar "CERT_FILE"
            , Opt.showDefault
            , Opt.value defaultPub
            ])
      <*> Opt.many (Opt.strOption $ mconcat [
              Opt.long "tls-cert"
            , Opt.metavar "CERT_FILE"
            , Opt.help "TLS chain certificate (X.509 format)"
            ])
      <*> Opt.strOption (mconcat [
              Opt.long "tls-priv"
            , Opt.metavar "KEY_FILE"
            , Opt.help "TLS private key"
            , Opt.showDefault
            , Opt.value defaultPriv
            ])

-------------------------------------------------------------------------------
-- Driver option parsers
-------------------------------------------------------------------------------

parseDriverRole :: Opt.Parser Role
parseDriverRole =
    Driver
      <$> Opt.optional (Opt.strOption (mconcat [
              Opt.long "working-dir"
            , Opt.help "Write result files to this directory, must already exist"
            , Opt.metavar "DIR"
            ]))
      <*> Opt.option Opt.auto (mconcat [
              Opt.long "duration"
            , Opt.help "Run the stress tests for this many seconds"
            , Opt.metavar "SECONDS"
            , Opt.value 60
            , Opt.showDefault
            ])
      <*> Opt.switch (mconcat [
              Opt.long "gen-charts"
            , Opt.help "Generate heap profile charts for stable components"
            ])

-------------------------------------------------------------------------------
-- Internal auxiliary
-------------------------------------------------------------------------------

parseGlobalOpts :: Opt.Parser GlobalOpts
parseGlobalOpts =
    GlobalOpts
      <$> Opt.switch (mconcat [
              Opt.long "verbose"
            , Opt.short 'v'
            , Opt.help "Trace test execution to stdout"
            ])

sub :: String -> String -> Opt.Parser a -> Opt.Mod Opt.CommandFields a
sub cmd desc parser =
    Opt.command cmd $
      Opt.info (parser Opt.<**> Opt.helper) (Opt.progDesc desc)

-------------------------------------------------------------------------------
-- Auxiliary: pretty-val
-------------------------------------------------------------------------------

newtype NotPretty a = WrapNotPretty { unwrapNotPretty :: a }
  deriving newtype (Show)

instance Show a => PrettyVal (NotPretty a) where
  prettyVal (WrapNotPretty x) =
      fromMaybe
        (error $ "prettyVal: could not parse " ++ show x)
        (parseValue $ show x)

instance PrettyVal PortNumber where
  prettyVal = prettyVal . (fromIntegral :: PortNumber -> Integer)

deriving anyclass instance PrettyVal ServerConfig
deriving anyclass instance PrettyVal InsecureConfig
deriving anyclass instance PrettyVal SecureConfig
deriving anyclass instance PrettyVal SslKeyLog