sn_testnet_deploy/
inventory.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
7use crate::{
8    ansible::{
9        inventory::{
10            generate_environment_inventory,
11            generate_full_cone_private_node_static_environment_inventory,
12            generate_symmetric_private_node_static_environment_inventory, AnsibleInventoryType,
13        },
14        provisioning::{AnsibleProvisioner, PrivateNodeProvisionInventory},
15        AnsibleRunner,
16    },
17    get_bootstrap_cache_url, get_environment_details, get_genesis_multiaddr,
18    s3::S3Repository,
19    ssh::SshClient,
20    terraform::TerraformRunner,
21    BinaryOption, CloudProvider, DeploymentType, EnvironmentDetails, Error, TestnetDeployer,
22};
23use alloy::hex::ToHexExt;
24use ant_service_management::{NodeRegistry, ServiceStatus};
25use color_eyre::{eyre::eyre, Result};
26use log::debug;
27use rand::seq::{IteratorRandom, SliceRandom};
28use semver::Version;
29use serde::{Deserialize, Serialize};
30use std::{
31    collections::{HashMap, HashSet},
32    convert::From,
33    fs::File,
34    io::Write,
35    net::{IpAddr, SocketAddr},
36    path::PathBuf,
37};
38
39const DEFAULT_CONTACTS_COUNT: usize = 100;
40const UNAVAILABLE_NODE: &str = "-";
41const TESTNET_BUCKET_NAME: &str = "sn-testnet";
42
43pub struct DeploymentInventoryService {
44    pub ansible_runner: AnsibleRunner,
45    // It may seem strange to have both the runner and the provisioner, because the provisioner is
46    // a wrapper around the runner, but it's for the purpose of sharing some code. More things
47    // could go into the provisioner later, which may eliminate the need to have the runner.
48    pub ansible_provisioner: AnsibleProvisioner,
49    pub cloud_provider: CloudProvider,
50    pub inventory_file_path: PathBuf,
51    pub s3_repository: S3Repository,
52    pub ssh_client: SshClient,
53    pub terraform_runner: TerraformRunner,
54    pub working_directory_path: PathBuf,
55}
56
57impl From<&TestnetDeployer> for DeploymentInventoryService {
58    fn from(item: &TestnetDeployer) -> Self {
59        let provider = match item.cloud_provider {
60            CloudProvider::Aws => "aws",
61            CloudProvider::DigitalOcean => "digital_ocean",
62        };
63        DeploymentInventoryService {
64            ansible_runner: item.ansible_provisioner.ansible_runner.clone(),
65            ansible_provisioner: item.ansible_provisioner.clone(),
66            cloud_provider: item.cloud_provider,
67            inventory_file_path: item
68                .working_directory_path
69                .join("ansible")
70                .join("inventory")
71                .join(format!("dev_inventory_{}.yml", provider)),
72            s3_repository: item.s3_repository.clone(),
73            ssh_client: item.ssh_client.clone(),
74            terraform_runner: item.terraform_runner.clone(),
75            working_directory_path: item.working_directory_path.clone(),
76        }
77    }
78}
79
80impl DeploymentInventoryService {
81    /// Generate or retrieve the inventory for the deployment.
82    ///
83    /// If we're creating a new environment and there is no inventory yet, a empty inventory will
84    /// be returned; otherwise the inventory will represent what is deployed currently.
85    ///
86    /// The `force` flag is used when the `deploy` command runs, to make sure that a new inventory
87    /// is generated, because it's possible that an old one with the same environment name has been
88    /// cached.
89    ///
90    /// The binary option will only be present on the first generation of the inventory, when the
91    /// testnet is initially deployed. On any subsequent runs, we don't have access to the initial
92    /// launch arguments. This means any branch specification is lost. In this case, we'll just
93    /// retrieve the version numbers from the genesis node in the node registry. Most of the time
94    /// it is the version numbers that will be of interest.
95    pub async fn generate_or_retrieve_inventory(
96        &self,
97        name: &str,
98        force: bool,
99        binary_option: Option<BinaryOption>,
100    ) -> Result<DeploymentInventory> {
101        println!("======================================");
102        println!("  Generating or Retrieving Inventory  ");
103        println!("======================================");
104        let inventory_path = get_data_directory()?.join(format!("{name}-inventory.json"));
105        if inventory_path.exists() && !force {
106            let inventory = DeploymentInventory::read(&inventory_path)?;
107            return Ok(inventory);
108        }
109
110        // This allows for the inventory to be generated without a Terraform workspace to be
111        // initialised, which is the case in the workflow for printing an inventory.
112        if !force {
113            let environments = self.terraform_runner.workspace_list()?;
114            if !environments.contains(&name.to_string()) {
115                return Err(eyre!("The '{}' environment does not exist", name));
116            }
117        }
118
119        // For new environments, whether it's a new or bootstrap deploy, the inventory files need
120        // to be generated for the Ansible run to work correctly.
121        //
122        // It is an idempotent operation; the files won't be generated if they already exist.
123        let output_inventory_dir_path = self
124            .working_directory_path
125            .join("ansible")
126            .join("inventory");
127        generate_environment_inventory(
128            name,
129            &self.inventory_file_path,
130            &output_inventory_dir_path,
131        )?;
132
133        let environment_details = match get_environment_details(name, &self.s3_repository).await {
134            Ok(details) => details,
135            Err(Error::EnvironmentDetailsNotFound(_)) => {
136                println!("Environment details not found: treating this as a new deployment");
137                return Ok(DeploymentInventory::empty(
138                    name,
139                    binary_option.ok_or_else(|| {
140                        eyre!("For a new deployment the binary option must be set")
141                    })?,
142                ));
143            }
144            Err(e) => return Err(e.into()),
145        };
146
147        let genesis_vm = self
148            .ansible_runner
149            .get_inventory(AnsibleInventoryType::Genesis, false)?;
150
151        let mut misc_vms = Vec::new();
152        let build_vm = self
153            .ansible_runner
154            .get_inventory(AnsibleInventoryType::Build, false)?;
155        misc_vms.extend(build_vm);
156
157        let full_cone_nat_gateway_vms = self
158            .ansible_runner
159            .get_inventory(AnsibleInventoryType::FullConeNatGateway, false)?;
160        let full_cone_private_node_vms = self
161            .ansible_runner
162            .get_inventory(AnsibleInventoryType::FullConePrivateNodes, false)?;
163
164        let symmetric_nat_gateway_vms = self
165            .ansible_runner
166            .get_inventory(AnsibleInventoryType::SymmetricNatGateway, false)?;
167        let symmetric_private_node_vms = self
168            .ansible_runner
169            .get_inventory(AnsibleInventoryType::SymmetricPrivateNodes, false)?;
170
171        let generic_node_vms = self
172            .ansible_runner
173            .get_inventory(AnsibleInventoryType::Nodes, false)?;
174
175        // Create static inventory for private nodes. Will be used during ansible-playbook run.
176        generate_full_cone_private_node_static_environment_inventory(
177            name,
178            &output_inventory_dir_path,
179            &full_cone_private_node_vms,
180            &full_cone_nat_gateway_vms,
181            &self.ssh_client.private_key_path,
182        )?;
183        generate_symmetric_private_node_static_environment_inventory(
184            name,
185            &output_inventory_dir_path,
186            &symmetric_private_node_vms,
187            &symmetric_nat_gateway_vms,
188            &self.ssh_client.private_key_path,
189        )?;
190
191        // Set up the SSH client to route through the NAT gateway if it exists. This updates all the client clones.
192        if !symmetric_nat_gateway_vms.is_empty() {
193            self.ssh_client.set_symmetric_nat_routed_vms(
194                &symmetric_private_node_vms,
195                &symmetric_nat_gateway_vms,
196            )?;
197        }
198        if !full_cone_nat_gateway_vms.is_empty() {
199            self.ssh_client.set_full_cone_nat_routed_vms(
200                &full_cone_private_node_vms,
201                &full_cone_nat_gateway_vms,
202            )?;
203        }
204
205        let peer_cache_node_vms = self
206            .ansible_runner
207            .get_inventory(AnsibleInventoryType::PeerCacheNodes, false)?;
208
209        let uploader_vms = if environment_details.deployment_type != DeploymentType::Bootstrap {
210            let uploader_and_sks = self.ansible_provisioner.get_uploader_secret_keys()?;
211            uploader_and_sks
212                .iter()
213                .map(|(vm, sks)| UploaderVirtualMachine {
214                    vm: vm.clone(),
215                    wallet_public_key: sks
216                        .iter()
217                        .enumerate()
218                        .map(|(user, sk)| (format!("safe{}", user + 1), sk.address().encode_hex()))
219                        .collect(),
220                })
221                .collect()
222        } else {
223            Vec::new()
224        };
225
226        println!("Retrieving node registries from all VMs...");
227        let mut failed_node_registry_vms = Vec::new();
228
229        let peer_cache_node_registries = self
230            .ansible_provisioner
231            .get_node_registries(&AnsibleInventoryType::PeerCacheNodes)?;
232        let peer_cache_node_vms =
233            NodeVirtualMachine::from_list(&peer_cache_node_vms, &peer_cache_node_registries);
234
235        let generic_node_registries = self
236            .ansible_provisioner
237            .get_node_registries(&AnsibleInventoryType::Nodes)?;
238        let generic_node_vms =
239            NodeVirtualMachine::from_list(&generic_node_vms, &generic_node_registries);
240
241        let symmetric_private_node_registries = self
242            .ansible_provisioner
243            .get_node_registries(&AnsibleInventoryType::SymmetricPrivateNodes)?;
244        let symmetric_private_node_vms = NodeVirtualMachine::from_list(
245            &symmetric_private_node_vms,
246            &symmetric_private_node_registries,
247        );
248        let full_cone_private_node_registries = self
249            .ansible_provisioner
250            .get_node_registries(&AnsibleInventoryType::FullConePrivateNodes)?;
251        debug!("full_cone_private_node_vms: {full_cone_private_node_vms:?}");
252        let full_cone_private_node_gateway_vm_map =
253            PrivateNodeProvisionInventory::match_private_node_vm_and_gateway_vm(
254                &full_cone_private_node_vms,
255                &full_cone_nat_gateway_vms,
256            )?;
257        debug!("full_cone_private_node_gateway_vm_map: {full_cone_private_node_gateway_vm_map:?}");
258        let full_cone_private_node_vms = NodeVirtualMachine::from_list(
259            &full_cone_private_node_vms,
260            &full_cone_private_node_registries,
261        );
262
263        let genesis_node_registry = self
264            .ansible_provisioner
265            .get_node_registries(&AnsibleInventoryType::Genesis)?;
266        let genesis_vm = NodeVirtualMachine::from_list(&genesis_vm, &genesis_node_registry);
267        let genesis_vm = if !genesis_vm.is_empty() {
268            Some(genesis_vm[0].clone())
269        } else {
270            None
271        };
272
273        failed_node_registry_vms.extend(peer_cache_node_registries.failed_vms);
274        failed_node_registry_vms.extend(generic_node_registries.failed_vms);
275        failed_node_registry_vms.extend(full_cone_private_node_registries.failed_vms);
276        failed_node_registry_vms.extend(symmetric_private_node_registries.failed_vms);
277        failed_node_registry_vms.extend(genesis_node_registry.failed_vms);
278
279        let binary_option = if let Some(binary_option) = binary_option {
280            binary_option
281        } else {
282            let (antnode_version, antctl_version) = {
283                let mut random_vm = None;
284                if !generic_node_vms.is_empty() {
285                    random_vm = generic_node_vms.first().cloned();
286                } else if !peer_cache_node_vms.is_empty() {
287                    random_vm = peer_cache_node_vms.first().cloned();
288                } else if genesis_vm.is_some() {
289                    random_vm = genesis_vm.clone()
290                };
291
292                let Some(random_vm) = random_vm else {
293                    return Err(eyre!("Unable to obtain a VM to retrieve versions"));
294                };
295
296                // It's reasonable to make the assumption that one antnode service is running.
297                let antnode_version = self.get_bin_version(
298                    &random_vm.vm,
299                    "/mnt/antnode-storage/data/antnode1/antnode --version",
300                    "Autonomi Node v",
301                )?;
302                let antctl_version = self.get_bin_version(
303                    &random_vm.vm,
304                    "antctl --version",
305                    "Autonomi Node Manager v",
306                )?;
307                (antnode_version, antctl_version)
308            };
309
310            let ant_version = if environment_details.deployment_type != DeploymentType::Bootstrap {
311                let random_uploader_vm = uploader_vms
312                    .choose(&mut rand::thread_rng())
313                    .ok_or_else(|| eyre!("No uploader VMs available to retrieve ant version"))?;
314                Some(self.get_bin_version(
315                    &random_uploader_vm.vm,
316                    "ant --version",
317                    "Autonomi Client v",
318                )?)
319            } else {
320                None
321            };
322
323            println!("Retrieved binary versions from previous deployment:");
324            println!("  antnode: {}", antnode_version);
325            println!("  antctl: {}", antctl_version);
326            if let Some(version) = &ant_version {
327                println!("  ant: {}", version);
328            }
329
330            BinaryOption::Versioned {
331                ant_version,
332                antnode_version,
333                antctl_version,
334            }
335        };
336
337        let (genesis_multiaddr, genesis_ip) =
338            if environment_details.deployment_type == DeploymentType::New {
339                match get_genesis_multiaddr(&self.ansible_runner, &self.ssh_client) {
340                    Ok((multiaddr, ip)) => (Some(multiaddr), Some(ip)),
341                    Err(_) => (None, None),
342                }
343            } else {
344                (None, None)
345            };
346        let inventory = DeploymentInventory {
347            binary_option,
348            environment_details,
349            failed_node_registry_vms,
350            faucet_address: genesis_ip.map(|ip| format!("{ip}:8000")),
351            full_cone_nat_gateway_vms,
352            full_cone_private_node_vms,
353            genesis_multiaddr,
354            genesis_vm,
355            name: name.to_string(),
356            misc_vms,
357            node_vms: generic_node_vms,
358            peer_cache_node_vms,
359            ssh_user: self.cloud_provider.get_ssh_user(),
360            ssh_private_key_path: self.ssh_client.private_key_path.clone(),
361            symmetric_nat_gateway_vms,
362            symmetric_private_node_vms,
363            uploaded_files: Vec::new(),
364            uploader_vms,
365        };
366        debug!("Inventory: {inventory:?}");
367        Ok(inventory)
368    }
369
370    /// Create all the environment inventory files. This also updates the SSH client to route the private nodes
371    /// the NAT gateway if it exists.
372    ///
373    /// This is used when 'generate_or_retrieve_inventory' is not used, but you still need to set up the inventory files.
374    pub fn setup_environment_inventory(&self, name: &str) -> Result<()> {
375        let output_inventory_dir_path = self
376            .working_directory_path
377            .join("ansible")
378            .join("inventory");
379        generate_environment_inventory(
380            name,
381            &self.inventory_file_path,
382            &output_inventory_dir_path,
383        )?;
384
385        let full_cone_nat_gateway_vms = self
386            .ansible_runner
387            .get_inventory(AnsibleInventoryType::FullConeNatGateway, false)?;
388        let full_cone_private_node_vms = self
389            .ansible_runner
390            .get_inventory(AnsibleInventoryType::FullConePrivateNodes, false)?;
391
392        let symmetric_nat_gateway_vms = self
393            .ansible_runner
394            .get_inventory(AnsibleInventoryType::SymmetricNatGateway, false)?;
395        let symmetric_private_node_vms = self
396            .ansible_runner
397            .get_inventory(AnsibleInventoryType::SymmetricPrivateNodes, false)?;
398
399        // Create static inventory for private nodes. Will be used during ansible-playbook run.
400        generate_symmetric_private_node_static_environment_inventory(
401            name,
402            &output_inventory_dir_path,
403            &symmetric_private_node_vms,
404            &symmetric_nat_gateway_vms,
405            &self.ssh_client.private_key_path,
406        )?;
407
408        generate_full_cone_private_node_static_environment_inventory(
409            name,
410            &output_inventory_dir_path,
411            &full_cone_private_node_vms,
412            &full_cone_nat_gateway_vms,
413            &self.ssh_client.private_key_path,
414        )?;
415
416        // Set up the SSH client to route through the NAT gateway if it exists. This updates all the client clones.
417        if !full_cone_nat_gateway_vms.is_empty() {
418            self.ssh_client.set_full_cone_nat_routed_vms(
419                &full_cone_private_node_vms,
420                &full_cone_nat_gateway_vms,
421            )?;
422        }
423
424        if !symmetric_nat_gateway_vms.is_empty() {
425            self.ssh_client.set_symmetric_nat_routed_vms(
426                &symmetric_private_node_vms,
427                &symmetric_nat_gateway_vms,
428            )?;
429        }
430
431        Ok(())
432    }
433
434    pub async fn upload_network_contacts(
435        &self,
436        inventory: &DeploymentInventory,
437        contacts_file_name: Option<String>,
438    ) -> Result<()> {
439        let temp_dir_path = tempfile::tempdir()?.into_path();
440        let temp_file_path = if let Some(file_name) = contacts_file_name {
441            temp_dir_path.join(file_name)
442        } else {
443            temp_dir_path.join(inventory.name.clone())
444        };
445
446        let mut file = std::fs::File::create(&temp_file_path)?;
447        let mut rng = rand::thread_rng();
448
449        let peer_cache_peers = inventory
450            .peer_cache_node_vms
451            .iter()
452            .flat_map(|vm| vm.get_quic_addresses())
453            .collect::<Vec<_>>();
454        let peer_cache_peers_len = peer_cache_peers.len();
455        for peer in peer_cache_peers
456            .iter()
457            .filter(|&peer| peer != UNAVAILABLE_NODE)
458            .cloned()
459            .choose_multiple(&mut rng, DEFAULT_CONTACTS_COUNT)
460        {
461            writeln!(file, "{peer}",)?;
462        }
463
464        if DEFAULT_CONTACTS_COUNT > peer_cache_peers_len {
465            let node_peers = inventory
466                .node_vms
467                .iter()
468                .flat_map(|vm| vm.get_quic_addresses())
469                .collect::<Vec<_>>();
470            for peer in node_peers
471                .iter()
472                .filter(|&peer| peer != UNAVAILABLE_NODE)
473                .cloned()
474                .choose_multiple(&mut rng, DEFAULT_CONTACTS_COUNT - peer_cache_peers_len)
475            {
476                writeln!(file, "{peer}",)?;
477            }
478        }
479
480        self.s3_repository
481            .upload_file(TESTNET_BUCKET_NAME, &temp_file_path, true)
482            .await?;
483
484        Ok(())
485    }
486
487    /// Connects to a VM with SSH and runs a command to retrieve the version of a binary.
488    fn get_bin_version(&self, vm: &VirtualMachine, command: &str, prefix: &str) -> Result<Version> {
489        let output = self.ssh_client.run_command(
490            &vm.public_ip_addr,
491            &self.cloud_provider.get_ssh_user(),
492            command,
493            true,
494        )?;
495        let version_line = output
496            .first()
497            .ok_or_else(|| eyre!("No output from {} command", command))?;
498        let version_str = version_line
499            .strip_prefix(prefix)
500            .ok_or_else(|| eyre!("Unexpected output format from {} command", command))?;
501        Version::parse(version_str).map_err(|e| eyre!("Failed to parse {} version: {}", command, e))
502    }
503}
504
505impl NodeVirtualMachine {
506    pub fn from_list(
507        vms: &[VirtualMachine],
508        node_registries: &DeploymentNodeRegistries,
509    ) -> Vec<Self> {
510        let mut node_vms = Vec::new();
511        for vm in vms {
512            let node_registry = node_registries
513                .retrieved_registries
514                .iter()
515                .find(|(name, _)| {
516                    if vm.name.contains("private") {
517                        let result = name == &vm.private_ip_addr.to_string();
518                        debug!(
519                            "Vm name: {name} is a private node with result {result}. Vm: {vm:?}"
520                        );
521                        result
522                    } else {
523                        name == &vm.name
524                    }
525                })
526                .map(|(_, reg)| reg);
527            let Some(node_registry) = node_registry else {
528                debug!("No node registry found for vm: {vm:?}. Skipping");
529                continue;
530            };
531
532            let node_vm = Self {
533                node_count: node_registry.nodes.len(),
534                node_listen_addresses: node_registry
535                    .nodes
536                    .iter()
537                    .map(|node| {
538                        if let Some(listen_addresses) = &node.listen_addr {
539                            listen_addresses
540                                .iter()
541                                .map(|addr| addr.to_string())
542                                .collect()
543                        } else {
544                            vec![UNAVAILABLE_NODE.to_string()]
545                        }
546                    })
547                    .collect(),
548                rpc_endpoint: node_registry
549                    .nodes
550                    .iter()
551                    .map(|node| {
552                        let id = if let Some(peer_id) = node.peer_id {
553                            peer_id.to_string().clone()
554                        } else {
555                            UNAVAILABLE_NODE.to_string()
556                        };
557                        (id, node.rpc_socket_addr)
558                    })
559                    .collect(),
560                safenodemand_endpoint: node_registry
561                    .daemon
562                    .as_ref()
563                    .and_then(|daemon| daemon.endpoint),
564                vm: vm.clone(),
565            };
566            node_vms.push(node_vm);
567        }
568        debug!("Node VMs generated from NodeRegistries: {node_vms:?}");
569        node_vms
570    }
571
572    pub fn get_quic_addresses(&self) -> Vec<String> {
573        self.node_listen_addresses
574            .iter()
575            .map(|addresses| {
576                addresses
577                    .iter()
578                    .find(|addr| {
579                        addr.contains("/quic-v1")
580                            && !addr.starts_with("/ip4/127.0.0.1")
581                            && !addr.starts_with("/ip4/10.")
582                    })
583                    .map(|s| s.to_string())
584                    .unwrap_or_else(|| UNAVAILABLE_NODE.to_string())
585            })
586            .collect()
587    }
588}
589
590/// The name of the OS user.
591pub type OsUser = String;
592
593#[derive(Clone, Debug, Serialize, Deserialize)]
594pub struct UploaderVirtualMachine {
595    pub vm: VirtualMachine,
596    /// The public key of the wallet for each OS user (1 uploader instance per OS user).
597    pub wallet_public_key: HashMap<OsUser, String>,
598}
599
600#[derive(Clone, Debug, Serialize, Deserialize)]
601pub struct NodeVirtualMachine {
602    pub vm: VirtualMachine,
603    pub node_count: usize,
604    pub node_listen_addresses: Vec<Vec<String>>,
605    pub rpc_endpoint: HashMap<String, SocketAddr>,
606    pub safenodemand_endpoint: Option<SocketAddr>,
607}
608
609#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Hash)]
610pub struct VirtualMachine {
611    pub id: u64,
612    pub name: String,
613    pub public_ip_addr: IpAddr,
614    pub private_ip_addr: IpAddr,
615}
616
617#[derive(Clone)]
618pub struct DeploymentNodeRegistries {
619    pub inventory_type: AnsibleInventoryType,
620    /// The (name, NodeRegistry) pairs for each VM that was successfully retrieved.
621    /// Note: for private nodes, the name is set to the private address of the VM.
622    pub retrieved_registries: Vec<(String, NodeRegistry)>,
623    pub failed_vms: Vec<String>,
624}
625
626impl DeploymentNodeRegistries {
627    pub fn print(&self) {
628        if self.retrieved_registries.is_empty() {
629            return;
630        }
631
632        Self::print_banner(&self.inventory_type.to_string());
633        for (vm_name, registry) in self.retrieved_registries.iter() {
634            println!("{vm_name}:");
635            for node in registry.nodes.iter() {
636                println!(
637                    "  {}: {} {}",
638                    node.service_name,
639                    node.version,
640                    Self::format_status(&node.status)
641                );
642            }
643        }
644        if !self.failed_vms.is_empty() {
645            println!(
646                "Failed to retrieve node registries for {}:",
647                self.inventory_type
648            );
649            for vm_name in self.failed_vms.iter() {
650                println!("- {}", vm_name);
651            }
652        }
653    }
654
655    fn format_status(status: &ServiceStatus) -> String {
656        match status {
657            ServiceStatus::Running => "RUNNING".to_string(),
658            ServiceStatus::Stopped => "STOPPED".to_string(),
659            ServiceStatus::Added => "ADDED".to_string(),
660            ServiceStatus::Removed => "REMOVED".to_string(),
661        }
662    }
663
664    fn print_banner(text: &str) {
665        let padding = 2;
666        let text_width = text.len() + padding * 2;
667        let border_chars = 2;
668        let total_width = text_width + border_chars;
669        let top_bottom = "═".repeat(total_width);
670
671        println!("╔{}╗", top_bottom);
672        println!("║ {:^width$} ║", text, width = text_width);
673        println!("╚{}╝", top_bottom);
674    }
675}
676
677#[derive(Clone, Debug, Serialize, Deserialize)]
678pub struct DeploymentInventory {
679    pub binary_option: BinaryOption,
680    pub environment_details: EnvironmentDetails,
681    pub failed_node_registry_vms: Vec<String>,
682    pub faucet_address: Option<String>,
683    pub full_cone_nat_gateway_vms: Vec<VirtualMachine>,
684    pub full_cone_private_node_vms: Vec<NodeVirtualMachine>,
685    pub genesis_vm: Option<NodeVirtualMachine>,
686    pub genesis_multiaddr: Option<String>,
687    pub misc_vms: Vec<VirtualMachine>,
688    pub name: String,
689    pub node_vms: Vec<NodeVirtualMachine>,
690    pub peer_cache_node_vms: Vec<NodeVirtualMachine>,
691    pub ssh_user: String,
692    pub ssh_private_key_path: PathBuf,
693    pub symmetric_nat_gateway_vms: Vec<VirtualMachine>,
694    pub symmetric_private_node_vms: Vec<NodeVirtualMachine>,
695    pub uploaded_files: Vec<(String, String)>,
696    pub uploader_vms: Vec<UploaderVirtualMachine>,
697}
698
699impl DeploymentInventory {
700    /// Create an inventory for a new deployment which is initially empty, other than the name and
701    /// binary option, which will have been selected.
702    pub fn empty(name: &str, binary_option: BinaryOption) -> DeploymentInventory {
703        Self {
704            binary_option,
705            environment_details: EnvironmentDetails::default(),
706            genesis_vm: Default::default(),
707            genesis_multiaddr: Default::default(),
708            failed_node_registry_vms: Default::default(),
709            faucet_address: Default::default(),
710            full_cone_nat_gateway_vms: Default::default(),
711            full_cone_private_node_vms: Default::default(),
712            misc_vms: Default::default(),
713            name: name.to_string(),
714            node_vms: Default::default(),
715            peer_cache_node_vms: Default::default(),
716            ssh_user: "root".to_string(),
717            ssh_private_key_path: Default::default(),
718            symmetric_nat_gateway_vms: Default::default(),
719            symmetric_private_node_vms: Default::default(),
720            uploaded_files: Default::default(),
721            uploader_vms: Default::default(),
722        }
723    }
724
725    pub fn get_tfvars_filename(&self) -> String {
726        let filename = self
727            .environment_details
728            .environment_type
729            .get_tfvars_filename(&self.name);
730        debug!("Using tfvars file {filename}",);
731        filename
732    }
733
734    pub fn is_empty(&self) -> bool {
735        self.peer_cache_node_vms.is_empty() && self.node_vms.is_empty()
736    }
737
738    pub fn vm_list(&self) -> Vec<VirtualMachine> {
739        let mut list = Vec::new();
740        list.extend(self.symmetric_nat_gateway_vms.clone());
741        list.extend(self.full_cone_nat_gateway_vms.clone());
742        list.extend(
743            self.peer_cache_node_vms
744                .iter()
745                .map(|node_vm| node_vm.vm.clone()),
746        );
747        list.extend(self.genesis_vm.iter().map(|node_vm| node_vm.vm.clone()));
748        list.extend(self.node_vms.iter().map(|node_vm| node_vm.vm.clone()));
749        list.extend(self.misc_vms.clone());
750        list.extend(
751            self.symmetric_private_node_vms
752                .iter()
753                .map(|node_vm| node_vm.vm.clone()),
754        );
755        list.extend(
756            self.full_cone_private_node_vms
757                .iter()
758                .map(|node_vm| node_vm.vm.clone()),
759        );
760        list.extend(
761            self.uploader_vms
762                .iter()
763                .map(|uploader_vm| uploader_vm.vm.clone()),
764        );
765        list
766    }
767
768    pub fn node_vm_list(&self) -> Vec<NodeVirtualMachine> {
769        let mut list = Vec::new();
770        list.extend(self.peer_cache_node_vms.iter().cloned());
771        list.extend(self.genesis_vm.iter().cloned());
772        list.extend(self.node_vms.iter().cloned());
773        list.extend(self.full_cone_private_node_vms.iter().cloned());
774        list.extend(self.symmetric_private_node_vms.iter().cloned());
775
776        list
777    }
778
779    pub fn peers(&self) -> HashSet<String> {
780        let mut list = HashSet::new();
781        list.extend(
782            self.peer_cache_node_vms
783                .iter()
784                .flat_map(|node_vm| node_vm.get_quic_addresses()),
785        );
786        list.extend(
787            self.genesis_vm
788                .iter()
789                .flat_map(|node_vm| node_vm.get_quic_addresses()),
790        );
791        list.extend(
792            self.node_vms
793                .iter()
794                .flat_map(|node_vm| node_vm.get_quic_addresses()),
795        );
796        list.extend(
797            self.full_cone_private_node_vms
798                .iter()
799                .flat_map(|node_vm| node_vm.get_quic_addresses()),
800        );
801        list.extend(
802            self.symmetric_private_node_vms
803                .iter()
804                .flat_map(|node_vm| node_vm.get_quic_addresses()),
805        );
806        list
807    }
808
809    pub fn save(&self) -> Result<()> {
810        let path = get_data_directory()?.join(format!("{}-inventory.json", self.name));
811        let serialized_data = serde_json::to_string_pretty(self)?;
812        let mut file = File::create(path)?;
813        file.write_all(serialized_data.as_bytes())?;
814        Ok(())
815    }
816
817    pub fn read(file_path: &PathBuf) -> Result<Self> {
818        let data = std::fs::read_to_string(file_path)?;
819        let deserialized_data: DeploymentInventory = serde_json::from_str(&data)?;
820        Ok(deserialized_data)
821    }
822
823    pub fn add_uploaded_files(&mut self, uploaded_files: Vec<(String, String)>) {
824        self.uploaded_files.extend_from_slice(&uploaded_files);
825    }
826
827    pub fn get_random_peer(&self) -> Option<String> {
828        let mut rng = rand::thread_rng();
829        self.peers().into_iter().choose(&mut rng)
830    }
831
832    pub fn peer_cache_node_count(&self) -> usize {
833        if let Some(first_vm) = self.peer_cache_node_vms.first() {
834            first_vm.node_count
835        } else {
836            0
837        }
838    }
839
840    pub fn genesis_node_count(&self) -> usize {
841        if let Some(genesis_vm) = &self.genesis_vm {
842            genesis_vm.node_count
843        } else {
844            0
845        }
846    }
847
848    pub fn node_count(&self) -> usize {
849        if let Some(first_vm) = self.node_vms.first() {
850            first_vm.node_count
851        } else {
852            0
853        }
854    }
855
856    pub fn full_cone_private_node_count(&self) -> usize {
857        if let Some(first_vm) = self.full_cone_private_node_vms.first() {
858            first_vm.node_count
859        } else {
860            0
861        }
862    }
863
864    pub fn symmetric_private_node_count(&self) -> usize {
865        if let Some(first_vm) = self.symmetric_private_node_vms.first() {
866            first_vm.node_count
867        } else {
868            0
869        }
870    }
871
872    pub fn print_report(&self, full: bool) -> Result<()> {
873        println!("**************************************");
874        println!("*                                    *");
875        println!("*          Inventory Report          *");
876        println!("*                                    *");
877        println!("**************************************");
878
879        println!("Environment Name: {}", self.name);
880        println!();
881        match &self.binary_option {
882            BinaryOption::BuildFromSource {
883                repo_owner, branch, ..
884            } => {
885                println!("==============");
886                println!("Branch Details");
887                println!("==============");
888                println!("Repo owner: {repo_owner}");
889                println!("Branch name: {branch}");
890                println!();
891            }
892            BinaryOption::Versioned {
893                ant_version: safe_version,
894                antnode_version: safenode_version,
895                antctl_version: safenode_manager_version,
896            } => {
897                println!("===============");
898                println!("Version Details");
899                println!("===============");
900                println!(
901                    "safe version: {}",
902                    safe_version
903                        .as_ref()
904                        .map_or("N/A".to_string(), |v| v.to_string())
905                );
906                println!("safenode version: {}", safenode_version);
907                println!("safenode-manager version: {}", safenode_manager_version);
908                println!();
909            }
910        }
911
912        if !self.peer_cache_node_vms.is_empty() {
913            println!("==============");
914            println!("Peer Cache VMs");
915            println!("==============");
916            for node_vm in self.peer_cache_node_vms.iter() {
917                println!("{}: {}", node_vm.vm.name, node_vm.vm.public_ip_addr);
918            }
919            println!("Nodes per VM: {}", self.peer_cache_node_count());
920            println!("SSH user: {}", self.ssh_user);
921            println!();
922
923            self.print_peer_cache_webserver();
924        }
925
926        println!("========");
927        println!("Node VMs");
928        println!("========");
929        if let Some(genesis_vm) = &self.genesis_vm {
930            println!("{}: {}", genesis_vm.vm.name, genesis_vm.vm.public_ip_addr);
931        }
932        for node_vm in self.node_vms.iter() {
933            println!("{}: {}", node_vm.vm.name, node_vm.vm.public_ip_addr);
934        }
935        println!("Nodes per VM: {}", self.node_count());
936        println!("SSH user: {}", self.ssh_user);
937        println!();
938
939        println!("=================");
940        println!("Full Cone Private Node VMs");
941        println!("=================");
942        let full_cone_private_node_nat_gateway_map =
943            PrivateNodeProvisionInventory::match_private_node_vm_and_gateway_vm(
944                self.full_cone_private_node_vms
945                    .iter()
946                    .map(|node_vm| node_vm.vm.clone())
947                    .collect::<Vec<_>>()
948                    .as_slice(),
949                &self.full_cone_nat_gateway_vms,
950            )?;
951
952        for (node_vm, nat_gateway_vm) in full_cone_private_node_nat_gateway_map.iter() {
953            println!(
954                "{}: {} ==routed through==> {}: {}",
955                node_vm.name,
956                node_vm.public_ip_addr,
957                nat_gateway_vm.name,
958                nat_gateway_vm.public_ip_addr
959            );
960            let ssh = if let Some(ssh_key_path) = self.ssh_private_key_path.to_str() {
961                format!(
962                    "ssh -i {ssh_key_path} root@{}",
963                    nat_gateway_vm.public_ip_addr,
964                )
965            } else {
966                format!("ssh root@{}", nat_gateway_vm.public_ip_addr,)
967            };
968            println!("SSH using NAT gateway: {ssh}");
969        }
970        println!("Nodes per VM: {}", self.node_count());
971        println!("SSH user: {}", self.ssh_user);
972        println!();
973
974        println!("=================");
975        println!("Symmetric Private Node VMs");
976        println!("=================");
977        let symmetric_private_node_nat_gateway_map =
978            PrivateNodeProvisionInventory::match_private_node_vm_and_gateway_vm(
979                self.symmetric_private_node_vms
980                    .iter()
981                    .map(|node_vm| node_vm.vm.clone())
982                    .collect::<Vec<_>>()
983                    .as_slice(),
984                &self.symmetric_nat_gateway_vms,
985            )?;
986
987        for (node_vm, nat_gateway_vm) in symmetric_private_node_nat_gateway_map.iter() {
988            println!(
989                "{}: {} ==routed through==> {}: {}",
990                node_vm.name,
991                node_vm.public_ip_addr,
992                nat_gateway_vm.name,
993                nat_gateway_vm.public_ip_addr
994            );
995            let ssh = if let Some(ssh_key_path) = self.ssh_private_key_path.to_str() {
996                format!(
997                        "ssh -i {ssh_key_path} -o ProxyCommand=\"ssh -W %h:%p root@{} -i {ssh_key_path}\" root@{}",
998                        nat_gateway_vm.public_ip_addr, node_vm.private_ip_addr
999                    )
1000            } else {
1001                format!(
1002                    "ssh -o ProxyCommand=\"ssh -W %h:%p root@{}\" root@{}",
1003                    nat_gateway_vm.public_ip_addr, node_vm.private_ip_addr
1004                )
1005            };
1006            println!("SSH using NAT gateway: {ssh}");
1007        }
1008
1009        if !self.uploader_vms.is_empty() {
1010            println!("============");
1011            println!("Uploader VMs");
1012            println!("============");
1013            for uploader_vm in self.uploader_vms.iter() {
1014                println!("{}: {}", uploader_vm.vm.name, uploader_vm.vm.public_ip_addr);
1015            }
1016            println!();
1017
1018            println!("===========================");
1019            println!("Uploader Wallet Public Keys");
1020            println!("===========================");
1021            for uploader_vm in self.uploader_vms.iter() {
1022                for (user, key) in uploader_vm.wallet_public_key.iter() {
1023                    println!("{}@{}: {}", uploader_vm.vm.name, user, key);
1024                }
1025            }
1026        }
1027
1028        if !self.misc_vms.is_empty() {
1029            println!("=========");
1030            println!("Other VMs");
1031            println!("=========");
1032        }
1033        if !self.misc_vms.is_empty() {
1034            for vm in self.misc_vms.iter() {
1035                println!("{}: {}", vm.name, vm.public_ip_addr);
1036            }
1037        }
1038
1039        for nat_gateway_vm in self.full_cone_nat_gateway_vms.iter() {
1040            println!("{}: {}", nat_gateway_vm.name, nat_gateway_vm.public_ip_addr);
1041        }
1042
1043        for nat_gateway_vm in self.symmetric_nat_gateway_vms.iter() {
1044            println!("{}: {}", nat_gateway_vm.name, nat_gateway_vm.public_ip_addr);
1045        }
1046
1047        println!("SSH user: {}", self.ssh_user);
1048        println!();
1049
1050        if full {
1051            println!("===============");
1052            println!("Full Peer List");
1053            println!("===============");
1054            let mut quic_listeners = Vec::new();
1055            let mut ws_listeners = Vec::new();
1056
1057            for node_vm in self.peer_cache_node_vms.iter().chain(self.node_vms.iter()) {
1058                for addresses in &node_vm.node_listen_addresses {
1059                    for addr in addresses {
1060                        if !addr.starts_with("/ip4/127.0.0.1") && !addr.starts_with("/ip4/10.") {
1061                            if addr.contains("/quic") {
1062                                quic_listeners.push(addr.clone());
1063                            } else if addr.contains("/ws") {
1064                                ws_listeners.push(addr.clone());
1065                            }
1066                        }
1067                    }
1068                }
1069            }
1070
1071            if !quic_listeners.is_empty() {
1072                println!("QUIC:");
1073                for addr in quic_listeners {
1074                    println!("  {addr}");
1075                }
1076                println!();
1077            }
1078
1079            if !ws_listeners.is_empty() {
1080                println!("Websocket:");
1081                for addr in ws_listeners {
1082                    println!("  {addr}");
1083                }
1084                println!();
1085            }
1086        } else {
1087            println!("============");
1088            println!("Sample Peers");
1089            println!("============");
1090            self.peer_cache_node_vms
1091                .iter()
1092                .chain(self.node_vms.iter())
1093                .map(|node_vm| node_vm.vm.public_ip_addr.to_string())
1094                .for_each(|ip| {
1095                    if let Some(peer) = self.peers().iter().find(|p| p.contains(&ip)) {
1096                        println!("{peer}");
1097                    }
1098                });
1099        }
1100        println!();
1101
1102        println!(
1103            "Genesis: {}",
1104            self.genesis_multiaddr
1105                .as_ref()
1106                .map_or("N/A", |genesis| genesis)
1107        );
1108        let inventory_file_path =
1109            get_data_directory()?.join(format!("{}-inventory.json", self.name));
1110        println!(
1111            "The full inventory is at {}",
1112            inventory_file_path.to_string_lossy()
1113        );
1114        println!();
1115
1116        if !self.uploaded_files.is_empty() {
1117            println!("Uploaded files:");
1118            for file in self.uploaded_files.iter() {
1119                println!("{}: {}", file.0, file.1);
1120            }
1121        }
1122
1123        if self.environment_details.evm_data_payments_address.is_some()
1124            || self.environment_details.evm_payment_token_address.is_some()
1125            || self.environment_details.evm_rpc_url.is_some()
1126        {
1127            println!("===========");
1128            println!("EVM Details");
1129            println!("===========");
1130            println!(
1131                "EVM data payments address: {}",
1132                self.environment_details
1133                    .evm_data_payments_address
1134                    .as_ref()
1135                    .map_or("N/A", |addr| addr)
1136            );
1137            println!(
1138                "EVM payment token address: {}",
1139                self.environment_details
1140                    .evm_payment_token_address
1141                    .as_ref()
1142                    .map_or("N/A", |addr| addr)
1143            );
1144            println!(
1145                "EVM RPC URL: {}",
1146                self.environment_details
1147                    .evm_rpc_url
1148                    .as_ref()
1149                    .map_or("N/A", |addr| addr)
1150            );
1151        }
1152
1153        Ok(())
1154    }
1155
1156    pub fn get_genesis_ip(&self) -> Option<IpAddr> {
1157        self.misc_vms
1158            .iter()
1159            .find(|vm| vm.name.contains("genesis"))
1160            .map(|vm| vm.public_ip_addr)
1161    }
1162
1163    pub fn print_peer_cache_webserver(&self) {
1164        println!("=====================");
1165        println!("Peer Cache Webservers");
1166        println!("=====================");
1167
1168        for node_vm in &self.peer_cache_node_vms {
1169            let webserver = get_bootstrap_cache_url(&node_vm.vm.public_ip_addr);
1170            println!("{}: {webserver}", node_vm.vm.name);
1171        }
1172    }
1173}
1174
1175pub fn get_data_directory() -> Result<PathBuf> {
1176    let path = dirs_next::data_dir()
1177        .ok_or_else(|| eyre!("Could not retrieve data directory"))?
1178        .join("autonomi")
1179        .join("testnet-deploy");
1180    if !path.exists() {
1181        std::fs::create_dir_all(path.clone())?;
1182    }
1183    Ok(path)
1184}