sn_releases/
lib.rs

1// Copyright (C) 2024 MaidSafe.net limited.
2//
3// This SAFE Network Software is licensed to you under The General Public License (GPL), version 3.
4// Unless required by applicable law or agreed to in writing, the SAFE Network Software distributed
5// under the GPL Licence is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
6// KIND, either express or implied. Please review the Licences for the specific language governing
7// permissions and limitations relating to use of the SAFE Network Software.
8
9pub use crate::error::{Error, Result};
10
11pub mod error;
12
13use async_trait::async_trait;
14use lazy_static::lazy_static;
15use reqwest::Client;
16use semver::Version;
17use serde_json::Value;
18use std::collections::HashMap;
19use std::env::consts::{ARCH, OS};
20use std::fmt;
21use std::path::{Path, PathBuf};
22use tar::Archive;
23use tokio::fs::File;
24use tokio::io::AsyncWriteExt;
25use zip::ZipArchive;
26
27const AUTONOMI_S3_BASE_URL: &str = "https://autonomi-cli.s3.eu-west-2.amazonaws.com";
28const GITHUB_API_URL: &str = "https://api.github.com";
29const NAT_DETECTION_S3_BASE_URL: &str = "https://nat-detection.s3.eu-west-2.amazonaws.com";
30const NODE_LAUNCHPAD_S3_BASE_URL: &str = "https://node-launchpad.s3.eu-west-2.amazonaws.com";
31const SAFENODE_MANAGER_S3_BASE_URL: &str = "https://sn-node-manager.s3.eu-west-2.amazonaws.com";
32const SAFENODE_RPC_CLIENT_S3_BASE_URL: &str =
33    "https://sn-node-rpc-client.s3.eu-west-2.amazonaws.com";
34const SAFENODE_S3_BASE_URL: &str = "https://sn-node.s3.eu-west-2.amazonaws.com";
35const WINSW_URL: &str = "https://sn-node-manager.s3.eu-west-2.amazonaws.com/WinSW-x64.exe";
36
37#[derive(Clone, Debug, Eq, Hash, PartialEq)]
38pub enum ReleaseType {
39    Autonomi,
40    NatDetection,
41    NodeLaunchpad,
42    Safenode,
43    SafenodeManager,
44    SafenodeManagerDaemon,
45    SafenodeRpcClient,
46}
47
48impl fmt::Display for ReleaseType {
49    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
50        write!(
51            f,
52            "{}",
53            match self {
54                ReleaseType::Autonomi => "autonomi",
55                ReleaseType::NatDetection => "nat-detection",
56                ReleaseType::NodeLaunchpad => "node-launchpad",
57                ReleaseType::Safenode => "safenode",
58                ReleaseType::SafenodeManager => "safenode-manager",
59                ReleaseType::SafenodeManagerDaemon => "safenodemand",
60                ReleaseType::SafenodeRpcClient => "safenode_rpc_client",
61            }
62        )
63    }
64}
65
66lazy_static! {
67    static ref RELEASE_TYPE_CRATE_NAME_MAP: HashMap<ReleaseType, &'static str> = {
68        let mut m = HashMap::new();
69        m.insert(ReleaseType::Autonomi, "autonomi-cli");
70        m.insert(ReleaseType::NatDetection, "nat-detection");
71        m.insert(ReleaseType::NodeLaunchpad, "node-launchpad");
72        m.insert(ReleaseType::Safenode, "sn_node");
73        m.insert(ReleaseType::SafenodeManager, "sn-node-manager");
74        m.insert(ReleaseType::SafenodeManagerDaemon, "sn-node-manager");
75        m.insert(ReleaseType::SafenodeRpcClient, "sn_node_rpc_client");
76        m
77    };
78}
79
80#[derive(Clone, Eq, Hash, PartialEq)]
81pub enum Platform {
82    LinuxMusl,
83    LinuxMuslAarch64,
84    LinuxMuslArm,
85    LinuxMuslArmV7,
86    MacOs,
87    MacOsAarch64,
88    Windows,
89}
90
91impl fmt::Display for Platform {
92    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
93        match self {
94            Platform::LinuxMusl => write!(f, "x86_64-unknown-linux-musl"),
95            Platform::LinuxMuslAarch64 => write!(f, "aarch64-unknown-linux-musl"),
96            Platform::LinuxMuslArm => write!(f, "arm-unknown-linux-musleabi"),
97            Platform::LinuxMuslArmV7 => write!(f, "armv7-unknown-linux-musleabihf"),
98            Platform::MacOs => write!(f, "x86_64-apple-darwin"),
99            Platform::MacOsAarch64 => write!(f, "aarch64-apple-darwin"),
100            Platform::Windows => write!(f, "x86_64-pc-windows-msvc"), // This appears to be the same as the above, so I'm using the same string.
101        }
102    }
103}
104
105#[derive(Clone, Debug, Eq, Hash, PartialEq)]
106pub enum ArchiveType {
107    TarGz,
108    Zip,
109}
110
111impl fmt::Display for ArchiveType {
112    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
113        match self {
114            ArchiveType::TarGz => write!(f, "tar.gz"),
115            ArchiveType::Zip => write!(f, "zip"),
116        }
117    }
118}
119
120pub type ProgressCallback = dyn Fn(u64, u64) + Send + Sync;
121
122#[async_trait]
123pub trait SafeReleaseRepoActions {
124    async fn get_latest_version(&self, release_type: &ReleaseType) -> Result<Version>;
125    async fn download_release_from_s3(
126        &self,
127        release_type: &ReleaseType,
128        version: &Version,
129        platform: &Platform,
130        archive_type: &ArchiveType,
131        dest_path: &Path,
132        callback: &ProgressCallback,
133    ) -> Result<PathBuf>;
134    async fn download_release(
135        &self,
136        url: &str,
137        dest_dir_path: &Path,
138        callback: &ProgressCallback,
139    ) -> Result<PathBuf>;
140    async fn download_winsw(&self, dest_path: &Path, callback: &ProgressCallback) -> Result<()>;
141    fn extract_release_archive(&self, archive_path: &Path, dest_dir_path: &Path)
142        -> Result<PathBuf>;
143}
144
145impl dyn SafeReleaseRepoActions {
146    pub fn default_config() -> Box<dyn SafeReleaseRepoActions> {
147        Box::new(SafeReleaseRepository {
148            github_api_base_url: GITHUB_API_URL.to_string(),
149            nat_detection_base_url: NAT_DETECTION_S3_BASE_URL.to_string(),
150            node_launchpad_base_url: NODE_LAUNCHPAD_S3_BASE_URL.to_string(),
151            autonomi_base_url: AUTONOMI_S3_BASE_URL.to_string(),
152            safenode_base_url: SAFENODE_S3_BASE_URL.to_string(),
153            safenode_manager_base_url: SAFENODE_MANAGER_S3_BASE_URL.to_string(),
154            safenode_rpc_client_base_url: SAFENODE_RPC_CLIENT_S3_BASE_URL.to_string(),
155        })
156    }
157}
158
159pub struct SafeReleaseRepository {
160    pub github_api_base_url: String,
161    pub nat_detection_base_url: String,
162    pub node_launchpad_base_url: String,
163    pub autonomi_base_url: String,
164    pub safenode_base_url: String,
165    pub safenode_manager_base_url: String,
166    pub safenode_rpc_client_base_url: String,
167}
168
169impl SafeReleaseRepository {
170    fn get_base_url(&self, release_type: &ReleaseType) -> String {
171        match release_type {
172            ReleaseType::NatDetection => self.nat_detection_base_url.clone(),
173            ReleaseType::NodeLaunchpad => self.node_launchpad_base_url.clone(),
174            ReleaseType::Autonomi => self.autonomi_base_url.clone(),
175            ReleaseType::Safenode => self.safenode_base_url.clone(),
176            ReleaseType::SafenodeManager => self.safenode_manager_base_url.clone(),
177            ReleaseType::SafenodeManagerDaemon => self.safenode_manager_base_url.clone(),
178            ReleaseType::SafenodeRpcClient => self.safenode_rpc_client_base_url.clone(),
179        }
180    }
181
182    async fn download_url(
183        &self,
184        url: &str,
185        dest_path: &Path,
186        callback: &ProgressCallback,
187    ) -> Result<()> {
188        let client = Client::new();
189        let mut response = client.get(url).send().await?;
190        if !response.status().is_success() {
191            return Err(Error::ReleaseBinaryNotFound(url.to_string()));
192        }
193
194        let total_size = response
195            .headers()
196            .get("content-length")
197            .and_then(|ct_len| ct_len.to_str().ok())
198            .and_then(|ct_len| ct_len.parse::<u64>().ok())
199            .unwrap_or(0);
200
201        let mut downloaded: u64 = 0;
202        let mut out_file = File::create(&dest_path).await?;
203
204        while let Some(chunk) = response.chunk().await.unwrap() {
205            downloaded += chunk.len() as u64;
206            out_file.write_all(&chunk).await?;
207            callback(downloaded, total_size);
208        }
209
210        Ok(())
211    }
212}
213
214#[async_trait]
215impl SafeReleaseRepoActions for SafeReleaseRepository {
216    /// Uses the crates.io API to obtain the latest version of a crate.
217    ///
218    /// # Arguments
219    ///
220    /// * `release_type` - A reference to a `ReleaseType` enum specifying the type of release to look for.
221    ///
222    /// # Returns
223    ///
224    /// Returns a `Result` containing a `String` with the latest version number in the semantic format.
225    /// Otherwise, returns an `Error`.
226    ///
227    /// # Errors
228    ///
229    /// This function will return an error if:
230    /// - The HTTP request to crates.io API fails
231    /// - The received JSON data does not have a `crate.newest_version` value
232    async fn get_latest_version(&self, release_type: &ReleaseType) -> Result<Version> {
233        // For the time being, the node launchpad needs to be treated as a special case, because it
234        // cannot be published.
235        if matches!(release_type, ReleaseType::NodeLaunchpad) {
236            return Ok(Version::parse("0.1.0")?);
237        }
238
239        let crate_name = *RELEASE_TYPE_CRATE_NAME_MAP.get(release_type).unwrap();
240        let url = format!("https://crates.io/api/v1/crates/{}", crate_name);
241
242        let client = reqwest::Client::new();
243        let response = client
244            .get(url)
245            .header("User-Agent", "reqwest")
246            .send()
247            .await?;
248        if !response.status().is_success() {
249            return Err(Error::CratesIoResponseError(response.status().as_u16()));
250        }
251
252        let body = response.text().await?;
253        let json: Value = serde_json::from_str(&body)?;
254
255        if let Some(version) = json["crate"]["newest_version"].as_str() {
256            return Ok(Version::parse(version)?);
257        }
258
259        Err(Error::LatestReleaseNotFound(release_type.to_string()))
260    }
261
262    /// Downloads a release binary archive from S3.
263    ///
264    /// # Arguments
265    ///
266    /// - `release_type`: The type of release.
267    /// - `version`: The version of the release.
268    /// - `platform`: The target platform.
269    /// - `archive_type`: The type of archive (e.g., tar.gz, zip).
270    /// - `dest_path`: The directory where the downloaded archive will be stored.
271    /// - `callback`: A callback function that can be used for download progress.
272    ///
273    /// # Returns
274    ///
275    /// A `Result` with `PathBuf` indicating the full path of the downloaded archive, or an error if
276    /// the download or file write operation fails.
277    async fn download_release_from_s3(
278        &self,
279        release_type: &ReleaseType,
280        version: &Version,
281        platform: &Platform,
282        archive_type: &ArchiveType,
283        dest_path: &Path,
284        callback: &ProgressCallback,
285    ) -> Result<PathBuf> {
286        let archive_ext = archive_type.to_string();
287        let url = format!(
288            "{}/{}-{}-{}.{}",
289            self.get_base_url(release_type),
290            release_type.to_string().to_lowercase(),
291            version,
292            platform,
293            archive_type
294        );
295
296        let archive_name = format!(
297            "{}-{}-{}.{}",
298            release_type.to_string().to_lowercase(),
299            version,
300            platform,
301            archive_ext
302        );
303        let archive_path = dest_path.join(archive_name);
304
305        self.download_url(&url, &archive_path, callback).await?;
306
307        Ok(archive_path)
308    }
309
310    async fn download_release(
311        &self,
312        url: &str,
313        dest_dir_path: &Path,
314        callback: &ProgressCallback,
315    ) -> Result<PathBuf> {
316        if !url.ends_with(".tar.gz") && !url.ends_with(".zip") {
317            return Err(Error::UrlIsNotArchive);
318        }
319
320        let file_name = url
321            .split('/')
322            .last()
323            .ok_or_else(|| Error::CannotParseFilenameFromUrl)?;
324        let dest_path = dest_dir_path.join(file_name);
325
326        self.download_url(url, &dest_path, callback).await?;
327
328        Ok(dest_path)
329    }
330
331    async fn download_winsw(&self, dest_path: &Path, callback: &ProgressCallback) -> Result<()> {
332        self.download_url(WINSW_URL, dest_path, callback).await?;
333        Ok(())
334    }
335
336    /// Extracts a release binary archive.
337    ///
338    /// The archive will include a single binary file.
339    ///
340    /// # Arguments
341    ///
342    /// - `archive_path`: The path of the archive file to extract.
343    /// - `dest_dir`: The directory where the archive should be extracted.
344    ///
345    /// # Returns
346    ///
347    /// A `Result` with `PathBuf` indicating the full path of the extracted binary.
348    fn extract_release_archive(
349        &self,
350        archive_path: &Path,
351        dest_dir_path: &Path,
352    ) -> Result<PathBuf> {
353        if !archive_path.exists() {
354            return Err(Error::Io(std::io::Error::new(
355                std::io::ErrorKind::NotFound,
356                format!("Archive not found at: {:?}", archive_path),
357            )));
358        }
359
360        if archive_path.extension() == Some(std::ffi::OsStr::new("gz")) {
361            let archive_file = std::fs::File::open(archive_path)?;
362            let tarball = flate2::read::GzDecoder::new(archive_file);
363            let mut archive = Archive::new(tarball);
364            if let Some(file) = (archive.entries()?).next() {
365                let mut file = file?;
366                let out_path = dest_dir_path.join(file.path()?);
367                file.unpack(&out_path)?;
368                return Ok(out_path);
369            }
370        } else if archive_path.extension() == Some(std::ffi::OsStr::new("zip")) {
371            let archive_file = std::fs::File::open(archive_path)?;
372            let mut archive = ZipArchive::new(archive_file)?;
373            if let Some(i) = (0..archive.len()).next() {
374                let mut file = archive.by_index(i)?;
375                let out_path = dest_dir_path.join(file.name());
376                if file.name().ends_with('/') {
377                    std::fs::create_dir_all(&out_path)?;
378                } else {
379                    let mut outfile = std::fs::File::create(&out_path)?;
380                    std::io::copy(&mut file, &mut outfile)?;
381                }
382                return Ok(out_path);
383            }
384        } else {
385            return Err(Error::Io(std::io::Error::new(
386                std::io::ErrorKind::InvalidInput,
387                "Unsupported archive format",
388            )));
389        }
390
391        Err(Error::Io(std::io::Error::new(
392            std::io::ErrorKind::Other,
393            "Failed to extract archive",
394        )))
395    }
396}
397
398pub fn get_running_platform() -> Result<Platform> {
399    match OS {
400        "linux" => match ARCH {
401            "x86_64" => Ok(Platform::LinuxMusl),
402            "armv7" => Ok(Platform::LinuxMuslArmV7),
403            "arm" => Ok(Platform::LinuxMuslArm),
404            "aarch64" => Ok(Platform::LinuxMuslAarch64),
405            &_ => Err(Error::PlatformNotSupported(format!(
406                "We currently do not have binaries for the {OS}/{ARCH} combination"
407            ))),
408        },
409        "windows" => {
410            if ARCH != "x86_64" {
411                return Err(Error::PlatformNotSupported(
412                    "We currently only have x86_64 binaries available for Windows".to_string(),
413                ));
414            }
415            Ok(Platform::Windows)
416        }
417        "macos" => match ARCH {
418            "x86_64" => Ok(Platform::MacOs),
419            "aarch64" => Ok(Platform::MacOsAarch64),
420            &_ => Err(Error::PlatformNotSupported(format!(
421                "We currently do not have binaries for the {OS}/{ARCH} combination"
422            ))),
423        },
424        &_ => Err(Error::PlatformNotSupported(format!(
425            "{OS} is not currently supported"
426        ))),
427    }
428}