dfx_core/extension/manager/
install.rs1use crate::error::extension::{
2 DownloadAndInstallExtensionToTempdirError, FinalizeInstallationError,
3 GetExtensionArchiveNameError, GetExtensionDownloadUrlError, GetExtensionManifestError,
4 GetHighestCompatibleVersionError, GetTopLevelDirectoryError, InstallExtensionError,
5};
6use crate::extension::{
7 catalog::ExtensionCatalog,
8 manager::ExtensionManager,
9 manifest::{ExtensionDependencies, ExtensionManifest},
10 url::ExtensionJsonUrl,
11};
12use crate::http::get::get_with_retries;
13use backoff::exponential::ExponentialBackoff;
14use flate2::read::GzDecoder;
15use reqwest::Url;
16use semver::{BuildMetadata, Prerelease, Version};
17use std::io::Cursor;
18#[cfg(unix)]
19use std::os::unix::fs::PermissionsExt;
20use std::path::{Path, PathBuf};
21use std::time::Duration;
22use tar::Archive;
23use tempfile::{TempDir, tempdir_in};
24
25pub enum InstallOutcome {
26 Installed(String, Version),
27 AlreadyInstalled(String, Version),
28}
29
30impl ExtensionManager {
31 pub async fn install_extension(
32 &self,
33 name: &str,
34 catalog_url: Option<&Url>,
35 install_as: Option<&str>,
36 version: Option<&Version>,
37 ) -> Result<InstallOutcome, InstallExtensionError> {
38 let url = if let Ok(url) = Url::parse(name) {
39 ExtensionJsonUrl::new(url)
40 } else {
41 ExtensionCatalog::fetch(catalog_url)
42 .await?
43 .lookup(name)
44 .ok_or(InstallExtensionError::ExtensionNotFound(name.to_string()))?
45 };
46
47 let manifest = Self::get_extension_manifest(&url).await?;
48 let extension_name: &str = &manifest.name;
49
50 let effective_extension_name = install_as.unwrap_or(extension_name);
51
52 if self
53 .get_extension_directory(effective_extension_name)
54 .exists()
55 {
56 let installed_manifest = ExtensionManifest::load(effective_extension_name, &self.dir)?;
57
58 return if matches!(version, Some(v) if *v != *installed_manifest.version) {
59 Err(InstallExtensionError::OtherVersionAlreadyInstalled(
60 extension_name.to_string(),
61 installed_manifest.version.clone(),
62 ))
63 } else {
64 Ok(InstallOutcome::AlreadyInstalled(
65 extension_name.to_string(),
66 installed_manifest.version.clone(),
67 ))
68 };
69 }
70
71 let extension_version = match version {
72 Some(version) => version.clone(),
73 None => self.get_highest_compatible_version(&url).await?,
74 };
75 let github_release_tag = get_git_release_tag(extension_name, &extension_version);
76 let extension_archive = get_extension_archive_name(extension_name)?;
77 let archive_url = get_extension_download_url(
78 &manifest.download_url_template(),
79 &github_release_tag,
80 &extension_archive,
81 )?;
82
83 let temp_dir = self
84 .download_and_unpack_extension_to_tempdir(archive_url)
85 .await?;
86
87 self.finalize_installation(extension_name, effective_extension_name, temp_dir)?;
88
89 Ok(InstallOutcome::Installed(
90 extension_name.to_string(),
91 extension_version,
92 ))
93 }
94
95 fn dfx_version_strip_semver(&self) -> Version {
99 let mut dfx_version = self.dfx_version.clone();
100 dfx_version.pre = Prerelease::EMPTY;
101 dfx_version.build = BuildMetadata::EMPTY;
102 dfx_version
103 }
104
105 async fn get_highest_compatible_version(
106 &self,
107 url: &ExtensionJsonUrl,
108 ) -> Result<Version, GetHighestCompatibleVersionError> {
109 let dependencies = ExtensionDependencies::fetch(url).await?;
110 let dfx_version = self.dfx_version_strip_semver();
111 dependencies
112 .find_highest_compatible_version(&dfx_version)?
113 .ok_or(GetHighestCompatibleVersionError::NoCompatibleVersionFound())
114 }
115
116 async fn get_extension_manifest(
117 url: &ExtensionJsonUrl,
118 ) -> Result<ExtensionManifest, GetExtensionManifestError> {
119 let retry_policy = ExponentialBackoff {
120 max_elapsed_time: Some(Duration::from_secs(60)),
121 ..Default::default()
122 };
123 let resp = get_with_retries(url.as_url().clone(), retry_policy)
124 .await
125 .map_err(GetExtensionManifestError::Get)?;
126
127 resp.json()
128 .await
129 .map_err(GetExtensionManifestError::ParseJson)
130 }
131
132 async fn download_and_unpack_extension_to_tempdir(
133 &self,
134 download_url: Url,
135 ) -> Result<TempDir, DownloadAndInstallExtensionToTempdirError> {
136 let retry_policy = ExponentialBackoff {
137 max_elapsed_time: Some(Duration::from_secs(60)),
138 ..Default::default()
139 };
140 let response = get_with_retries(download_url.clone(), retry_policy)
141 .await
142 .map_err(DownloadAndInstallExtensionToTempdirError::ExtensionDownloadFailed)?;
143
144 let bytes = response
145 .bytes()
146 .await
147 .map_err(DownloadAndInstallExtensionToTempdirError::ExtensionDownloadFailed)?;
148
149 crate::fs::composite::ensure_dir_exists(&self.dir)
150 .map_err(DownloadAndInstallExtensionToTempdirError::EnsureExtensionDirExistsFailed)?;
151
152 let temp_dir = tempdir_in(&self.dir).map_err(|e| {
153 DownloadAndInstallExtensionToTempdirError::CreateTemporaryDirectoryFailed(
154 self.dir.to_path_buf(),
155 e,
156 )
157 })?;
158
159 let mut archive = Archive::new(GzDecoder::new(Cursor::new(bytes)));
160 archive.unpack(temp_dir.path()).map_err(|e| {
161 DownloadAndInstallExtensionToTempdirError::DecompressFailed(download_url, e)
162 })?;
163
164 Ok(temp_dir)
165 }
166
167 #[allow(clippy::collapsible_if)]
168 fn finalize_installation(
169 &self,
170 extension_name: &str,
171 effective_extension_name: &str,
172 temp_dir: TempDir,
173 ) -> Result<(), FinalizeInstallationError> {
174 let effective_extension_dir = &self.get_extension_directory(effective_extension_name);
175 let top_level_dir = get_top_level_directory(temp_dir.path())?;
176 crate::fs::rename(&top_level_dir, effective_extension_dir)?;
177
178 let installed_manifest = ExtensionManifest::load(effective_extension_name, &self.dir)?;
179
180 if matches!(installed_manifest.subcommands, Some(subcommands) if !subcommands.0.is_empty())
181 {
182 if extension_name != effective_extension_name {
183 crate::fs::rename(
185 &effective_extension_dir.join(extension_name),
186 &effective_extension_dir.join(effective_extension_name),
187 )?;
188 }
189 #[cfg(unix)]
190 {
191 let bin = effective_extension_dir.join(effective_extension_name);
192 crate::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o500))?;
193 }
194 }
195 Ok(())
196 }
197}
198
199fn get_top_level_directory(dir: &Path) -> Result<PathBuf, GetTopLevelDirectoryError> {
200 Ok(crate::fs::read_dir(dir)?
203 .next()
204 .ok_or(GetTopLevelDirectoryError::NoTopLevelDirectoryEntry)?
205 .map_err(GetTopLevelDirectoryError::ReadDirEntry)?
206 .path())
207}
208
209fn get_extension_download_url(
210 download_url_template: &str,
211 github_release_tag: &str,
212 extension_archive_name: &str,
213) -> Result<Url, GetExtensionDownloadUrlError> {
214 let download_url = download_url_template
215 .replace("{{tag}}", github_release_tag)
216 .replace("{{basename}}", extension_archive_name)
217 .replace("{{archive-format}}", "tar.gz");
218
219 Url::parse(&download_url).map_err(|source| GetExtensionDownloadUrlError {
220 url: download_url,
221 source,
222 })
223}
224
225fn get_git_release_tag(extension_name: &str, extension_verion: &Version) -> String {
226 format!("{extension_name}-v{extension_verion}",)
227}
228
229fn get_extension_archive_name(
230 extension_name: &str,
231) -> Result<String, GetExtensionArchiveNameError> {
232 Ok(format!(
233 "{extension_name}-{arch}-{platform}",
234 platform = match std::env::consts::OS {
235 "linux" => "unknown-linux-gnu",
236 "macos" => "apple-darwin",
237 unsupported_platform =>
239 return Err(GetExtensionArchiveNameError::PlatformNotSupported(
240 unsupported_platform.to_string()
241 )),
242 },
243 arch = std::env::consts::ARCH,
244 ))
245}