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