Skip to main content

spin_templates/
source.rs

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