testcontainers 0.5.1.0 → 0.5.2.0
raw patch · 7 files changed
+170/−51 lines, 7 filesdep ~networknew-uploaderPVP: major bump suggested
API removals or changes: PVP suggests a major version bump
Dependency ranges changed: network
API changes (from Hackage documentation)
- TestContainers: [$sel:id:TimeoutException] :: TimeoutException -> ContainerId
- TestContainers: [$sel:id:UnexpectedEndOfPipe] :: UnexpectedEndOfPipe -> ContainerId
- TestContainers: [$sel:port:Port] :: Port -> Int
- TestContainers: [$sel:protocol:Port] :: Port -> Text
- TestContainers: newtype TimeoutException
- TestContainers.Docker: [$sel:id:TimeoutException] :: TimeoutException -> ContainerId
- TestContainers.Docker: [$sel:id:UnexpectedEndOfPipe] :: UnexpectedEndOfPipe -> ContainerId
- TestContainers.Docker: [$sel:port:Port] :: Port -> Int
- TestContainers.Docker: [$sel:protocol:Port] :: Port -> Text
- TestContainers.Docker: newtype TimeoutException
- TestContainers.Tasty: [$sel:id:TimeoutException] :: TimeoutException -> ContainerId
- TestContainers.Tasty: [$sel:id:UnexpectedEndOfPipe] :: UnexpectedEndOfPipe -> ContainerId
- TestContainers.Tasty: [$sel:port:Port] :: Port -> Int
- TestContainers.Tasty: [$sel:protocol:Port] :: Port -> Text
- TestContainers.Tasty: newtype TimeoutException
+ TestContainers: [containerName] :: TimeoutException -> Maybe Text
+ TestContainers: [imageName] :: TimeoutException -> Maybe ImageTag
+ TestContainers: [protocol] :: Port -> Text
+ TestContainers: data TimeoutException
+ TestContainers: prefixedLogConsumer :: Text -> LogConsumer
+ TestContainers: withCopyFileToContainer :: FilePath -> FilePath -> ContainerRequest -> ContainerRequest
+ TestContainers.Docker: [containerName] :: TimeoutException -> Maybe Text
+ TestContainers.Docker: [imageName] :: TimeoutException -> Maybe ImageTag
+ TestContainers.Docker: [protocol] :: Port -> Text
+ TestContainers.Docker: data TimeoutException
+ TestContainers.Docker: prefixedLogConsumer :: Text -> LogConsumer
+ TestContainers.Docker: withCopyFileToContainer :: FilePath -> FilePath -> ContainerRequest -> ContainerRequest
+ TestContainers.Docker.Internal: [containerName] :: DockerException -> Maybe Text
+ TestContainers.Docker.Internal: [imageName] :: DockerException -> Maybe Text
+ TestContainers.Docker.Internal: prefixedLogConsumer :: Text -> LogConsumer
+ TestContainers.Tasty: [containerName] :: TimeoutException -> Maybe Text
+ TestContainers.Tasty: [imageName] :: TimeoutException -> Maybe ImageTag
+ TestContainers.Tasty: [protocol] :: Port -> Text
+ TestContainers.Tasty: data TimeoutException
+ TestContainers.Tasty: prefixedLogConsumer :: Text -> LogConsumer
+ TestContainers.Tasty: withCopyFileToContainer :: FilePath -> FilePath -> ContainerRequest -> ContainerRequest
- TestContainers: InspectOutputMissingNetwork :: ContainerId -> DockerException
+ TestContainers: InspectOutputMissingNetwork :: ContainerId -> Maybe Text -> Maybe Text -> DockerException
- TestContainers: InspectOutputUnexpected :: ContainerId -> DockerException
+ TestContainers: InspectOutputUnexpected :: ContainerId -> Maybe Text -> Maybe Text -> DockerException
- TestContainers: TimeoutException :: ContainerId -> TimeoutException
+ TestContainers: TimeoutException :: ContainerId -> Maybe ImageTag -> Maybe Text -> TimeoutException
- TestContainers: UnknownPortMapping :: ContainerId -> Text -> DockerException
+ TestContainers: UnknownPortMapping :: ContainerId -> Text -> Maybe Text -> Maybe Text -> DockerException
- TestContainers: [id] :: DockerException -> ContainerId
+ TestContainers: [id] :: UnexpectedEndOfPipe -> ContainerId
- TestContainers: type MonadDocker m = (m ~ TestContainer)
+ TestContainers: type MonadDocker (m :: Type -> Type) = m ~ TestContainer
- TestContainers.Docker: InspectOutputMissingNetwork :: ContainerId -> DockerException
+ TestContainers.Docker: InspectOutputMissingNetwork :: ContainerId -> Maybe Text -> Maybe Text -> DockerException
- TestContainers.Docker: InspectOutputUnexpected :: ContainerId -> DockerException
+ TestContainers.Docker: InspectOutputUnexpected :: ContainerId -> Maybe Text -> Maybe Text -> DockerException
- TestContainers.Docker: TimeoutException :: ContainerId -> TimeoutException
+ TestContainers.Docker: TimeoutException :: ContainerId -> Maybe ImageTag -> Maybe Text -> TimeoutException
- TestContainers.Docker: UnknownPortMapping :: ContainerId -> Text -> DockerException
+ TestContainers.Docker: UnknownPortMapping :: ContainerId -> Text -> Maybe Text -> Maybe Text -> DockerException
- TestContainers.Docker: [id] :: DockerException -> ContainerId
+ TestContainers.Docker: [id] :: UnexpectedEndOfPipe -> ContainerId
- TestContainers.Docker: type MonadDocker m = (m ~ TestContainer)
+ TestContainers.Docker: type MonadDocker (m :: Type -> Type) = m ~ TestContainer
- TestContainers.Docker.Internal: InspectOutputMissingNetwork :: ContainerId -> DockerException
+ TestContainers.Docker.Internal: InspectOutputMissingNetwork :: ContainerId -> Maybe Text -> Maybe Text -> DockerException
- TestContainers.Docker.Internal: InspectOutputUnexpected :: ContainerId -> DockerException
+ TestContainers.Docker.Internal: InspectOutputUnexpected :: ContainerId -> Maybe Text -> Maybe Text -> DockerException
- TestContainers.Docker.Internal: UnknownPortMapping :: ContainerId -> Text -> DockerException
+ TestContainers.Docker.Internal: UnknownPortMapping :: ContainerId -> Text -> Maybe Text -> Maybe Text -> DockerException
- TestContainers.Hspec: withContainers :: forall a. TestContainer a -> (a -> IO ()) -> IO ()
+ TestContainers.Hspec: withContainers :: TestContainer a -> (a -> IO ()) -> IO ()
- TestContainers.Monad: type MonadDocker m = (m ~ TestContainer)
+ TestContainers.Monad: type MonadDocker (m :: Type -> Type) = m ~ TestContainer
- TestContainers.Tasty: InspectOutputMissingNetwork :: ContainerId -> DockerException
+ TestContainers.Tasty: InspectOutputMissingNetwork :: ContainerId -> Maybe Text -> Maybe Text -> DockerException
- TestContainers.Tasty: InspectOutputUnexpected :: ContainerId -> DockerException
+ TestContainers.Tasty: InspectOutputUnexpected :: ContainerId -> Maybe Text -> Maybe Text -> DockerException
- TestContainers.Tasty: TimeoutException :: ContainerId -> TimeoutException
+ TestContainers.Tasty: TimeoutException :: ContainerId -> Maybe ImageTag -> Maybe Text -> TimeoutException
- TestContainers.Tasty: UnknownPortMapping :: ContainerId -> Text -> DockerException
+ TestContainers.Tasty: UnknownPortMapping :: ContainerId -> Text -> Maybe Text -> Maybe Text -> DockerException
- TestContainers.Tasty: [id] :: DockerException -> ContainerId
+ TestContainers.Tasty: [id] :: UnexpectedEndOfPipe -> ContainerId
- TestContainers.Tasty: [port] :: DockerException -> Text
+ TestContainers.Tasty: [port] :: Port -> Int
- TestContainers.Tasty: type MonadDocker m = (m ~ TestContainer)
+ TestContainers.Tasty: type MonadDocker (m :: Type -> Type) = m ~ TestContainer
- TestContainers.Tasty: withContainers :: forall a. TestContainer a -> (IO a -> TestTree) -> TestTree
+ TestContainers.Tasty: withContainers :: TestContainer a -> (IO a -> TestTree) -> TestTree
Files
- CHANGELOG.md +4/−0
- src/TestContainers.hs +2/−0
- src/TestContainers/Docker.hs +118/−46
- src/TestContainers/Docker/Internal.hs +31/−3
- test/TestContainers/TastySpec.hs +6/−0
- test/data/init-script.sql +6/−0
- testcontainers.cabal +3/−2
CHANGELOG.md view
@@ -1,5 +1,9 @@ # Revision history for testcontainer-hs +## 0.5.2.0 -- 2025-08-28++* Introduce `withCopyFileToContainer` to copy local files to the container (@LaurentRDC, https://github.com/testcontainers/testcontainers-hs/pull/62)+ ## 0.5.1.0 -- 2025-01-14 * Introduce `withWorkingDirectory` to set the working directory inside a container (@alexbiehl, https://github.com/testcontainers/testcontainers-hs/pull/37)
src/TestContainers.hs view
@@ -31,6 +31,7 @@ M.setMemory, M.setCpus, M.withWorkingDirectory,+ M.withCopyFileToContainer, M.withNetwork, M.withNetworkAlias, M.setLink,@@ -41,6 +42,7 @@ -- * Logs M.LogConsumer, M.consoleLogConsumer,+ M.prefixedLogConsumer, -- * @docker network@ parameters M.NetworkRequest,
src/TestContainers/Docker.hs view
@@ -89,6 +89,7 @@ setRm, setEnv, withWorkingDirectory,+ withCopyFileToContainer, withNetwork, withNetworkAlias, setLink,@@ -99,6 +100,7 @@ -- * Following logs LogConsumer, consoleLogConsumer,+ prefixedLogConsumer, withFollowLogs, -- * Network related functionality@@ -159,7 +161,7 @@ import Control.Concurrent (threadDelay) import Control.Exception (IOException, throw)-import Control.Monad (forM_, replicateM, unless)+import Control.Monad (forM_, replicateM, unless, void) import Control.Monad.Catch ( Exception, MonadCatch,@@ -229,6 +231,7 @@ docker, dockerFollowLogs, dockerWithStdin,+ prefixedLogConsumer, ) import TestContainers.Docker.Network ( Network,@@ -288,7 +291,8 @@ labels :: [(Text, Text)], noReaper :: Bool, followLogs :: Maybe LogConsumer,- workDirectory :: Maybe Text+ workDirectory :: Maybe Text,+ copyFilesToContainer :: [(FilePath, FilePath)] } instance WithoutReaper ContainerRequest where@@ -324,7 +328,8 @@ labels = mempty, noReaper = False, followLogs = Nothing,- workDirectory = Nothing+ workDirectory = Nothing,+ copyFilesToContainer = mempty } -- | Set the name of a Docker container. This is equivalent to invoking @docker run@@@ -415,6 +420,27 @@ withWorkingDirectory workdir request = request {workDirectory = Just workdir} +-- | Copies a file from the host to the container. Call this function+-- multiple times to copy multiple files to the container.+--+-- This can be used, for example, to initialize a database:+--+-- >>> :{+-- containerRequest (fromTag "postgres:16-alpine")+-- & withCopyFileToContainer "my-init-script.sql" "/docker-entrypoint-initdb.d/"+-- :}+--+-- @since 0.5.2.0+withCopyFileToContainer ::+ -- | File on the host+ FilePath ->+ -- | Directory in the container+ FilePath ->+ ContainerRequest ->+ ContainerRequest+withCopyFileToContainer fileFromHost containerDirectory request =+ request {copyFilesToContainer = copyFilesToContainer request <> [(fileFromHost, containerDirectory)]}+ -- | Set the network the container will connect to. This is equivalent to passing -- @--network network_name@ to @docker run@. --@@ -556,7 +582,8 @@ labels, noReaper, followLogs,- workDirectory+ workDirectory,+ copyFilesToContainer } = request config@Config {configTracer, configCreateReaper} <-@@ -578,35 +605,43 @@ Just . (prefix <>) . ("-" <>) . pack <$> replicateM 6 (Random.randomRIO ('a', 'z')) - let dockerRun :: [Text]- dockerRun =+ -- Instead of using `docker run`, we use the more manual `docker create` + `docker start`.+ -- This allows to get the container ID early from `docker create`, and thus+ -- optionally copy files using `docker cp`.+ let dockerCreate :: [Text]+ dockerCreate = concat $- [["run"]]- ++ [["--detach"]]- ++ [["--name", containerName] | Just containerName <- [name]]- ++ [["--label", label <> "=" <> value] | (label, value) <- additionalLabels ++ labels]+ [["create"]]+ ++ [["--cpus", value] | Just value <- [cpus]] ++ [["--env", variable <> "=" <> value] | (variable, value) <- env]- ++ [["--publish", pack (show port) <> "/" <> protocol] | Port {port, protocol} <- exposedPorts]+ ++ [["--label", label <> "=" <> value] | (label, value) <- additionalLabels ++ labels]+ ++ [["--link", container] | container <- links]+ ++ [["--memory", value] | Just value <- [memory]]+ ++ [["--name", containerName] | Just containerName <- [name]] ++ [["--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]+ ++ [["--publish", pack (show port) <> "/" <> protocol] | Port {port, protocol} <- exposedPorts] ++ [["--rm"] | rmOnExit]+ ++ [["--volume", src <> ":" <> dest] | (src, dest) <- volumeMounts] ++ [["--workdir", workdir] | Just workdir <- [workDirectory]]- ++ [["--memory", value] | Just value <- [memory]]- ++ [["--cpus", value] | Just value <- [cpus]] ++ [[tag]]- ++ [command | Just command <- [cmd]] - stdout <- docker configTracer dockerRun+ (id :: ContainerId) <- strip . pack <$> docker configTracer dockerCreate - let id :: ContainerId- !id =- -- N.B. Force to not leak STDOUT String- strip (pack stdout)+ forM_ copyFilesToContainer $ \(hostFile, containerFile) ->+ docker configTracer ["cp", pack hostFile, id <> ":" <> pack containerFile] - -- Careful, this is really meant to be lazy+ let dockerStart :: [Text]+ dockerStart =+ concat $+ [["start"]]+ ++ [[id]]+ ++ [command | Just command <- [cmd]]++ void $ docker configTracer dockerStart++ let -- Careful, this is really meant to be lazy ~inspectOutput = unsafePerformIO $ internalInspect configTracer id@@ -848,9 +883,13 @@ -- | The exception thrown by `waitUntilTimeout`. -- -- @since 0.1.0.0-newtype TimeoutException = TimeoutException+data TimeoutException = TimeoutException { -- | The id of the underlying container that was not ready in time.- id :: ContainerId+ id :: ContainerId,+ -- | The image tag of the container+ imageName :: Maybe ImageTag,+ -- | The container name+ containerName :: Maybe Text } deriving (Eq, Show) @@ -859,9 +898,13 @@ -- | The exception thrown by `waitForState`. -- -- @since 0.1.0.0-newtype InvalidStateException = InvalidStateException+data InvalidStateException = InvalidStateException { -- | The id of the underlying container that was not ready in time.- id :: ContainerId+ id :: ContainerId,+ -- | The image tag of the container+ imageName :: Maybe ImageTag,+ -- | The container name+ containerName :: Maybe Text } deriving stock (Eq, Show) @@ -872,7 +915,7 @@ -- -- @since 0.5.0.0 waitForState :: (State -> Bool) -> WaitUntilReady-waitForState isReady = WaitReady $ \Container {id} -> do+waitForState isReady = WaitReady $ \Container {id, image} -> do let wait = do Config {configTracer} <- ask@@ -880,6 +923,13 @@ internalInspect configTracer id let state = containerState inspectOutput+ containerName = inspectOutput ^? Optics.key "Name" % Optics._String+ exception =+ InvalidStateException+ { id = id,+ imageName = Just (imageTag image),+ containerName = containerName+ } if isReady state then pure ()@@ -887,10 +937,10 @@ case stateStatus state of Exited -> -- Once exited, state won't change!- throwM InvalidStateException {id}+ throwM exception Dead -> -- Once dead, state won't change!- throwM InvalidStateException {id}+ throwM exception _ -> do liftIO (threadDelay 500000) wait@@ -1071,8 +1121,15 @@ timeout (seconds * 1000000) $ runInIO (interpreter rest) case result of- Nothing ->- throwM $ TimeoutException {id}+ Nothing -> do+ let Container {image, inspectOutput} = container+ containerName = inspectOutput ^? Optics.key "Name" % Optics._String+ throwM $+ TimeoutException+ { id,+ imageName = Just (imageTag image),+ containerName = containerName+ } Just {} -> pure () WaitMany first second -> do@@ -1142,13 +1199,19 @@ -- | Get the IP address of a running Docker container using @docker inspect@. internalContainerIp :: Container -> Text-internalContainerIp Container {id, inspectOutput} =+internalContainerIp Container {id, inspectOutput, image} = case inspectOutput ^? Optics.key "NetworkSettings" % Optics.key "IPAddress" % Optics._String of- Nothing ->- throw $ InspectOutputUnexpected {id}+ Nothing -> do+ let containerName = inspectOutput ^? Optics.key "Name" % Optics._String+ throw $+ InspectOutputUnexpected+ { id,+ imageName = Just (imageTag image),+ containerName = containerName+ } Just address -> address @@ -1157,7 +1220,7 @@ -- -- @since 0.5.0.0 containerAlias :: Container -> Text-containerAlias Container {id, inspectOutput} =+containerAlias Container {id, inspectOutput, image} = case inspectOutput ^? pre ( Optics.key "NetworkSettings"@@ -1167,10 +1230,13 @@ % Optics.values % Optics._String ) of- Nothing ->+ Nothing -> do+ let containerName = inspectOutput ^? Optics.key "Name" % Optics._String throw $ InspectOutputMissingNetwork- { id+ { id,+ imageName = Just (imageTag image),+ containerName = containerName } Just alias -> alias@@ -1180,7 +1246,7 @@ -- -- @since 0.5.0.0 containerGateway :: Container -> Text-containerGateway Container {id, inspectOutput} =+containerGateway Container {id, inspectOutput, image} = case inspectOutput ^? pre ( Optics.key "NetworkSettings"@@ -1189,10 +1255,13 @@ % Optics.key "Gateway" % Optics._String ) of- Nothing ->+ Nothing -> do+ let containerName = inspectOutput ^? Optics.key "Name" % Optics._String throw $ InspectOutputMissingNetwork- { id+ { id,+ imageName = Just (imageTag image),+ containerName = containerName } Just gatewayIp -> gatewayIp@@ -1201,7 +1270,7 @@ -- -- @since 0.1.0.0 containerPort :: Container -> Port -> Int-containerPort Container {id, inspectOutput} Port {port, protocol} =+containerPort Container {id, inspectOutput, image} 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@@ -1219,11 +1288,14 @@ % Optics._String ) of Nothing ->- throw $- UnknownPortMapping- { id,- port = textPort- }+ let containerName = inspectOutput ^? Optics.key "Name" % Optics._String+ in throw $+ UnknownPortMapping+ { id,+ port = textPort,+ imageName = Just (imageTag image),+ containerName = containerName+ } Just hostPort -> read (unpack hostPort)
src/TestContainers/Docker/Internal.hs view
@@ -20,6 +20,7 @@ Pipe (..), LogConsumer, consoleLogConsumer,+ prefixedLogConsumer, dockerFollowLogs, -- * Common abstractions for Docker resources@@ -38,6 +39,7 @@ import qualified Data.ByteString as ByteString import Data.Foldable (traverse_) import Data.Text (Text, pack, unpack)+import Data.Text.Encoding (encodeUtf8) import System.Exit (ExitCode (..)) import qualified System.IO import qualified System.Process as Process@@ -81,15 +83,27 @@ } | InspectUnknownContainerId {id :: ContainerId} | InspectOutputInvalidJSON {id :: ContainerId}- | InspectOutputMissingNetwork {id :: ContainerId}- | InspectOutputUnexpected {id :: ContainerId}+ | InspectOutputMissingNetwork+ { id :: ContainerId,+ imageName :: Maybe Text,+ containerName :: Maybe Text+ }+ | InspectOutputUnexpected+ { id :: ContainerId,+ imageName :: Maybe Text,+ containerName :: Maybe Text+ } | UnknownPortMapping { -- | Id of the `Container` that we tried to lookup the -- port mapping. id :: ContainerId, -- | Textual representation of port mapping we were -- trying to look up.- port :: Text+ port :: Text,+ -- | The image tag of the container+ imageName :: Maybe Text,+ -- | The container name+ containerName :: Maybe Text } deriving (Eq, Show) @@ -157,6 +171,20 @@ ByteString.hPut System.IO.stdout (ByteString.singleton 0x0a) Stderr -> do ByteString.hPutStr System.IO.stderr line+ ByteString.hPut System.IO.stderr (ByteString.singleton 0x0a)++-- | A 'LogConsumer' that prefixes with some prefix identifier.+-- This makes it easier to identify which containers/callsites produced which logs.+prefixedLogConsumer :: Text -> LogConsumer+prefixedLogConsumer prefix pipe line = do+ let prefixText = "[" <> prefix <> "] "+ prefixedLine = encodeUtf8 prefixText <> line+ case pipe of+ Stdout -> do+ ByteString.hPutStr System.IO.stdout prefixedLine+ ByteString.hPut System.IO.stdout (ByteString.singleton 0x0a)+ Stderr -> do+ ByteString.hPutStr System.IO.stderr prefixedLine ByteString.hPut System.IO.stderr (ByteString.singleton 0x0a) -- | Forwards container logs to a 'LogConsumer'. This is equivalent of calling @docker logs containerId --follow@
test/TestContainers/TastySpec.hs view
@@ -27,6 +27,7 @@ waitUntilMappedPortReachable, waitUntilTimeout, withContainers,+ withCopyFileToContainer, withFollowLogs, withNetwork, (&),@@ -77,6 +78,11 @@ & withNetwork net & setWaitingFor (waitForHttp "16686/tcp" "/" [200])++ _postgres <-+ run $+ containerRequest (fromTag "postgres:16-alpine")+ & withCopyFileToContainer "test/data/init-script.sql" "/docker-entrypoint-initdb.d/" _helloWorld <- run $
+ test/data/init-script.sql view
@@ -0,0 +1,6 @@++create table customers (+ id bigint not null,+ name varchar not null,+ primary key (id)+);
testcontainers.cabal view
@@ -1,6 +1,6 @@ cabal-version: >=1.10 name: testcontainers-version: 0.5.1.0+version: 0.5.2.0 synopsis: Docker containers for your integration tests. description: testcontainers is a Haskell library that provides a friendly API to@@ -18,6 +18,7 @@ extra-source-files: CHANGELOG.md README.md+ test/data/init-script.sql tested-with: GHC ==8.8.4 || ==8.10.7 || ==9.0.2 || ==9.2.4 || ==9.4.2 || ==9.8.2@@ -54,7 +55,7 @@ , http-client >=0.5.14 && <1 , http-types >=0.12.3 && <1 , mtl >=2.2.2 && <3- , network >=2.8.0 && <3.2+ , network >=2.8.0 && <3.3 , optics-core >=0.1 && <0.5 , process >=1.6.5 && <1.7 , random >=1.2 && <2