Skip to main content

iota_cluster_test/
cluster.rs

1// Copyright (c) Mysten Labs, Inc.
2// Modifications Copyright (c) 2024 IOTA Stiftung
3// SPDX-License-Identifier: Apache-2.0
4
5use std::{net::SocketAddr, path::Path};
6
7use async_trait::async_trait;
8use iota_config::{Config, IOTA_GENESIS_FILENAME, IOTA_KEYSTORE_FILENAME, node::Genesis};
9use iota_graphql_rpc::{
10    config::ConnectionConfig, test_infra::cluster::start_graphql_server_with_fn_rpc,
11};
12use iota_indexer::test_utils::{IndexerTypeConfig, start_test_indexer};
13use iota_keys::keystore::{AccountKeystore, FileBasedKeystore, Keystore};
14use iota_sdk::{
15    iota_client_config::{IotaClientConfig, IotaEnv},
16    wallet_context::WalletContext,
17};
18use iota_swarm::memory::Swarm;
19use iota_swarm_config::{genesis_config::GenesisConfig, network_config::PersistedNetworkConfig};
20use iota_types::crypto::AccountPrivateKey;
21use tempfile::tempdir;
22use test_cluster::{TestCluster, TestClusterBuilder};
23use tracing::info;
24
25use super::config::{ClusterTestOpt, Env};
26
27const DEVNET_FAUCET_ADDR: &str = "https://faucet.devnet.iota.cafe:443";
28const TESTNET_FAUCET_ADDR: &str = "https://faucet.testnet.iota.cafe:443";
29const DEVNET_FULLNODE_ADDR: &str = "https://api.devnet.iota.cafe:443";
30const TESTNET_FULLNODE_ADDR: &str = "https://api.testnet.iota.cafe:443";
31
32pub struct ClusterFactory;
33
34impl ClusterFactory {
35    pub async fn start(
36        options: &ClusterTestOpt,
37    ) -> Result<Box<dyn Cluster + Sync + Send>, anyhow::Error> {
38        Ok(match &options.env {
39            Env::NewLocal => Box::new(LocalNewCluster::start(options).await?) as Box<_>,
40            _ => Box::new(RemoteRunningCluster::start(options).await?) as Box<_>,
41        })
42    }
43}
44
45/// Cluster Abstraction
46#[async_trait]
47pub trait Cluster {
48    async fn start(options: &ClusterTestOpt) -> Result<Self, anyhow::Error>
49    where
50        Self: Sized;
51
52    fn fullnode_url(&self) -> &str;
53
54    /// gRPC endpoint of the fullnode, when one is available (local clusters).
55    fn grpc_url(&self) -> Option<&str>;
56
57    fn user_key(&self) -> AccountPrivateKey;
58    fn indexer_url(&self) -> &Option<String>;
59
60    /// Returns faucet url in a remote cluster.
61    fn remote_faucet_url(&self) -> Option<&str>;
62
63    /// Returns faucet key in a local cluster.
64    fn local_faucet_key(&self) -> Option<&AccountPrivateKey>;
65
66    /// Place to put config for the wallet, and any locally running services.
67    fn config_directory(&self) -> &Path;
68}
69
70/// Represents an up and running cluster deployed remotely.
71pub struct RemoteRunningCluster {
72    fullnode_url: String,
73    faucet_url: String,
74    config_directory: tempfile::TempDir,
75}
76
77#[async_trait]
78impl Cluster for RemoteRunningCluster {
79    async fn start(options: &ClusterTestOpt) -> Result<Self, anyhow::Error> {
80        let (fullnode_url, faucet_url) = match options.env {
81            Env::Devnet => (
82                String::from(DEVNET_FULLNODE_ADDR),
83                String::from(DEVNET_FAUCET_ADDR),
84            ),
85            Env::Testnet => (
86                String::from(TESTNET_FULLNODE_ADDR),
87                String::from(TESTNET_FAUCET_ADDR),
88            ),
89            Env::CustomRemote => (
90                options
91                    .fullnode_address
92                    .clone()
93                    .expect("expect 'fullnode_address' for Env::Custom"),
94                options
95                    .faucet_address
96                    .clone()
97                    .expect("expect 'faucet_address' for Env::Custom"),
98            ),
99            Env::NewLocal => {
100                unreachable!("the NewLocal variant shouldn't use RemoteRunningCluster")
101            }
102        };
103
104        // TODO: test connectivity before proceeding?
105
106        Ok(Self {
107            fullnode_url,
108            faucet_url,
109            config_directory: tempfile::tempdir()?,
110        })
111    }
112
113    fn fullnode_url(&self) -> &str {
114        &self.fullnode_url
115    }
116
117    fn grpc_url(&self) -> Option<&str> {
118        None
119    }
120
121    fn indexer_url(&self) -> &Option<String> {
122        &None
123    }
124
125    fn user_key(&self) -> AccountPrivateKey {
126        AccountPrivateKey::random()
127    }
128
129    fn remote_faucet_url(&self) -> Option<&str> {
130        Some(&self.faucet_url)
131    }
132
133    fn local_faucet_key(&self) -> Option<&AccountPrivateKey> {
134        None
135    }
136
137    fn config_directory(&self) -> &Path {
138        self.config_directory.path()
139    }
140}
141
142/// Represents a local Cluster which starts per cluster test run.
143pub struct LocalNewCluster {
144    test_cluster: TestCluster,
145    fullnode_url: String,
146    grpc_url: String,
147    indexer_url: Option<String>,
148    faucet_key: AccountPrivateKey,
149    config_directory: tempfile::TempDir,
150}
151
152impl LocalNewCluster {
153    #[allow(unused)]
154    pub fn swarm(&self) -> &Swarm {
155        &self.test_cluster.swarm
156    }
157}
158
159#[async_trait]
160impl Cluster for LocalNewCluster {
161    async fn start(options: &ClusterTestOpt) -> Result<Self, anyhow::Error> {
162        let data_ingestion_path = tempdir()?.keep();
163        // TODO: options should contain port instead of address
164        let fullnode_rpc_addr = options.fullnode_address.as_ref().map(|addr| {
165            addr.parse::<SocketAddr>()
166                .expect("unable to parse fullnode address")
167        });
168
169        let indexer_address = options.indexer_address.as_ref().map(|addr| {
170            addr.parse::<SocketAddr>()
171                .expect("unable to parse indexer address")
172        });
173
174        let mut cluster_builder = TestClusterBuilder::new()
175            .enable_fullnode_events()
176            .with_data_ingestion_dir(data_ingestion_path.clone())
177            // disable full node pruning: tests read `balance_changes` /
178            // `object_changes` which needs historical data.
179            .disable_fullnode_pruning();
180
181        // Check if we already have a config directory that is passed
182        if let Some(config_dir) = options.config_dir.clone() {
183            assert!(options.epoch_duration_ms.is_none());
184            // Derive the node configs of the network from its persisted state.
185            let network_config = PersistedNetworkConfig::read(&config_dir)?.into_network_config(
186                &config_dir,
187                Genesis::new_from_file(config_dir.join(IOTA_GENESIS_FILENAME)),
188            )?;
189            cluster_builder = cluster_builder.set_network_config(network_config);
190
191            cluster_builder = cluster_builder.with_config_dir(config_dir);
192        } else {
193            // Let the faucet account hold 1000 gas objects on genesis
194            let genesis_config = GenesisConfig::custom_genesis(1, 100);
195            // Custom genesis should be build here where we add the extra accounts
196            cluster_builder = cluster_builder.set_genesis_config(genesis_config);
197
198            if let Some(epoch_duration_ms) = options.epoch_duration_ms {
199                cluster_builder = cluster_builder.with_epoch_duration_ms(epoch_duration_ms);
200            }
201        }
202
203        if let Some(fullnode_rpc_addr) = fullnode_rpc_addr {
204            cluster_builder = cluster_builder.with_fullnode_rpc_addr(fullnode_rpc_addr);
205        }
206
207        let mut test_cluster = cluster_builder.build().await;
208
209        // Use the wealthy account for faucet
210        let faucet_key = test_cluster.swarm.config_mut().account_keys.swap_remove(0);
211        let faucet_address = faucet_key.public_key().derive_address();
212        info!(?faucet_address, "faucet_address");
213
214        // This cluster has fullnode handle, safe to unwrap
215        let fullnode_url = test_cluster.fullnode_handle.rpc_url.clone();
216        let grpc_url = test_cluster.grpc_url();
217
218        if let (Some(pg_address), Some(indexer_address)) =
219            (options.pg_address.clone(), indexer_address)
220        {
221            // Start in writer mode
222            start_test_indexer(
223                pg_address.clone(),
224                // reset the existing db
225                true,
226                None,
227                test_cluster.grpc_url(),
228                IndexerTypeConfig::writer_mode(None),
229                Some(data_ingestion_path.clone()),
230            )
231            .await;
232
233            // Start in reader mode
234            start_test_indexer(
235                pg_address,
236                false,
237                None,
238                test_cluster.grpc_url(),
239                IndexerTypeConfig::reader_mode(indexer_address.to_string()),
240                Some(data_ingestion_path),
241            )
242            .await;
243        }
244
245        if let Some(graphql_address) = &options.graphql_address {
246            let graphql_address = graphql_address.parse::<SocketAddr>()?;
247            let graphql_connection_config = ConnectionConfig::new(
248                Some(graphql_address.port()),
249                Some(graphql_address.ip().to_string()),
250                options.pg_address.clone(),
251                None,
252                None,
253                None,
254                None,
255                None,
256            );
257
258            start_graphql_server_with_fn_rpc(
259                graphql_connection_config.clone(),
260                Some(test_cluster.grpc_url()),
261                // resolves to default cancellation_token
262                None,
263                // resolves to default service config
264                None,
265            )
266            .await;
267        }
268
269        // Let nodes connect to one another
270        tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
271
272        // TODO: test connectivity before proceeding?
273        Ok(Self {
274            test_cluster,
275            fullnode_url,
276            grpc_url,
277            faucet_key,
278            config_directory: tempfile::tempdir()?,
279            indexer_url: options.indexer_address.clone(),
280        })
281    }
282
283    fn fullnode_url(&self) -> &str {
284        &self.fullnode_url
285    }
286
287    fn grpc_url(&self) -> Option<&str> {
288        Some(&self.grpc_url)
289    }
290
291    fn indexer_url(&self) -> &Option<String> {
292        &self.indexer_url
293    }
294
295    fn user_key(&self) -> AccountPrivateKey {
296        AccountPrivateKey::random()
297    }
298
299    fn remote_faucet_url(&self) -> Option<&str> {
300        None
301    }
302
303    fn local_faucet_key(&self) -> Option<&AccountPrivateKey> {
304        Some(&self.faucet_key)
305    }
306
307    fn config_directory(&self) -> &Path {
308        self.config_directory.path()
309    }
310}
311
312// Make linter happy
313#[async_trait]
314impl Cluster for Box<dyn Cluster + Send + Sync> {
315    async fn start(_options: &ClusterTestOpt) -> Result<Self, anyhow::Error> {
316        unreachable!(
317            "if we already have a boxed Cluster trait object we wouldn't have to call this function"
318        );
319    }
320    fn fullnode_url(&self) -> &str {
321        (**self).fullnode_url()
322    }
323    fn grpc_url(&self) -> Option<&str> {
324        (**self).grpc_url()
325    }
326    fn indexer_url(&self) -> &Option<String> {
327        (**self).indexer_url()
328    }
329
330    fn user_key(&self) -> AccountPrivateKey {
331        (**self).user_key()
332    }
333
334    fn remote_faucet_url(&self) -> Option<&str> {
335        (**self).remote_faucet_url()
336    }
337
338    fn local_faucet_key(&self) -> Option<&AccountPrivateKey> {
339        (**self).local_faucet_key()
340    }
341
342    fn config_directory(&self) -> &Path {
343        (**self).config_directory()
344    }
345}
346
347pub fn new_wallet_context_from_cluster(
348    cluster: &(dyn Cluster + Sync + Send),
349    private_key: AccountPrivateKey,
350) -> WalletContext {
351    let config_dir = cluster.config_directory();
352    let wallet_config_path = config_dir.join("client.yaml");
353    let fullnode_url = cluster.fullnode_url();
354    info!("Use RPC: {fullnode_url}");
355    let keystore_path = config_dir.join(IOTA_KEYSTORE_FILENAME);
356    let mut keystore = Keystore::from(FileBasedKeystore::new(&keystore_path).unwrap());
357    let address = private_key.public_key().derive_address();
358    keystore.add_key(None, private_key).unwrap();
359    IotaClientConfig::new(keystore)
360        .with_envs([IotaEnv::new("localnet", fullnode_url)])
361        .with_active_address(address)
362        .with_active_env("localnet".to_string())
363        .persisted(&wallet_config_path)
364        .save()
365        .unwrap();
366
367    info!(
368        "Initialize wallet from config path: {:?}",
369        wallet_config_path
370    );
371
372    WalletContext::new(&wallet_config_path).unwrap_or_else(|e| {
373        panic!("failed to init wallet context from path {wallet_config_path:?}, error: {e}")
374    })
375}