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