Skip to main content

spin_environments/environment/
catalogue.rs

1use std::time::{Duration, SystemTime};
2
3const SPIN_ENV_REPO: &str = "https://github.com/spinframework/spin-environments";
4const ENVS_DIR_IN_REPO: &str = "envs";
5
6pub struct Catalogue {
7    git_root: PathBuf,
8    envs_root: PathBuf,
9}
10
11static CATALOGUE_UPDATE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
12const JUST_IN_TIME_UPDATE_TIMEOUT: Duration = Duration::from_secs(2);
13const RECENCY_WINDOW: Duration = Duration::from_hours(1);
14
15impl Catalogue {
16    pub fn try_default() -> anyhow::Result<Self> {
17        let root = dirs::cache_dir()
18            .ok_or(anyhow::anyhow!("No system cache directory"))?
19            .join("spin")
20            .join("environments");
21        Ok(Self::new(root))
22    }
23
24    async fn is_recent(&self) -> bool {
25        let Some(last_update_file) = self.last_update_file() else {
26            return false;
27        };
28
29        match tokio::fs::read_to_string(&last_update_file).await {
30            Err(_) => false,
31            Ok(text) => {
32                let Ok(time_since_epoch) = text.parse() else {
33                    return false;
34                };
35                let now = SystemTime::now();
36                let Some(last) =
37                    SystemTime::UNIX_EPOCH.checked_add(Duration::from_secs(time_since_epoch))
38                else {
39                    return false;
40                };
41                let Ok(diff) = now.duration_since(last) else {
42                    return false;
43                };
44                diff < RECENCY_WINDOW
45            }
46        }
47    }
48
49    fn last_update_file(&self) -> Option<PathBuf> {
50        let parent_dir = self.git_root.parent()?;
51        let last_update_file = parent_dir.join("environments-last-update.txt");
52        Some(last_update_file)
53    }
54
55    async fn save_last_update_time(&self) {
56        let Some(last_update_file) = self.last_update_file() else {
57            return;
58        };
59        let Ok(last_update_dur) = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH) else {
60            return;
61        };
62        let last_update_text = last_update_dur.as_secs().to_string();
63        _ = tokio::fs::write(&last_update_file, last_update_text).await;
64    }
65
66    fn new(git_root: PathBuf) -> Self {
67        Self {
68            git_root: git_root.clone(),
69            envs_root: git_root.join(ENVS_DIR_IN_REPO),
70        }
71    }
72
73    /// Updates if we have not updated recently, ignoring
74    /// failures. The scenario here is unversioned environments,
75    /// where we don't want them to get stale, but don't want
76    /// to slow the user down with frequent checks or long stalls
77    /// while airplane wifi tries to reach the repo, only to
78    /// find out there's nothing to go...
79    async fn try_update(&self) {
80        if self.is_recent().await {
81            return;
82        }
83
84        _ = tokio::time::timeout(JUST_IN_TIME_UPDATE_TIMEOUT, self.update()).await;
85    }
86
87    pub async fn update(&self) -> anyhow::Result<()> {
88        // We don't want two git pulls running concurrently
89        let _guard = CATALOGUE_UPDATE_LOCK.lock();
90
91        let url = Url::parse(SPIN_ENV_REPO)?;
92        let git_source = GitSource::new(&url, None, &self.git_root);
93        if self.git_root.exists() {
94            git_source.pull().await?;
95        } else {
96            tokio::fs::create_dir_all(&self.git_root).await?;
97            git_source.clone_repo().await?;
98        }
99        self.save_last_update_time().await;
100        Ok(())
101    }
102
103    /// This requires `env_id` to be normalised to the `ns@version` form
104    pub async fn get(&self, env_id: &str) -> anyhow::Result<Option<EnvironmentDefinition>> {
105        // We don't want to keep returning old versions of a mutable env
106        // until we get an unrelated reason to update.
107        if is_unversioned(env_id) {
108            // update if we can, in case the unversioned env has changed
109            self.try_update().await;
110        }
111
112        // We add (redundant) directories to avoid having a single flat
113        // namespace that becomes unmanageable.
114        //
115        // ENV_ROOT
116        // |-- foo
117        // |  |-- foo@1.2.toml
118        // |  |-- foo@1.6.toml
119        // |-- bar
120        // |  |-- bar.toml
121        let ns = sans_version(env_id);
122        // TODO: I suppose we should stop people making up path injectiony kind of names
123        // although I am unconvinced such a thing would get you anything you don't have already
124        let path = self.envs_root.join(ns).join(format!("{env_id}.toml"));
125        if !path.exists() {
126            return Ok(None);
127        }
128        let toml_text = tokio::fs::read_to_string(&path)
129            .await
130            .with_context(|| format!("Environment '{env_id}' not found"))?;
131        let env_def = toml::from_str(&toml_text)
132            .with_context(|| format!("Environment '{env_id}' definition is invalid format"))?;
133        Ok(Some(env_def))
134    }
135
136    pub async fn list(&self) -> Vec<String> {
137        let mut envs = vec![];
138
139        let Ok(read_dir) = self.envs_root.read_dir() else {
140            return Default::default();
141        };
142
143        for ns_entry in read_dir {
144            let Ok(ns_entry) = ns_entry else {
145                continue; // avoid blocking the list for one error
146            };
147            if ns_entry.path().is_dir() {
148                let Ok(ns_reader) = ns_entry.path().read_dir() else {
149                    continue;
150                };
151                for env_entry in ns_reader {
152                    let Ok(env_entry) = env_entry else {
153                        continue;
154                    };
155                    if env_entry.path().is_file()
156                        && let Some(env_name) =
157                            env_entry.path().file_stem().and_then(|s| s.to_str())
158                    {
159                        envs.push(env_name.to_owned());
160                    }
161                }
162            }
163        }
164
165        envs
166    }
167}
168
169fn sans_version(id: &str) -> &str {
170    match id.rsplit_once('@') {
171        None => id,
172        Some((stem, _)) => stem,
173    }
174}
175
176fn is_unversioned(id: &str) -> bool {
177    id.rsplit_once('@').is_none()
178}
179
180// From here on this is a copy of plugins/git.rs, which itself was
181// recycled from templates...
182
183use anyhow::{Context, Result};
184use std::io::ErrorKind;
185use std::path::{Path, PathBuf};
186use tokio::process::Command;
187use url::Url;
188
189use crate::environment::definition::EnvironmentDefinition;
190
191const DEFAULT_BRANCH: &str = "main";
192
193/// Enables cloning and fetching the latest of a git repository to a local
194/// directory.
195pub struct GitSource {
196    /// Address to remote git repository.
197    source_url: Url,
198    /// Branch to clone/fetch.
199    branch: String,
200    /// Destination to clone repository into.
201    git_root: PathBuf,
202}
203
204impl GitSource {
205    /// Creates a new git source
206    pub fn new(source_url: &Url, branch: Option<String>, git_root: impl AsRef<Path>) -> GitSource {
207        Self {
208            source_url: source_url.clone(),
209            branch: branch.unwrap_or_else(|| DEFAULT_BRANCH.to_owned()),
210            git_root: git_root.as_ref().to_owned(),
211        }
212    }
213
214    /// Clones a contents of a git repository to a local directory
215    pub async fn clone_repo(&self) -> Result<()> {
216        let mut git = Command::new("git");
217        git.args([
218            "clone",
219            self.source_url.as_ref(),
220            "--branch",
221            &self.branch,
222            "--single-branch",
223        ])
224        .arg(&self.git_root);
225        let clone_result = git.output().await.understand_git_result();
226        if let Err(e) = clone_result {
227            anyhow::bail!("Error cloning Git repo {}: {}", self.source_url, e)
228        }
229        Ok(())
230    }
231
232    /// Fetches the latest changes from the source repository
233    pub async fn pull(&self) -> Result<()> {
234        let mut git = Command::new("git");
235        git.arg("-C").arg(&self.git_root).arg("pull");
236        let pull_result = git.output().await.understand_git_result();
237        if let Err(e) = pull_result {
238            anyhow::bail!(
239                "Error updating Git repo at {}: {}",
240                self.git_root.display(),
241                e
242            )
243        }
244        Ok(())
245    }
246}
247
248// TODO: the following and templates/git.rs are duplicates
249
250pub(crate) enum GitError {
251    ProgramFailed(Vec<u8>),
252    ProgramNotFound,
253    Other(anyhow::Error),
254}
255
256impl std::fmt::Display for GitError {
257    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
258        match self {
259            Self::ProgramNotFound => f.write_str("`git` command not found - is git installed?"),
260            Self::Other(e) => e.fmt(f),
261            Self::ProgramFailed(stderr) => match std::str::from_utf8(stderr) {
262                Ok(s) => f.write_str(s),
263                Err(_) => f.write_str("(cannot get error)"),
264            },
265        }
266    }
267}
268
269pub(crate) trait UnderstandGitResult {
270    fn understand_git_result(self) -> Result<Vec<u8>, GitError>;
271}
272
273impl UnderstandGitResult for Result<std::process::Output, std::io::Error> {
274    fn understand_git_result(self) -> Result<Vec<u8>, GitError> {
275        match self {
276            Ok(output) => {
277                if output.status.success() {
278                    Ok(output.stdout)
279                } else {
280                    Err(GitError::ProgramFailed(output.stderr))
281                }
282            }
283            Err(e) => match e.kind() {
284                // TODO: consider cases like insufficient permission?
285                ErrorKind::NotFound => Err(GitError::ProgramNotFound),
286                _ => {
287                    let err = anyhow::Error::from(e).context("Failed to run `git` command");
288                    Err(GitError::Other(err))
289                }
290            },
291        }
292    }
293}