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