1use crate::target::S3Target;
11use std::path::PathBuf;
12use std::process::{Child, Command, Stdio};
13use std::time::{Duration, Instant};
14
15struct ReplicaSpec {
17 http_port: u16,
18 grpc_port: u16,
19 env: Vec<(String, String)>,
20}
21
22struct Replica {
23 spec: ReplicaSpec,
24 child: Option<Child>,
25}
26
27pub struct ReplicaSet {
29 gonzalod: PathBuf,
30 replicas: Vec<Replica>,
31}
32
33impl ReplicaSet {
34 pub async fn spawn(n: usize, target: &S3Target) -> Result<Self, String> {
36 assert!(n >= 1, "need at least one replica");
37 let gonzalod = locate_gonzalod()?;
38 let mut set = ReplicaSet {
39 gonzalod,
40 replicas: Vec::with_capacity(n),
41 };
42 for i in 0..n {
43 let http_port = 18080 + i as u16;
46 let grpc_port = 18150 + i as u16;
47 let env = replica_env(target, http_port, grpc_port);
48 let spec = ReplicaSpec {
49 http_port,
50 grpc_port,
51 env,
52 };
53 let child = set.launch(&spec)?;
54 set.replicas.push(Replica {
55 spec,
56 child: Some(child),
57 });
58 }
59 for i in 0..n {
60 set.wait_ready(i).await?;
61 }
62 Ok(set)
63 }
64
65 pub fn base_urls(&self) -> Vec<String> {
67 self.replicas
68 .iter()
69 .map(|r| format!("http://127.0.0.1:{}", r.spec.http_port))
70 .collect()
71 }
72
73 pub fn len(&self) -> usize {
75 self.replicas.len()
76 }
77
78 pub fn is_empty(&self) -> bool {
80 self.replicas.is_empty()
81 }
82
83 pub fn kill(&mut self, idx: usize) {
85 if let Some(mut child) = self.replicas[idx].child.take() {
86 let _ = child.kill();
87 let _ = child.wait();
88 }
89 }
90
91 pub async fn respawn(&mut self, idx: usize) -> Result<(), String> {
93 if self.replicas[idx].child.is_none() {
94 let spec = ReplicaSpec {
95 http_port: self.replicas[idx].spec.http_port,
96 grpc_port: self.replicas[idx].spec.grpc_port,
97 env: self.replicas[idx].spec.env.clone(),
98 };
99 let child = self.launch(&spec)?;
100 self.replicas[idx].child = Some(child);
101 }
102 self.wait_ready(idx).await
103 }
104
105 fn launch(&self, spec: &ReplicaSpec) -> Result<Child, String> {
106 Command::new(&self.gonzalod)
107 .env_clear()
108 .envs(spec.env.iter().map(|(k, v)| (k.as_str(), v.as_str())))
109 .env("PATH", std::env::var("PATH").unwrap_or_default())
111 .stdout(Stdio::null())
112 .stderr(Stdio::inherit())
113 .spawn()
114 .map_err(|e| format!("spawn gonzalod ({}): {e}", self.gonzalod.display()))
115 }
116
117 async fn wait_ready(&self, idx: usize) -> Result<(), String> {
119 let url = format!(
120 "http://127.0.0.1:{}/readyz",
121 self.replicas[idx].spec.http_port
122 );
123 let client = reqwest::Client::new();
124 let deadline = Instant::now() + Duration::from_secs(20);
125 loop {
126 if let Ok(resp) = client
127 .get(&url)
128 .timeout(Duration::from_secs(2))
129 .send()
130 .await
131 && resp.status().is_success()
132 {
133 return Ok(());
134 }
135 if Instant::now() >= deadline {
136 return Err(format!("replica {idx} never became ready at {url}"));
137 }
138 tokio::time::sleep(Duration::from_millis(150)).await;
139 }
140 }
141}
142
143impl Drop for ReplicaSet {
144 fn drop(&mut self) {
145 for r in &mut self.replicas {
146 if let Some(mut child) = r.child.take() {
147 let _ = child.kill();
148 let _ = child.wait();
149 }
150 }
151 }
152}
153
154fn replica_env(target: &S3Target, http_port: u16, grpc_port: u16) -> Vec<(String, String)> {
156 let mut env = vec![
157 ("GONZALO_STORE".into(), "s3".into()),
158 ("GONZALO_S3_BUCKET".into(), target.bucket.clone()),
159 ("GONZALO_S3_ENDPOINT".into(), target.endpoint.clone()),
160 ("AWS_ACCESS_KEY_ID".into(), target.access_key.clone()),
161 ("AWS_SECRET_ACCESS_KEY".into(), target.secret_key.clone()),
162 ("GONZALO_HTTP_ADDR".into(), format!("127.0.0.1:{http_port}")),
163 ("GONZALO_GRPC_ADDR".into(), format!("127.0.0.1:{grpc_port}")),
164 ];
165 env.push((
166 "GONZALO_S3_REGION".into(),
167 target.region.clone().unwrap_or_else(|| "us-east-1".into()),
168 ));
169 env
170}
171
172fn locate_gonzalod() -> Result<PathBuf, String> {
175 if let Some(p) = std::env::var_os("GONZALO_SOAK_GONZALOD_BIN") {
176 let p = PathBuf::from(p);
177 return if p.exists() {
178 Ok(p)
179 } else {
180 Err(format!(
181 "GONZALO_SOAK_GONZALOD_BIN set but not found: {}",
182 p.display()
183 ))
184 };
185 }
186 let exe = std::env::current_exe().map_err(|e| format!("current_exe: {e}"))?;
188 let mut dir = exe.parent().ok_or("exe has no parent")?.to_path_buf();
189 if dir.file_name().is_some_and(|n| n == "deps") {
190 dir = dir.parent().ok_or("deps has no parent")?.to_path_buf();
191 }
192 let bin = dir.join("gonzalod");
193 if bin.exists() {
194 Ok(bin)
195 } else {
196 Err(format!(
197 "gonzalod binary not found at {} — build it first: `cargo build --bin gonzalod` \
198 (or set GONZALO_SOAK_GONZALOD_BIN)",
199 bin.display()
200 ))
201 }
202}