rdkafka-hs-1.1.0.0: test-integration/Kafka/Compatibility/KafkaFixture.hs
-- | Disposable Apache processes owned by this test run. The explicit
-- qualification entry point requires a released Kafka distribution and Java.
module Kafka.Compatibility.KafkaFixture
( KafkaFixture,
createKafkaFixture,
SecureExtension (..),
createSecureKafkaFixture,
destroyKafkaFixture,
fixtureEnv,
fixtureRoot,
fixtureCommand,
ownedCommand,
stopBroker,
startBroker,
awaitBrokers,
fixtureAllStopped,
withBrokersStopped,
)
where
import Control.Concurrent (threadDelay)
import Control.Concurrent.MVar
import Control.Exception (SomeException, bracket, bracketOnError, bracket_, mask, mask_, onException, throwIO, try)
import Control.Monad (forM, forM_, unless, void)
import Data.IORef
import Data.List (intercalate, sort)
import Data.Text qualified as T
import Kafka.Metadata qualified as D
import Kafka.Producer qualified as P
import Kafka.TestEnv
import Kafka.Types
import Network.Socket qualified as N
import System.Directory
import System.Environment (getEnvironment, lookupEnv)
import System.Exit (ExitCode (..))
import System.IO
import System.Posix.Files (setFileMode)
import System.Posix.Signals (sigKILL, signalProcess)
import System.Process
import System.Timeout (timeout)
import Test.Tasty.HUnit (assertFailure)
data Node = Node Int Int FilePath (MVar (Maybe ProcessHandle))
data KafkaFixture = KafkaFixture
{ fixtureEnv :: TestEnv,
fixtureRoot :: FilePath,
kafkaHome :: FilePath,
fixtureNodes :: [Node],
healthEnv :: TestEnv,
secureExtension :: Maybe SecureExtension
}
-- Security owns credentials and policy; this runner owns every process and port.
data SecureExtension = SecureExtension
{ securePrepare :: FilePath -> IO (),
secureConfiguration :: FilePath -> Int -> Int -> Int -> IO [String],
secureEnvironment :: FilePath -> [(String, String)],
secureCleanup :: FilePath -> IO ()
}
bounded :: Int -> String -> IO a -> IO a
bounded seconds label action = timeout (seconds * 1000000) action >>= maybe (assertFailure label) pure
-- This function can terminate only a ProcessHandle returned by createProcess.
-- It never resolves a machine-global broker or reads an externally supplied PID.
stopProcess :: ProcessHandle -> IO ()
stopProcess process =
getProcessExitCode process >>= \case
Just _ -> pure ()
Nothing -> do
terminateProcess process
timeout 15000000 (waitForProcess process) >>= \case
Just _ -> pure ()
Nothing -> do
getPid process >>= mapM_ (signalProcess sigKILL)
void (bounded 5 "owned Kafka process did not exit after SIGKILL" (waitForProcess process))
fixtureCommand :: KafkaFixture -> String -> [String] -> IO String
fixtureCommand fixture command = ownedCommand (fixtureRoot fixture) (kafkaHome fixture <> "/bin/" <> command <> ".sh")
ownedCommand :: FilePath -> FilePath -> [String] -> IO String
ownedCommand root command args = mask $ \restore -> do
(outputPath, outputHandle) <- openTempFile root "command-out-"
(_, errorHandle) <- openTempFile root "command-err-" `onException` hClose outputHandle
-- createProcess closes the parent's UseHandle copies after duplicating them.
(_, _, _, process) <-
createProcess
(proc command args)
{ std_in = NoStream,
std_out = UseHandle outputHandle,
std_err = UseHandle errorHandle
}
`onException` (hClose outputHandle >> hClose errorHandle)
code <- restore (bounded 45 (command <> " timed out") (waitForProcess process)) `onException` stopProcess process
output <- readFile outputPath
case code of
ExitSuccess -> pure output
_ -> assertFailure ("owned fixture command failed: " <> show (command, code) <> "; private output retained for sanitized inspection")
createKafkaFixture :: IO KafkaFixture
createKafkaFixture = createFixture 3 Nothing
createSecureKafkaFixture :: SecureExtension -> IO KafkaFixture
createSecureKafkaFixture = createFixture 1 . Just
createFixture :: Int -> Maybe SecureExtension -> IO KafkaFixture
createFixture count extension = do
home <- lookupEnv "RDKAFKA_TEST_KAFKA_HOME" >>= maybe (assertFailure "set RDKAFKA_TEST_KAFKA_HOME to a released Apache Kafka distribution") pure
forM_ ["kafka-storage", "kafka-server-start", "kafka-topics", "kafka-configs"] $ \name ->
doesFileExist (home <> "/bin/" <> name <> ".sh") >>= \found -> unless found (assertFailure ("missing Kafka fixture command: " <> name))
createDirectoryIfMissing True "dist-compatibility/kafka-fixtures"
(root, handle) <- openTempFile "dist-compatibility/kafka-fixtures" "owned-"
hClose handle
removeFile root
createDirectory root
setFileMode root 0o700
writeFile (root <> "/owned-by-rdkafka-hs") "run-owned Apache qualification fixture\n"
-- Bind all ports while preparing the files. The default primary and
-- global brokers cannot be selected: occupied ports never bind successfully.
bracket (reserveAll (3 * count) >>= newIORef) releasePorts $ \reservations -> do
reserved <- readIORef reservations
let ports = map snd reserved
brokerPorts = take count ports
controllerPorts = take count (drop count ports)
internalPorts = drop (2 * count) ports
hostname = maybe "127.0.0.1:" (const "localhost:") extension
addresses = [BrokerAddress (hostname <> T.pack (show port)) | port <- brokerPorts]
environment <- newTestEnv addresses
health <- maybe (pure environment) (const (newTestEnv [BrokerAddress ("127.0.0.1:" <> T.pack (show port)) | port <- internalPorts])) extension
nodes <- forM (zip [1 ..] brokerPorts) $ \(node, port) -> Node node port (root <> "/node-" <> show node <> ".properties") <$> newMVar Nothing
let fixture = KafkaFixture environment root home nodes health extension
voters = intercalate "," [show node <> "@127.0.0.1:" <> show port | (node, port) <- zip [1 .. count] controllerPorts]
flip onException (destroyKafkaFixture fixture) $ do
mapM_ (\security -> securePrepare security root) extension
cluster <- filter (`notElem` ['\r', '\n']) <$> fixtureCommand fixture "kafka-storage" ["random-uuid"]
forM_ (zip3 nodes controllerPorts internalPorts) $ \(Node node port config _, controller, internal) -> do
additional <- maybe (pure []) (\security -> secureConfiguration security root port controller internal) extension
writeFile config $
unlines $
[ "process.roles=broker,controller",
"node.id=" <> show node,
"controller.quorum.voters=" <> voters,
"listeners=PLAINTEXT://127.0.0.1:" <> show port <> ",CONTROLLER://127.0.0.1:" <> show controller,
"advertised.listeners=PLAINTEXT://127.0.0.1:" <> show port <> ",CONTROLLER://127.0.0.1:" <> show controller,
"inter.broker.listener.name=PLAINTEXT",
"controller.listener.names=CONTROLLER",
"listener.security.protocol.map=CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT",
"log.dirs=" <> root <> "/node-" <> show node <> "-data",
"default.replication.factor=" <> show count,
"min.insync.replicas=1",
"num.partitions=1",
"offsets.topic.replication.factor=" <> show count,
"offsets.topic.num.partitions=3",
"transaction.state.log.replication.factor=" <> show count,
"transaction.state.log.min.isr=" <> show (min 2 count),
"transaction.state.log.num.partitions=3",
"group.initial.rebalance.delay.ms=0",
"auto.create.topics.enable=false",
"num.network.threads=2",
"num.io.threads=2",
"num.replica.fetchers=1"
]
<> additional
setFileMode config 0o600
void (fixtureCommand fixture "kafka-storage" ["format", "-t", cluster, "-c", config])
-- Transfer the sockets out of the cleanup reference exactly once before
-- Java binds. Partial acquisition and cancellation retain cleanup.
releasePorts reservations
mapM_ (startNode fixture) nodes
awaitBrokers fixture
version <- fixtureCommand fixture "kafka-topics" ["--version"]
writeFile (root <> "/version.txt") version
putStrLn ("[owned-kafka] root=" <> root <> " brokers=" <> show addresses <> " version=" <> version)
pure fixture
where
releasePorts reference = atomicModifyIORef' reference (\sockets -> ([], sockets)) >>= mapM_ (N.close . fst)
reserveAll 0 = pure []
reserveAll remaining = bracketOnError reservePort (N.close . fst) $ \socket -> (socket :) <$> reserveAll (remaining - 1)
reservePort = do
socket <- N.socket N.AF_INET N.Stream N.defaultProtocol
flip onException (N.close socket) $ do
N.bind socket (N.SockAddrInet 0 (N.tupleToHostAddress (127, 0, 0, 1)))
port <- fromIntegral <$> N.socketPort socket
whenForbidden port
pure (socket, port)
whenForbidden port = unless (port `notElem` [9092, 39092, 39644]) (assertFailure "reserved a protected broker port")
startNode :: KafkaFixture -> Node -> IO ()
startNode fixture (Node node _ config state) = mask_ $ modifyMVar_ state $ \case
Just _ -> assertFailure "owned broker is already running"
Nothing -> do
variables <- getEnvironment
let extra = [("KAFKA_HEAP_OPTS", "-Xms256m -Xmx384m"), ("LOG_DIR", fixtureRoot fixture <> "/node-" <> show node <> "-logs")] <> maybe [] (\security -> secureEnvironment security (fixtureRoot fixture)) (secureExtension fixture)
logHandle <- openFile (fixtureRoot fixture <> "/node-" <> show node <> ".stdout.log") AppendMode
(_, _, _, process) <-
createProcess
(proc (kafkaHome fixture <> "/bin/kafka-server-start.sh") [config])
{ env = Just (extra <> filter (\(key, _) -> key `notElem` map fst extra) variables),
std_in = NoStream,
std_out = UseHandle logHandle,
std_err = UseHandle logHandle
}
`onException` hClose logHandle
pure (Just process)
stopNode :: Node -> IO ()
stopNode (Node _ _ _ state) = modifyMVar_ state $ \process -> mapM_ stopProcess process >> pure Nothing
findNode :: KafkaFixture -> BrokerId -> IO Node
findNode fixture (BrokerId wanted) = case [node | node@(Node identifier _ _ _) <- fixtureNodes fixture, identifier == wanted] of
[node] -> pure node
_ -> assertFailure "broker ID is outside this run-owned topology"
stopBroker :: KafkaFixture -> BrokerId -> IO ()
stopBroker fixture broker = findNode fixture broker >>= stopNode
startBroker :: KafkaFixture -> BrokerId -> IO ()
startBroker fixture broker = findNode fixture broker >>= startNode fixture
awaitBrokers :: KafkaFixture -> IO ()
awaitBrokers fixture = bracket (P.newProducer (P.brokersList (teBrokers (healthEnv fixture)) <> P.extraProp "log_level" "0") >>= either throwIO pure) releaseProducer $ \producer ->
bounded 45 "owned Apache brokers did not become reachable" $
let await =
D.allTopicsMetadata producer (Timeout 1000) >>= \case
Right metadata | sort (map D.bmBrokerId (D.kmBrokers metadata)) == [BrokerId node | Node node _ _ _ <- fixtureNodes fixture] -> pure ()
_ -> threadDelay 100000 >> await
in await
destroyKafkaFixture :: KafkaFixture -> IO ()
destroyKafkaFixture fixture = do
-- Cleanup is installed before any process starts, including partial setup.
results <- mapM (try @SomeException . stopNode) (fixtureNodes fixture)
case [exception | Left exception <- results] of
exception : _ -> throwIO exception
[] -> pure ()
mapM_ (\security -> secureCleanup security (fixtureRoot fixture)) (secureExtension fixture)
forM_ (fixtureNodes fixture) $ \(Node node _ _ _) -> do
let dataDirectory = fixtureRoot fixture <> "/node-" <> show node <> "-data"
exists <- doesDirectoryExist dataDirectory
if exists then removeDirectoryRecursive dataDirectory else pure ()
writeFile (fixtureRoot fixture <> "/cleanup.txt") "All owned process handles exited; owned broker data removed.\n"
fixtureAllStopped :: KafkaFixture -> IO Bool
fixtureAllStopped fixture = and <$> mapM stopped (fixtureNodes fixture)
where
stopped (Node _ _ _ state) = readMVar state >>= maybe (pure True) (fmap (/= Nothing) . getProcessExitCode)
-- Install restoration for each successfully stopped process before stopping
-- the next. Start all controllers before the caller waits for quorum health.
withBrokersStopped :: KafkaFixture -> IO a -> IO a
withBrokersStopped fixture action = foldr (\node next -> bracket_ (stopNode node) (startNode fixture node) next) action (fixtureNodes fixture)