sn_testnet_deploy/
lib.rs

1// Copyright (c) 2023, MaidSafe.
2// All rights reserved.
3//
4// This SAFE Network Software is licensed under the BSD-3-Clause license.
5// Please see the LICENSE file for more details.
6
7pub mod ansible;
8pub mod bootstrap;
9pub mod clients;
10pub mod deploy;
11pub mod digital_ocean;
12pub mod error;
13pub mod funding;
14pub mod infra;
15pub mod inventory;
16pub mod logs;
17pub mod reserved_ip;
18pub mod rpc_client;
19pub mod s3;
20pub mod safe;
21pub mod setup;
22pub mod ssh;
23pub mod terraform;
24pub mod upscale;
25
26const STORAGE_REQUIRED_PER_NODE: u16 = 7;
27
28use crate::{
29    ansible::{
30        extra_vars::ExtraVarsDocBuilder,
31        inventory::{cleanup_environment_inventory, AnsibleInventoryType},
32        provisioning::AnsibleProvisioner,
33        AnsibleRunner,
34    },
35    error::{Error, Result},
36    inventory::{DeploymentInventory, VirtualMachine},
37    rpc_client::RpcClient,
38    s3::S3Repository,
39    ssh::SshClient,
40    terraform::TerraformRunner,
41};
42use ant_service_management::ServiceStatus;
43use flate2::read::GzDecoder;
44use indicatif::{ProgressBar, ProgressStyle};
45use infra::{build_terraform_args, InfraRunOptions};
46use log::{debug, trace};
47use semver::Version;
48use serde::{Deserialize, Serialize};
49use serde_json::json;
50use std::{
51    fs::File,
52    io::{BufRead, BufReader, BufWriter, Write},
53    net::IpAddr,
54    path::{Path, PathBuf},
55    process::{Command, Stdio},
56    str::FromStr,
57    time::Duration,
58};
59use tar::Archive;
60
61const ANSIBLE_DEFAULT_FORKS: usize = 50;
62
63#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
64pub enum DeploymentType {
65    /// The deployment has been bootstrapped from an existing network.
66    Bootstrap,
67    /// Client deployment.
68    Client,
69    /// The deployment is a new network.
70    #[default]
71    New,
72}
73
74#[derive(Debug, Clone, Default, Serialize, Deserialize)]
75pub struct AnvilNodeData {
76    pub data_payments_address: String,
77    pub deployer_wallet_private_key: String,
78    pub payment_token_address: String,
79    pub rpc_url: String,
80}
81
82impl std::fmt::Display for DeploymentType {
83    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84        match self {
85            DeploymentType::Bootstrap => write!(f, "bootstrap"),
86            DeploymentType::Client => write!(f, "clients"),
87            DeploymentType::New => write!(f, "new"),
88        }
89    }
90}
91
92impl std::str::FromStr for DeploymentType {
93    type Err = String;
94
95    fn from_str(s: &str) -> Result<Self, Self::Err> {
96        match s.to_lowercase().as_str() {
97            "bootstrap" => Ok(DeploymentType::Bootstrap),
98            "clients" => Ok(DeploymentType::Client),
99            "new" => Ok(DeploymentType::New),
100            _ => Err(format!("Invalid deployment type: {s}")),
101        }
102    }
103}
104
105#[derive(Debug, Clone)]
106pub enum NodeType {
107    FullConePrivateNode,
108    Generic,
109    Genesis,
110    PeerCache,
111    SymmetricPrivateNode,
112    Upnp,
113}
114
115impl std::fmt::Display for NodeType {
116    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
117        match self {
118            NodeType::FullConePrivateNode => write!(f, "full-cone-private"),
119            NodeType::Generic => write!(f, "generic"),
120            NodeType::Genesis => write!(f, "genesis"),
121            NodeType::PeerCache => write!(f, "peer-cache"),
122            NodeType::SymmetricPrivateNode => write!(f, "symmetric-private"),
123            NodeType::Upnp => write!(f, "upnp"),
124        }
125    }
126}
127
128impl std::str::FromStr for NodeType {
129    type Err = String;
130
131    fn from_str(s: &str) -> Result<Self, Self::Err> {
132        match s.to_lowercase().as_str() {
133            "full-cone-private" => Ok(NodeType::FullConePrivateNode),
134            "generic" => Ok(NodeType::Generic),
135            "genesis" => Ok(NodeType::Genesis),
136            "peer-cache" => Ok(NodeType::PeerCache),
137            "symmetric-private" => Ok(NodeType::SymmetricPrivateNode),
138            "upnp" => Ok(NodeType::Upnp),
139            _ => Err(format!("Invalid node type: {s}")),
140        }
141    }
142}
143
144impl NodeType {
145    pub fn telegraf_role(&self) -> &'static str {
146        match self {
147            NodeType::FullConePrivateNode => "NAT_STATIC_FULL_CONE_NODE",
148            NodeType::Generic => "GENERIC_NODE",
149            NodeType::Genesis => "GENESIS_NODE",
150            NodeType::PeerCache => "PEER_CACHE_NODE",
151            NodeType::SymmetricPrivateNode => "NAT_RANDOMIZED_NODE",
152            NodeType::Upnp => "UPNP_NODE",
153        }
154    }
155
156    pub fn to_ansible_inventory_type(&self) -> AnsibleInventoryType {
157        match self {
158            NodeType::FullConePrivateNode => AnsibleInventoryType::FullConePrivateNodes,
159            NodeType::Generic => AnsibleInventoryType::Nodes,
160            NodeType::Genesis => AnsibleInventoryType::Genesis,
161            NodeType::PeerCache => AnsibleInventoryType::PeerCacheNodes,
162            NodeType::SymmetricPrivateNode => AnsibleInventoryType::SymmetricPrivateNodes,
163            NodeType::Upnp => AnsibleInventoryType::Upnp,
164        }
165    }
166}
167
168#[derive(Clone, Debug, Default, Eq, Serialize, Deserialize, PartialEq)]
169pub enum EvmNetwork {
170    #[default]
171    Anvil,
172    ArbitrumOne,
173    ArbitrumSepoliaTest,
174    Custom,
175}
176
177impl std::fmt::Display for EvmNetwork {
178    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
179        match self {
180            EvmNetwork::Anvil => write!(f, "evm-custom"),
181            EvmNetwork::ArbitrumOne => write!(f, "evm-arbitrum-one"),
182            EvmNetwork::ArbitrumSepoliaTest => write!(f, "evm-arbitrum-sepolia-test"),
183            EvmNetwork::Custom => write!(f, "evm-custom"),
184        }
185    }
186}
187
188impl std::str::FromStr for EvmNetwork {
189    type Err = String;
190
191    fn from_str(s: &str) -> Result<Self, Self::Err> {
192        match s.to_lowercase().as_str() {
193            "anvil" => Ok(EvmNetwork::Anvil),
194            "arbitrum-one" => Ok(EvmNetwork::ArbitrumOne),
195            "arbitrum-sepolia-test" => Ok(EvmNetwork::ArbitrumSepoliaTest),
196            "custom" => Ok(EvmNetwork::Custom),
197            _ => Err(format!("Invalid EVM network type: {s}")),
198        }
199    }
200}
201
202#[derive(Clone, Debug, Default, Serialize, Deserialize)]
203pub struct EvmDetails {
204    pub network: EvmNetwork,
205    pub data_payments_address: Option<String>,
206    pub payment_token_address: Option<String>,
207    pub rpc_url: Option<String>,
208}
209
210#[derive(Clone, Debug, Default, Serialize, Deserialize)]
211pub struct EnvironmentDetails {
212    pub deployment_type: DeploymentType,
213    pub environment_type: EnvironmentType,
214    pub evm_details: EvmDetails,
215    pub funding_wallet_address: Option<String>,
216    pub network_id: Option<u8>,
217    pub region: String,
218    pub rewards_address: Option<String>,
219}
220
221#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
222pub enum EnvironmentType {
223    #[default]
224    Development,
225    Production,
226    Staging,
227}
228
229impl EnvironmentType {
230    pub fn get_tfvars_filenames(&self, name: &str, region: &str) -> Vec<String> {
231        match self {
232            EnvironmentType::Development => vec![
233                "dev.tfvars".to_string(),
234                format!("dev-images-{region}.tfvars", region = region),
235            ],
236            EnvironmentType::Staging => vec![
237                "staging.tfvars".to_string(),
238                format!("staging-images-{region}.tfvars", region = region),
239            ],
240            EnvironmentType::Production => {
241                vec![
242                    format!("{name}.tfvars", name = name),
243                    format!("production-images-{region}.tfvars", region = region),
244                ]
245            }
246        }
247    }
248
249    pub fn get_default_peer_cache_node_count(&self) -> u16 {
250        match self {
251            EnvironmentType::Development => 5,
252            EnvironmentType::Production => 5,
253            EnvironmentType::Staging => 5,
254        }
255    }
256
257    pub fn get_default_node_count(&self) -> u16 {
258        match self {
259            EnvironmentType::Development => 25,
260            EnvironmentType::Production => 25,
261            EnvironmentType::Staging => 25,
262        }
263    }
264
265    pub fn get_default_symmetric_private_node_count(&self) -> u16 {
266        self.get_default_node_count()
267    }
268
269    pub fn get_default_full_cone_private_node_count(&self) -> u16 {
270        self.get_default_node_count()
271    }
272    pub fn get_default_upnp_private_node_count(&self) -> u16 {
273        self.get_default_node_count()
274    }
275}
276
277impl std::fmt::Display for EnvironmentType {
278    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
279        match self {
280            EnvironmentType::Development => write!(f, "development"),
281            EnvironmentType::Production => write!(f, "production"),
282            EnvironmentType::Staging => write!(f, "staging"),
283        }
284    }
285}
286
287impl FromStr for EnvironmentType {
288    type Err = Error;
289
290    fn from_str(s: &str) -> Result<Self, Self::Err> {
291        match s.to_lowercase().as_str() {
292            "development" => Ok(EnvironmentType::Development),
293            "production" => Ok(EnvironmentType::Production),
294            "staging" => Ok(EnvironmentType::Staging),
295            _ => Err(Error::EnvironmentNameFromStringError(s.to_string())),
296        }
297    }
298}
299
300/// Specify the binary option for the deployment.
301///
302/// There are several binaries involved in the deployment:
303/// * safenode
304/// * safenode_rpc_client
305/// * faucet
306/// * safe
307///
308/// The `safe` binary is only used for smoke testing the deployment, although we don't really do
309/// that at the moment.
310///
311/// The options are to build from source, or supply a pre-built, versioned binary, which will be
312/// fetched from S3. Building from source adds significant time to the deployment.
313#[derive(Clone, Debug, Serialize, Deserialize)]
314pub enum BinaryOption {
315    /// Binaries will be built from source.
316    BuildFromSource {
317        /// A comma-separated list that will be passed to the `--features` argument.
318        antnode_features: Option<String>,
319        branch: String,
320        repo_owner: String,
321        /// Skip building the binaries, if they were already built during the previous run using the same
322        /// branch, repo owner and testnet name.
323        skip_binary_build: bool,
324    },
325    /// Pre-built, versioned binaries will be fetched from S3.
326    Versioned {
327        ant_version: Option<Version>,
328        antctl_version: Option<Version>,
329        antnode_version: Option<Version>,
330    },
331}
332
333impl BinaryOption {
334    pub fn should_provision_build_machine(&self) -> bool {
335        match self {
336            BinaryOption::BuildFromSource {
337                skip_binary_build, ..
338            } => !skip_binary_build,
339            BinaryOption::Versioned { .. } => false,
340        }
341    }
342
343    pub fn print(&self) {
344        match self {
345            BinaryOption::BuildFromSource {
346                antnode_features,
347                branch,
348                repo_owner,
349                skip_binary_build: _,
350            } => {
351                println!("Source configuration:");
352                println!("  Repository owner: {repo_owner}");
353                println!("  Branch: {branch}");
354                if let Some(features) = antnode_features {
355                    println!("  Antnode features: {features}");
356                }
357            }
358            BinaryOption::Versioned {
359                ant_version,
360                antctl_version,
361                antnode_version,
362            } => {
363                println!("Versioned binaries configuration:");
364                if let Some(version) = ant_version {
365                    println!("  ant version: {version}");
366                }
367                if let Some(version) = antctl_version {
368                    println!("  antctl version: {version}");
369                }
370                if let Some(version) = antnode_version {
371                    println!("  antnode version: {version}");
372                }
373            }
374        }
375    }
376}
377
378#[derive(Debug, Clone, Copy)]
379pub enum CloudProvider {
380    Aws,
381    DigitalOcean,
382}
383
384impl std::fmt::Display for CloudProvider {
385    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
386        match self {
387            CloudProvider::Aws => write!(f, "aws"),
388            CloudProvider::DigitalOcean => write!(f, "digital-ocean"),
389        }
390    }
391}
392
393impl CloudProvider {
394    pub fn get_ssh_user(&self) -> String {
395        match self {
396            CloudProvider::Aws => "ubuntu".to_string(),
397            CloudProvider::DigitalOcean => "root".to_string(),
398        }
399    }
400}
401
402#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
403pub enum LogFormat {
404    Default,
405    Json,
406}
407
408impl LogFormat {
409    pub fn parse_from_str(val: &str) -> Result<Self> {
410        match val {
411            "default" => Ok(LogFormat::Default),
412            "json" => Ok(LogFormat::Json),
413            _ => Err(Error::LoggingConfiguration(
414                "The only valid values for this argument are \"default\" or \"json\"".to_string(),
415            )),
416        }
417    }
418
419    pub fn as_str(&self) -> &'static str {
420        match self {
421            LogFormat::Default => "default",
422            LogFormat::Json => "json",
423        }
424    }
425}
426
427#[derive(Clone)]
428pub struct UpgradeOptions {
429    pub ansible_verbose: bool,
430    pub branch: Option<String>,
431    pub custom_inventory: Option<Vec<VirtualMachine>>,
432    pub env_variables: Option<Vec<(String, String)>>,
433    pub force: bool,
434    pub forks: usize,
435    pub interval: Duration,
436    pub name: String,
437    pub node_type: Option<NodeType>,
438    pub pre_upgrade_delay: Option<u64>,
439    pub provider: CloudProvider,
440    pub repo_owner: Option<String>,
441    pub version: Option<String>,
442}
443
444impl UpgradeOptions {
445    pub fn get_ansible_vars(&self) -> String {
446        let mut extra_vars = ExtraVarsDocBuilder::default();
447        extra_vars.add_variable("interval", &self.interval.as_millis().to_string());
448        if let Some(env_variables) = &self.env_variables {
449            extra_vars.add_env_variable_list("env_variables", env_variables.clone());
450        }
451        if self.force {
452            extra_vars.add_variable("force", &self.force.to_string());
453        }
454        if let Some(version) = &self.version {
455            extra_vars.add_variable("antnode_version", version);
456        }
457        if let Some(pre_upgrade_delay) = &self.pre_upgrade_delay {
458            extra_vars.add_variable("pre_upgrade_delay", &pre_upgrade_delay.to_string());
459        }
460
461        if let (Some(repo_owner), Some(branch)) = (&self.repo_owner, &self.branch) {
462            let binary_option = BinaryOption::BuildFromSource {
463                antnode_features: None,
464                branch: branch.clone(),
465                repo_owner: repo_owner.clone(),
466                skip_binary_build: true,
467            };
468            extra_vars.add_node_url_or_version(&self.name, &binary_option);
469        }
470
471        extra_vars.build()
472    }
473}
474
475#[derive(Default)]
476pub struct TestnetDeployBuilder {
477    ansible_forks: Option<usize>,
478    ansible_verbose_mode: bool,
479    deployment_type: EnvironmentType,
480    environment_name: String,
481    provider: Option<CloudProvider>,
482    region: Option<String>,
483    ssh_secret_key_path: Option<PathBuf>,
484    state_bucket_name: Option<String>,
485    terraform_binary_path: Option<PathBuf>,
486    vault_password_path: Option<PathBuf>,
487    working_directory_path: Option<PathBuf>,
488}
489
490impl TestnetDeployBuilder {
491    pub fn new() -> Self {
492        Default::default()
493    }
494
495    pub fn ansible_verbose_mode(&mut self, ansible_verbose_mode: bool) -> &mut Self {
496        self.ansible_verbose_mode = ansible_verbose_mode;
497        self
498    }
499
500    pub fn ansible_forks(&mut self, ansible_forks: usize) -> &mut Self {
501        self.ansible_forks = Some(ansible_forks);
502        self
503    }
504
505    pub fn deployment_type(&mut self, deployment_type: EnvironmentType) -> &mut Self {
506        self.deployment_type = deployment_type;
507        self
508    }
509
510    pub fn environment_name(&mut self, name: &str) -> &mut Self {
511        self.environment_name = name.to_string();
512        self
513    }
514
515    pub fn provider(&mut self, provider: CloudProvider) -> &mut Self {
516        self.provider = Some(provider);
517        self
518    }
519
520    pub fn state_bucket_name(&mut self, state_bucket_name: String) -> &mut Self {
521        self.state_bucket_name = Some(state_bucket_name);
522        self
523    }
524
525    pub fn terraform_binary_path(&mut self, terraform_binary_path: PathBuf) -> &mut Self {
526        self.terraform_binary_path = Some(terraform_binary_path);
527        self
528    }
529
530    pub fn working_directory(&mut self, working_directory_path: PathBuf) -> &mut Self {
531        self.working_directory_path = Some(working_directory_path);
532        self
533    }
534
535    pub fn ssh_secret_key_path(&mut self, ssh_secret_key_path: PathBuf) -> &mut Self {
536        self.ssh_secret_key_path = Some(ssh_secret_key_path);
537        self
538    }
539
540    pub fn vault_password_path(&mut self, vault_password_path: PathBuf) -> &mut Self {
541        self.vault_password_path = Some(vault_password_path);
542        self
543    }
544
545    pub fn region(&mut self, region: String) -> &mut Self {
546        self.region = Some(region);
547        self
548    }
549
550    pub fn build(&self) -> Result<TestnetDeployer> {
551        let provider = self.provider.unwrap_or(CloudProvider::DigitalOcean);
552        match provider {
553            CloudProvider::DigitalOcean => {
554                let digital_ocean_pat = std::env::var("DO_PAT").map_err(|_| {
555                    Error::CloudProviderCredentialsNotSupplied("DO_PAT".to_string())
556                })?;
557                // The DO_PAT variable is not actually read by either Terraform or Ansible.
558                // Each tool uses a different variable, so instead we set each of those variables
559                // to the value of DO_PAT. This means the user only needs to set one variable.
560                std::env::set_var("DIGITALOCEAN_TOKEN", digital_ocean_pat.clone());
561                std::env::set_var("DO_API_TOKEN", digital_ocean_pat);
562            }
563            _ => {
564                return Err(Error::CloudProviderNotSupported(provider.to_string()));
565            }
566        }
567
568        let state_bucket_name = match self.state_bucket_name {
569            Some(ref bucket_name) => bucket_name.clone(),
570            None => std::env::var("TERRAFORM_STATE_BUCKET_NAME")?,
571        };
572
573        let default_terraform_bin_path = PathBuf::from("terraform");
574        let terraform_binary_path = self
575            .terraform_binary_path
576            .as_ref()
577            .unwrap_or(&default_terraform_bin_path);
578
579        let working_directory_path = match self.working_directory_path {
580            Some(ref work_dir_path) => work_dir_path.clone(),
581            None => std::env::current_dir()?.join("resources"),
582        };
583
584        let ssh_secret_key_path = match self.ssh_secret_key_path {
585            Some(ref ssh_sk_path) => ssh_sk_path.clone(),
586            None => PathBuf::from(std::env::var("SSH_KEY_PATH")?),
587        };
588
589        let vault_password_path = match self.vault_password_path {
590            Some(ref vault_pw_path) => vault_pw_path.clone(),
591            None => PathBuf::from(std::env::var("ANSIBLE_VAULT_PASSWORD_PATH")?),
592        };
593
594        let region = match self.region {
595            Some(ref region) => region.clone(),
596            None => "lon1".to_string(),
597        };
598
599        let terraform_runner = TerraformRunner::new(
600            terraform_binary_path.to_path_buf(),
601            working_directory_path
602                .join("terraform")
603                .join("testnet")
604                .join(provider.to_string()),
605            provider,
606            &state_bucket_name,
607        )?;
608        let ansible_runner = AnsibleRunner::new(
609            self.ansible_forks.unwrap_or(ANSIBLE_DEFAULT_FORKS),
610            self.ansible_verbose_mode,
611            &self.environment_name,
612            provider,
613            ssh_secret_key_path.clone(),
614            vault_password_path,
615            working_directory_path.join("ansible"),
616        )?;
617        let ssh_client = SshClient::new(ssh_secret_key_path);
618        let ansible_provisioner =
619            AnsibleProvisioner::new(ansible_runner, provider, ssh_client.clone());
620        let rpc_client = RpcClient::new(
621            PathBuf::from("/usr/local/bin/safenode_rpc_client"),
622            working_directory_path.clone(),
623        );
624
625        // Remove any `safe` binary from a previous deployment. Otherwise you can end up with
626        // mismatched binaries.
627        let safe_path = working_directory_path.join("safe");
628        if safe_path.exists() {
629            std::fs::remove_file(safe_path)?;
630        }
631
632        let testnet = TestnetDeployer::new(
633            ansible_provisioner,
634            provider,
635            self.deployment_type.clone(),
636            &self.environment_name,
637            rpc_client,
638            S3Repository {},
639            ssh_client,
640            terraform_runner,
641            working_directory_path,
642            region,
643        )?;
644
645        Ok(testnet)
646    }
647}
648
649#[derive(Clone)]
650pub struct TestnetDeployer {
651    pub ansible_provisioner: AnsibleProvisioner,
652    pub cloud_provider: CloudProvider,
653    pub deployment_type: EnvironmentType,
654    pub environment_name: String,
655    pub inventory_file_path: PathBuf,
656    pub region: String,
657    pub rpc_client: RpcClient,
658    pub s3_repository: S3Repository,
659    pub ssh_client: SshClient,
660    pub terraform_runner: TerraformRunner,
661    pub working_directory_path: PathBuf,
662}
663
664impl TestnetDeployer {
665    #[allow(clippy::too_many_arguments)]
666    pub fn new(
667        ansible_provisioner: AnsibleProvisioner,
668        cloud_provider: CloudProvider,
669        deployment_type: EnvironmentType,
670        environment_name: &str,
671        rpc_client: RpcClient,
672        s3_repository: S3Repository,
673        ssh_client: SshClient,
674        terraform_runner: TerraformRunner,
675        working_directory_path: PathBuf,
676        region: String,
677    ) -> Result<TestnetDeployer> {
678        if environment_name.is_empty() {
679            return Err(Error::EnvironmentNameRequired);
680        }
681        let inventory_file_path = working_directory_path
682            .join("ansible")
683            .join("inventory")
684            .join("dev_inventory_digital_ocean.yml");
685        Ok(TestnetDeployer {
686            ansible_provisioner,
687            cloud_provider,
688            deployment_type,
689            environment_name: environment_name.to_string(),
690            inventory_file_path,
691            region,
692            rpc_client,
693            ssh_client,
694            s3_repository,
695            terraform_runner,
696            working_directory_path,
697        })
698    }
699
700    pub async fn init(&self) -> Result<()> {
701        if self
702            .s3_repository
703            .folder_exists(
704                "sn-testnet",
705                &format!("testnet-logs/{}", self.environment_name),
706            )
707            .await?
708        {
709            return Err(Error::LogsForPreviousTestnetExist(
710                self.environment_name.clone(),
711            ));
712        }
713
714        self.terraform_runner.init()?;
715        let workspaces = self.terraform_runner.workspace_list()?;
716        if !workspaces.contains(&self.environment_name) {
717            self.terraform_runner
718                .workspace_new(&self.environment_name)?;
719        } else {
720            println!("Workspace {} already exists", self.environment_name);
721        }
722
723        let rpc_client_path = self.working_directory_path.join("safenode_rpc_client");
724        if !rpc_client_path.is_file() {
725            println!("Downloading the rpc client for safenode...");
726            let archive_name = "safenode_rpc_client-latest-x86_64-unknown-linux-musl.tar.gz";
727            get_and_extract_archive_from_s3(
728                &self.s3_repository,
729                "sn-node-rpc-client",
730                archive_name,
731                &self.working_directory_path,
732            )
733            .await?;
734            #[cfg(unix)]
735            {
736                use std::os::unix::fs::PermissionsExt;
737                let mut permissions = std::fs::metadata(&rpc_client_path)?.permissions();
738                permissions.set_mode(0o755); // rwxr-xr-x
739                std::fs::set_permissions(&rpc_client_path, permissions)?;
740            }
741        }
742
743        Ok(())
744    }
745
746    pub fn plan(&self, options: &InfraRunOptions) -> Result<()> {
747        println!("Selecting {} workspace...", options.name);
748        self.terraform_runner.workspace_select(&options.name)?;
749
750        let args = build_terraform_args(options)?;
751
752        self.terraform_runner
753            .plan(Some(args), options.tfvars_filenames.clone())?;
754        Ok(())
755    }
756
757    pub fn start(
758        &self,
759        interval: Duration,
760        node_type: Option<NodeType>,
761        custom_inventory: Option<Vec<VirtualMachine>>,
762    ) -> Result<()> {
763        self.ansible_provisioner.start_nodes(
764            &self.environment_name,
765            interval,
766            node_type,
767            custom_inventory,
768        )?;
769        Ok(())
770    }
771
772    /// Get the status of all nodes in a network.
773    ///
774    /// First, a playbook runs `safenode-manager status` against all the machines, to get the
775    /// current state of all the nodes. Then all the node registry files are retrieved and
776    /// deserialized to a `NodeRegistry`, allowing us to output the status of each node on each VM.
777    pub fn status(&self) -> Result<()> {
778        self.ansible_provisioner.status()?;
779
780        let peer_cache_node_registries = self
781            .ansible_provisioner
782            .get_node_registries(&AnsibleInventoryType::PeerCacheNodes)?;
783        let generic_node_registries = self
784            .ansible_provisioner
785            .get_node_registries(&AnsibleInventoryType::Nodes)?;
786        let symmetric_private_node_registries = self
787            .ansible_provisioner
788            .get_node_registries(&AnsibleInventoryType::SymmetricPrivateNodes)?;
789        let full_cone_private_node_registries = self
790            .ansible_provisioner
791            .get_node_registries(&AnsibleInventoryType::FullConePrivateNodes)?;
792        let upnp_private_node_registries = self
793            .ansible_provisioner
794            .get_node_registries(&AnsibleInventoryType::Upnp)?;
795        let genesis_node_registry = self
796            .ansible_provisioner
797            .get_node_registries(&AnsibleInventoryType::Genesis)?
798            .clone();
799
800        peer_cache_node_registries.print();
801        generic_node_registries.print();
802        symmetric_private_node_registries.print();
803        full_cone_private_node_registries.print();
804        upnp_private_node_registries.print();
805        genesis_node_registry.print();
806
807        let all_registries = [
808            &peer_cache_node_registries,
809            &generic_node_registries,
810            &symmetric_private_node_registries,
811            &full_cone_private_node_registries,
812            &upnp_private_node_registries,
813            &genesis_node_registry,
814        ];
815
816        let mut total_nodes = 0;
817        let mut running_nodes = 0;
818        let mut stopped_nodes = 0;
819        let mut added_nodes = 0;
820        let mut removed_nodes = 0;
821
822        for (_, registry) in all_registries
823            .iter()
824            .flat_map(|r| r.retrieved_registries.iter())
825        {
826            for node in registry.nodes.iter() {
827                total_nodes += 1;
828                match node.status {
829                    ServiceStatus::Running => running_nodes += 1,
830                    ServiceStatus::Stopped => stopped_nodes += 1,
831                    ServiceStatus::Added => added_nodes += 1,
832                    ServiceStatus::Removed => removed_nodes += 1,
833                }
834            }
835        }
836
837        let peer_cache_hosts = peer_cache_node_registries.retrieved_registries.len();
838        let generic_hosts = generic_node_registries.retrieved_registries.len();
839        let symmetric_private_hosts = symmetric_private_node_registries.retrieved_registries.len();
840        let full_cone_private_hosts = full_cone_private_node_registries.retrieved_registries.len();
841        let upnp_private_hosts = upnp_private_node_registries.retrieved_registries.len();
842
843        let peer_cache_nodes = peer_cache_node_registries
844            .retrieved_registries
845            .iter()
846            .flat_map(|(_, n)| n.nodes.iter())
847            .count();
848        let generic_nodes = generic_node_registries
849            .retrieved_registries
850            .iter()
851            .flat_map(|(_, n)| n.nodes.iter())
852            .count();
853        let symmetric_private_nodes = symmetric_private_node_registries
854            .retrieved_registries
855            .iter()
856            .flat_map(|(_, n)| n.nodes.iter())
857            .count();
858        let full_cone_private_nodes = full_cone_private_node_registries
859            .retrieved_registries
860            .iter()
861            .flat_map(|(_, n)| n.nodes.iter())
862            .count();
863        let upnp_private_nodes = upnp_private_node_registries
864            .retrieved_registries
865            .iter()
866            .flat_map(|(_, n)| n.nodes.iter())
867            .count();
868
869        println!("-------");
870        println!("Summary");
871        println!("-------");
872        println!(
873            "Total peer cache nodes ({}x{}): {}",
874            peer_cache_hosts,
875            if peer_cache_hosts > 0 {
876                peer_cache_nodes / peer_cache_hosts
877            } else {
878                0
879            },
880            peer_cache_nodes
881        );
882        println!(
883            "Total generic nodes ({}x{}): {}",
884            generic_hosts,
885            if generic_hosts > 0 {
886                generic_nodes / generic_hosts
887            } else {
888                0
889            },
890            generic_nodes
891        );
892        println!(
893            "Total symmetric private nodes ({}x{}): {}",
894            symmetric_private_hosts,
895            if symmetric_private_hosts > 0 {
896                symmetric_private_nodes / symmetric_private_hosts
897            } else {
898                0
899            },
900            symmetric_private_nodes
901        );
902        println!(
903            "Total full cone private nodes ({}x{}): {}",
904            full_cone_private_hosts,
905            if full_cone_private_hosts > 0 {
906                full_cone_private_nodes / full_cone_private_hosts
907            } else {
908                0
909            },
910            full_cone_private_nodes
911        );
912        println!(
913            "Total UPnP private nodes ({}x{}): {}",
914            upnp_private_hosts,
915            if upnp_private_hosts > 0 {
916                upnp_private_nodes / upnp_private_hosts
917            } else {
918                0
919            },
920            upnp_private_nodes
921        );
922        println!("Total nodes: {total_nodes}");
923        println!("Running nodes: {running_nodes}");
924        println!("Stopped nodes: {stopped_nodes}");
925        println!("Added nodes: {added_nodes}");
926        println!("Removed nodes: {removed_nodes}");
927
928        Ok(())
929    }
930
931    pub fn cleanup_node_logs(&self, setup_cron: bool) -> Result<()> {
932        self.ansible_provisioner.cleanup_node_logs(setup_cron)?;
933        Ok(())
934    }
935
936    pub fn start_telegraf(
937        &self,
938        node_type: Option<NodeType>,
939        custom_inventory: Option<Vec<VirtualMachine>>,
940    ) -> Result<()> {
941        self.ansible_provisioner.start_telegraf(
942            &self.environment_name,
943            node_type,
944            custom_inventory,
945        )?;
946        Ok(())
947    }
948
949    pub fn stop(
950        &self,
951        interval: Duration,
952        node_type: Option<NodeType>,
953        custom_inventory: Option<Vec<VirtualMachine>>,
954        delay: Option<u64>,
955        service_names: Option<Vec<String>>,
956    ) -> Result<()> {
957        self.ansible_provisioner.stop_nodes(
958            &self.environment_name,
959            interval,
960            node_type,
961            custom_inventory,
962            delay,
963            service_names,
964        )?;
965        Ok(())
966    }
967
968    pub fn stop_telegraf(
969        &self,
970        node_type: Option<NodeType>,
971        custom_inventory: Option<Vec<VirtualMachine>>,
972    ) -> Result<()> {
973        self.ansible_provisioner.stop_telegraf(
974            &self.environment_name,
975            node_type,
976            custom_inventory,
977        )?;
978        Ok(())
979    }
980
981    pub fn upgrade(&self, options: UpgradeOptions) -> Result<()> {
982        self.ansible_provisioner.upgrade_nodes(&options)?;
983        Ok(())
984    }
985
986    pub fn upgrade_antctl(
987        &self,
988        version: Version,
989        node_type: Option<NodeType>,
990        custom_inventory: Option<Vec<VirtualMachine>>,
991    ) -> Result<()> {
992        self.ansible_provisioner.upgrade_antctl(
993            &self.environment_name,
994            &version,
995            node_type,
996            custom_inventory,
997        )?;
998        Ok(())
999    }
1000
1001    pub fn upgrade_geoip_telegraf(&self, name: &str) -> Result<()> {
1002        self.ansible_provisioner.upgrade_geoip_telegraf(name)?;
1003        Ok(())
1004    }
1005
1006    pub fn upgrade_node_telegraf(&self, name: &str) -> Result<()> {
1007        self.ansible_provisioner.upgrade_node_telegraf(name)?;
1008        Ok(())
1009    }
1010
1011    pub fn upgrade_client_telegraf(&self, name: &str) -> Result<()> {
1012        self.ansible_provisioner.upgrade_client_telegraf(name)?;
1013        Ok(())
1014    }
1015
1016    pub async fn clean(&self) -> Result<()> {
1017        let environment_details =
1018            get_environment_details(&self.environment_name, &self.s3_repository)
1019                .await
1020                .inspect_err(|err| {
1021                    println!("Failed to get environment details: {err}. Continuing cleanup...");
1022                })
1023                .ok();
1024        if let Some(environment_details) = &environment_details {
1025            funding::drain_funds(&self.ansible_provisioner, environment_details).await?;
1026        }
1027
1028        self.destroy_infra(environment_details).await?;
1029
1030        cleanup_environment_inventory(
1031            &self.environment_name,
1032            &self
1033                .working_directory_path
1034                .join("ansible")
1035                .join("inventory"),
1036            None,
1037        )?;
1038
1039        println!("Deleted Ansible inventory for {}", self.environment_name);
1040
1041        if let Err(err) = self
1042            .s3_repository
1043            .delete_object("sn-environment-type", &self.environment_name)
1044            .await
1045        {
1046            println!("Failed to delete environment type: {err}. Continuing cleanup...");
1047        }
1048        Ok(())
1049    }
1050
1051    async fn destroy_infra(&self, environment_details: Option<EnvironmentDetails>) -> Result<()> {
1052        infra::select_workspace(&self.terraform_runner, &self.environment_name)?;
1053
1054        let options = InfraRunOptions::generate_existing(
1055            &self.environment_name,
1056            &self.region,
1057            &self.terraform_runner,
1058            environment_details.as_ref(),
1059        )
1060        .await?;
1061
1062        let args = build_terraform_args(&options)?;
1063        let tfvars_filenames = if let Some(environment_details) = &environment_details {
1064            environment_details
1065                .environment_type
1066                .get_tfvars_filenames(&self.environment_name, &self.region)
1067        } else {
1068            vec![]
1069        };
1070
1071        self.terraform_runner
1072            .destroy(Some(args), Some(tfvars_filenames))?;
1073
1074        infra::delete_workspace(&self.terraform_runner, &self.environment_name)?;
1075
1076        Ok(())
1077    }
1078}
1079
1080//
1081// Shared Helpers
1082//
1083
1084pub fn get_genesis_multiaddr(
1085    ansible_runner: &AnsibleRunner,
1086    ssh_client: &SshClient,
1087) -> Result<Option<(String, IpAddr)>> {
1088    let genesis_inventory = ansible_runner.get_inventory(AnsibleInventoryType::Genesis, true)?;
1089    if genesis_inventory.is_empty() {
1090        return Ok(None);
1091    }
1092    let genesis_ip = genesis_inventory[0].public_ip_addr;
1093
1094    // It's possible for the genesis host to be altered from its original state where a node was
1095    // started with the `--first` flag.
1096    // First attempt: try to find node with first=true
1097    let multiaddr = ssh_client
1098        .run_command(
1099            &genesis_ip,
1100            "root",
1101            "jq -r '.nodes[] | select(.initial_peers_config.first == true) | .listen_addr[] | select(contains(\"127.0.0.1\") | not) | select(contains(\"quic-v1\"))' /var/antctl/node_registry.json | head -n 1",
1102            false,
1103        )
1104        .map(|output| output.first().cloned())
1105        .unwrap_or_else(|err| {
1106            log::error!("Failed to find first node with quic-v1 protocol: {err:?}");
1107            None
1108        });
1109
1110    // Second attempt: if first attempt failed, see if any node is available.
1111    let multiaddr = match multiaddr {
1112        Some(addr) => addr,
1113        None => ssh_client
1114            .run_command(
1115                &genesis_ip,
1116                "root",
1117                "jq -r '.nodes[] | .listen_addr[] | select(contains(\"127.0.0.1\") | not) | select(contains(\"quic-v1\"))' /var/antctl/node_registry.json | head -n 1",
1118                false,
1119            )?
1120            .first()
1121            .cloned()
1122            .ok_or_else(|| Error::GenesisListenAddress)?,
1123    };
1124
1125    Ok(Some((multiaddr, genesis_ip)))
1126}
1127
1128pub fn get_anvil_node_data(
1129    ansible_runner: &AnsibleRunner,
1130    ssh_client: &SshClient,
1131) -> Result<AnvilNodeData> {
1132    let evm_inventory = ansible_runner.get_inventory(AnsibleInventoryType::EvmNodes, true)?;
1133    if evm_inventory.is_empty() {
1134        return Err(Error::EvmNodeNotFound);
1135    }
1136
1137    let evm_ip = evm_inventory[0].public_ip_addr;
1138    debug!("Retrieved IP address for EVM node: {evm_ip}");
1139    let csv_file_path = "/home/ant/.local/share/autonomi/evm_testnet_data.csv";
1140
1141    const MAX_ATTEMPTS: u8 = 5;
1142    const RETRY_DELAY: Duration = Duration::from_secs(5);
1143
1144    for attempt in 1..=MAX_ATTEMPTS {
1145        match ssh_client.run_command(&evm_ip, "ant", &format!("cat {csv_file_path}"), false) {
1146            Ok(output) => {
1147                if let Some(csv_contents) = output.first() {
1148                    let parts: Vec<&str> = csv_contents.split(',').collect();
1149                    if parts.len() != 4 {
1150                        return Err(Error::EvmTestnetDataParsingError(
1151                            "Expected 4 fields in the CSV".to_string(),
1152                        ));
1153                    }
1154
1155                    let evm_testnet_data = AnvilNodeData {
1156                        rpc_url: parts[0].trim().to_string(),
1157                        payment_token_address: parts[1].trim().to_string(),
1158                        data_payments_address: parts[2].trim().to_string(),
1159                        deployer_wallet_private_key: parts[3].trim().to_string(),
1160                    };
1161                    return Ok(evm_testnet_data);
1162                }
1163            }
1164            Err(e) => {
1165                if attempt == MAX_ATTEMPTS {
1166                    return Err(e);
1167                }
1168                println!(
1169                    "Attempt {} failed to read EVM testnet data. Retrying in {} seconds...",
1170                    attempt,
1171                    RETRY_DELAY.as_secs()
1172                );
1173            }
1174        }
1175        std::thread::sleep(RETRY_DELAY);
1176    }
1177
1178    Err(Error::EvmTestnetDataNotFound)
1179}
1180
1181pub fn get_multiaddr(
1182    ansible_runner: &AnsibleRunner,
1183    ssh_client: &SshClient,
1184) -> Result<(String, IpAddr)> {
1185    let node_inventory = ansible_runner.get_inventory(AnsibleInventoryType::Nodes, true)?;
1186    // For upscaling a bootstrap deployment, we'd need to select one of the nodes that's already
1187    // provisioned. So just try the first one.
1188    let node_ip = node_inventory
1189        .iter()
1190        .find(|vm| vm.name.ends_with("-node-1"))
1191        .ok_or_else(|| Error::NodeAddressNotFound)?
1192        .public_ip_addr;
1193
1194    debug!("Getting multiaddr from node {node_ip}");
1195
1196    let multiaddr =
1197        ssh_client
1198        .run_command(
1199            &node_ip,
1200            "root",
1201            // fetch the first multiaddr which does not contain the localhost addr.
1202            "jq -r '.nodes[] | .listen_addr[] | select(contains(\"127.0.0.1\") | not)' /var/antctl/node_registry.json | head -n 1",
1203            false,
1204        )?.first()
1205        .cloned()
1206        .ok_or_else(|| Error::NodeAddressNotFound)?;
1207
1208    // The node_ip is obviously inside the multiaddr, but it's just being returned as a
1209    // separate item for convenience.
1210    Ok((multiaddr, node_ip))
1211}
1212
1213pub async fn get_and_extract_archive_from_s3(
1214    s3_repository: &S3Repository,
1215    bucket_name: &str,
1216    archive_bucket_path: &str,
1217    dest_path: &Path,
1218) -> Result<()> {
1219    // In this case, not using unwrap leads to having to provide a very trivial error variant that
1220    // doesn't seem very valuable.
1221    let archive_file_name = archive_bucket_path.split('/').next_back().unwrap();
1222    let archive_dest_path = dest_path.join(archive_file_name);
1223    s3_repository
1224        .download_object(bucket_name, archive_bucket_path, &archive_dest_path)
1225        .await?;
1226    extract_archive(&archive_dest_path, dest_path)?;
1227    Ok(())
1228}
1229
1230pub fn extract_archive(archive_path: &Path, dest_path: &Path) -> Result<()> {
1231    let archive_file = File::open(archive_path)?;
1232    let decoder = GzDecoder::new(archive_file);
1233    let mut archive = Archive::new(decoder);
1234    let entries = archive.entries()?;
1235    for entry_result in entries {
1236        let mut entry = entry_result?;
1237        let extract_path = dest_path.join(entry.path()?);
1238        if entry.header().entry_type() == tar::EntryType::Directory {
1239            std::fs::create_dir_all(extract_path)?;
1240            continue;
1241        }
1242        let mut file = BufWriter::new(File::create(extract_path)?);
1243        std::io::copy(&mut entry, &mut file)?;
1244    }
1245    std::fs::remove_file(archive_path)?;
1246    Ok(())
1247}
1248
1249pub fn run_external_command(
1250    binary_path: PathBuf,
1251    working_directory_path: PathBuf,
1252    args: Vec<String>,
1253    suppress_stdout: bool,
1254    suppress_stderr: bool,
1255) -> Result<Vec<String>> {
1256    let mut command = Command::new(binary_path.clone());
1257    for arg in &args {
1258        command.arg(arg);
1259    }
1260    command.stdout(Stdio::piped());
1261    command.stderr(Stdio::piped());
1262    command.current_dir(working_directory_path.clone());
1263    debug!("Running {binary_path:#?} with args {args:#?}");
1264    debug!("Working directory set to {working_directory_path:#?}");
1265
1266    let mut child = command.spawn()?;
1267    let mut output_lines = Vec::new();
1268
1269    if let Some(ref mut stdout) = child.stdout {
1270        let reader = BufReader::new(stdout);
1271        for line in reader.lines() {
1272            let line = line?;
1273            if !suppress_stdout {
1274                println!("{line}");
1275            }
1276            output_lines.push(line);
1277        }
1278    }
1279
1280    if let Some(ref mut stderr) = child.stderr {
1281        let reader = BufReader::new(stderr);
1282        for line in reader.lines() {
1283            let line = line?;
1284            if !suppress_stderr {
1285                eprintln!("{line}");
1286            }
1287            output_lines.push(line);
1288        }
1289    }
1290
1291    let output = child.wait()?;
1292    if !output.success() {
1293        // Using `unwrap` here avoids introducing another error variant, which seems excessive.
1294        let binary_path = binary_path.to_str().unwrap();
1295        return Err(Error::ExternalCommandRunFailed {
1296            binary: binary_path.to_string(),
1297            exit_status: output,
1298        });
1299    }
1300
1301    Ok(output_lines)
1302}
1303
1304pub fn is_binary_on_path(binary_name: &str) -> bool {
1305    if let Ok(path) = std::env::var("PATH") {
1306        for dir in path.split(':') {
1307            let mut full_path = PathBuf::from(dir);
1308            full_path.push(binary_name);
1309            if full_path.exists() {
1310                return true;
1311            }
1312        }
1313    }
1314    false
1315}
1316
1317pub fn get_wallet_directory() -> Result<PathBuf> {
1318    Ok(dirs_next::data_dir()
1319        .ok_or_else(|| Error::CouldNotRetrieveDataDirectory)?
1320        .join("safe")
1321        .join("client")
1322        .join("wallet"))
1323}
1324
1325pub async fn notify_slack(inventory: DeploymentInventory) -> Result<()> {
1326    let webhook_url =
1327        std::env::var("SLACK_WEBHOOK_URL").map_err(|_| Error::SlackWebhookUrlNotSupplied)?;
1328
1329    let mut message = String::new();
1330    message.push_str("*Testnet Details*\n");
1331    message.push_str(&format!("Name: {}\n", inventory.name));
1332    message.push_str(&format!("Node count: {}\n", inventory.peers().len()));
1333    message.push_str(&format!("Faucet address: {:?}\n", inventory.faucet_address));
1334    match inventory.binary_option {
1335        BinaryOption::BuildFromSource {
1336            ref repo_owner,
1337            ref branch,
1338            ..
1339        } => {
1340            message.push_str("*Branch Details*\n");
1341            message.push_str(&format!("Repo owner: {repo_owner}\n"));
1342            message.push_str(&format!("Branch: {branch}\n"));
1343        }
1344        BinaryOption::Versioned {
1345            ant_version: ref safe_version,
1346            antnode_version: ref safenode_version,
1347            antctl_version: ref safenode_manager_version,
1348            ..
1349        } => {
1350            message.push_str("*Version Details*\n");
1351            message.push_str(&format!(
1352                "ant version: {}\n",
1353                safe_version
1354                    .as_ref()
1355                    .map_or("None".to_string(), |v| v.to_string())
1356            ));
1357            message.push_str(&format!(
1358                "safenode version: {}\n",
1359                safenode_version
1360                    .as_ref()
1361                    .map_or("None".to_string(), |v| v.to_string())
1362            ));
1363            message.push_str(&format!(
1364                "antctl version: {}\n",
1365                safenode_manager_version
1366                    .as_ref()
1367                    .map_or("None".to_string(), |v| v.to_string())
1368            ));
1369        }
1370    }
1371
1372    message.push_str("*Sample Peers*\n");
1373    message.push_str("```\n");
1374    for peer in inventory.peers().iter().take(20) {
1375        message.push_str(&format!("{peer}\n"));
1376    }
1377    message.push_str("```\n");
1378    message.push_str("*Available Files*\n");
1379    message.push_str("```\n");
1380    for (addr, file_name) in inventory.uploaded_files.iter() {
1381        message.push_str(&format!("{addr}: {file_name}\n"))
1382    }
1383    message.push_str("```\n");
1384
1385    let payload = json!({
1386        "text": message,
1387    });
1388    reqwest::Client::new()
1389        .post(webhook_url)
1390        .json(&payload)
1391        .send()
1392        .await?;
1393    println!("{message}");
1394    println!("Posted notification to Slack");
1395    Ok(())
1396}
1397
1398fn print_duration(duration: Duration) {
1399    let total_seconds = duration.as_secs();
1400    let minutes = total_seconds / 60;
1401    let seconds = total_seconds % 60;
1402    debug!("Time taken: {minutes} minutes and {seconds} seconds");
1403}
1404
1405pub fn get_progress_bar(length: u64) -> Result<ProgressBar> {
1406    let progress_bar = ProgressBar::new(length);
1407    progress_bar.set_style(
1408        ProgressStyle::default_bar()
1409            .template("{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {pos}/{len}")?
1410            .progress_chars("#>-"),
1411    );
1412    progress_bar.enable_steady_tick(Duration::from_millis(100));
1413    Ok(progress_bar)
1414}
1415
1416pub async fn get_environment_details(
1417    environment_name: &str,
1418    s3_repository: &S3Repository,
1419) -> Result<EnvironmentDetails> {
1420    let temp_file = tempfile::NamedTempFile::new()?;
1421
1422    let max_retries = 3;
1423    let mut retries = 0;
1424    let env_details = loop {
1425        debug!("Downloading the environment details file for {environment_name} from S3");
1426        match s3_repository
1427            .download_object("sn-environment-type", environment_name, temp_file.path())
1428            .await
1429        {
1430            Ok(_) => {
1431                debug!("Downloaded the environment details file for {environment_name} from S3");
1432                let content = match std::fs::read_to_string(temp_file.path()) {
1433                    Ok(content) => content,
1434                    Err(err) => {
1435                        log::error!("Could not read the environment details file: {err:?}");
1436                        if retries < max_retries {
1437                            debug!("Retrying to read the environment details file");
1438                            retries += 1;
1439                            continue;
1440                        } else {
1441                            return Err(Error::EnvironmentDetailsNotFound(
1442                                environment_name.to_string(),
1443                            ));
1444                        }
1445                    }
1446                };
1447                trace!("Content of the environment details file: {content}");
1448
1449                match serde_json::from_str(&content) {
1450                    Ok(environment_details) => break environment_details,
1451                    Err(err) => {
1452                        log::error!("Could not parse the environment details file: {err:?}");
1453                        if retries < max_retries {
1454                            debug!("Retrying to parse the environment details file");
1455                            retries += 1;
1456                            continue;
1457                        } else {
1458                            return Err(Error::EnvironmentDetailsNotFound(
1459                                environment_name.to_string(),
1460                            ));
1461                        }
1462                    }
1463                }
1464            }
1465            Err(err) => {
1466                log::error!(
1467                    "Could not download the environment details file for {environment_name} from S3: {err:?}"
1468                );
1469                if retries < max_retries {
1470                    retries += 1;
1471                    continue;
1472                } else {
1473                    return Err(Error::EnvironmentDetailsNotFound(
1474                        environment_name.to_string(),
1475                    ));
1476                }
1477            }
1478        }
1479    };
1480
1481    debug!("Fetched environment details: {env_details:?}");
1482
1483    Ok(env_details)
1484}
1485
1486pub async fn write_environment_details(
1487    s3_repository: &S3Repository,
1488    environment_name: &str,
1489    environment_details: &EnvironmentDetails,
1490) -> Result<()> {
1491    let temp_dir = tempfile::tempdir()?;
1492    let path = temp_dir.path().to_path_buf().join(environment_name);
1493    let mut file = File::create(&path)?;
1494    let json = serde_json::to_string(environment_details)?;
1495    file.write_all(json.as_bytes())?;
1496    s3_repository
1497        .upload_file("sn-environment-type", &path, true)
1498        .await?;
1499    Ok(())
1500}
1501
1502pub fn calculate_size_per_attached_volume(node_count: u16) -> u16 {
1503    if node_count == 0 {
1504        return 0;
1505    }
1506    let total_volume_required = node_count * STORAGE_REQUIRED_PER_NODE;
1507
1508    // 7 attached volumes per VM
1509    (total_volume_required as f64 / 7.0).ceil() as u16
1510}
1511
1512pub fn get_bootstrap_cache_url(ip_addr: &IpAddr) -> String {
1513    format!("http://{ip_addr}/bootstrap_cache.json")
1514}