Skip to main content

nexus_sdk/test_utils/
containers.rs

1//! Module defining container setups via
2//! [`testcontainers`].
3//!
4//! The module supports Sui and Redis.
5
6use {
7    anyhow::Context as _,
8    portpicker::pick_unused_port,
9    std::time::{Duration, Instant},
10    testcontainers::{
11        core::{client, ports::ContainerPort, ContainerRequest},
12        runners::AsyncRunner,
13        ContainerAsync,
14        GenericImage,
15        ImageExt,
16    },
17    testcontainers_modules::{postgres::Postgres, redis::Redis},
18};
19
20/// A running Sui container built from [`GenericImage`].
21pub type SuiContainer = ContainerAsync<GenericImage>;
22pub type PgContainer = ContainerAsync<Postgres>;
23pub type RedisContainer = ContainerAsync<Redis>;
24pub type ExecCommand = testcontainers::core::ExecCommand;
25
26const SUI_TOOLS_IMAGE: &str = "mysten/sui-tools";
27const SUI_TOOLS_TAG_AMD64: &str = "testnet-v1.77.2";
28const SUI_TOOLS_TAG_ARM64: &str = "testnet-v1.77.2-arm64";
29const SUI_RPC_PORT: ContainerPort = ContainerPort::Tcp(9000);
30const SUI_FAUCET_PORT: ContainerPort = ContainerPort::Tcp(9123);
31const SUI_GRAPHQL_PORT: ContainerPort = ContainerPort::Tcp(9125);
32const SUI_GRPC_READY_TIMEOUT: Duration = Duration::from_secs(60);
33const SUI_GRPC_READY_INTERVAL: Duration = Duration::from_millis(100);
34
35/// A running Sui test network and its mapped service ports.
36pub struct SuiInstance {
37    /// The container handle.
38    pub container: SuiContainer,
39    /// The mapped Sui gRPC port.
40    pub rpc_port: u16,
41    /// The mapped faucet port.
42    pub faucet_port: u16,
43}
44
45/// Returns why Docker-based tests cannot run in the current environment.
46pub async fn docker_unavailable_reason() -> Option<String> {
47    let docker = match client::docker_client_instance().await {
48        Ok(docker) => docker,
49        Err(error) => return Some(format!("failed to create Docker client: {error}")),
50    };
51
52    docker
53        .version()
54        .await
55        .err()
56        .map(|error| format!("failed to query Docker daemon: {error}"))
57}
58
59fn sui_request(
60    tag: &str,
61    epoch_duration: Option<Duration>,
62) -> anyhow::Result<ContainerRequest<GenericImage>> {
63    let mut command = vec![
64        "start".to_owned(),
65        "--force-regenesis".to_owned(),
66        "--with-faucet".to_owned(),
67    ];
68
69    if let Some(duration) = epoch_duration {
70        let duration_ms: u64 = duration
71            .as_millis()
72            .try_into()
73            .context("convert Sui epoch duration to milliseconds")?;
74        command.push("--epoch-duration-ms".to_owned());
75        command.push(duration_ms.to_string());
76    }
77
78    Ok(GenericImage::new(SUI_TOOLS_IMAGE, tag)
79        .with_entrypoint("sui")
80        .with_exposed_port(SUI_RPC_PORT)
81        .with_exposed_port(SUI_FAUCET_PORT)
82        .with_exposed_port(SUI_GRAPHQL_PORT)
83        .with_env_var("RUST_LOG", "warning,sui_node=info")
84        .with_cmd(command))
85}
86
87async fn wait_for_sui_grpc(rpc_port: u16) -> anyhow::Result<()> {
88    let rpc_url = format!("http://127.0.0.1:{rpc_port}");
89    let started = Instant::now();
90    let mut last_error = None;
91
92    while started.elapsed() < SUI_GRPC_READY_TIMEOUT {
93        match crate::sui::grpc::Client::new(&rpc_url) {
94            Ok(mut client) => match client
95                .ledger_client()
96                .get_service_info(crate::sui::grpc::GetServiceInfoRequest::default())
97                .await
98            {
99                Ok(response) => {
100                    if response.into_inner().chain_id.is_some() {
101                        return Ok(());
102                    }
103                    last_error = Some("service info omitted the chain id".to_owned());
104                }
105                Err(error) => last_error = Some(error.to_string()),
106            },
107            Err(error) => last_error = Some(error.to_string()),
108        }
109        tokio::time::sleep(SUI_GRPC_READY_INTERVAL).await;
110    }
111
112    anyhow::bail!(
113        "Sui gRPC did not become ready at {rpc_url} within {:?}: {}",
114        SUI_GRPC_READY_TIMEOUT,
115        last_error.unwrap_or_else(|| "no response".to_owned())
116    )
117}
118
119/// Starts a Sui container with the default epoch duration.
120///
121/// Use [`try_setup_sui_instance`] when startup errors must be handled or the
122/// epoch duration must be configured.
123///
124/// # Panics
125///
126/// Panics when the container cannot be started.
127pub async fn setup_sui_instance() -> SuiInstance {
128    try_setup_sui_instance(None)
129        .await
130        .expect("Failed to start Sui container.")
131}
132
133/// Tries to start a Sui container with the requested epoch duration.
134///
135/// An explicit duration keeps suites with epoch scoped transactions within one
136/// epoch. Passing [`None`] retains the Sui local network default.
137pub async fn try_setup_sui_instance(
138    epoch_duration: Option<Duration>,
139) -> anyhow::Result<SuiInstance> {
140    let rpc_host_port = pick_unused_port().context("find Sui RPC port")?;
141    let faucet_host_port = loop {
142        let port = pick_unused_port().context("find Sui faucet port")?;
143        if port != rpc_host_port {
144            break port;
145        }
146    };
147
148    let docker = client::docker_client_instance()
149        .await
150        .context("connect to Docker")?;
151
152    let sui_tools_tag = docker
153        .version()
154        .await
155        .ok()
156        .and_then(|version| version.arch)
157        .map(|arch| arch.to_ascii_lowercase())
158        .as_deref()
159        .map(|arch| match arch {
160            "arm64" | "aarch64" => SUI_TOOLS_TAG_ARM64,
161            _ => SUI_TOOLS_TAG_AMD64,
162        })
163        .unwrap_or_else(|| {
164            if cfg!(target_arch = "aarch64") {
165                SUI_TOOLS_TAG_ARM64
166            } else {
167                SUI_TOOLS_TAG_AMD64
168            }
169        });
170
171    let sui_request = sui_request(sui_tools_tag, epoch_duration)?
172        .with_mapped_port(rpc_host_port, SUI_RPC_PORT)
173        .with_mapped_port(faucet_host_port, SUI_FAUCET_PORT);
174
175    let container = sui_request.start().await.context("start Sui container")?;
176    wait_for_sui_grpc(rpc_host_port).await?;
177
178    Ok(SuiInstance {
179        container,
180        rpc_port: rpc_host_port,
181        faucet_port: faucet_host_port,
182    })
183}
184
185/// Spins up a Redis container and returns its handle and mapped Redis port.
186pub async fn setup_redis_instance() -> (RedisContainer, u16) {
187    const MAX_ATTEMPTS: usize = 10;
188
189    for attempt in 1..=MAX_ATTEMPTS {
190        let host_port = pick_unused_port().expect("No free port for Redis.");
191
192        let redis_request = Redis::default()
193            .with_tag("7.4-alpine")
194            .with_env_var("REDIS_PASSWORD", "my_secret_password")
195            .with_mapped_port(host_port, ContainerPort::Tcp(6379));
196
197        match redis_request.start().await {
198            Ok(container) => {
199                let host_port = container
200                    .get_host_port_ipv4(6379)
201                    .await
202                    .expect("Failed to get Redis port.");
203                return (container, host_port);
204            }
205            Err(err) => {
206                let msg = err.to_string();
207                if msg.contains("address already in use")
208                    || msg.contains("failed to bind host port")
209                    || msg.contains("bind host port")
210                {
211                    eprintln!(
212                        "setup_redis_instance: port bind collision on attempt {attempt}/{MAX_ATTEMPTS}: {msg}"
213                    );
214                    continue;
215                }
216
217                panic!("Failed to start Redis container: {err}");
218            }
219        }
220    }
221
222    panic!("Failed to start Redis container after {MAX_ATTEMPTS} attempts");
223}
224
225#[cfg(test)]
226mod tests {
227    use {super::*, std::time::Duration, testcontainers::Image};
228
229    #[test]
230    fn sui_request_uses_configured_epoch_duration() {
231        let command = sui_request(SUI_TOOLS_TAG_AMD64, Some(Duration::from_secs(600)))
232            .unwrap()
233            .cmd()
234            .map(|argument| argument.into_owned())
235            .collect::<Vec<_>>();
236
237        assert!(command
238            .windows(2)
239            .any(|pair| pair == ["--epoch-duration-ms", "600000"]));
240    }
241
242    #[test]
243    fn sui_request_preserves_the_container_contract() {
244        let request = sui_request(SUI_TOOLS_TAG_AMD64, None).unwrap();
245        let command = request
246            .cmd()
247            .map(|argument| argument.into_owned())
248            .collect::<Vec<_>>();
249        let environment = request
250            .env_vars()
251            .map(|(name, value)| (name.into_owned(), value.into_owned()))
252            .collect::<Vec<_>>();
253
254        assert_eq!(command, vec!["start", "--force-regenesis", "--with-faucet"]);
255        assert_eq!(request.descriptor(), "mysten/sui-tools:testnet-v1.77.2");
256        assert_eq!(request.entrypoint(), Some("sui"));
257        assert_eq!(
258            request.image().expose_ports(),
259            &[SUI_RPC_PORT, SUI_FAUCET_PORT, SUI_GRAPHQL_PORT]
260        );
261        assert_eq!(
262            environment,
263            vec![("RUST_LOG".to_owned(), "warning,sui_node=info".to_owned())]
264        );
265        assert!(request.image().ready_conditions().is_empty());
266    }
267}