Skip to main content

spin_templates/
source.rs

1use std::path::{Path, PathBuf};
2
3use anyhow::{Context, anyhow};
4use itertools::Itertools;
5use tempfile::{TempDir, tempdir};
6use tokio::process::Command;
7use url::Url;
8
9use crate::{directory::subdirectories, git::UnderstandGitResult};
10
11const TEMPLATE_SOURCE_DIR: &str = "templates";
12const TEMPLATE_VERSION_TAG_PREFIX: &str = "spin/templates/v";
13
14/// A source from which to install templates.
15#[derive(Debug)]
16pub enum TemplateSource {
17    /// Install from a Git repository at the specified URL. If a branch is
18    /// specified, templates are installed from that branch or tag; otherwise,
19    /// they are installed from HEAD.
20    ///
21    /// Templates much be in a `/templates` directory under the root of the
22    /// repository.
23    Git(GitTemplateSource),
24    /// Install from a directory in the file system.
25    ///
26    /// Templates much be in a `/templates` directory under the specified
27    /// root.
28    File(PathBuf),
29    /// Install from a remote tarball.
30    ///
31    /// Templates should be in a `/templates` directory under the root of the tarball.
32    /// The implementation also allows for there to be a single root directory containing
33    /// the `templates` directory - this makes it compatible with GitHub release tarballs.
34    RemoteTar(Url),
35}
36
37/// Settings for installing templates from a Git repository.
38#[derive(Debug)]
39pub struct GitTemplateSource {
40    /// The URL of the Git repository from which to install templates.
41    url: Url,
42    /// The branch or tag from which to install templates; inferred if omitted.
43    branch: Option<String>,
44    /// The version of the Spin client, used for branch inference.
45    // We have to pass this through because vergen is only on the root bin
46    spin_version: String,
47}
48
49impl TemplateSource {
50    /// Creates a `TemplateSource` referring to the specified Git repository
51    /// and branch.
52    pub fn try_from_git(
53        git_url: impl AsRef<str>,
54        branch: &Option<String>,
55        spin_version: &str,
56    ) -> anyhow::Result<Self> {
57        let url_str = git_url.as_ref();
58        let url =
59            Url::parse(url_str).with_context(|| format!("Failed to parse {url_str} as URL"))?;
60        Ok(Self::Git(GitTemplateSource {
61            url,
62            branch: branch.clone(),
63            spin_version: spin_version.to_owned(),
64        }))
65    }
66
67    pub(crate) fn to_install_record(&self) -> Option<crate::reader::RawInstalledFrom> {
68        match self {
69            Self::Git(g) => Some(crate::reader::RawInstalledFrom::Git {
70                git: g.url.to_string(),
71            }),
72            Self::File(p) => {
73                // Saving a relative path would be meaningless (but should never happen)
74                if p.is_absolute() {
75                    Some(crate::reader::RawInstalledFrom::File {
76                        dir: format!("{}", p.display()),
77                    })
78                } else {
79                    None
80                }
81            }
82            Self::RemoteTar(url) => Some(crate::reader::RawInstalledFrom::RemoteTar {
83                url: url.to_string(),
84            }),
85        }
86    }
87
88    // Sorry I know this is a bit ugly
89    /// For a Git source, resolves the tag to use as the source.
90    /// For other sources, returns None.
91    pub async fn resolved_tag(&self) -> Option<String> {
92        match self {
93            Self::Git(g) => version_matched_tag(g.url.as_str(), &g.spin_version).await,
94            _ => None,
95        }
96    }
97
98    pub(crate) fn as_git_url(&self) -> Option<&Url> {
99        match self {
100            TemplateSource::Git(git) => Some(&git.url),
101            _ => None,
102        }
103    }
104}
105
106pub(crate) struct LocalTemplateSource {
107    root: PathBuf,
108    _temp_dir: Option<TempDir>,
109}
110
111impl TemplateSource {
112    pub(crate) async fn get_local(&self) -> anyhow::Result<LocalTemplateSource> {
113        match self {
114            Self::Git(git_source) => clone_local(git_source).await,
115            Self::File(path) => check_local(path).await,
116            Self::RemoteTar(url) => download_untar_local(url).await,
117        }
118    }
119
120    pub(crate) fn requires_copy(&self) -> bool {
121        match self {
122            Self::Git { .. } => true,
123            Self::File(_) => false,
124            Self::RemoteTar(_) => true,
125        }
126    }
127}
128
129impl LocalTemplateSource {
130    pub async fn template_directories(&self) -> anyhow::Result<Vec<PathBuf>> {
131        let templates_root = self.root.join(TEMPLATE_SOURCE_DIR);
132        if templates_root.exists() {
133            subdirectories(&templates_root).with_context(|| {
134                format!("Failed to read contents of '{TEMPLATE_SOURCE_DIR}' directory")
135            })
136        } else {
137            Err(anyhow!(
138                "Template source {} does not contain a '{}' directory",
139                self.root.display(),
140                TEMPLATE_SOURCE_DIR
141            ))
142        }
143    }
144}
145
146async fn clone_local(git_source: &GitTemplateSource) -> anyhow::Result<LocalTemplateSource> {
147    let temp_dir = tempdir()?;
148    let path = temp_dir.path().to_owned();
149
150    let url_str = git_source.url.as_str();
151
152    let actual_branch = match &git_source.branch {
153        Some(b) => Some(b.clone()),
154        None => version_matched_tag(url_str, &git_source.spin_version).await,
155    };
156
157    let mut git = Command::new("git");
158    git.arg("clone");
159    git.arg("--depth").arg("1");
160
161    if let Some(b) = actual_branch {
162        git.arg("--branch").arg(b);
163    }
164
165    git.arg(url_str).arg(&path);
166
167    let clone_result = git.output().await.understand_git_result();
168    match clone_result {
169        Ok(_) => Ok(LocalTemplateSource {
170            root: path,
171            _temp_dir: Some(temp_dir),
172        }),
173        Err(e) => Err(anyhow!("Error cloning Git repo {}: {}", url_str, e)),
174    }
175}
176
177async fn version_matched_tag(url: &str, spin_version: &str) -> Option<String> {
178    let preferred_tag = version_preferred_tag(spin_version);
179
180    let mut git = Command::new("git");
181    git.arg("ls-remote");
182    git.arg("--exit-code");
183    git.arg(url);
184    git.arg(&preferred_tag);
185
186    match git.output().await.understand_git_result() {
187        Ok(_) => Some(preferred_tag),
188        Err(_) => None,
189    }
190}
191
192fn version_preferred_tag(text: &str) -> String {
193    let mm_version = match semver::Version::parse(text) {
194        Ok(version) => format!("{}.{}", version.major, version.minor),
195        Err(_) => text.to_owned(),
196    };
197    format!("{TEMPLATE_VERSION_TAG_PREFIX}{mm_version}")
198}
199
200async fn check_local(path: &Path) -> anyhow::Result<LocalTemplateSource> {
201    if path.exists() {
202        Ok(LocalTemplateSource {
203            root: path.to_owned(),
204            _temp_dir: None,
205        })
206    } else {
207        Err(anyhow!("Path not found: {}", path.display()))
208    }
209}
210
211/// Download a tarball to a temorary directory
212async fn download_untar_local(url: &Url) -> anyhow::Result<LocalTemplateSource> {
213    use bytes::buf::Buf;
214
215    let temp_dir = tempdir()?;
216    let path = temp_dir.path().to_owned();
217
218    let resp = reqwest::get(url.clone())
219        .await
220        .with_context(|| format!("Failed to download from {url}"))?;
221    let tar_content = resp
222        .bytes()
223        .await
224        .with_context(|| format!("Failed to download from {url}"))?;
225
226    let reader = flate2::read::GzDecoder::new(tar_content.reader());
227    let mut archive = tar::Archive::new(reader);
228    archive
229        .unpack(&path)
230        .context("Failed to unpack tar archive")?;
231
232    let templates_root = bypass_gh_added_root(path);
233
234    Ok(LocalTemplateSource {
235        root: templates_root,
236        _temp_dir: Some(temp_dir),
237    })
238}
239
240/// GitHub adds a prefix directory to release tarballs (e.g. spin-v3.0.0/...).
241/// We try to locate the repo root within the unpacked tarball.
242fn bypass_gh_added_root(unpack_dir: PathBuf) -> PathBuf {
243    // If the unpack dir directly contains a `templates` dir then we are done.
244    if has_templates_dir(&unpack_dir) {
245        return unpack_dir;
246    }
247
248    let Ok(dirs) = unpack_dir.read_dir() else {
249        // If we can't traverse the unpack directory then return it and
250        // let the top level try to make sense of it.
251        return unpack_dir;
252    };
253
254    // Is there a single directory at the root?  If not, we can't be in the GitHub situation:
255    // return the root of the unpacking. (The take(2) here is because we don't need to traverse
256    // the full list - we only care whether there is more than one.)
257    let Ok(dir) = dirs.filter_map(Result::ok).exactly_one() else {
258        return unpack_dir;
259    };
260
261    let candidate_repo_root = dir.path();
262    let Ok(mut candidate_repo_dirs) = candidate_repo_root.read_dir() else {
263        // Again, if it all goes awry, propose the base unpack directory.
264        return unpack_dir;
265    };
266    let has_templates_dir = candidate_repo_dirs.any(is_templates_dir);
267
268    if has_templates_dir {
269        candidate_repo_root
270    } else {
271        unpack_dir
272    }
273}
274
275fn has_templates_dir(path: &Path) -> bool {
276    let Ok(mut dirs) = path.read_dir() else {
277        return false;
278    };
279
280    dirs.any(is_templates_dir)
281}
282
283fn is_templates_dir(dir_entry: Result<std::fs::DirEntry, std::io::Error>) -> bool {
284    dir_entry.is_ok_and(|d| d.file_name() == TEMPLATE_SOURCE_DIR)
285}
286
287#[cfg(test)]
288mod test {
289    use super::*;
290
291    #[test]
292    fn preferred_tag_excludes_patch_version() {
293        assert_eq!("spin/templates/v1.2", version_preferred_tag("1.2.3"));
294    }
295
296    #[test]
297    fn preferred_tag_excludes_prerelease_and_build() {
298        assert_eq!(
299            "spin/templates/v1.2",
300            version_preferred_tag("1.2.3-preview.1")
301        );
302        assert_eq!(
303            "spin/templates/v1.2",
304            version_preferred_tag("1.2.3+build.0f74628")
305        );
306        assert_eq!(
307            "spin/templates/v1.2",
308            version_preferred_tag("1.2.3-alpha+0f74628")
309        );
310    }
311
312    #[test]
313    fn preferred_tag_defaults_sensibly_on_bad_semver() {
314        assert_eq!("spin/templates/v1.2", version_preferred_tag("1.2"));
315        assert_eq!("spin/templates/v1.2.3.4", version_preferred_tag("1.2.3.4"));
316        assert_eq!("spin/templates/vgarbage", version_preferred_tag("garbage"));
317    }
318}