1pub 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 Bootstrap,
71 Client,
73 #[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#[derive(Clone, Debug, Serialize, Deserialize)]
349pub enum BinaryOption {
350 BuildFromSource {
352 antnode_features: Option<String>,
354 branch: String,
355 repo_owner: String,
356 skip_binary_build: bool,
359 },
360 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 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 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); 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 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 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
1164pub 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 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 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 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 "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 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 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 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 (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}