1use serde::Deserialize;
7use std::collections::HashMap;
8use std::fs;
9use std::path::Path;
10use std::process::Command;
11use std::thread;
12use std::time::{Duration, Instant};
13use thiserror::Error;
14
15#[derive(Debug, Error)]
16pub enum SchemaFetchError {
17 #[error("{0}")]
18 Validation(String),
19
20 #[error("I/O error: {0}")]
21 Io(#[from] std::io::Error),
22
23 #[error("JSON error: {0}")]
24 Json(#[from] serde_json::Error),
25
26 #[error("Git command failed: {0}")]
27 Git(String),
28}
29
30pub type SchemaFetchResult<T> = std::result::Result<T, SchemaFetchError>;
31
32#[derive(Debug, Deserialize)]
33#[serde(deny_unknown_fields)]
34pub struct RepoSchemaConfig {
35 pub url: String,
36 #[allow(dead_code)]
39 #[serde(default)]
40 pub sha: Option<String>,
41 pub path: String,
42}
43
44#[derive(Debug, Deserialize)]
45#[serde(deny_unknown_fields)]
46pub struct RepoSchemaConfigs {
47 pub repos: HashMap<String, RepoSchemaConfig>,
48}
49
50impl RepoSchemaConfigs {
51 pub fn from_file(path: &Path) -> SchemaFetchResult<Self> {
52 let content = fs::read_to_string(path)?;
53 Ok(serde_json::from_str(&content)?)
54 }
55}
56
57pub fn fetch_schemas(config: &RepoSchemaConfigs, out_dir: &Path) -> SchemaFetchResult<()> {
59 if out_dir.exists() {
60 return Err(SchemaFetchError::Validation(format!(
61 "Output directory already exists: {}",
62 out_dir.display()
63 )));
64 }
65 fs::create_dir_all(out_dir)?;
66
67 let mut repo_names: Vec<_> = config.repos.keys().collect();
69 repo_names.sort();
70
71 let results: Vec<_> = thread::scope(|scope| {
72 let handles: Vec<_> = repo_names
73 .iter()
74 .map(|repo_name| {
75 let source = &config.repos[*repo_name];
76 let name = repo_name.as_str();
77 (
78 name,
79 scope.spawn(move || fetch_repo_schemas(name, source, out_dir)),
80 )
81 })
82 .collect();
83
84 handles
85 .into_iter()
86 .map(|(name, handle)| {
87 let result = match handle.join() {
88 Ok(Ok(())) => Ok(()),
89 Ok(Err(error)) => Err(error.to_string()),
90 Err(_) => Err(format!("{}: thread panicked", name)),
91 };
92 (name, result)
93 })
94 .collect::<Vec<_>>()
95 });
96
97 let mut errors = Vec::new();
98 for (_repo_name, result) in results {
99 match result {
100 Ok(()) => {}
101 Err(error) => errors.push(error),
102 }
103 }
104
105 if errors.is_empty() {
106 Ok(())
107 } else {
108 Err(SchemaFetchError::Git(errors.join("\n")))
109 }
110}
111
112pub fn fetch_schemas_from_file(config_path: &Path, out_dir: &Path) -> SchemaFetchResult<()> {
114 let config = RepoSchemaConfigs::from_file(config_path)?;
115 fetch_schemas(&config, out_dir)
116}
117
118const MAX_RETRIES: u32 = 3;
119const INITIAL_RETRY_DELAY: Duration = Duration::from_secs(3);
120const GIT_TIMEOUT_SECS: u64 = 30;
122
123fn fetch_repo_schemas(
124 repo_name: &str,
125 source: &RepoSchemaConfig,
126 out_dir: &Path,
127) -> SchemaFetchResult<()> {
128 let mut delay = INITIAL_RETRY_DELAY;
129 let mut last_error = None;
130
131 for attempt in 0..=MAX_RETRIES {
132 if attempt > 0 {
133 thread::sleep(delay);
134 delay *= 2;
135 }
136
137 let started = Instant::now();
138 match try_fetch_repo_schemas(repo_name, source, out_dir) {
139 Ok(()) => {
140 tracing::info!(
141 repo = repo_name,
142 attempt,
143 elapsed_ms = started.elapsed().as_millis() as u64,
144 "Fetched schemas"
145 );
146 return Ok(());
147 }
148 Err(error) => {
149 tracing::warn!(
150 repo = repo_name,
151 attempt,
152 elapsed_ms = started.elapsed().as_millis() as u64,
153 error = %error,
154 "Schema fetch attempt failed"
155 );
156 last_error = Some(error);
157 }
158 }
159 }
160
161 Err(last_error.expect("at least one fetch attempt must run"))
162}
163
164fn try_fetch_repo_schemas(
165 repo_name: &str,
166 source: &RepoSchemaConfig,
167 out_dir: &Path,
168) -> SchemaFetchResult<()> {
169 let temp_dir = tempfile::tempdir()?;
170 let repo_path = temp_dir.path().join("repo");
171 let repo_str = repo_path.to_str().ok_or_else(|| {
172 SchemaFetchError::Validation(format!("{}: temp path contains invalid UTF-8", repo_name))
173 })?;
174
175 git(
179 &[
180 "clone",
181 "--filter=blob:none",
182 "--sparse",
183 "--depth=1",
184 "--single-branch",
185 "--no-tags",
186 &source.url,
187 repo_str,
188 ],
189 None,
190 repo_name,
191 )?;
192 git(
193 &["sparse-checkout", "set", &source.path],
194 Some(&repo_path),
195 repo_name,
196 )?;
197
198 copy_schemas(&repo_path.join(&source.path), out_dir)
199}
200
201fn git(args: &[&str], cwd: Option<&Path>, repo_name: &str) -> SchemaFetchResult<()> {
204 let mut command = if cfg!(target_os = "linux") {
205 let mut command = Command::new("timeout");
206 command.arg(GIT_TIMEOUT_SECS.to_string()).arg("git");
207 command
208 } else {
209 Command::new("git")
210 };
211 command.args(args);
212 if let Some(directory) = cwd {
213 command.current_dir(directory);
214 }
215 let output = command.output()?;
216 if output.status.success() {
217 Ok(())
218 } else if output.status.code() == Some(124) {
219 Err(SchemaFetchError::Git(format!(
220 "{}: timed out after {}s",
221 repo_name, GIT_TIMEOUT_SECS
222 )))
223 } else {
224 Err(SchemaFetchError::Git(format!(
225 "{}: {}",
226 repo_name,
227 String::from_utf8_lossy(&output.stderr).trim()
228 )))
229 }
230}
231
232fn copy_schemas(src: &Path, dest: &Path) -> SchemaFetchResult<()> {
233 if !src.exists() {
234 return Err(SchemaFetchError::Validation(format!(
235 "Schemas path does not exist: {}",
236 src.display()
237 )));
238 }
239
240 for entry in fs::read_dir(src)? {
241 let entry = entry?;
242 if entry.file_type()?.is_dir() {
243 let namespace = entry.file_name();
244 let src_schema = entry.path().join("schema.json");
245 if src_schema.exists() {
246 let dest_namespace = dest.join(&namespace);
247 fs::create_dir_all(&dest_namespace)?;
248 fs::copy(src_schema, dest_namespace.join("schema.json"))?;
249 }
250 }
251 }
252 Ok(())
253}
254
255#[cfg(test)]
256mod tests {
257 use super::*;
258 use tempfile::TempDir;
259
260 #[test]
261 fn test_from_file_valid_json() {
262 let dir = TempDir::new().unwrap();
263 let path = dir.path().join("repos.json");
264 fs::write(
265 &path,
266 r#"{
267 "repos": {
268 "sentry": {
269 "url": "https://github.com/getsentry/sentry",
270 "sha": "abc123",
271 "path": "schemas/"
272 }
273 }
274 }"#,
275 )
276 .unwrap();
277
278 let config = RepoSchemaConfigs::from_file(&path).unwrap();
279 assert_eq!(config.repos.len(), 1);
280 let sentry = config.repos.get("sentry").unwrap();
281 assert_eq!(sentry.url, "https://github.com/getsentry/sentry");
282 assert_eq!(sentry.sha, Some("abc123".to_string()));
283 assert_eq!(sentry.path, "schemas/");
284 }
285
286 #[test]
287 fn test_from_file_rejects_unknown_fields() {
288 let dir = TempDir::new().unwrap();
289 let path = dir.path().join("repos.json");
290 fs::write(
291 &path,
292 r#"{
293 "repos": {
294 "sentry": {
295 "url": "https://github.com/getsentry/sentry",
296 "path": "schemas/",
297 "unknown_field": true
298 }
299 }
300 }"#,
301 )
302 .unwrap();
303
304 let error = RepoSchemaConfigs::from_file(&path).unwrap_err();
305 assert!(error.to_string().contains("unknown field `unknown_field`"));
306 }
307
308 #[test]
309 fn test_copy_schemas() {
310 let src_dir = TempDir::new().unwrap();
311 let dest_dir = TempDir::new().unwrap();
312 let namespace = src_dir.path().join("relay");
313 fs::create_dir_all(&namespace).unwrap();
314 fs::write(namespace.join("schema.json"), r#"{"version": "1.0"}"#).unwrap();
315
316 copy_schemas(src_dir.path(), dest_dir.path()).unwrap();
317
318 assert!(dest_dir.path().join("relay/schema.json").exists());
319 }
320
321 #[test]
322 fn test_fetch_schemas_errors_if_output_exists() {
323 let out_dir = TempDir::new().unwrap();
324 let config = RepoSchemaConfigs {
325 repos: HashMap::new(),
326 };
327
328 let error = fetch_schemas(&config, out_dir.path()).unwrap_err();
329 assert!(
330 error
331 .to_string()
332 .contains("Output directory already exists")
333 );
334 }
335}