Skip to main content

sentry_options/
schema_fetch.rs

1//! Fetch schema snapshots from the repositories listed in `repos.json`.
2//!
3//! This is an explicit tooling API. The options runtime remains file-based and
4//! never fetches schemas as part of initialization or value reads.
5
6use 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    /// Optional repository pin retained for compatibility with `repos.json`.
37    /// Schema snapshots currently follow the repository's default branch.
38    #[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
57/// Fetch every configured repository's schemas into a new directory.
58pub 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    // Sort repo names for deterministic error reporting.
68    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
112/// Read `repos.json` and fetch its configured schemas.
113pub 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);
120/// should only take a couple seconds
121const 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    // Only the tip of the default branch is needed. Without --depth the
176    // blob-less clone still pulls full commit and tree history, which for
177    // getsentry is ~70MiB and ~400k objects per fetch.
178    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
201/// Bounds each git call with coreutils `timeout`, which macOS doesn't ship, so the
202/// bound only applies on Linux (CI/CD). Elsewhere git runs unbounded.
203fn 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}