defmodule Mix.Tasks.TestcontainerEx.Test do @moduledoc """ Runs Docker-dependent tests with automatic Ryuk setup and teardown. This task handles the full lifecycle: 1. Ensures Docker is available (starts Colima if needed) 2. Starts a Ryuk reaper container for automatic test container cleanup 3. Runs the requested tests (including :needs_dock tests) 4. Stops Ryuk and cleans up on completion ## Usage # Run all tests including Docker-dependent ones mix testcontainer_ex.test # Run only Docker-dependent tests mix testcontainer_ex.test --only needs_dock # Run a specific test file mix testcontainer_ex.test test/container/postgres_container_test.exs # Run with a specific tag mix testcontainer_ex.test --include needs_dock --exclude dood_limitation # Only set up Ryuk (don't run tests) mix testcontainer_ex.test --setup-only # Tear down Ryuk and clean up mix testcontainer_ex.test --teardown ## Options --setup-only Start Ryuk and exit (don't run tests) --teardown Stop Ryuk and clean up test containers --only TAG Run only tests with this tag (can be repeated) --include TAG Include tests with this tag (passed to mix test) --exclude TAG Exclude tests with this tag (passed to mix test) ## Environment variables RYUK_IMAGE Ryuk image (default: testcontainers/ryuk:0.14.0) RYUK_PORT Host port for Ryuk (default: auto) TEST_TIMEOUT Timeout in ms (default: 300000) """ use Mix.Task @impl true def run(args) do {opts, rest, _} = OptionParser.parse(args, strict: [ setup_only: :boolean, teardown: :boolean, only: :string, include: :string, exclude: :string ] ) if opts[:teardown] do teardown() else setup() if opts[:setup_only] != true do run_tests(rest, opts) end end end # ── Docker check ────────────────────────────────────────────────────────────── defp ensure_docker! do case System.find_executable("docker") do nil -> Mix.raise("Docker is not installed or not in PATH") _docker -> :ok end end defp ensure_colima! do if System.find_executable("colima") do case System.cmd("colima", ["status"], stderr_to_stdout: true) do {_, 0} -> Mix.shell().info("[testcontainer_ex] Colima is running") _ -> Mix.shell().info("[testcontainer_ex] Starting Colima...") {_, 0} = System.cmd("colima", ["start"], stderr_to_stdout: true) Mix.shell().info("[testcontainer_ex] Colima started") end end end defp wait_for_docker! do Mix.shell().info("[testcontainer_ex] Waiting for Docker daemon...") 1..60 |> Enum.reduce_while(:timeout, fn _attempt, _acc -> case System.cmd("docker", ["info"], stderr_to_stdout: true) do {_, 0} -> Mix.shell().info("[testcontainer_ex] Docker daemon is ready") {:halt, :ok} _ -> Process.sleep(1_000) {:cont, :timeout} end end) |> case do :ok -> :ok :timeout -> Mix.raise("Docker daemon did not become ready after 60s") end end defp docker_socket_paths do [ "/var/run/docker.sock", Path.expand("~/.docker/run/docker.sock"), Path.expand("~/.docker/desktop/docker.sock"), Path.expand("~/.colima/default/docker.sock"), Path.expand("~/.colima/docker.sock") ] end defp detect_docker_socket_path do Enum.find_value(docker_socket_paths(), fn path -> case File.stat(path) do {:ok, stat} -> if :erlang.band(stat.mode, 0o170000) == 0o140000 do path end _ -> nil end end) || Mix.raise("Could not find Docker socket") end # ── Ryuk management ─────────────────────────────────────────────────────────── defp start_ryuk do ryuk_image = System.get_env("RYUK_IMAGE", "testcontainers/ryuk:0.14.0") container_name = "testcontainer_ex-ryuk-#{:erlang.unique_integer([:positive])}" # Clean up orphaned Ryuk containers System.cmd( "docker", ["ps", "-a", "--filter", "name=testcontainer_ex-ryuk", "--format", "{{.ID}}"], stderr_to_stdout: true ) |> elem(0) |> String.split("\n", trim: true) |> Enum.each(fn id -> System.cmd("docker", ["rm", "-f", id], stderr_to_stdout: true) end) # Pull Ryuk image Mix.shell().info("[testcontainer_ex] Pulling Ryuk image: #{ryuk_image}") {_, 0} = System.cmd("docker", ["pull", ryuk_image], stderr_to_stdout: true) # Detect Docker socket docker_socket_path = detect_docker_socket_path() Mix.shell().info("[testcontainer_ex] Using Docker socket: #{docker_socket_path}") System.put_env("CONTAINER_ENGINE_HOST", "unix://#{docker_socket_path}") # Determine how to give Ryuk access to Docker: # - Linux: bind-mount the Docker socket directly # - macOS (Colima/Docker Desktop): use host network or TCP, because the # socket path is inside a VM that containers can't bind-mount from the host os_type = os_type() mount_args = case os_type do :linux -> ["-v", "#{docker_socket_path}:/var/run/docker.sock"] :macos -> ["--network", "host"] :windows -> ["-v", "//var/run/docker.sock:/var/run/docker.sock"] end # Start Ryuk container ryuk_port = System.get_env("RYUK_PORT", "0") port_arg = if ryuk_port == "0", do: "-p", else: "-p#{ryuk_port}:8080" docker_args = [ "run", "-d", "--name", container_name, "--restart", "unless-stopped" ] ++ mount_args ++ if os_type == :macos do # With --network host, -p is not needed ["--privileged", ryuk_image] else [port_arg, "8080", "--privileged", ryuk_image] end {output, 0} = System.cmd("docker", docker_args, stderr_to_stdout: true) ryuk_id = String.trim(output) Mix.shell().info("[testcontainer_ex] Ryuk container started: #{String.slice(ryuk_id, 0, 12)}") # Store PID for cleanup pid_file = Path.join(System.tmp_dir!(), "testcontainer_ex-ryuk.pid") File.write!(pid_file, "#{container_name}\n#{ryuk_id}\n") # Wait for Ryuk to be ready Mix.shell().info("[testcontainer_ex] Waiting for Ryuk to be ready...") Process.sleep(3_000) case System.cmd("docker", ["inspect", "--format", "{{.State.Running}}", ryuk_id], stderr_to_stdout: true ) do {"true\n", 0} -> Mix.shell().info("[testcontainer_ex] Ryuk is running") _ -> Mix.shell().info( "[testcontainer_ex] Ryuk container may not be healthy, continuing anyway" ) end # Register cleanup via System.at_exit System.at_exit(fn _ -> teardown_ryuk(container_name) :ok end) end defp os_type do case :os.type() do {:win32, _} -> :windows {:unix, :darwin} -> :macos {:unix, _} -> :linux end end defp setup do ensure_docker!() ensure_colima!() wait_for_docker!() start_ryuk() end defp teardown do pid_file = Path.join(System.tmp_dir!(), "testcontainer_ex-ryuk.pid") if File.exists?(pid_file) do [container_name | _] = File.read!(pid_file) |> String.split("\n") teardown_ryuk(container_name) File.rm!(pid_file) else # Try to find any Ryuk containers {output, 0} = System.cmd( "docker", ["ps", "-a", "--filter", "name=testcontainer_ex-ryuk", "--format", "{{.Names}}"], stderr_to_stdout: true ) output |> String.split("\n", trim: true) |> Enum.each(&teardown_ryuk/1) end # Clean up test containers Mix.shell().info("[testcontainer_ex] Cleaning up test containers...") System.cmd( "docker", ["ps", "-a", "--filter", "label=org.testcontainer_ex", "--format", "{{.ID}}"], stderr_to_stdout: true ) |> elem(0) |> String.split("\n", trim: true) |> Enum.each(fn id -> System.cmd("docker", ["rm", "-f", id], stderr_to_stdout: true) end) Mix.shell().info("[testcontainer_ex] Cleanup complete") end defp teardown_ryuk(container_name) do case System.cmd("docker", ["ps", "--filter", "name=#{container_name}", "--format", "{{.ID}}"], stderr_to_stdout: true ) do {"", _} -> :ok {id, _} -> id = String.trim(id) Mix.shell().info("[testcontainer_ex] Stopping Ryuk: #{String.slice(id, 0, 12)}") System.cmd("docker", ["stop", id], stderr_to_stdout: true) System.cmd("docker", ["rm", id], stderr_to_stdout: true) Mix.shell().info("[testcontainer_ex] Ryuk removed") end end # ── Test execution ──────────────────────────────────────────────────────────── defp run_tests(rest, opts) do timeout = String.to_integer(System.get_env("TEST_TIMEOUT", "300000")) mix_test_args = [ "--exclude", "flaky", "--timeout", to_string(timeout) ] # Handle --only tag mix_test_args = case opts[:only] do nil -> mix_test_args _tag -> mix_test_args ++ ["--include", "needs_dock", "--exclude", "dood_limitation"] end # Handle --include tags mix_test_args = case opts[:include] do nil -> mix_test_args tag -> mix_test_args ++ ["--include", tag] end # Handle --exclude tags mix_test_args = case opts[:exclude] do nil -> mix_test_args tag -> mix_test_args ++ ["--exclude", tag] end # Add any remaining args (test files, line numbers, etc.) all_args = mix_test_args ++ rest Mix.shell().info("[testcontainer_ex] Running: mix test #{Enum.join(all_args, " ")}") {_, exit_code} = System.cmd("mix", ["test" | all_args], into: IO.stream(:stdio, :line), env: [{"MIX_ENV", "test"}] ) if exit_code != 0 do Mix.raise("mix test failed with exit code #{exit_code}") end end end