Skip to content

Commit 6e9883e

Browse files
authored
Merge pull request #2957 from itowlson/templates-install-from-remote-tar
Install templates from remote tarball
2 parents 1f08962 + a6c2f94 commit 6e9883e

File tree

6 files changed

+123
-4
lines changed

6 files changed

+123
-4
lines changed

Cargo.lock

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/templates/Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@ edition = { workspace = true }
66

77
[dependencies]
88
anyhow = { workspace = true }
9+
bytes = { workspace = true }
910
dialoguer = "0.11"
1011
fs_extra = "1"
1112
heck = "0.5"
13+
flate2 = "1"
1214
indexmap = { version = "2", features = ["serde"] }
1315
itertools = { workspace = true }
1416
lazy_static = "1"
@@ -18,10 +20,12 @@ liquid-derive = "0.26"
1820
path-absolutize = "3"
1921
pathdiff = "0.2"
2022
regex = { workspace = true }
23+
reqwest = { workspace = true }
2124
semver = "1"
2225
serde = { workspace = true }
2326
spin-common = { path = "../common" }
2427
spin-manifest = { path = "../manifest" }
28+
tar = "0.4"
2529
tempfile = { workspace = true }
2630
tokio = { workspace = true, features = ["fs", "process", "rt", "macros"] }
2731
toml = { workspace = true }

crates/templates/src/reader.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ pub(crate) fn parse_manifest_toml(text: impl AsRef<str>) -> anyhow::Result<RawTe
118118
pub(crate) enum RawInstalledFrom {
119119
Git { git: String },
120120
File { dir: String },
121+
RemoteTar { url: String },
121122
}
122123

123124
pub(crate) fn parse_installed_from(text: impl AsRef<str>) -> Option<RawInstalledFrom> {

crates/templates/src/source.rs

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,12 @@ pub enum TemplateSource {
2525
/// Templates much be in a `/templates` directory under the specified
2626
/// root.
2727
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),
2834
}
2935

3036
/// Settings for installing templates from a Git repository.
@@ -72,6 +78,9 @@ impl TemplateSource {
7278
None
7379
}
7480
}
81+
Self::RemoteTar(url) => Some(crate::reader::RawInstalledFrom::RemoteTar {
82+
url: url.to_string(),
83+
}),
7584
}
7685
}
7786

@@ -96,13 +105,15 @@ impl TemplateSource {
96105
match self {
97106
Self::Git(git_source) => clone_local(git_source).await,
98107
Self::File(path) => check_local(path).await,
108+
Self::RemoteTar(url) => download_untar_local(url).await,
99109
}
100110
}
101111

102112
pub(crate) fn requires_copy(&self) -> bool {
103113
match self {
104114
Self::Git { .. } => true,
105115
Self::File(_) => false,
116+
Self::RemoteTar(_) => true,
106117
}
107118
}
108119
}
@@ -192,6 +203,84 @@ async fn check_local(path: &Path) -> anyhow::Result<LocalTemplateSource> {
192203
}
193204
}
194205

