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) -> anyhow::Result<Vec<String>> {
137        let mut envs = vec![];
138
139        for ns_entry in self.envs_root.read_dir()? {
140            let Ok(ns_entry) = ns_entry else {
141                continue; // avoid blocking the list for one error
142            };
143            if ns_entry.path().is_dir() {
144                let Ok(ns_reader) = ns_entry.path().read_dir() else {
145                    continue;
146                };
147                for env_entry in ns_reader {
148                    let Ok(env_entry) = env_entry else {
149                        continue;
150                    };
151                    if env_entry.path().is_file()
152                        && let Some(env_name) =
153                            env_entry.path().file_stem().and_then(|s| s.to_str())
154                    {
155                        envs.push(env_name.to_owned());
156                    }
157                }
158            }
159        }
160
161        Ok(envs)
162    }
163}
164
165fn sans_version(id: &str) -> &str {
166    match id.rsplit_once('@') {
167        None => id,
168        Some((stem, _)) => stem,
169    }
170}
171
172fn is_unversioned(id: &str) -> bool {
173    id.rsplit_once('@').is_none()
174}
175
176// From here on this is a copy of plugins/git.rs, which itself was
177// recycled from templates...
178
179use anyhow::{Context, Result};
180use std::io::ErrorKind;
181use std::path::{Path, PathBuf};
182use tokio::process::Command;
183use url::Url;
184
185use crate::environment::definition::EnvironmentDefinition;
186
187const DEFAULT_BRANCH: &str = "main";
188
189/// Enables cloning and fetching the latest of a git repository to a local
190/// directory.
191pub struct GitSource {
192    /// Address to remote git repository.
193    source_url: Url,
194    /// Branch to clone/fetch.
195    branch: String,
196    /// Destination to clone repository into.
197    git_root: PathBuf,
198}
199
200impl GitSource {
201    /// Creates a new git source
202    pub fn new(source_url: &Url, branch: Option<String>, git_root: impl AsRef<Path>) -> GitSource {
203        Self {
204            source_url: source_url.clone(),
205            branch: branch.unwrap_or_else(|| DEFAULT_BRANCH.to_owned()),
206            git_root: git_root.as_ref().to_owned(),
207        }
208    }
209
210    /// Clones a contents of a git repository to a local directory
211    pub async fn clone_repo(&self) -> Result<()> {
212        let mut git = Command::new("git");
213        git.args([
214            "clone",
215            self.source_url.as_ref(),
216            "--branch",
217            &self.branch,
218            "--single-branch",
219        ])
220        .arg(&self.git_root);
221        let clone_result = git.output().await.understand_git_result();
222        if let Err(e) = clone_result {
223            anyhow::bail!("Error cloning Git repo {}: {}", self.source_url, e)
224        }
225        Ok(())
226    }
227
228    /// Fetches the latest changes from the source repository
229    pub async fn pull(&self) -> Result<()> {
230        let mut git = Command::new("git");
231        git.arg("-C").arg(&self.git_root).arg("pull");
232        let pull_result = git.output().await.understand_git_result();
233        if let Err(e) = pull_result {
234            anyhow::bail!(
235                "Error updating Git repo at {}: {}",
236                self.git_root.display(),
237                e
238            )
239        }
240        Ok(())
241    }
242}
243
244// TODO: the following and templates/git.rs are duplicates
245
246pub(crate) enum GitError {
247    ProgramFailed(Vec<u8>),
248    ProgramNotFound,
249    Other(anyhow::Error),
250}
251
252impl std::fmt::Display for GitError {
253    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
254        match self {
255            Self::ProgramNotFound => f.write_str("`git` command not found - is git installed?"),
256            Self::Other(e) => e.fmt(f),
257            Self::ProgramFailed(stderr) => match std::str::from_utf8(stderr) {
258                Ok(s) => f.write_str(s),
259                Err(_) => f.write_str("(cannot get error)"),
260            },
261        }
262    }
263}
264
265pub(crate) trait UnderstandGitResult {
266    fn understand_git_result(self) -> Result<Vec<u8>, GitError>;
267}
268
269impl UnderstandGitResult for Result<std::process::Output, std::io::Error> {
270    fn understand_git_result(self) -> Result<Vec<u8>, GitError> {
271        match self {
272            Ok(output) => {
273                if output.status.success() {
274                    Ok(output.stdout)
275                } else {
276                    Err(GitError::ProgramFailed(output.stderr))
277                }
278            }
279            Err(e) => match e.kind() {
280                // TODO: consider cases like insufficient permission?
281                ErrorKind::NotFound => Err(GitError::ProgramNotFound),
282                _ => {
283                    let err = anyhow::Error::from(e).context("Failed to run `git` command");
284                    Err(GitError::Other(err))
285                }
286            },
287        }
288    }
289}