Skip to main content

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