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