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