{-# LANGUAGE BangPatterns #-} {-# LANGUAGE ConstraintKinds #-} {-# LANGUAGE DerivingStrategies #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE FlexibleInstances #-} {-# LANGUAGE GeneralizedNewtypeDeriving #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE RankNTypes #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE StandaloneDeriving #-} {-# LANGUAGE TypeFamilies #-} {-# LANGUAGE UndecidableInstances #-} module TestContainers.Docker ( MonadDocker, TestContainer, -- * Configuration Config (..), defaultDockerConfig, determineConfig, -- * Exeuction tracing Tracer, Trace (..), newTracer, withTrace, -- * Docker image ImageTag, Image, imageTag, -- * Port Port (..), -- * Docker container ContainerId, Container, containerId, containerImage, containerAlias, containerGateway, containerIp, containerPort, containerAddress, containerReleaseKey, -- * Container state State, Status (..), stateError, stateExitCode, stateFinishedAt, stateOOMKilled, statePid, stateStartedAt, stateStatus, -- * Predicates to assert container state successfulExit, -- * Referring to images ToImage, fromTag, fromBuildContext, fromDockerfile, build, -- * Exceptions DockerException (..), -- * Running containers ContainerRequest, containerRequest, withLabels, setName, setFixedName, setSuffixedName, setRandomName, setCmd, setVolumeMounts, setRm, setEnv, setNetwork, withNetwork, withNetworkAlias, setLink, setExpose, setWaitingFor, run, -- * Following logs LogConsumer, consoleLogConsumer, withFollowLogs, -- * Network related functionality NetworkId, Network, NetworkRequest, networkId, networkRequest, createNetwork, withIpv6, withDriver, -- * Managing the container lifecycle InspectOutput, inspect, stop, kill, rm, withLogs, -- * Wait for containers to become ready WaitUntilReady, waitUntilReady, -- * Only block for defined amounts of time TimeoutException (..), waitUntilTimeout, -- * Wait for container state waitForState, -- * Wait until a specific pattern appears in the logs waitWithLogs, Pipe (..), UnexpectedEndOfPipe (..), waitForLogLine, -- * Misc. Docker functions dockerHostOs, isDockerOnLinux, -- * Wait until a socket is reachable waitUntilMappedPortReachable, -- * Wait until the http server responds with a specific status code waitForHttp, -- * Reaper createRyukReaper, -- * Reexports for convenience ResIO, runResourceT, (&), ) where import Control.Concurrent (threadDelay) import Control.Exception (IOException, throw) import Control.Monad (forM_, replicateM, unless) import Control.Monad.Catch ( Exception, MonadCatch, MonadThrow, bracket, throwM, try, ) import Control.Monad.IO.Class (MonadIO (liftIO)) import Control.Monad.IO.Unlift (MonadUnliftIO (withRunInIO)) import Control.Monad.Reader (MonadReader (..)) import Control.Monad.Trans.Resource ( ReleaseKey, ResIO, register, runResourceT, ) import Data.Aeson (decode') import qualified Data.Aeson.Optics as Optics import qualified Data.ByteString.Lazy.Char8 as LazyByteString import Data.Function ((&)) import Data.List (find) import Data.String (IsString (..)) import Data.Text (Text, pack, splitOn, strip, unpack) import Data.Text.Encoding (encodeUtf8) import Data.Text.Encoding.Error (lenientDecode) import qualified Data.Text.Lazy as LazyText import qualified Data.Text.Lazy.Encoding as LazyText import Data.Text.Read (decimal) import GHC.Stack (withFrozenCallStack) import Network.HTTP.Client ( HttpException, Manager, Request (..), defaultManagerSettings, defaultRequest, httpNoBody, newManager, responseStatus, ) import Network.HTTP.Types (statusCode) import qualified Network.Socket as Socket import Optics.Fold (pre) import Optics.Operators ((^?)) import Optics.Optic ((%)) import System.Directory (doesFileExist) import System.IO (Handle, hClose) import System.IO.Unsafe (unsafePerformIO) import qualified System.Process as Process import qualified System.Random as Random import System.Timeout (timeout) import TestContainers.Config ( Config (..), defaultDockerConfig, determineConfig, ) import TestContainers.Docker.Internal ( ContainerId, DockerException (..), InspectOutput, LogConsumer, Pipe (..), consoleLogConsumer, docker, dockerFollowLogs, dockerWithStdin, ) import TestContainers.Docker.Network ( Network, NetworkId, NetworkRequest, createNetwork, networkId, networkRequest, withDriver, withIpv6, ) import TestContainers.Docker.Reaper ( Reaper, newRyukReaper, reaperLabels, ryukImageTag, ryukPort, ) import TestContainers.Docker.State ( State, Status (..), containerState, stateError, stateExitCode, stateFinishedAt, stateOOMKilled, statePid, stateStartedAt, stateStatus, ) import TestContainers.Monad ( MonadDocker, TestContainer, ) import TestContainers.Trace (Trace (..), Tracer, newTracer, withTrace) import Prelude hiding (error, id) import qualified Prelude -- | Parameters for a running a Docker container. -- -- @since 0.1.0.0 data ContainerRequest = ContainerRequest { toImage :: ToImage, cmd :: Maybe [Text], env :: [(Text, Text)], exposedPorts :: [Port], volumeMounts :: [(Text, Text)], network :: Maybe (Either Network Text), networkAlias :: Maybe Text, links :: [ContainerId], naming :: NamingStrategy, rmOnExit :: Bool, readiness :: WaitUntilReady, labels :: [(Text, Text)], noReaper :: Bool, followLogs :: Maybe LogConsumer } -- | Parameters for a naming a Docker container. -- -- @since 0.4.0.0 data NamingStrategy = RandomName | FixedName Text | SuffixedName Text -- | Default `ContainerRequest`. Used as base for every Docker container. -- -- @since 0.1.0.0 containerRequest :: ToImage -> ContainerRequest containerRequest image = ContainerRequest { toImage = image, naming = RandomName, cmd = Nothing, env = [], exposedPorts = [], volumeMounts = [], network = Nothing, networkAlias = Nothing, links = [], rmOnExit = False, readiness = mempty, labels = mempty, noReaper = False, followLogs = Nothing } -- | Set the name of a Docker container. This is equivalent to invoking @docker run@ -- with the @--name@ parameter. -- -- @since 0.1.0.0 setName :: Text -> ContainerRequest -> ContainerRequest setName = setFixedName {-# DEPRECATED setName "See setFixedName" #-} -- | Set the name of a Docker container. This is equivalent to invoking @docker run@ -- with the @--name@ parameter. -- -- @since 0.4.0.0 setFixedName :: Text -> ContainerRequest -> ContainerRequest setFixedName newName req = -- TODO error on empty Text req {naming = FixedName newName} -- | Set the name randomly given of a Docker container. This is equivalent to omitting -- the @--name@ parameter calling @docker run@. -- -- @since 0.4.0.0 setRandomName :: ContainerRequest -> ContainerRequest setRandomName req = -- TODO error on empty Text req {naming = RandomName} -- | Set the name randomly suffixed of a Docker container. This is equivalent to invoking -- @docker run@ with the @--name@ parameter. -- -- @since 0.4.0.0 setSuffixedName :: Text -> ContainerRequest -> ContainerRequest setSuffixedName preffix req = -- TODO error on empty Text req {naming = SuffixedName preffix} -- | The command to execute inside the Docker container. This is the equivalent -- of passing the command on the @docker run@ invocation. -- -- @since 0.1.0.0 setCmd :: [Text] -> ContainerRequest -> ContainerRequest setCmd newCmd req = req {cmd = Just newCmd} -- | The volume mounts to link to Docker container. This is the equivalent -- of passing the command on the @docker run -v@ invocation. setVolumeMounts :: [(Text, Text)] -> ContainerRequest -> ContainerRequest setVolumeMounts newVolumeMounts req = req {volumeMounts = newVolumeMounts} -- | Wether to remove the container once exited. This is equivalent to passing -- @--rm@ to @docker run@. (default is `True`). -- -- @since 0.1.0.0 setRm :: Bool -> ContainerRequest -> ContainerRequest setRm newRm req = req {rmOnExit = newRm} -- | Set the environment for the container. This is equivalent to passing @--env key=value@ -- to @docker run@. -- -- @since 0.1.0.0 setEnv :: [(Text, Text)] -> ContainerRequest -> ContainerRequest setEnv newEnv req = req {env = newEnv} -- | Set the network the container will connect to. This is equivalent to passing -- @--network network_name@ to @docker run@. -- -- @since 0.4.0.0 setNetwork :: Text -> ContainerRequest -> ContainerRequest setNetwork networkName req = req {network = Just (Right networkName)} -- | Set the network the container will connect to. This is equivalent to passing -- @--network network_name@ to @docker run@. -- -- @since 0.4.0.0 withNetwork :: Network -> ContainerRequest -> ContainerRequest withNetwork network req = req {network = Just (Left network)} -- | Set the network alias for this container. This is equivalent to passing -- @--network-alias alias@ to @docker run@. -- -- @since 0.4.0.0 withNetworkAlias :: Text -> ContainerRequest -> ContainerRequest withNetworkAlias alias req = req {networkAlias = Just alias} -- | Sets labels for a container -- -- @since 0.4.0.0 withLabels :: [(Text, Text)] -> ContainerRequest -> ContainerRequest withLabels xs request = request {labels = xs} -- | Set link on the container. This is equivalent to passing @--link other_container@ -- to @docker run@. -- -- @since 0.1.0.0 setLink :: [ContainerId] -> ContainerRequest -> ContainerRequest setLink newLink req = req {links = newLink} -- | Forwards container logs to the given 'LogConsumer' once ran. -- -- @since 0.4.0.0 withFollowLogs :: LogConsumer -> ContainerRequest -> ContainerRequest withFollowLogs logConsumer request = request {followLogs = Just logConsumer} -- | Defintion of a 'Port'. Allows for specifying ports using various protocols. Due to the -- 'Num' and 'IsString' instance allows for convenient Haskell literals. -- -- >>> "80" :: Port -- 80/tcp -- -- >>> "80/tcp" :: Port -- 80/tcp -- -- >>> 80 :: Port -- 80/tcp -- -- >>> "90/udp" :: Port -- 90/udp data Port = Port { port :: Int, protocol :: Text } deriving stock (Eq, Ord) defaultProtocol :: Text defaultProtocol = "tcp" -- @since 0.4.0.0 instance Show Port where show Port {port, protocol} = show port <> "/" <> unpack protocol -- | A cursed but handy instance supporting literal 'Port's. -- -- @since 0.4.0.0 instance Num Port where fromInteger x = Port {port = fromIntegral x, protocol = defaultProtocol} (+) = Prelude.error "not implemented" (*) = Prelude.error "not implemented" abs = Prelude.error "not implemented" signum = Prelude.error "not implemented" negate = Prelude.error "not implemented" -- | A cursed but handy instance supporting literal 'Port's of them -- form @"8080"@, @"8080/udp"@, @"8080/tcp"@. -- -- @since 0.4.0.0 instance IsString Port where fromString input = case splitOn "/" (pack input) of [numberish] | Right (port, "") <- decimal numberish -> Port {port, protocol = defaultProtocol} [numberish, protocol] | Right (port, "") <- decimal numberish -> Port {port, protocol} _ -> Prelude.error ("invalid port literal: " <> input) -- | Set exposed ports on the container. This is equivalent to setting @--publish $PORT@ to -- @docker run@. Docker assigns a random port for the host port. You will have to use `containerIp` -- and `containerPort` to connect to the published port. -- -- @ -- container <- `run` $ `containerRequest` `redis` & `setExpose` [ 6379 ] -- let (redisHost, redisPort) = (`containerIp` container, `containerPort` container 6379) -- print (redisHost, redisPort) -- @ -- -- @since 0.1.0.0 setExpose :: [Port] -> ContainerRequest -> ContainerRequest setExpose newExpose req = req {exposedPorts = newExpose} -- | Set the waiting strategy on the container. Depending on a Docker container -- it can take some time until the provided service is ready. You will want to -- use to `setWaitingFor` to block until the container is ready to use. -- -- @since 0.1.0.0 setWaitingFor :: WaitUntilReady -> ContainerRequest -> ContainerRequest setWaitingFor newWaitingFor req = req {readiness = newWaitingFor} -- | Runs a Docker container from an `Image` and `ContainerRequest`. A finalizer -- is registered so that the container is aways stopped when it goes out of scope. -- This function is essentially @docker run@. -- -- @since 0.1.0.0 run :: ContainerRequest -> TestContainer Container run request = do let ContainerRequest { toImage, naming, cmd, env, exposedPorts, volumeMounts, network, networkAlias, links, rmOnExit, readiness, labels, noReaper, followLogs } = request config@Config {configTracer, configCreateReaper} <- ask additionalLabels <- if noReaper then do pure [] else reaperLabels <$> configCreateReaper image@Image {tag} <- runToImage toImage name <- case naming of RandomName -> return Nothing FixedName n -> return $ Just n SuffixedName prefix -> Just . (prefix <>) . ("-" <>) . pack <$> replicateM 6 (Random.randomRIO ('a', 'z')) let dockerRun :: [Text] dockerRun = concat $ [["run"]] ++ [["--detach"]] ++ [["--name", containerName] | Just containerName <- [name]] ++ [["--label", label <> "=" <> value] | (label, value) <- additionalLabels ++ labels] ++ [["--env", variable <> "=" <> value] | (variable, value) <- env] ++ [["--publish", pack (show port) <> "/" <> protocol] | Port {port, protocol} <- exposedPorts] ++ [["--network", networkName] | Just (Right networkName) <- [network]] ++ [["--network", networkId dockerNetwork] | Just (Left dockerNetwork) <- [network]] ++ [["--network-alias", alias] | Just alias <- [networkAlias]] ++ [["--link", container] | container <- links] ++ [["--volume", src <> ":" <> dest] | (src, dest) <- volumeMounts] ++ [["--rm"] | rmOnExit] ++ [[tag]] ++ [command | Just command <- [cmd]] stdout <- docker configTracer dockerRun let id :: ContainerId !id = -- N.B. Force to not leak STDOUT String strip (pack stdout) -- Careful, this is really meant to be lazy ~inspectOutput = unsafePerformIO $ internalInspect configTracer id -- We don't issue 'ReleaseKeys' for cleanup anymore. Ryuk takes care of cleanup -- for us once the session has been closed. releaseKey <- register (pure ()) forM_ followLogs $ dockerFollowLogs configTracer id let container = Container { id, releaseKey, image, inspectOutput, config } -- Last but not least, execute the WaitUntilReady checks waitUntilReady container readiness pure container -- | Sets up a Ryuk 'Reaper'. -- -- @since 0.4.0.0 createRyukReaper :: TestContainer Reaper createRyukReaper = do ryukContainer <- run $ containerRequest (fromTag ryukImageTag) & skipReaper & setVolumeMounts [("/var/run/docker.sock", "/var/run/docker.sock")] & setExpose [ryukPort] & setWaitingFor (waitUntilMappedPortReachable ryukPort) & setRm True (ryukContainerAddress, ryukContainerPort) <- containerAddress ryukContainer ryukPort newRyukReaper ryukContainerAddress ryukContainerPort -- | Internal attribute, serving as a loop breaker: When runnign a container -- we ensure a 'Reaper' is present, since the 'Reaper' itself is a running -- container we need to break the loop to avoid spinning up a new 'Reaper' for -- the 'Reaper'. skipReaper :: ContainerRequest -> ContainerRequest skipReaper request = request {noReaper = True} -- | Kills a Docker container. `kill` is essentially @docker kill@. -- -- @since 0.1.0.0 kill :: Container -> TestContainer () kill Container {id} = do tracer <- askTracer _ <- docker tracer ["kill", id] return () -- | Stops a Docker container. `stop` is essentially @docker stop@. -- -- @since 0.1.0.0 stop :: Container -> TestContainer () stop Container {id} = do tracer <- askTracer _ <- docker tracer ["stop", id] return () -- | Remove a Docker container. `rm` is essentially @docker rm -f@ -- -- @since 0.1.0.0 rm :: Container -> TestContainer () rm Container {id} = do tracer <- askTracer _ <- docker tracer ["rm", "-f", "-v", id] return () -- | Access STDOUT and STDERR of a running Docker container. This is essentially -- @docker logs@ under the hood. -- -- @since 0.1.0.0 withLogs :: Container -> (Handle -> Handle -> TestContainer a) -> TestContainer a withLogs Container {id} logger = do let acquire :: TestContainer (Handle, Handle, Handle, Process.ProcessHandle) acquire = liftIO $ Process.runInteractiveProcess "docker" ["logs", "--follow", unpack id] Nothing Nothing release :: (Handle, Handle, Handle, Process.ProcessHandle) -> TestContainer () release (stdin, stdout, stderr, handle) = liftIO $ Process.cleanupProcess (Just stdin, Just stdout, Just stderr, handle) bracket acquire release $ \(stdin, stdout, stderr, _handle) -> do -- No need to keep it around... liftIO $ hClose stdin logger stdout stderr -- | A tag to a Docker image. -- -- @since 0.1.0.0 type ImageTag = Text -- | A description of how to build an `Image`. -- -- @since 0.1.0.0 data ToImage = ToImage { runToImage :: TestContainer Image } -- | Build the `Image` referred to by the argument. If the construction of the -- image is expensive (e.g. a call to `fromBuildContext`) we don't want to -- repeatedly build the image. Instead, `build` can be used to execute the -- underlying Docker build once and re-use the resulting `Image`. -- -- @since 0.1.0.0 build :: ToImage -> TestContainer ToImage build toImage = do image <- runToImage toImage return $ toImage { runToImage = pure image } -- | Default `ToImage`. Doesn't apply anything to to `ContainerRequests`. -- -- @since 0.1.0.0 defaultToImage :: TestContainer Image -> ToImage defaultToImage action = ToImage { runToImage = action } -- | Get an `Image` from a tag. -- -- @since 0.1.0.0 fromTag :: ImageTag -> ToImage fromTag tag = defaultToImage $ do tracer <- askTracer output <- docker tracer ["pull", "--quiet", tag] return $ Image { tag = strip (pack output) } -- | Build the image from a build path and an optional path to the -- Dockerfile (default is Dockerfile) -- -- @since 0.1.0.0 fromBuildContext :: FilePath -> Maybe FilePath -> ToImage fromBuildContext path mdockerfile = defaultToImage $ do let args | Just dockerfile <- mdockerfile = ["build", "--quiet", "--file", pack dockerfile, pack path] | otherwise = ["build", "--quiet", pack path] tracer <- askTracer output <- docker tracer args return $ Image { tag = strip (pack output) } -- | Build a contextless image only from a Dockerfile passed as `Text`. -- -- @since 0.1.0.0 fromDockerfile :: Text -> ToImage fromDockerfile dockerfile = defaultToImage $ do tracer <- askTracer output <- dockerWithStdin tracer ["build", "--quiet", "-"] dockerfile return $ Image { tag = strip (pack output) } -- | A strategy that describes how to asses readiness of a `Container`. Allows -- Users to plug in their definition of readiness. -- -- @since 0.1.0.0 data WaitUntilReady = -- | A blocking action that attests readiness WaitReady -- Check to run (Container -> TestContainer ()) | -- | In order to keep readiness checking at bay this node -- ensures checks are not exceeding their time share WaitUntilTimeout -- Timeout in seconds Int -- Action to watch with with timeout WaitUntilReady | WaitMany -- First check WaitUntilReady -- Next check WaitUntilReady -- | @since 0.4.0.0 instance Semigroup WaitUntilReady where (<>) = WaitMany -- | @since 0.4.0.0 instance Monoid WaitUntilReady where mempty = WaitReady mempty -- | The exception thrown by `waitForLine` in case the expected log line -- wasn't found. -- -- @since 0.1.0.0 newtype UnexpectedEndOfPipe = UnexpectedEndOfPipe { -- | The id of the underlying container. id :: ContainerId } deriving (Eq, Show) instance Exception UnexpectedEndOfPipe -- | The exception thrown by `waitUntilTimeout`. -- -- @since 0.1.0.0 newtype TimeoutException = TimeoutException { -- | The id of the underlying container that was not ready in time. id :: ContainerId } deriving (Eq, Show) instance Exception TimeoutException -- | The exception thrown by `waitForState`. -- -- @since 0.1.0.0 newtype InvalidStateException = InvalidStateException { -- | The id of the underlying container that was not ready in time. id :: ContainerId } deriving stock (Eq, Show) instance Exception InvalidStateException -- | @waitForState@ waits for a certain state of the container. If the container reaches a terminal -- state 'InvalidStateException' will be thrown. -- -- @since 0.4.0.0 waitForState :: (State -> Bool) -> WaitUntilReady waitForState isReady = WaitReady $ \Container {id} -> do let wait = do Config {configTracer} <- ask inspectOutput <- internalInspect configTracer id let state = containerState inspectOutput if isReady state then pure () else do case stateStatus state of Exited -> -- Once exited, state won't change! throwM InvalidStateException {id} Dead -> -- Once dead, state won't change! throwM InvalidStateException {id} _ -> do liftIO (threadDelay 500000) wait wait -- | @successfulExit@ is supposed to be used in conjunction with 'waitForState'. -- -- @since 0.4.0.0 successfulExit :: State -> Bool successfulExit state = stateStatus state == Exited && stateExitCode state == Just 0 -- | @waitUntilTimeout n waitUntilReady@ waits @n@ seconds for the container -- to be ready. If the container is not ready by then a `TimeoutException` will -- be thrown. -- -- @since 0.1.0.0 waitUntilTimeout :: Int -> WaitUntilReady -> WaitUntilReady waitUntilTimeout = WaitUntilTimeout -- | Waits for a specific http status code. -- This combinator should always be used with `waitUntilTimeout`. -- -- @since 0.4.0.0 waitForHttp :: -- | Port Port -> -- | URL path String -> -- | Acceptable status codes [Int] -> WaitUntilReady waitForHttp port path acceptableStatusCodes = WaitReady $ \container -> do Config {configTracer} <- ask let wait :: (MonadIO m, MonadCatch m) => m () wait = liftIO (newManager defaultManagerSettings) >>= retry retry :: (MonadIO m, MonadCatch m) => Manager -> m () retry manager = do (endpointHost, endpointPort) <- containerAddress container port let request = defaultRequest { host = encodeUtf8 endpointHost, port = endpointPort, path = encodeUtf8 (pack path) } result <- try $ statusCode . responseStatus <$> liftIO (httpNoBody request manager) case result of Right code -> do withTrace configTracer (TraceHttpCall endpointHost endpointPort (Right code)) unless (code `elem` acceptableStatusCodes) $ retry manager Left (exception :: HttpException) -> do withTrace configTracer (TraceHttpCall endpointHost endpointPort (Left $ show exception)) liftIO (threadDelay 500000) retry manager wait -- | Waits until the port of a container is ready to accept connections. -- This combinator should always be used with `waitUntilTimeout`. -- -- @since 0.1.0.0 waitUntilMappedPortReachable :: Port -> WaitUntilReady waitUntilMappedPortReachable port = WaitReady $ \container -> do withFrozenCallStack $ do Config {configTracer} <- ask let resolve endpointHost endpointPort = do let hints = Socket.defaultHints {Socket.addrSocketType = Socket.Stream} head <$> Socket.getAddrInfo (Just hints) (Just endpointHost) (Just (show endpointPort)) open addr = do socket <- Socket.socket (Socket.addrFamily addr) (Socket.addrSocketType addr) (Socket.addrProtocol addr) Socket.connect socket (Socket.addrAddress addr) pure socket wait = do (endpointHost, endpointPort) <- containerAddress container port result <- try (resolve (unpack endpointHost) endpointPort >>= open) case result of Right socket -> do withTrace configTracer (TraceOpenSocket endpointHost endpointPort Nothing) Socket.close socket pure () Left (exception :: IOException) -> do withTrace configTracer (TraceOpenSocket endpointHost endpointPort (Just exception)) threadDelay 500000 wait liftIO wait -- | A low-level primitive that allows scanning the logs for specific log lines -- that indicate readiness of a container. -- -- The `Handle`s passed to the function argument represent @stdout@ and @stderr@ -- of the container. -- -- @since 0.1.0.0 waitWithLogs :: (Container -> Handle -> Handle -> IO ()) -> WaitUntilReady waitWithLogs waiter = WaitReady $ \container -> withLogs container $ \stdout stderr -> liftIO $ waiter container stdout stderr -- | Waits for a specific line to occur in the logs. Throws a `UnexpectedEndOfPipe` -- exception in case the desired line can not be found on the logs. -- -- Say you want to find "Ready to accept connections" in the logs on Stdout try: -- -- @ -- waitForLogLine Stdout ("Ready to accept connections" ``LazyText.isInfixOf``) -- @ -- -- @since 0.1.0.0 waitForLogLine :: Pipe -> (LazyText.Text -> Bool) -> WaitUntilReady waitForLogLine whereToLook matches = waitWithLogs $ \Container {id} stdout stderr -> do let logs :: Handle logs = case whereToLook of Stdout -> stdout Stderr -> stderr logContent <- LazyByteString.hGetContents logs let logLines :: [LazyText.Text] logLines = -- FIXME: This is assuming UTF8 encoding. Do better! map (LazyText.decodeUtf8With lenientDecode) (LazyByteString.lines logContent) case find matches logLines of Just _ -> pure () Nothing -> throwM $ UnexpectedEndOfPipe {id} -- | Blocks until the container is ready. `waitUntilReady` might throw exceptions -- depending on the used `WaitUntilReady` on the container. -- -- In case the readiness check times out 'waitUntilReady' throws a -- 'TimeoutException'. -- -- @since 0.1.0.0 waitUntilReady :: Container -> WaitUntilReady -> TestContainer () waitUntilReady container@Container {id} input = do Config {configDefaultWaitTimeout} <- ask interpreter $ case configDefaultWaitTimeout of Just seconds -> waitUntilTimeout seconds input Nothing -> input where interpreter :: WaitUntilReady -> TestContainer () interpreter wait = case wait of WaitReady check -> check container WaitUntilTimeout seconds rest -> withRunInIO $ \runInIO -> do result <- timeout (seconds * 1000000) $ runInIO (interpreter rest) case result of Nothing -> throwM $ TimeoutException {id} Just {} -> pure () WaitMany first second -> do interpreter first interpreter second -- | Handle to a Docker image. -- -- @since 0.1.0.0 data Image = Image { -- | The image tag assigned by Docker. Uniquely identifies an `Image` -- within Docker. tag :: ImageTag } deriving (Eq, Show) -- | The image tag assigned by Docker. Uniquely identifies an `Image` -- within Docker. -- -- @since 0.1.0.0 imageTag :: Image -> ImageTag imageTag Image {tag} = tag -- | Handle to a Docker container. -- -- @since 0.1.0.0 data Container = Container { -- | The container Id assigned by Docker, uniquely identifying this `Container`. id :: ContainerId, -- | Underlying `ReleaseKey` for the resource finalizer. releaseKey :: ReleaseKey, -- | The underlying `Image` of this container. image :: Image, -- | Configuration used to create and run this container. config :: Config, -- | Memoized output of `docker inspect`. This is being calculated lazily. inspectOutput :: InspectOutput } -- | Returns the id of the container. -- -- @since 0.1.0.0 containerId :: Container -> ContainerId containerId Container {id} = id -- | Returns the underlying image of the container. -- -- @since 0.1.0.0 containerImage :: Container -> Image containerImage Container {image} = image -- | Returns the internal release key used for safely shutting down -- the container. Use this with care. This function is considered -- an internal detail. -- -- @since 0.1.0.0 containerReleaseKey :: Container -> ReleaseKey containerReleaseKey Container {releaseKey} = releaseKey {-# DEPRECATED containerReleaseKey "Containers are cleaned up with a separate resource reaper. Releasing the container manually is not going to work." #-} -- | Looks up the ip address of the container. -- -- @since 0.1.0.0 containerIp :: Container -> Text containerIp = internalContainerIp -- | Get the IP address of a running Docker container using @docker inspect@. internalContainerIp :: Container -> Text internalContainerIp Container {id, inspectOutput} = case inspectOutput ^? Optics.key "NetworkSettings" % Optics.key "IPAddress" % Optics._String of Nothing -> throw $ InspectOutputUnexpected {id} Just address -> address -- | Get the container's network alias. -- Takes the first alias found. -- -- @since 0.4.0.0 containerAlias :: Container -> Text containerAlias Container {id, inspectOutput} = case inspectOutput ^? pre ( Optics.key "NetworkSettings" % Optics.key "Networks" % Optics.members % Optics.key "Aliases" % Optics.values % Optics._String ) of Nothing -> throw $ InspectOutputMissingNetwork { id } Just alias -> alias -- | Get the IP address for the container's gateway, i.e. the host. -- Takes the first gateway address found. -- -- @since 0.4.0.0 containerGateway :: Container -> Text containerGateway Container {id, inspectOutput} = case inspectOutput ^? pre ( Optics.key "NetworkSettings" % Optics.key "Networks" % Optics.members % Optics.key "Gateway" % Optics._String ) of Nothing -> throw $ InspectOutputMissingNetwork { id } Just gatewayIp -> gatewayIp -- | Looks up an exposed port on the host. -- -- @since 0.1.0.0 containerPort :: Container -> Port -> Int containerPort Container {id, inspectOutput} Port {port, protocol} = let -- TODO also support UDP ports -- Using IsString so it works both with Text (aeson<2) and Aeson.Key (aeson>=2) textPort :: (IsString s) => s textPort = fromString $ show port <> "/" <> unpack protocol in -- TODO be more mindful, make sure to grab the -- port from the right host address case inspectOutput ^? pre ( Optics.key "NetworkSettings" % Optics.key "Ports" % Optics.key textPort % Optics.values % Optics.key "HostPort" % Optics._String ) of Nothing -> throw $ UnknownPortMapping { id, port = textPort } Just hostPort -> read (unpack hostPort) -- | Returns the domain and port exposing the given container's port. Differs -- from 'containerPort' in that 'containerAddress' will return the container's -- domain and port if the program is running in the same network. Otherwise, -- 'containerAddress' will use the exposed port on the Docker host. -- -- @since 0.4.0.0 containerAddress :: (MonadIO m) => Container -> Port -> m (Text, Int) containerAddress container Port {port, protocol} = do inDocker <- isRunningInDocker pure $ if inDocker then (containerAlias container, port) else ("localhost", containerPort container (Port {port, protocol})) -- | Runs the `docker inspect` command. Memoizes the result. -- -- @since 0.1.0.0 inspect :: Container -> TestContainer InspectOutput inspect Container {inspectOutput} = pure inspectOutput -- | Runs the `docker inspect` command. -- -- @since 0.1.0.0 internalInspect :: (MonadThrow m, MonadIO m) => Tracer -> ContainerId -> m InspectOutput internalInspect tracer id = do stdout <- docker tracer ["inspect", id] case decode' (LazyByteString.pack stdout) of Nothing -> throwM $ InspectOutputInvalidJSON {id} Just [] -> throwM $ InspectUnknownContainerId {id} Just [value] -> pure value Just _ -> Prelude.error "Internal: Multiple results where I expected single result" askTracer :: (MonadReader Config m) => m Tracer askTracer = do Config {configTracer} <- ask pure configTracer {-# INLINE askTracer #-} dockerHostOs :: TestContainer Text dockerHostOs = do tracer <- askTracer strip . pack <$> docker tracer ["version", "--format", "{{.Server.Os}}"] isDockerOnLinux :: TestContainer Bool isDockerOnLinux = ("linux" ==) <$> dockerHostOs -- | Detects if we are actually running in a Docker container. isRunningInDocker :: (MonadIO m) => m Bool isRunningInDocker = liftIO $ doesFileExist "/.dockerenv"