testcontainers / testcontainers-rs

A library for integration-testing against docker containers from within Rust.
https://rust.testcontainers.org
Apache License 2.0
728 stars 142 forks source link

Unable to use exposed port #750

Open baghai opened 5 days ago

baghai commented 5 days ago

As a continuation of #749, I am trying to get a very simple example working. Unfortunately, I seem to be misusing the functions and am unable to figure out how to expose a port and then access the exposed port.

This is the example I am trying to make work. It is based on the example from the Quickstart.

$ cat src/main.rs
use testcontainers::{
    core::{IntoContainerPort, WaitFor},
    runners::AsyncRunner,
    GenericImage,
};

#[tokio::main]
async fn main() {
    let container = GenericImage::new("redis", "7.2.4")
        .with_exposed_port(6379.tcp())
        .with_wait_for(WaitFor::message_on_stdout("Ready to accept connections"))
        .start()
        .await
        .unwrap();

    let host = container.get_host().await.unwrap();
    let port = container.get_host_port_ipv4(6379).await.unwrap();
    let url = format!("redis://{host}:{port}");
    println!("Redis is running on {}", url);
}

Unfortunately, it does not work.

$ cargo run
    Finished dev [unoptimized + debuginfo] target(s) in 0.07s
     Running `target/debug/asdf`
thread 'main' panicked at src/main.rs:17:57:
called `Result::unwrap()` on an `Err` value: PortNotExposed { id: "98ae2d954adcbbcc99dbeb7bcccfd5aa6d3507e0d3b36f741849d6b589934ec4", port: Tcp(6379) }
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

How can a port be exposed and then used?

baghai commented 4 days ago

Alright, I preliminarily dismissed that I am running Podman, not Docker. As it turns out, this error is related to Podman.

With the following code, I am viewing the network settings of the container.

use testcontainers::{
    core::{IntoContainerPort, WaitFor},
    core::client,
    runners::AsyncRunner,
    GenericImage,
};

#[tokio::main]
async fn main() {
    let container = GenericImage::new("redis", "7.2.4")
        .with_exposed_port(6379.tcp())
        .with_wait_for(WaitFor::message_on_stdout("Ready to accept connections"))
        .start()
        .await
        .unwrap();

    let docker = client::docker_client_instance().await.unwrap();
    let id = container.id();
    let ports = docker
        .inspect_container(id, None)
        .await
        .unwrap()
        .network_settings
        .unwrap_or_default()
        .ports;
        // .map(Ports::try_from)
        // .transpose()?
        // .unwrap_or_default();
    println!("{:?}", ports);

    let host = container.get_host().await.unwrap();
    let port = container.get_host_port_ipv4(6379).await.unwrap();
    let url = format!("redis://{host}:{port}");
    println!("Redis is running on {}", url);
}

Here is the output with Podman (Fedora 41):

$ cargo run
   Compiling quickstart v0.1.0 (/tmp/testcontainers-rs-quickstart)
    Finished dev [unoptimized + debuginfo] target(s) in 6.70s
     Running `target/debug/quickstart`
Some({"6379/tcp": Some([PortBinding { host_ip: Some(""), host_port: Some("44971") }])})
thread 'main' panicked at src/main.rs:34:57:
called `Result::unwrap()` on an `Err` value: PortNotExposed { id: "33272a500a0a9f34126fb2f752df14f68b0649ec824a86dc753a79661031e99a", port: Tcp(6379) }
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

And here is the output with Docker (Ubuntu 24.04.1 + Docker 24.0.7 from App Center):

$ cargo run
    Finished dev [unoptimized + debuginfo] target(s) in 0.24s
     Running `target/debug/quickstart`
Some({"6379/tcp": Some([PortBinding { host_ip: Some("0.0.0.0"), host_port: Some("32770") }, PortBinding { host_ip: Some("::"), host_port: Some("32770") }])})
Redis is running on redis://localhost:32770

To spell it out, the host_ip differs. While Docker provides both 0.0.0.0 and ::, Podman only has "".

I will try to figure out why that is and what can be done about this.

The code responsible for extracting port and host information is in Ports::try_from. As is, this code does not handle the case that Podman returns.

baghai commented 4 days ago

One can get the list of running containers with this:

