sn_testnet_deploy/
clients.rs

1// Copyright (c) 2023, MaidSafe.
2// All rights reserved.
3//
4// This SAFE Network Software is licensed under the BSD-3-Clause license.
5// Please see the LICENSE file for more details.
6
7use crate::{
8    ansible::{
9        inventory::cleanup_environment_inventory,
10        provisioning::{AnsibleProvisioner, ProvisionOptions},
11        AnsibleRunner,
12    },
13    error::{Error, Result},
14    get_environment_details,
15    infra::ClientsInfraRunOptions,
16    inventory::ClientsDeploymentInventory,
17    print_duration,
18    s3::S3Repository,
19    ssh::SshClient,
20    terraform::TerraformRunner,
21    write_environment_details, BinaryOption, CloudProvider, DeploymentType, EnvironmentDetails,
22    EnvironmentType, EvmDetails,
23};
24use alloy::primitives::U256;
25use serde::{Deserialize, Serialize};
26use std::{path::PathBuf, time::Instant};
27
28const ANSIBLE_DEFAULT_FORKS: usize = 50;
29
30#[derive(Clone, Serialize, Deserialize)]
31pub struct ClientsDeployOptions {
32    pub binary_option: BinaryOption,
33    pub chunk_size: Option<u64>,
34    pub client_env_variables: Option<Vec<(String, String)>>,
35    pub client_vm_count: Option<u16>,
36    pub client_vm_size: Option<String>,
37    pub current_inventory: ClientsDeploymentInventory,
38    pub enable_download_verifier: bool,
39    pub enable_performance_verifier: bool,
40    pub enable_random_verifier: bool,
41    pub enable_telegraf: bool,
42    pub enable_uploaders: bool,
43    pub environment_type: EnvironmentType,
44    pub evm_details: EvmDetails,
45    pub file_address: Option<String>,
46    pub expected_hash: Option<String>,
47    pub expected_size: Option<u64>,
48    pub funding_wallet_secret_key: Option<String>,
49    pub initial_gas: Option<U256>,
50    pub initial_tokens: Option<U256>,
51    pub max_archived_log_files: u16,
52    pub max_log_files: u16,
53    pub max_uploads: Option<u32>,
54    pub name: String,
55    pub network_id: Option<u8>,
56    pub network_contacts_url: Option<String>,
57    pub output_inventory_dir_path: PathBuf,
58    pub peer: Option<String>,
59    pub uploaders_count: u16,
60    pub upload_size: Option<u16>,
61    pub wallet_secret_keys: Option<Vec<String>>,
62}
63
64#[derive(Default)]
65pub struct ClientsDeployBuilder {
66    ansible_forks: Option<usize>,
67    ansible_verbose_mode: bool,
68    deployment_type: EnvironmentType,
69    environment_name: String,
70    provider: Option<CloudProvider>,
71    region: Option<String>,
72    ssh_secret_key_path: Option<PathBuf>,
73    state_bucket_name: Option<String>,
74    terraform_binary_path: Option<PathBuf>,
75    vault_password_path: Option<PathBuf>,
76    working_directory_path: Option<PathBuf>,
77}
78
79impl ClientsDeployBuilder {
80    pub fn new() -> Self {
81        Default::default()
82    }
83
84    pub fn ansible_verbose_mode(&mut self, ansible_verbose_mode: bool) -> &mut Self {
85        self.ansible_verbose_mode = ansible_verbose_mode;
86        self
87    }
88
89    pub fn ansible_forks(&mut self, ansible_forks: usize) -> &mut Self {
90        self.ansible_forks = Some(ansible_forks);
91        self
92    }
93
94    pub fn deployment_type(&mut self, deployment_type: EnvironmentType) -> &mut Self {
95        self.deployment_type = deployment_type;
96        self
97    }
98
99    pub fn environment_name(&mut self, name: &str) -> &mut Self {
100        self.environment_name = name.to_string();
101        self
102    }
103
104    pub fn provider(&mut self, provider: CloudProvider) -> &mut Self {
105        self.provider = Some(provider);
106        self
107    }
108
109    pub fn state_bucket_name(&mut self, state_bucket_name: String) -> &mut Self {
110        self.state_bucket_name = Some(state_bucket_name);
111        self
112    }
113
114    pub fn terraform_binary_path(&mut self, terraform_binary_path: PathBuf) -> &mut Self {
115        self.terraform_binary_path = Some(terraform_binary_path);
116        self
117    }
118
119    pub fn working_directory(&mut self, working_directory_path: PathBuf) -> &mut Self {
120        self.working_directory_path = Some(working_directory_path);
121        self
122    }
123
124    pub fn ssh_secret_key_path(&mut self, ssh_secret_key_path: PathBuf) -> &mut Self {
125        self.ssh_secret_key_path = Some(ssh_secret_key_path);
126        self
127    }
128
129    pub fn vault_password_path(&mut self, vault_password_path: PathBuf) -> &mut Self {
130        self.vault_password_path = Some(vault_password_path);
131        self
132    }
133
134    pub fn region(&mut self, region: String) -> &mut Self {
135        self.region = Some(region);
136        self
137    }
138
139    pub fn build(&self) -> Result<ClientsDeployer> {
140        let provider = self.provider.unwrap_or(CloudProvider::DigitalOcean);
141        match provider {
142            CloudProvider::DigitalOcean => {
143                let digital_ocean_pat = std::env::var("DO_PAT").map_err(|_| {
144                    Error::CloudProviderCredentialsNotSupplied("DO_PAT".to_string())
145                })?;
146                // The DO_PAT variable is not actually read by either Terraform or Ansible.
147                // Each tool uses a different variable, so instead we set each of those variables
148                // to the value of DO_PAT. This means the user only needs to set one variable.
149                std::env::set_var("DIGITALOCEAN_TOKEN", digital_ocean_pat.clone());
150                std::env::set_var("DO_API_TOKEN", digital_ocean_pat);
151            }
152            _ => {
153                return Err(Error::CloudProviderNotSupported(provider.to_string()));
154            }
155        }
156
157        let state_bucket_name = match self.state_bucket_name {
158            Some(ref bucket_name) => bucket_name.clone(),
159            None => std::env::var("CLIENT_TERRAFORM_STATE_BUCKET_NAME")?,
160        };
161
162        let default_terraform_bin_path = PathBuf::from("terraform");
163        let terraform_binary_path = self
164            .terraform_binary_path
165            .as_ref()
166            .unwrap_or(&default_terraform_bin_path);
167
168        let working_directory_path = match self.working_directory_path {
169            Some(ref work_dir_path) => work_dir_path.clone(),
170            None => std::env::current_dir()?.join("resources"),
171        };
172
173        let ssh_secret_key_path = match self.ssh_secret_key_path {
174            Some(ref ssh_sk_path) => ssh_sk_path.clone(),
175            None => PathBuf::from(std::env::var("SSH_KEY_PATH")?),
176        };
177
178        let vault_password_path = match self.vault_password_path {
179            Some(ref vault_pw_path) => vault_pw_path.clone(),
180            None => PathBuf::from(std::env::var("ANSIBLE_VAULT_PASSWORD_PATH")?),
181        };
182
183        let region = match self.region {
184            Some(ref region) => region.clone(),
185            None => "lon1".to_string(),
186        };
187
188        let terraform_runner = TerraformRunner::new(
189            terraform_binary_path.to_path_buf(),
190            working_directory_path
191                .join("terraform")
192                .join("clients")
193                .join(provider.to_string()),
194            provider,
195            &state_bucket_name,
196        )?;
197
198        let ansible_runner = AnsibleRunner::new(
199            self.ansible_forks.unwrap_or(ANSIBLE_DEFAULT_FORKS),
200            self.ansible_verbose_mode,
201            &self.environment_name,
202            provider,
203            ssh_secret_key_path.clone(),
204            vault_password_path,
205            working_directory_path.join("ansible"),
206        )?;
207
208        let ssh_client = SshClient::new(ssh_secret_key_path);
209        let ansible_provisioner =
210            AnsibleProvisioner::new(ansible_runner, provider, ssh_client.clone());
211
212        let client_deployer = ClientsDeployer::new(
213            ansible_provisioner,
214            provider,
215            self.deployment_type.clone(),
216            &self.environment_name,
217            S3Repository {},
218            ssh_client,
219            terraform_runner,
220            working_directory_path,
221            region,
222        )?;
223
224        Ok(client_deployer)
225    }
226}
227
228#[derive(Clone)]
229pub struct ClientsDeployer {
230    pub ansible_provisioner: AnsibleProvisioner,
231    pub cloud_provider: CloudProvider,
232    pub deployment_type: EnvironmentType,
233    pub environment_name: String,
234    pub inventory_file_path: PathBuf,
235    pub region: String,
236    pub s3_repository: S3Repository,
237    pub ssh_client: SshClient,
238    pub terraform_runner: TerraformRunner,
239    pub working_directory_path: PathBuf,
240}
241
242impl ClientsDeployer {
243    #[allow(clippy::too_many_arguments)]
244    pub fn new(
245        ansible_provisioner: AnsibleProvisioner,
246        cloud_provider: CloudProvider,
247        deployment_type: EnvironmentType,
248        environment_name: &str,
249        s3_repository: S3Repository,
250        ssh_client: SshClient,
251        terraform_runner: TerraformRunner,
252        working_directory_path: PathBuf,
253        region: String,
254    ) -> Result<ClientsDeployer> {
255        if environment_name.is_empty() {
256            return Err(Error::EnvironmentNameRequired);
257        }
258        let inventory_file_path = working_directory_path
259            .join("ansible")
260            .join("inventory")
261            .join("dev_inventory_digital_ocean.yml");
262
263        Ok(ClientsDeployer {
264            ansible_provisioner,
265            cloud_provider,
266            deployment_type,
267            environment_name: environment_name.to_string(),
268            inventory_file_path,
269            region,
270            s3_repository,
271            ssh_client,
272            terraform_runner,
273            working_directory_path,
274        })
275    }
276
277    pub fn create_or_update_infra(&self, options: &ClientsInfraRunOptions) -> Result<()> {
278        let start = Instant::now();
279        println!("Selecting {} workspace...", options.name);
280        self.terraform_runner.workspace_select(&options.name)?;
281
282        let args = options.build_terraform_args()?;
283
284        println!("Running terraform apply...");
285        self.terraform_runner
286            .apply(args, Some(options.tfvars_filenames.clone()))?;
287        print_duration(start.elapsed());
288        Ok(())
289    }
290
291    pub async fn init(&self) -> Result<()> {
292        self.terraform_runner.init()?;
293        let workspaces = self.terraform_runner.workspace_list()?;
294        if !workspaces.contains(&self.environment_name) {
295            self.terraform_runner
296                .workspace_new(&self.environment_name)?;
297        } else {
298            println!("Workspace {} already exists", self.environment_name);
299        }
300
301        Ok(())
302    }
303
304    pub fn plan(&self, options: &ClientsInfraRunOptions) -> Result<()> {
305        println!("Selecting {} workspace...", options.name);
306        self.terraform_runner.workspace_select(&options.name)?;
307
308        let args = options.build_terraform_args()?;
309
310        self.terraform_runner
311            .plan(Some(args), Some(options.tfvars_filenames.clone()))?;
312        Ok(())
313    }
314
315    pub async fn deploy(&self, options: ClientsDeployOptions) -> Result<()> {
316        println!(
317            "Deploying client for environment: {}",
318            self.environment_name
319        );
320
321        let build_custom_binaries = {
322            match &options.binary_option {
323                BinaryOption::BuildFromSource { .. } => true,
324                BinaryOption::Versioned { .. } => false,
325            }
326        };
327
328        let start = Instant::now();
329        println!("Initializing infrastructure...");
330
331        let infra_options = ClientsInfraRunOptions {
332            client_image_id: None,
333            client_vm_count: options.client_vm_count,
334            client_vm_size: options.client_vm_size.clone(),
335            enable_build_vm: build_custom_binaries,
336            name: options.name.clone(),
337            tfvars_filenames: options.current_inventory.get_tfvars_filenames(),
338        };
339
340        self.create_or_update_infra(&infra_options)?;
341
342        write_environment_details(
343            &self.s3_repository,
344            &options.name,
345            &EnvironmentDetails {
346                deployment_type: DeploymentType::Client,
347                environment_type: options.environment_type.clone(),
348                evm_details: EvmDetails {
349                    network: options.evm_details.network.clone(),
350                    data_payments_address: options.evm_details.data_payments_address.clone(),
351                    payment_token_address: options.evm_details.payment_token_address.clone(),
352                    rpc_url: options.evm_details.rpc_url.clone(),
353                },
354                funding_wallet_address: None,
355                network_id: options.network_id,
356                region: self.region.clone(),
357                rewards_address: None,
358            },
359        )
360        .await?;
361
362        println!("Provisioning Client with Ansible...");
363        let provision_options = ProvisionOptions::from(options.clone());
364
365        if build_custom_binaries {
366            self.ansible_provisioner
367                .print_ansible_run_banner("Build Custom Binaries");
368            self.ansible_provisioner
369                .build_safe_network_binaries(&provision_options, Some(vec!["ant".to_string()]))
370                .map_err(|err| {
371                    println!("Failed to build safe network binaries {err:?}");
372                    err
373                })?;
374        }
375
376        self.ansible_provisioner
377            .print_ansible_run_banner("Provision Clients");
378        self.ansible_provisioner
379            .provision_clients(
380                &provision_options,
381                options.peer.clone(),
382                options.network_contacts_url.clone(),
383            )
384            .await
385            .map_err(|err| {
386                println!("Failed to provision Clients {err:?}");
387                err
388            })?;
389
390        self.ansible_provisioner
391            .print_ansible_run_banner("Provision Downloaders");
392        self.ansible_provisioner
393            .provision_downloaders(
394                &provision_options,
395                options.peer.clone(),
396                options.network_contacts_url.clone(),
397            )
398            .await
399            .map_err(|err| {
400                println!("Failed to provision downloaders {err:?}");
401                err
402            })?;
403
404        println!("Deployment completed successfully in {:?}", start.elapsed());
405        Ok(())
406    }
407
408    async fn destroy_infra(&self, environment_details: &EnvironmentDetails) -> Result<()> {
409        crate::infra::select_workspace(&self.terraform_runner, &self.environment_name)?;
410
411        let options = ClientsInfraRunOptions::generate_existing(
412            &self.environment_name,
413            &self.terraform_runner,
414            environment_details,
415        )
416        .await?;
417
418        let mut args = Vec::new();
419        if let Some(vm_count) = options.client_vm_count {
420            args.push(("ant_client_vm_count".to_string(), vm_count.to_string()));
421        }
422        if let Some(vm_size) = &options.client_vm_size {
423            args.push(("ant_client_droplet_size".to_string(), vm_size.clone()));
424        }
425        args.push((
426            "use_custom_bin".to_string(),
427            options.enable_build_vm.to_string(),
428        ));
429
430        self.terraform_runner
431            .destroy(Some(args), Some(options.tfvars_filenames.clone()))?;
432
433        crate::infra::delete_workspace(&self.terraform_runner, &self.environment_name)?;
434
435        Ok(())
436    }
437
438    pub async fn clean(&self) -> Result<()> {
439        let environment_details =
440            get_environment_details(&self.environment_name, &self.s3_repository).await?;
441        crate::funding::drain_funds(&self.ansible_provisioner, &environment_details).await?;
442
443        self.destroy_infra(&environment_details).await?;
444
445        cleanup_environment_inventory(
446            &self.environment_name,
447            &self
448                .working_directory_path
449                .join("ansible")
450                .join("inventory"),
451            None,
452        )?;
453
454        self.s3_repository
455            .delete_object("sn-environment-type", &self.environment_name)
456            .await?;
457        Ok(())
458    }
459}