sn_testnet_deploy/
infra.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 log::debug;
8
9use crate::{
10    error::{Error, Result},
11    print_duration,
12    terraform::{TerraformResource, TerraformRunner},
13    EnvironmentDetails, TestnetDeployer,
14};
15use std::time::Instant;
16
17const BUILD_VM: &str = "build";
18const CLIENT: &str = "ant_client";
19const EVM_NODE: &str = "evm_node";
20const FULL_CONE_NAT_GATEWAY: &str = "full_cone_nat_gateway";
21const FULL_CONE_PRIVATE_NODE: &str = "full_cone_private_node";
22const FULL_CONE_PRIVATE_NODE_ATTACHED_VOLUME: &str = "full_cone_private_node_attached_volume";
23const GENESIS_NODE: &str = "genesis_bootstrap";
24const GENESIS_NODE_ATTACHED_VOLUME: &str = "genesis_node_attached_volume";
25const NODE: &str = "node";
26const NODE_ATTACHED_VOLUME: &str = "node_attached_volume";
27const PEER_CACHE_NODE: &str = "peer_cache_node";
28const PEER_CACHE_NODE_ATTACHED_VOLUME: &str = "peer_cache_node_attached_volume";
29const SYMMETRIC_NAT_GATEWAY: &str = "symmetric_nat_gateway";
30const SYMMETRIC_PRIVATE_NODE: &str = "symmetric_private_node";
31const SYMMETRIC_PRIVATE_NODE_ATTACHED_VOLUME: &str = "symmetric_private_node_attached_volume";
32
33const SIZE: &str = "size";
34const IMAGE: &str = "image";
35
36#[derive(Clone, Debug)]
37pub struct InfraRunOptions {
38    /// Set to None for new deployments, as the value will be fetched from tfvars.
39    pub client_image_id: Option<String>,
40    pub client_vm_count: Option<u16>,
41    pub client_vm_size: Option<String>,
42    pub enable_build_vm: bool,
43    pub evm_node_count: Option<u16>,
44    pub evm_node_vm_size: Option<String>,
45    /// Set to None for new deployments, as the value will be fetched from tfvars.
46    pub evm_node_image_id: Option<String>,
47    pub full_cone_nat_gateway_vm_size: Option<String>,
48    pub full_cone_private_node_vm_count: Option<u16>,
49    pub full_cone_private_node_volume_size: Option<u16>,
50    pub genesis_vm_count: Option<u16>,
51    pub genesis_node_volume_size: Option<u16>,
52    pub name: String,
53    /// Set to None for new deployments, as the value will be fetched from tfvars.
54    pub nat_gateway_image_id: Option<String>,
55    /// Set to None for new deployments, as the value will be fetched from tfvars.
56    pub node_image_id: Option<String>,
57    pub node_vm_count: Option<u16>,
58    pub node_vm_size: Option<String>,
59    pub node_volume_size: Option<u16>,
60    /// Set to None for new deployments, as the value will be fetched from tfvars.
61    pub peer_cache_image_id: Option<String>,
62    pub peer_cache_node_vm_count: Option<u16>,
63    pub peer_cache_node_vm_size: Option<String>,
64    pub peer_cache_node_volume_size: Option<u16>,
65    pub region: String,
66    pub symmetric_nat_gateway_vm_size: Option<String>,
67    pub symmetric_private_node_vm_count: Option<u16>,
68    pub symmetric_private_node_volume_size: Option<u16>,
69    pub tfvars_filenames: Option<Vec<String>>,
70}
71
72impl InfraRunOptions {
73    /// Generate the options for an existing deployment.
74    pub async fn generate_existing(
75        name: &str,
76        region: &str,
77        terraform_runner: &TerraformRunner,
78        environment_details: Option<&EnvironmentDetails>,
79    ) -> Result<Self> {
80        let resources = terraform_runner.show(name)?;
81
82        let resource_count = |resource_name: &str| -> u16 {
83            resources
84                .iter()
85                .filter(|r| r.resource_name == resource_name)
86                .count() as u16
87        };
88
89        let peer_cache_node_vm_count = resource_count(PEER_CACHE_NODE);
90        debug!("Peer cache node count: {peer_cache_node_vm_count}");
91        let (peer_cache_node_volume_size, peer_cache_node_vm_size, peer_cache_image_id) =
92            if peer_cache_node_vm_count > 0 {
93                let volume_size =
94                    get_value_for_resource(&resources, PEER_CACHE_NODE_ATTACHED_VOLUME, SIZE)?;
95                debug!("Peer cache node volume size: {volume_size:?}");
96                let vm_size = get_value_for_resource(&resources, PEER_CACHE_NODE, SIZE)?;
97                debug!("Peer cache node size: {vm_size:?}");
98                let image_id = get_value_for_resource(&resources, PEER_CACHE_NODE, IMAGE)?;
99                debug!("Peer cache node image id: {image_id:?}");
100
101                (volume_size, vm_size, image_id)
102            } else {
103                (None, None, None)
104            };
105
106        let genesis_node_vm_count = resource_count(GENESIS_NODE);
107        debug!("Genesis node count: {genesis_node_vm_count}");
108        let genesis_node_volume_size = if genesis_node_vm_count > 0 {
109            get_value_for_resource(&resources, GENESIS_NODE_ATTACHED_VOLUME, SIZE)?
110        } else {
111            None
112        };
113        debug!("Genesis node volume size: {genesis_node_volume_size:?}");
114
115        let node_vm_count = resource_count(NODE);
116        debug!("Node count: {node_vm_count}");
117        let node_volume_size = if node_vm_count > 0 {
118            get_value_for_resource(&resources, NODE_ATTACHED_VOLUME, SIZE)?
119        } else {
120            None
121        };
122        debug!("Node volume size: {node_volume_size:?}");
123
124        let mut nat_gateway_image_id: Option<String> = None;
125        let symmetric_private_node_vm_count = resource_count(SYMMETRIC_PRIVATE_NODE);
126        debug!("Symmetric private node count: {symmetric_private_node_vm_count}");
127        let (symmetric_private_node_volume_size, symmetric_nat_gateway_vm_size) =
128            if symmetric_private_node_vm_count > 0 {
129                let symmetric_private_node_volume_size = get_value_for_resource(
130                    &resources,
131                    SYMMETRIC_PRIVATE_NODE_ATTACHED_VOLUME,
132                    SIZE,
133                )?;
134                debug!(
135                    "Symmetric private node volume size: {symmetric_private_node_volume_size:?}"
136                );
137                // gateways should exists if private nodes exist
138                let symmetric_nat_gateway_vm_size =
139                    get_value_for_resource(&resources, SYMMETRIC_NAT_GATEWAY, SIZE)?;
140
141                debug!("Symmetric nat gateway size: {symmetric_nat_gateway_vm_size:?}");
142
143                nat_gateway_image_id =
144                    get_value_for_resource(&resources, SYMMETRIC_NAT_GATEWAY, IMAGE)?;
145                debug!("Nat gateway image: {nat_gateway_image_id:?}");
146
147                (
148                    symmetric_private_node_volume_size,
149                    symmetric_nat_gateway_vm_size,
150                )
151            } else {
152                (None, None)
153            };
154
155        let full_cone_private_node_vm_count = resource_count(FULL_CONE_PRIVATE_NODE);
156        debug!("Full cone private node count: {full_cone_private_node_vm_count}");
157        let (full_cone_private_node_volume_size, full_cone_nat_gateway_vm_size) =
158            if full_cone_private_node_vm_count > 0 {
159                let full_cone_private_node_volume_size = get_value_for_resource(
160                    &resources,
161                    FULL_CONE_PRIVATE_NODE_ATTACHED_VOLUME,
162                    SIZE,
163                )?;
164                debug!(
165                    "Full cone private node volume size: {full_cone_private_node_volume_size:?}"
166                );
167                // gateways should exists if private nodes exist
168                let full_cone_nat_gateway_vm_size =
169                    get_value_for_resource(&resources, FULL_CONE_NAT_GATEWAY, SIZE)?;
170                debug!("Full cone nat gateway size: {full_cone_nat_gateway_vm_size:?}");
171
172                nat_gateway_image_id =
173                    get_value_for_resource(&resources, FULL_CONE_NAT_GATEWAY, IMAGE)?;
174                debug!("Nat gateway image: {nat_gateway_image_id:?}");
175
176                (
177                    full_cone_private_node_volume_size,
178                    full_cone_nat_gateway_vm_size,
179                )
180            } else {
181                (None, None)
182            };
183
184        let client_vm_count = resource_count(CLIENT);
185        debug!("Client count: {client_vm_count}");
186        let (client_vm_size, client_image_id) = if client_vm_count > 0 {
187            let vm_size = get_value_for_resource(&resources, CLIENT, SIZE)?;
188            debug!("Client size: {vm_size:?}");
189            let image_id = get_value_for_resource(&resources, CLIENT, IMAGE)?;
190            debug!("Client image id: {image_id:?}");
191            (vm_size, image_id)
192        } else {
193            (None, None)
194        };
195
196        let build_vm_count = resource_count(BUILD_VM);
197        debug!("Build VM count: {build_vm_count}");
198        let enable_build_vm = build_vm_count > 0;
199
200        // Node VM size var is re-used for nodes, evm nodes, symmetric and full cone private nodes
201        let (node_vm_size, node_image_id) = if node_vm_count > 0 {
202            let vm_size = get_value_for_resource(&resources, NODE, SIZE)?;
203            debug!("Node size obtained from {NODE}: {vm_size:?}");
204            let image_id = get_value_for_resource(&resources, NODE, IMAGE)?;
205            debug!("Node image id obtained from {NODE}: {image_id:?}");
206            (vm_size, image_id)
207        } else if symmetric_private_node_vm_count > 0 {
208            let vm_size = get_value_for_resource(&resources, SYMMETRIC_PRIVATE_NODE, SIZE)?;
209            debug!("Node size obtained from {SYMMETRIC_PRIVATE_NODE}: {vm_size:?}");
210            let image_id = get_value_for_resource(&resources, SYMMETRIC_PRIVATE_NODE, IMAGE)?;
211            debug!("Node image id obtained from {SYMMETRIC_PRIVATE_NODE}: {image_id:?}");
212            (vm_size, image_id)
213        } else if full_cone_private_node_vm_count > 0 {
214            let vm_size = get_value_for_resource(&resources, FULL_CONE_PRIVATE_NODE, SIZE)?;
215            debug!("Node size obtained from {FULL_CONE_PRIVATE_NODE}: {vm_size:?}");
216            let image_id = get_value_for_resource(&resources, FULL_CONE_PRIVATE_NODE, IMAGE)?;
217            debug!("Node image id obtained from {FULL_CONE_PRIVATE_NODE}: {image_id:?}");
218            (vm_size, image_id)
219        } else {
220            (None, None)
221        };
222
223        let evm_node_count = resource_count(EVM_NODE);
224        debug!("EVM node count: {evm_node_count}");
225        let (evm_node_vm_size, evm_node_image_id) = if evm_node_count > 0 {
226            let emv_node_vm_size = get_value_for_resource(&resources, EVM_NODE, SIZE)?;
227            debug!("EVM node size: {emv_node_vm_size:?}");
228            let evm_node_image_id = get_value_for_resource(&resources, EVM_NODE, IMAGE)?;
229            debug!("EVM node image id: {evm_node_image_id:?}");
230            (emv_node_vm_size, evm_node_image_id)
231        } else {
232            (None, None)
233        };
234
235        let options = Self {
236            client_image_id,
237            client_vm_count: Some(client_vm_count),
238            client_vm_size,
239            enable_build_vm,
240            evm_node_count: Some(evm_node_count),
241            evm_node_vm_size,
242            evm_node_image_id,
243            full_cone_nat_gateway_vm_size,
244            full_cone_private_node_vm_count: Some(full_cone_private_node_vm_count),
245            full_cone_private_node_volume_size,
246            genesis_vm_count: Some(genesis_node_vm_count),
247            genesis_node_volume_size,
248            name: name.to_string(),
249            nat_gateway_image_id,
250            node_image_id,
251            node_vm_count: Some(node_vm_count),
252            node_vm_size,
253            node_volume_size,
254            peer_cache_image_id,
255            peer_cache_node_vm_count: Some(peer_cache_node_vm_count),
256            peer_cache_node_vm_size,
257            peer_cache_node_volume_size,
258            region: region.to_string(),
259            symmetric_nat_gateway_vm_size,
260            symmetric_private_node_vm_count: Some(symmetric_private_node_vm_count),
261            symmetric_private_node_volume_size,
262            tfvars_filenames: environment_details
263                .map(|details| details.environment_type.get_tfvars_filenames(name, region)),
264        };
265
266        Ok(options)
267    }
268}
269
270impl TestnetDeployer {
271    /// Create or update the infrastructure for a deployment.
272    pub fn create_or_update_infra(&self, options: &InfraRunOptions) -> Result<()> {
273        let start = Instant::now();
274        println!("Selecting {} workspace...", options.name);
275        self.terraform_runner.workspace_select(&options.name)?;
276
277        let args = build_terraform_args(options)?;
278
279        println!("Running terraform apply...");
280        self.terraform_runner
281            .apply(args, options.tfvars_filenames.clone())?;
282        print_duration(start.elapsed());
283        Ok(())
284    }
285}
286
287#[derive(Clone, Debug)]
288pub struct ClientsInfraRunOptions {
289    pub client_image_id: Option<String>,
290    pub client_vm_count: Option<u16>,
291    pub client_vm_size: Option<String>,
292    /// Set to None for new deployments, as the value will be fetched from tfvars.
293    pub enable_build_vm: bool,
294    pub name: String,
295    pub tfvars_filenames: Vec<String>,
296}
297
298impl ClientsInfraRunOptions {
299    /// Generate the options for an existing Client deployment.
300    pub async fn generate_existing(
301        name: &str,
302        terraform_runner: &TerraformRunner,
303        environment_details: &EnvironmentDetails,
304    ) -> Result<Self> {
305        let resources = terraform_runner.show(name)?;
306
307        let resource_count = |resource_name: &str| -> u16 {
308            resources
309                .iter()
310                .filter(|r| r.resource_name == resource_name)
311                .count() as u16
312        };
313
314        let client_vm_count = resource_count(CLIENT);
315        debug!("Client count: {client_vm_count}");
316        let (client_vm_size, client_image_id) = if client_vm_count > 0 {
317            let vm_size = get_value_for_resource(&resources, CLIENT, SIZE)?;
318            debug!("Client size: {vm_size:?}");
319            let image_id = get_value_for_resource(&resources, CLIENT, IMAGE)?;
320            debug!("Client image id: {image_id:?}");
321            (vm_size, image_id)
322        } else {
323            (None, None)
324        };
325
326        let build_vm_count = resource_count(BUILD_VM);
327        debug!("Build VM count: {build_vm_count}");
328        let enable_build_vm = build_vm_count > 0;
329
330        let options = Self {
331            client_image_id,
332            client_vm_count: Some(client_vm_count),
333            client_vm_size,
334            enable_build_vm,
335            name: name.to_string(),
336            tfvars_filenames: environment_details
337                .environment_type
338                .get_tfvars_filenames(name, &environment_details.region),
339        };
340
341        Ok(options)
342    }
343
344    pub fn build_terraform_args(&self) -> Result<Vec<(String, String)>> {
345        let mut args = Vec::new();
346
347        if let Some(client_vm_count) = self.client_vm_count {
348            args.push((
349                "ant_client_vm_count".to_string(),
350                client_vm_count.to_string(),
351            ));
352        }
353        if let Some(client_vm_size) = &self.client_vm_size {
354            args.push((
355                "ant_client_droplet_size".to_string(),
356                client_vm_size.clone(),
357            ));
358        }
359        if let Some(client_image_id) = &self.client_image_id {
360            args.push((
361                "ant_client_droplet_image_id".to_string(),
362                client_image_id.clone(),
363            ));
364        }
365
366        args.push((
367            "use_custom_bin".to_string(),
368            self.enable_build_vm.to_string(),
369        ));
370
371        Ok(args)
372    }
373}
374
375/// Build the terraform arguments from InfraRunOptions
376pub fn build_terraform_args(options: &InfraRunOptions) -> Result<Vec<(String, String)>> {
377    let mut args = Vec::new();
378
379    args.push(("region".to_string(), options.region.clone()));
380
381    if let Some(client_image_id) = &options.client_image_id {
382        args.push((
383            "ant_client_droplet_image_id".to_string(),
384            client_image_id.clone(),
385        ));
386    }
387
388    if let Some(client_vm_count) = options.client_vm_count {
389        args.push((
390            "ant_client_vm_count".to_string(),
391            client_vm_count.to_string(),
392        ));
393    }
394
395    if let Some(client_vm_size) = &options.client_vm_size {
396        args.push((
397            "ant_client_droplet_size".to_string(),
398            client_vm_size.clone(),
399        ));
400    }
401
402    args.push((
403        "use_custom_bin".to_string(),
404        options.enable_build_vm.to_string(),
405    ));
406
407    if let Some(evm_node_count) = options.evm_node_count {
408        args.push(("evm_node_vm_count".to_string(), evm_node_count.to_string()));
409    }
410
411    if let Some(evm_node_vm_size) = &options.evm_node_vm_size {
412        args.push((
413            "evm_node_droplet_size".to_string(),
414            evm_node_vm_size.clone(),
415        ));
416    }
417
418    if let Some(emv_node_image_id) = &options.evm_node_image_id {
419        args.push((
420            "evm_node_droplet_image_id".to_string(),
421            emv_node_image_id.clone(),
422        ));
423    }
424
425    if let Some(full_cone_gateway_vm_size) = &options.full_cone_nat_gateway_vm_size {
426        args.push((
427            "full_cone_nat_gateway_droplet_size".to_string(),
428            full_cone_gateway_vm_size.clone(),
429        ));
430    }
431
432    if let Some(full_cone_private_node_vm_count) = options.full_cone_private_node_vm_count {
433        args.push((
434            "full_cone_private_node_vm_count".to_string(),
435            full_cone_private_node_vm_count.to_string(),
436        ));
437    }
438
439    if let Some(full_cone_private_node_volume_size) = options.full_cone_private_node_volume_size {
440        args.push((
441            "full_cone_private_node_volume_size".to_string(),
442            full_cone_private_node_volume_size.to_string(),
443        ));
444    }
445
446    if let Some(genesis_vm_count) = options.genesis_vm_count {
447        args.push(("genesis_vm_count".to_string(), genesis_vm_count.to_string()));
448    }
449
450    if let Some(genesis_node_volume_size) = options.genesis_node_volume_size {
451        args.push((
452            "genesis_node_volume_size".to_string(),
453            genesis_node_volume_size.to_string(),
454        ));
455    }
456
457    if let Some(nat_gateway_image_id) = &options.nat_gateway_image_id {
458        args.push((
459            "nat_gateway_droplet_image_id".to_string(),
460            nat_gateway_image_id.clone(),
461        ));
462    }
463
464    if let Some(node_image_id) = &options.node_image_id {
465        args.push(("node_droplet_image_id".to_string(), node_image_id.clone()));
466    }
467
468    if let Some(node_vm_count) = options.node_vm_count {
469        args.push(("node_vm_count".to_string(), node_vm_count.to_string()));
470    }
471
472    if let Some(node_vm_size) = &options.node_vm_size {
473        args.push(("node_droplet_size".to_string(), node_vm_size.clone()));
474    }
475
476    if let Some(node_volume_size) = options.node_volume_size {
477        args.push(("node_volume_size".to_string(), node_volume_size.to_string()));
478    }
479
480    if let Some(peer_cache_image_id) = &options.peer_cache_image_id {
481        args.push((
482            "peer_cache_droplet_image_id".to_string(),
483            peer_cache_image_id.clone(),
484        ));
485    }
486
487    if let Some(peer_cache_node_vm_count) = options.peer_cache_node_vm_count {
488        args.push((
489            "peer_cache_node_vm_count".to_string(),
490            peer_cache_node_vm_count.to_string(),
491        ));
492    }
493
494    if let Some(peer_cache_vm_size) = &options.peer_cache_node_vm_size {
495        args.push((
496            "peer_cache_droplet_size".to_string(),
497            peer_cache_vm_size.clone(),
498        ));
499    }
500
501    if let Some(reserved_ips) = crate::reserved_ip::get_reserved_ips_args(&options.name) {
502        args.push(("peer_cache_reserved_ips".to_string(), reserved_ips));
503    }
504
505    if let Some(peer_cache_node_volume_size) = options.peer_cache_node_volume_size {
506        args.push((
507            "peer_cache_node_volume_size".to_string(),
508            peer_cache_node_volume_size.to_string(),
509        ));
510    }
511
512    if let Some(nat_gateway_vm_size) = &options.symmetric_nat_gateway_vm_size {
513        args.push((
514            "symmetric_nat_gateway_droplet_size".to_string(),
515            nat_gateway_vm_size.clone(),
516        ));
517    }
518
519    if let Some(symmetric_private_node_vm_count) = options.symmetric_private_node_vm_count {
520        args.push((
521            "symmetric_private_node_vm_count".to_string(),
522            symmetric_private_node_vm_count.to_string(),
523        ));
524    }
525
526    if let Some(symmetric_private_node_volume_size) = options.symmetric_private_node_volume_size {
527        args.push((
528            "symmetric_private_node_volume_size".to_string(),
529            symmetric_private_node_volume_size.to_string(),
530        ));
531    }
532
533    Ok(args)
534}
535
536/// Select a Terraform workspace for an environment.
537/// Returns an error if the environment doesn't exist.
538pub fn select_workspace(terraform_runner: &TerraformRunner, name: &str) -> Result<()> {
539    terraform_runner.init()?;
540    let workspaces = terraform_runner.workspace_list()?;
541    if !workspaces.contains(&name.to_string()) {
542        return Err(Error::EnvironmentDoesNotExist(name.to_string()));
543    }
544    terraform_runner.workspace_select(name)?;
545    println!("Selected {name} workspace");
546    Ok(())
547}
548
549pub fn delete_workspace(terraform_runner: &TerraformRunner, name: &str) -> Result<()> {
550    // The 'dev' workspace is one we always expect to exist, for admin purposes.
551    // You can't delete a workspace while it is selected, so we select 'dev' before we delete
552    // the current workspace.
553    terraform_runner.workspace_select("dev")?;
554    terraform_runner.workspace_delete(name)?;
555    println!("Deleted {name} workspace");
556    Ok(())
557}
558
559/// Extract a specific field value from terraform resources with proper type conversion.
560fn get_value_for_resource<T>(
561    resources: &[TerraformResource],
562    resource_name: &str,
563    field_name: &str,
564) -> Result<Option<T>, Error>
565where
566    T: From<TerraformValue>,
567{
568    let field_value = resources
569        .iter()
570        .filter(|r| r.resource_name == resource_name)
571        .try_fold(None, |acc_value: Option<serde_json::Value>, r| {
572            if let Some(value) = r.values.get(field_name) {
573                match acc_value {
574                    Some(ref existing_value) if existing_value != value => {
575                        log::error!("Expected value: {existing_value}, got value: {value}");
576                        Err(Error::TerraformResourceValueMismatch {
577                            expected: existing_value.to_string(),
578                            actual: value.to_string(),
579                        })
580                    }
581                    _ => Ok(Some(value.clone())),
582                }
583            } else {
584                Ok(acc_value)
585            }
586        })?;
587
588    Ok(field_value.map(TerraformValue::from).map(T::from))
589}
590
591/// Wrapper for terraform values to ensure proper conversion
592#[derive(Debug, Clone)]
593enum TerraformValue {
594    String(String),
595    Number(u64),
596    Bool(bool),
597    Other(serde_json::Value),
598}
599
600impl From<serde_json::Value> for TerraformValue {
601    fn from(value: serde_json::Value) -> Self {
602        if value.is_string() {
603            // Extract the inner string without quotes
604            // Unwrap is safe here because we checked is_string above
605            TerraformValue::String(value.as_str().unwrap().to_string())
606        } else if value.is_u64() {
607            // Unwrap is safe here because we checked is_u64 above
608            TerraformValue::Number(value.as_u64().unwrap())
609        } else if value.is_boolean() {
610            // Unwrap is safe here because we checked is_boolean above
611            TerraformValue::Bool(value.as_bool().unwrap())
612        } else {
613            TerraformValue::Other(value)
614        }
615    }
616}
617
618// Implement From<TerraformValue> for the types you need
619impl From<TerraformValue> for String {
620    fn from(value: TerraformValue) -> Self {
621        match value {
622            TerraformValue::String(s) => s,
623            TerraformValue::Number(n) => n.to_string(),
624            TerraformValue::Bool(b) => b.to_string(),
625            TerraformValue::Other(v) => v.to_string(),
626        }
627    }
628}
629
630impl From<TerraformValue> for u16 {
631    fn from(value: TerraformValue) -> Self {
632        match value {
633            TerraformValue::Number(n) => n as u16,
634            TerraformValue::String(s) => s.parse().unwrap_or(0),
635            _ => 0,
636        }
637    }
638}