spin_environments/environment/
catalogue.rs1use 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 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 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 pub async fn get(&self, env_id: &str) -> anyhow::Result<Option<EnvironmentDefinition>> {
105 if is_unversioned(env_id) {
108 self.try_update().await;
110 }
111
112 let ns = sans_version(env_id);
122 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; };
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
180use 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
193pub struct GitSource {
196 source_url: Url,
198 branch: String,
200 git_root: PathBuf,
202}
203
204impl GitSource {
205 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 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 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
248pub(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 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}