206+
/// Download a tarball to a temorary directory
207+
async fn download_untar_local(url: &Url) -> anyhow::Result<LocalTemplateSource> {
208+
use bytes::buf::Buf;
209+
210+
let temp_dir = tempdir()?;
211+
let path = temp_dir.path().to_owned();
212+
213+
let resp = reqwest::get(url.clone())
214+
.await
215+
.with_context(|| format!("Failed to download from {url}"))?;
216+
let tar_content = resp
217+
.bytes()
218+
.await
219+
.with_context(|| format!("Failed to download from {url}"))?;
220+
221+
let reader = flate2::read::GzDecoder::new(tar_content.reader());
222+
let mut archive = tar::Archive::new(reader);
223+
archive
224+
.unpack(&path)
225+
.context("Failed to unpack tar archive")?;
226+
227+
let templates_root = bypass_gh_added_root(path);
228+
229+
Ok(LocalTemplateSource {
230+
root: templates_root,
231+
_temp_dir: Some(temp_dir),
232+
})
233+
}
234+
235+
/// GitHub adds a prefix directory to release tarballs (e.g. spin-v3.0.0/...).
236+
/// We try to locate the repo root within the unpacked tarball.
237+
fn bypass_gh_added_root(unpack_dir: PathBuf) -> PathBuf {
238+
// If the unpack dir directly contains a `templates` dir then we are done.
239+
if has_templates_dir(&unpack_dir) {
240+
return unpack_dir;
241+
}
242+
243+
let Ok(dirs) = unpack_dir.read_dir() else {
244+
// If we can't traverse the unpack directory then return it and
245+
// let the top level try to make sense of it.
246+
return unpack_dir;
247+
};
248+
249+
// Is there a single directory at the root? If not, we can't be in the GitHub situation:
250+
// return the root of the unpacking. (The take(2) here is because we don't need to traverse
251+
// the full list - we only care whether there is more than one.)
252+
let dirs = dirs.filter_map(|de| de.ok()).take(2).collect::<Vec<_>>();
253+
if dirs.len() != 1 {
254+
return unpack_dir;
255+
}
256+
257+
// 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.
258+
let candidate_repo_root = dirs[0].path();
259+
let Ok(mut candidate_repo_dirs) = candidate_repo_root.read_dir() else {
260+
// Again, if it all goes awry, propose the base unpack directory.
261+
return unpack_dir;
262+
};
263+
let has_templates_dir = candidate_repo_dirs.any(is_templates_dir);
264+
265+
if has_templates_dir {
266+
candidate_repo_root
267+
} else {
268+
unpack_dir
269+
}
270+
}
271+
272+
fn has_templates_dir(path: &Path) -> bool {
273+
let Ok(mut dirs) = path.read_dir() else {
274+
return false;
275+
};
276+
277+
dirs.any(is_templates_dir)
278+
}
279+
280+
fn is_templates_dir(dir_entry: Result<std::fs::DirEntry, std::io::Error>) -> bool {
281+
dir_entry.is_ok_and(|d| d.file_name() == TEMPLATE_SOURCE_DIR)
282+
}
283+
195284
#[cfg(test)]
196285
mod test {
197286
use super::*;

crates/templates/src/template.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ pub struct Template {
3737
enum InstalledFrom {
3838
Git(String),
3939
Directory(String),
40+
RemoteTar(String),
4041
Unknown,
4142
}
4243

@@ -254,6 +255,7 @@ impl Template {
254255
match &self.installed_from {
255256
InstalledFrom::Git(repo) => repo,
256257
InstalledFrom::Directory(path) => path,
258+
InstalledFrom::RemoteTar(url) => url,
257259
InstalledFrom::Unknown => "",
258260
}
259261
}
@@ -625,6 +627,7 @@ fn read_install_record(layout: &TemplateLayout) -> InstalledFrom {
625627
match installed_from_text.and_then(parse_installed_from) {
626628
Some(RawInstalledFrom::Git { git }) => InstalledFrom::Git(git),
627629
Some(RawInstalledFrom::File { dir }) => InstalledFrom::Directory(dir),
630+
Some(RawInstalledFrom::RemoteTar { url }) => InstalledFrom::RemoteTar(url),
628631
None => InstalledFrom::Unknown,
629632
}
630633
}

src/commands/templates.rs

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ use crate::build_info::*;
1515

1616
const INSTALL_FROM_DIR_OPT: &str = "FROM_DIR";
1717
const INSTALL_FROM_GIT_OPT: &str = "FROM_GIT";
18+
const INSTALL_FROM_TAR_OPT: &str = "FROM_TAR";
1819
const UPGRADE_ONLY: &str = "GIT_URL";
1920

2021
const DEFAULT_TEMPLATES_INSTALL_PROMPT: &str =
@@ -64,6 +65,7 @@ pub struct Install {
6465
long = "git",
6566
alias = "repo",
6667
conflicts_with = INSTALL_FROM_DIR_OPT,
68+
conflicts_with = INSTALL_FROM_TAR_OPT,
6769
)]
6870
pub git: Option<String>,
6971

@@ -76,9 +78,19 @@ pub struct Install {
7678
name = INSTALL_FROM_DIR_OPT,
7779
long = "dir",
7880
conflicts_with = INSTALL_FROM_GIT_OPT,
81+
conflicts_with = INSTALL_FROM_TAR_OPT,
7982
)]
8083
pub dir: Option<PathBuf>,
8184

85+
/// URL to a tarball in .tar.gz format containing the template(s) to install.
86+
#[clap(
87+
name = INSTALL_FROM_TAR_OPT,
88+
long = "tar",
89+
conflicts_with = INSTALL_FROM_GIT_OPT,
90+
conflicts_with = INSTALL_FROM_DIR_OPT,
91+
)]
92+
pub tar_url: Option<String>,
93+
8294
/// If present, updates existing templates instead of skipping.
8395
#[clap(long = "upgrade", alias = "update")]
8496
pub update: bool,
@@ -119,16 +131,20 @@ impl Install {
119131
pub async fn run(self) -> Result<()> {
120132
let template_manager = TemplateManager::try_default()
121133
.context("Failed to construct template directory path")?;
122-
let source = match (&self.git, &self.dir) {
123-
(Some(git), None) => {
134+
let source = match (&self.git, &self.dir, &self.tar_url) {
135+
(Some(git), None, None) => {
124136
let git_url = infer_github(git);
125137
TemplateSource::try_from_git(git_url, &self.branch, SPIN_VERSION)?
126138
}
127-
(None, Some(dir)) => {
139+
(None, Some(dir), None) => {
128140
let abs_dir = dir.absolutize().map(|d| d.to_path_buf());
129141
TemplateSource::File(abs_dir.unwrap_or_else(|_| dir.clone()))
130142
}
131-
_ => anyhow::bail!("Exactly one of `git` and `dir` sources must be specified"),
143+
(None, None, Some(tar_url)) => {
144+
let url = url::Url::parse(tar_url).context("Invalid URL for remote tar")?;
145+
TemplateSource::RemoteTar(url)
146+
}
147+
_ => anyhow::bail!("Exactly one of `git`, `dir`, or `tar` must be specified"),
132148
};
133149

134150
let reporter = ConsoleProgressReporter;
@@ -204,6 +220,7 @@ impl Upgrade {
204220
git: self.git.clone(),
205221
branch: self.branch.clone(),
206222
dir: None,
223+
tar_url: None,
207224
update: true,
208225
};
209226

@@ -620,6 +637,7 @@ async fn install_default_templates() -> anyhow::Result<()> {
620637
git: Some(DEFAULT_TEMPLATE_REPO.to_owned()),
621638
branch: None,
622639
dir: None,
640+
tar_url: None,
623641
update: false,
624642
};
625643
install_cmd

0 commit comments

Comments
 (0)