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) -> 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; };
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
176use 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
189pub struct GitSource {
192 source_url: Url,
194 branch: String,
196 git_root: PathBuf,
198}
199
200impl GitSource {
201 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 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 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
244pub(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 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}