diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 29caf1a..47ce14a 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -11,29 +11,35 @@ jobs: name: ${{ matrix.os }} / ghc ${{ matrix.ghc }} runs-on: ${{ matrix.os }} strategy: + fail-fast: false matrix: - os: [ubuntu-latest] # [macOS-latest, windows-latest] do not come with docker :( - cabal: ["3.12.1.0"] + os: + - ubuntu-latest + - ubuntu-24.04-arm + - macos-26-intel + - windows-latest + cabal: + - "3.14" ghc: - - 9.2.8 - - 9.4.8 - - 9.6.4 - - 9.8.2 + - "9.2" + - "9.12" steps: - uses: actions/checkout@v2 if: github.event.action == 'opened' || github.event.action == 'synchronize' || github.event.ref == 'refs/heads/master' - - uses: haskell-actions/setup@v2.6.1 + - uses: haskell-actions/setup@v2 id: setup-haskell-cabal name: Setup Haskell with: ghc-version: ${{ matrix.ghc }} cabal-version: ${{ matrix.cabal }} + - name: Configure + run: cabal configure -O0 --enable-tests --enable-benchmarks --test-show-details=direct + - name: Freeze - run: | - cabal freeze + run: cabal freeze - uses: actions/cache@v3 name: Cache ~/.cabal/store @@ -42,11 +48,17 @@ jobs: key: ${{ runner.os }}-${{ matrix.ghc }}-${{ hashFiles('cabal.project.freeze') }} - name: Build - run: | - cabal configure -O0 --enable-tests --enable-benchmarks --test-show-details=direct - cabal build all + run: cabal build all + + - name: Set up Docker + if: runner.os == 'macOS' + uses: docker/setup-docker-action@v5 - name: Test + # GitHub-hosted Windows runners have no Linux Docker daemon, so the + # Docker-dependent test suite cannot run there. The Build step above still + # verifies the library compiles on Windows. + if: runner.os != 'Windows' run: | cabal test all diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index b33fcfa..dbda448 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -10,12 +10,12 @@ jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v6 if: github.event.action == 'opened' || github.event.action == 'synchronize' || github.event.ref == 'refs/heads/master' - name: Check code is formatted using Ormolu run: | - curl -L https://github.com/tweag/ormolu/releases/download/0.7.2.0/ormolu-Linux.zip -o ormolu.zip + curl -L https://github.com/tweag/ormolu/releases/download/0.8.1.0/ormolu-x86_64-linux.zip -o ormolu.zip unzip ormolu.zip git ls-files | grep \.hs | xargs ./ormolu --mode=inplace diff --git a/src/TestContainers/Docker.hs b/src/TestContainers/Docker.hs index f6011d2..66f8786 100644 --- a/src/TestContainers/Docker.hs +++ b/src/TestContainers/Docker.hs @@ -670,11 +670,24 @@ run request = do -- @since 0.5.0.0 createRyukReaper :: TestContainer Reaper createRyukReaper = do - dockerSocketLocation <- - liftIO $ - lookupEnv "DOCKER_HOST" - <&> (>>= stripPrefix "unix://") - <&> fromMaybe "/var/run/docker.sock" + -- Ryuk needs access to the Docker daemon socket to reap resources. The way + -- that socket is exposed depends on whether the daemon runs Linux or Windows + -- containers. Docker automatically selects the matching ryuk image variant + -- from the multi-arch manifest based on the daemon's OS. + dockerSocketMount <- do + onLinux <- isDockerOnLinux + if onLinux + then do + dockerSocketLocation <- + liftIO $ + lookupEnv "DOCKER_HOST" + <&> (>>= stripPrefix "unix://") + <&> fromMaybe "/var/run/docker.sock" + pure (pack dockerSocketLocation, "/var/run/docker.sock") + else + -- Windows containers talk to the daemon through the named pipe rather + -- than a unix socket. This is what the windows/amd64 ryuk build expects. + pure ("\\\\.\\pipe\\docker_engine", "\\\\.\\pipe\\docker_engine") ryukContainer <- run $ containerRequest (fromTag ryukImageTag) @@ -682,9 +695,14 @@ createRyukReaper = do -- Ryuk destroys itself once it reaped the resources, -- no need to register itself with itself. withoutReaper - & setVolumeMounts [(pack dockerSocketLocation, "/var/run/docker.sock")] + & setVolumeMounts [dockerSocketMount] & setExpose [ryukPort] - & setWaitingFor (waitUntilMappedPortReachable ryukPort) + -- Wait for ryuk's own startup log rather than probing its port. + -- A TCP probe (waitUntilMappedPortReachable) registers as a client + -- that immediately disconnects without sending any labels; ryuk 0.14.0 + -- treats this as a completed session and exits with its 1 ns + -- reconnection timeout, so the subsequent real connection gets RST. + & setWaitingFor (waitForLogLine Stdout ("Started" `LazyText.isInfixOf`)) & setRm True let (ryukContainerAddress, ryukContainerPort) = @@ -786,11 +804,20 @@ defaultToImage action = fromTag :: ImageTag -> ToImage fromTag tag = defaultToImage $ do tracer <- askTracer - output <- docker tracer ["pull", "--quiet", tag] - return $ - Image - { tag = strip (pack output) - } + pullWithRetry tracer 3 + where + pull tracer = do + output <- docker tracer ["pull", "--quiet", tag] + pure $ Image {tag = strip (pack output)} + pullWithRetry :: Tracer -> Int -> TestContainer Image + pullWithRetry tracer 0 = pull tracer + pullWithRetry tracer n = do + result <- try (pull tracer) + case result of + Right image -> pure image + Left (_ :: DockerException) -> do + liftIO (threadDelay 2000000) + pullWithRetry tracer (n - 1) -- | Get an `Image` from an image id. This doesn't run @docker pull@ or any other Docker command -- on construction. @@ -1104,9 +1131,13 @@ waitForLogLine whereToLook matches = waitWithLogs $ \Container {id} stdout stder waitUntilReady :: Container -> WaitUntilReady -> TestContainer () waitUntilReady container@Container {id} input = do Config {configDefaultWaitTimeout} <- ask - interpreter $ case configDefaultWaitTimeout of - Just seconds -> waitUntilTimeout seconds input - Nothing -> input + interpreter $ case input of + WaitUntilTimeout {} -> + input + _ -> + case configDefaultWaitTimeout of + Just seconds -> waitUntilTimeout seconds input + Nothing -> input where interpreter :: WaitUntilReady -> TestContainer () interpreter wait = diff --git a/src/TestContainers/Docker/Reaper.hs b/src/TestContainers/Docker/Reaper.hs index faaf5c6..0b5b01e 100644 --- a/src/TestContainers/Docker/Reaper.hs +++ b/src/TestContainers/Docker/Reaper.hs @@ -2,6 +2,7 @@ {-# LANGUAGE GeneralizedNewtypeDeriving #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE ScopedTypeVariables #-} module TestContainers.Docker.Reaper ( Reaper (..), @@ -14,6 +15,8 @@ module TestContainers.Docker.Reaper ) where +import Control.Concurrent (threadDelay) +import Control.Exception (IOException, try) import Control.Monad (replicateM) import Control.Monad.Trans.Resource (MonadResource, allocate) import Data.Text (Text, pack, unpack) @@ -58,7 +61,7 @@ newtype Ryuk = Ryuk {ryukSocket :: Socket.Socket} -- @since 0.5.0.0 ryukImageTag :: Text ryukImageTag = - "docker.io/testcontainers/ryuk:0.3.4" + "docker.io/testcontainers/ryuk:0.14.0" -- | Exposed port for the ryuk reaper. -- @@ -84,24 +87,12 @@ newRyukReaper host port = do (_releaseKey, (_socket, ryuk)) <- allocate ( do - let hints = - Socket.defaultHints {Socket.addrSocketType = Socket.Stream} - address <- - head <$> Socket.getAddrInfo (Just hints) (Just (unpack host)) (Just (show port)) - socket <- - Socket.socket - (Socket.addrFamily address) - (Socket.addrSocketType address) - (Socket.addrProtocol address) - Socket.connect socket (Socket.addrAddress address) - - -- Construct the reaper and regiter the session with it. - -- Doing it here intead of in the teardown (like we did before) + socket <- connectWithRetry 60 + -- Construct the reaper and register the session with it. + -- Doing it here instead of in the teardown (like we did before) -- guarantees the Reaper knows about our session. - let reaper = - newReaper sessionId (Ryuk socket) + let reaper = newReaper sessionId (Ryuk socket) register reaper sessionIdLabel sessionId - pure (socket, reaper) ) ( \(socket, _ryuk) -> do @@ -111,6 +102,29 @@ newRyukReaper host port = do ) pure ryuk + where + -- On macOS (Docker Desktop / Colima), port-forwarding from the VM to the + -- host can lag a moment behind the container's own "Started" log line. + -- Retry the connect rather than failing immediately. + connectWithRetry :: Int -> IO Socket.Socket + connectWithRetry 0 = do + let hints = Socket.defaultHints {Socket.addrSocketType = Socket.Stream} + address <- head <$> Socket.getAddrInfo (Just hints) (Just (unpack host)) (Just (show port)) + socket <- Socket.socket (Socket.addrFamily address) (Socket.addrSocketType address) (Socket.addrProtocol address) + Socket.connect socket (Socket.addrAddress address) + pure socket + connectWithRetry attemptsLeft = do + let hints = Socket.defaultHints {Socket.addrSocketType = Socket.Stream} + address <- head <$> Socket.getAddrInfo (Just hints) (Just (unpack host)) (Just (show port)) + socket <- Socket.socket (Socket.addrFamily address) (Socket.addrSocketType address) (Socket.addrProtocol address) + result <- try (Socket.connect socket (Socket.addrAddress address)) + case result of + Right () -> + pure socket + Left (_ :: IOException) -> do + Socket.close socket + threadDelay 500000 + connectWithRetry (attemptsLeft - 1) newReaper :: -- | Session id diff --git a/src/TestContainers/Image.hs b/src/TestContainers/Image.hs index a2308cb..fdb2793 100644 --- a/src/TestContainers/Image.hs +++ b/src/TestContainers/Image.hs @@ -23,21 +23,21 @@ import TestContainers.Docker as Docker -- | Image for Redis database. -- -- @ --- redis = fromTag "redis:5.0" +-- redis = fromTag "redis:7.4" -- @ -- -- @since 0.1.0.0 redis :: ToImage redis = - fromTag "redis:5.0" + fromTag "redis:7.4" -- | Image for Mongo database. -- -- @ --- mongo = Tag "mongo:4.0.17" +-- mongo = Tag "mongo:7.0" -- @ -- -- @since 0.1.0.0 mongo :: ToImage mongo = - fromTag "mongo:4.0.17" + fromTag "mongo:7.0" diff --git a/test/TestContainers/TastySpec.hs b/test/TestContainers/TastySpec.hs index a2fba0b..0f6dbcf 100644 --- a/test/TestContainers/TastySpec.hs +++ b/test/TestContainers/TastySpec.hs @@ -3,7 +3,7 @@ module TestContainers.TastySpec (main, test_all) where -import Data.Text.Lazy (isInfixOf) +import qualified Data.Text.Lazy as LazyText import Test.Tasty (TestTree, defaultMain, testGroup) import Test.Tasty.HUnit import TestContainers.Tasty @@ -51,19 +51,17 @@ containers1 = do _rabbitmq <- run $ - containerRequest (fromTag "rabbitmq:3.8.4") + containerRequest (fromTag "rabbitmq:3.13") & setRm False & setExpose [5672] & withNetwork net & withFollowLogs consoleLogConsumer & setWaitingFor - ( waitForLogLine Stdout (("completed with" `isInfixOf`)) - <> waitUntilMappedPortReachable 5672 - ) + (waitUntilTimeout 300 (waitForLogLine Stdout ("started TCP listener" `LazyText.isInfixOf`))) _nginx <- run $ - containerRequest (fromTag "nginx:1.23.1-alpine") + containerRequest (fromTag "nginx:1.27-alpine") & setExpose [80] & withNetwork net & setWaitingFor @@ -73,7 +71,7 @@ containers1 = do _jaeger <- run $ - containerRequest (fromTag "jaegertracing/all-in-one:1.6") + containerRequest (fromTag "jaegertracing/all-in-one:1.62.0") & setExpose ["5775/udp", "6831/udp", "6832/udp", "5778", "16686/tcp"] & withNetwork net & setWaitingFor @@ -81,7 +79,7 @@ containers1 = do _postgres <- run $ - containerRequest (fromTag "postgres:16-alpine") + containerRequest (fromTag "postgres:17-alpine") & withCopyFileToContainer "test/data/init-script.sql" "/docker-entrypoint-initdb.d/" _helloWorld <- diff --git a/testcontainers.cabal b/testcontainers.cabal index 6c89bc2..b6b1d95 100644 --- a/testcontainers.cabal +++ b/testcontainers.cabal @@ -86,7 +86,7 @@ test-suite tests , hspec >=2.0 && <3.0 , tasty , tasty-discover >=4.2.1 && <6 - , tasty-hspec + , tasty-hspec >=1.1.3 && <1.3 , tasty-hunit , testcontainers , text