nexus_sdk/test_utils/
containers.rs1use {
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
20pub 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
35pub struct SuiInstance {
37 pub container: SuiContainer,
39 pub rpc_port: u16,
41 pub faucet_port: u16,
43}
44
45pub 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
119pub async fn setup_sui_instance() -> SuiInstance {
128 try_setup_sui_instance(None)
129 .await
130 .expect("Failed to start Sui container.")
131}
132
133pub 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
185pub 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}