packages feed

om-legion-6.9.0.3: test/om-legion-test-driver.hs

{-# LANGUAGE DerivingStrategies #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE NumericUnderscores #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE StandaloneDeriving #-}
{-# LANGUAGE TypeApplications #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE UndecidableInstances #-}

{- | Description: Run an om-legion tests. -}
module Main (
  main,
) where


import Control.Concurrent (threadDelay)
import Control.Lens (Identity, view)
import Control.Monad (unless)
import Control.Monad.Logger.CallStack (LogLevel(LevelDebug, LevelError,
  LevelInfo, LevelOther, LevelWarn), LoggingT(runLoggingT), MonadLogger,
  MonadLoggerIO, logDebug, logError, logInfo)
import Data.Aeson (Value, eitherDecode)
import Data.Aeson.Lens (AsPrimitive(_String), AsValue(_Array), key)
import Data.Foldable (for_)
import Data.String (IsString(fromString))
import OM.Fork (Race, runRace)
import OM.Logging (standardLogging)
import OM.Show (showt)
import System.Console.GetOpt (ArgDescr(OptArg, ReqArg), ArgOrder(Permute),
  OptDescr(Option), getOpt, usageInfo)
import System.Environment (getArgs)
import System.Exit (ExitCode(ExitFailure, ExitSuccess))
import Test.Hspec (Spec, describe, it, shouldReturn)
import Test.Hspec.Runner (defaultConfig, evaluateSummary, runSpec)
import Test.OM.Legion (Op(Inc), Request(OpReq, ReadState, ReadStats),
  k8sConfig)
import UnliftIO (MonadIO(liftIO), MonadUnliftIO, SomeException, finally,
  try)
import UnliftIO.Process (proc, readProcess, readProcessWithExitCode,
  showCommandForUser, waitForProcess, withCreateProcess)
import qualified Data.Text as Text
import qualified Data.Vector as Vector


main :: IO ()
main = do
  config <- getConfig
  let
    logging = standardLogging (logLevel config)
    it_ :: String -> (Race => LoggingT IO ()) -> Spec
    it_ doesWhat action =
      it doesWhat
      . flip shouldReturn ()
      . flip runLoggingT logging
      $ runRace
      $ flip finally (terminateCluster config)
      $ action
  runTests $
    describe "om-legion" $ do
      it_ "scales up" $ do
        launchCluster config
        runOps config 0 [OpReq Inc, OpReq Inc, OpReq Inc]
        resizeCluster (namespace config) 3
        runOps config 1 [OpReq Inc, OpReq Inc, OpReq Inc]
        runOps config 2 [OpReq Inc, OpReq Inc, OpReq Inc]
        for_ [0..2] $ \n ->
          runOps config n [ ReadState, ReadStats ]
        checkStable config 3 9

      it_ "scales down" $ do
        launchCluster config
        resizeCluster (namespace config) 3
        for_ [0..2] $ \n ->
          runOps config n [OpReq Inc, OpReq Inc, OpReq Inc]
        resizeCluster (namespace config) 1
        runOps config 0 [ ReadState, ReadStats ]
        checkStable config 1 9

      it_ "recovers from pod deletion" $ do
        launchCluster config
        resizeCluster (namespace config) 3
        runOps config 2 [OpReq Inc, OpReq Inc, OpReq Inc, ReadStats]
        runOps config 2 [ReadState]
        cmd "kubectl" ["-n", namespace config, "delete", "pod", "test-2"]
        runOps config 0 [OpReq Inc, OpReq Inc, OpReq Inc, ReadStats]
        waitForStable config 3 50 6


runOps
  :: ( MonadFail m
     , MonadLogger m
     , MonadUnliftIO m
     )
  => Config Identity
  -> Int
  -> [Request]
  -> m ()
runOps config n ops = do
  cmd
    "kubectl"
    (
      [ "-n"
      , (namespace config)
      , "run"
      , "test-driver"
      , "--image=us.gcr.io/friendlee/om-legion-test:latest"
      , "-i"
      , "--rm"
      , "--restart=Never"
      , "--env=NAMESPACE=" <> namespace config
      , "--env=LOG_LEVEL=DEBUG"
      , "--serviceaccount=" <> namespace config
      , "--wait=true"
      , "--"
      , "/bin/om-legion-test-run"
      , show n
      ] <> (show <$> ops)
    )


resizeCluster
  :: ( MonadFail m
     , MonadLogger m
     , MonadUnliftIO m
     )
  => String
  -> Int
  -> m ()
resizeCluster namespace n = do
  cmd
    "kubectl"
    [
      "-n",
      namespace,
      "patch",
      "statefulset",
      namespace,
      "-p",
      "{\"spec\": {\"replicas\": " <> showt n <> "}}"
    ]
  for_ [0..n-1] (waitForReady namespace)


waitForReady
  :: ( MonadFail m
     , MonadLogger m
     , MonadUnliftIO m
     )
  => String
  -> Int
  -> m ()
waitForReady namespace n = do
    cmd "kubectl" ["-n", namespace, "get", "pods"]
    json <-
      readProcess
        "kubectl"
        [ "-n"
        , namespace
        , "get"
        , "pod"
        , namespace <> "-" <> show n
        , "-o"
        , "json"
        ]
        ""
    case eitherDecode (fromString json) of
      Left err -> fail $ "Can't check ready: " <> show (n, err)
      Right val ->
        case isReady val of
          True -> pure ()
          False -> do
            logInfo $ "Not yet ready: " <> showt n
            liftIO $ threadDelay 1_000_000
            waitForReady namespace n
  where
    isReady :: Value -> Bool
    isReady =
      not
      . Vector.null
      . Vector.filter
          (\condition ->
            view (key "status" . _String) condition == "True"
            && view (key "type" . _String) condition == "Ready"
          )
      . view (key "status" . key "conditions" . _Array)


getConfig
  :: ( MonadFail m
     , MonadIO m
     )
  => m (Config Identity)
getConfig = do
    let logging = standardLogging LevelDebug
    flip runLoggingT logging $ do
      args <- liftIO $ getArgs
      case getOpt Permute options args of
        (o, [], []) ->
          let
            config = foldl (.) id o (Config Nothing Nothing LevelInfo)
          in
            case validate config of
              Nothing -> do
                logError ("Invalid config: " <> showt config)
                usage
              Just cfg -> pure cfg
        (_, _, err) -> do
          logError ("opt-result: " <> showt err)
          usage
  where
    usage :: (MonadLogger m, MonadFail m) => m a
    usage = do
      logError $ fromString (usageInfo "Usage:" options)
      fail "Invalid usage."

    options :: [OptDescr (Config Maybe -> Config Maybe)]
    options =
      [ Option
          "n"
          ["namespace"]
          (ReqArg (\val cfg -> cfg {namespace = Just val}) "<namespace>")
          "The k8s namespace to deploy to."
      , Option
          "i"
          ["image"]
          (ReqArg (\val cfg -> cfg {image = Just val}) "<docker-image>")
          "The docker image to deploy."
      , Option
          ""
          ["log-level"]
          (
            OptArg
              (\mLevel cfg ->
                case mLevel of
                  Nothing      -> cfg {logLevel = LevelInfo}
                  Just "DEBUG" -> cfg {logLevel = LevelDebug}
                  Just "INFO"  -> cfg {logLevel = LevelInfo}
                  Just "WARN"  -> cfg {logLevel = LevelWarn}
                  Just "ERROR" -> cfg {logLevel = LevelError}
                  Just other   -> cfg {logLevel = LevelOther (fromString other)}
              )
              "<log-level>"
          )
          "The logging level to choose. [DEBUG, INFO, WARN, ERROR]"
      ]


runTests :: Spec -> IO ()
runTests spec =
  runSpec spec defaultConfig >>= evaluateSummary


terminateCluster
  :: forall m.
     ( MonadFail m
     , MonadLoggerIO m
     , MonadUnliftIO m
     )
  => Config Identity
  -> m ()
terminateCluster Config {namespace} =
    finally
      (do
        logDumpedState 0
        logDumpedState 1
        logDumpedState 2
      ) (
        cmd
          "kubectl"
          [ "delete"
          , "namespace"
          , namespace
          ]
      )
  where
    logDumpedState :: Int -> m ()
    logDumpedState n = do
      (_, out, _) <-
        readProcessWithExitCode
          "bash"
          [
            "-c",
            "kubectl exec -i -n "
            <> show namespace
            <> " "
            <> show namespace
            <> "-"
            <> show n
            <> " bash -- -c 'cat log | gzip' | gzip -d"
          ]
          ""
      logInfo $ "logs " <> showt n <> ": " <> showt out


launchCluster
  :: ( MonadFail m
     , MonadLogger m
     , MonadUnliftIO m
     )
  => Config Identity
  -> m ()
launchCluster Config { namespace, image } = do
  readProcessWithExitCode
      "kubectl"
      [
        "apply",
        "-f",
        "-"
      ]
      (Text.unpack (k8sConfig namespace image))
    >>= \case
      (ExitSuccess, out, err) -> do
        logInfo (fromString out)
        unless (null err) $ logError (fromString err)
      err -> fail ("Got apply error: " <> show err)
  waitForReady namespace 0


cmd
  :: ( MonadFail m
     , MonadLogger m
     , MonadUnliftIO m
     )
  => String
  -> [String]
  -> m ()
cmd prog args = do
  withCreateProcess
    (proc prog args)
    (\_ _ _ handle ->
      waitForProcess handle >>= \case
        result@(ExitFailure _) ->
          fail
            $ "Failed:\n"
            <> showCommandForUser prog args
            <> "\nwith: " <> show result
        result@ExitSuccess ->
          logDebug ("got: " <> showt result)
   )


{- | Check that n nodes are reporting stable stats. -}
checkStable
  :: ( MonadFail m
     , MonadLogger m
     , MonadUnliftIO m
     )
  => Config Identity
  -> Int
  -> Int
  -> m ()
checkStable config n v = do
  cmd
    "kubectl"
    [ "-n"
    , (namespace config)
    , "run"
    , "test-driver"
    , "--image=us.gcr.io/friendlee/om-legion-test:latest"
    , "-i"
    , "--rm"
    , "--restart=Never"
    , "--env=NAMESPACE=" <> namespace config
    , "--env=LOG_LEVEL=DEBUG"
    , "--serviceaccount=" <> namespace config
    , "--wait=true"
    , "--"
    , "/bin/om-legion-test-stable"
    , show n
    , show v
    ]


waitForStable
  :: ( MonadFail m
     , MonadLogger m
     , MonadUnliftIO m
     )
  => Config Identity
  -> Int
  -> Int
  -> Int
  -> m ()
waitForStable config n v tries =
  case tries of
    0 -> fail "The cluster doesn't seem like it's going to stabilize."
    _ -> do
      runOps config 0 [ReadState, ReadStats]
      try (checkStable config n v) >>= \case
        Left (_err :: SomeException) -> do
          logInfo $ "Not yet stable."
          waitForStable config n v (tries - 1)
        Right () -> pure ()


data Config f = Config
  { namespace :: F f String
  ,     image :: F f String
  ,  logLevel :: LogLevel
  }
deriving stock instance Show (Config Maybe)


type family F a b where
  F Identity a = a
  F f a = f a


validate :: Config Maybe -> Maybe (Config Identity)
validate (Config namespace image logLevel) =
  Config
    <$> namespace
    <*> image
    <*> pure logLevel