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