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