$ curl --unix-socket /run/user/1000/podman/podman.sock http://v1.47/containers/json

Port mapping output from Podman:

$ curl --silent --unix-socket /run/user/1000/podman/podman.sock http://v1.47/containers/70fdd33d3a13f4fb68dab82431c614d0d46b3e224c703a3ce314acfad675a2bc/json|jq .NetworkSettings.Ports
{
  "6379/tcp": [
    {
      "HostIp": "",
      "HostPort": "44971"
    }
  ]
}

Here the output with Docker:

$ curl --silent --unix-socket /var/run/docker.sock http://v1.47/containers/17e344a692c1121f76f65efa712a1c4302f09666eeef8077795937b799454723/json |jq .NetworkSettings.Ports
{
  "6379/tcp": [
    {
      "HostIp": "0.0.0.0",
      "HostPort": "32770"
    },
    {
      "HostIp": "::",
      "HostPort": "32770"
    }
  ]
}
baghai commented 3 days ago

Here is the official API documentation: https://docs.docker.com/reference/api/engine/version/v1.47/#tag/Container/operation/ContainerInspect

The description of .NetworkSettings.Ports is as follows:

PortMap describes the mapping of container ports to host ports, using the container's port-number and protocol as key in the format /, for example, 80/udp.

If a container's port is mapped for multiple protocols, separate entries are added to the mapping table.

As shown in the previous comment, Ports is an array of a object with two fields (quoting from the documentation that I have linked above):

Does Podman follow this definition? I am not sure.

Shall testcontainers-rs have a workaround for Podman? I will now look into other implementations of testcontainers to see if and how they account for the output Podman is providing.

baghai commented 3 days ago

In testcontainers-go, the HostIp seems to be ignored: DockerContainer.MappedPort()

The following issue suggests that the intention of Docker is to map an exposed port to the same port on both, IPv4 and IPv6: https://github.com/moby/moby/issues/42442

DDtKey commented 3 days ago

That's a good research, thank you!

I think we need to improve the experience with podman.

Also, Testonctiners usually have separate guide for alternative engines, e.g Go version. We need to improve our documentation

baghai commented 1 day ago

My temporal workaround is the following modification.

From 7ba724182ee9d4383dfb1bdde8c0b6b1d7395196 Mon Sep 17 00:00:00 2001
From: baghai <184649356+baghai@users.noreply.github.com>
Date: Mon, 21 Oct 2024 13:50:07 +0200
Subject: [PATCH] Ports: work around for Podman

---
 testcontainers/src/core/ports.rs | 8 ++++++++
 1 file changed, 8 insertions(+)

diff --git a/testcontainers/src/core/ports.rs b/testcontainers/src/core/ports.rs
index 0a8e377..6eece89 100644
--- a/testcontainers/src/core/ports.rs
+++ b/testcontainers/src/core/ports.rs
@@ -96,6 +96,14 @@ impl TryFrom<PortMap> for Ports {
                         .parse()
                         .map_err(PortMappingError::FailedToParseHostPort)?;

+                    // FIXME QUICK AND DIRTY WORKAROUND FOR PODMAN
+                    // Podman has empty HostIp.
+                    // Does Podman ensure that both IPv4 and IPv6 are on the same port?
+                    if binding.host_ip.clone().unwrap() == "" {
+                        ipv4_mapping.insert(container_port, host_port);
+                        ipv6_mapping.insert(container_port, host_port);
+                    }
+
                     // switch on the IP version of the `HostIp`
                     let mapping = match binding.host_ip.map(|ip| ip.parse()) {
                         Some(Ok(IpAddr::V4(_))) => {
-- 
2.43.5

I will try to come up with a good solution to this problem. Assuming that there are or will be more situations where workarounds for Podman are required, I would like to find a method to detect if Podman or Docker is used, then switch on that outcome. Alternatively, it may be valid to do something similar as testcontainers-go is doing, i.e. ignore the differentiation of IPv4 and IPv6. My gut feeling is that this is the worse option.

DDtKey commented 1 day ago

Alternatively, it may be valid to do something similar as testcontainers-go is doing, i.e. ignore the differentiation of IPv4 and IPv6

Personally, I find the differentiation useful, it provides better control IMO. But we can try to find a compromise