mina_node_testing/node/ocaml/
config.rs

1//! OCaml Node Configuration Module
2//!
3//! This module provides configuration structures and executable management
4//! for OCaml Mina nodes in the testing framework. It supports multiple
5//! execution modes including local binaries and Docker containers.
6//!
7//! # Key Components
8//!
9//! - [`OcamlNodeExecutable`] - Execution method selection (local/Docker)
10//! - [`OcamlNodeTestingConfig`] - High-level node configuration
11//! - [`OcamlNodeConfig`] - Low-level process configuration
12//! - [`DaemonJson`] - Genesis configuration management
13//!
14//! # Executable Auto-Detection
15//!
16//! The module automatically detects the best available execution method:
17//! 1. Local `mina` binary (preferred)
18//! 2. Docker with default image (fallback)
19//! 3. Custom Docker images (configurable)
20
21use std::{
22    ffi::{OsStr, OsString},
23    fs,
24    path::PathBuf,
25    process::{Command, Stdio},
26    str::FromStr,
27};
28
29use node::{
30    account::AccountSecretKey,
31    core::log::{info, system_time, warn},
32    p2p::connection::outgoing::P2pConnectionOutgoingInitOpts,
33};
34use serde::{Deserialize, Serialize};
35
36/// High-level configuration for OCaml node testing scenarios.
37///
38/// This struct provides the main configuration interface for creating
39/// OCaml nodes in test scenarios, abstracting away low-level details
40/// like port allocation and process management.
41#[derive(Serialize, Deserialize, Debug, Clone)]
42pub struct OcamlNodeTestingConfig {
43    /// List of initial peer connection targets
44    pub initial_peers: Vec<P2pConnectionOutgoingInitOpts>,
45    /// Genesis ledger configuration (file path or in-memory)
46    pub daemon_json: DaemonJson,
47    /// Optional block producer secret key
48    pub block_producer: Option<AccountSecretKey>,
49}
50
51impl Default for OcamlNodeTestingConfig {
52    fn default() -> Self {
53        Self {
54            initial_peers: vec![],
55            daemon_json: DaemonJson::Custom("/var/lib/coda/config_6929a7ec.json".to_owned()),
56            block_producer: None,
57        }
58    }
59}
60
61#[derive(Serialize, Deserialize, Debug, Clone)]
62pub enum DaemonJson {
63    // TODO(binier): have presets.
64    Custom(String),
65    InMem(serde_json::Value),
66}
67
68#[derive(Serialize, Deserialize, Debug, Clone)]
69pub enum DaemonJsonGenConfig {
70    Counts { whales: usize, fish: usize },
71    DelegateTable(Vec<(u64, Vec<u64>)>),
72}
73
74#[derive(Debug, Clone)]
75pub struct OcamlNodeConfig {
76    /// Command for mina executable.
77    pub executable: OcamlNodeExecutable,
78    pub dir: temp_dir::TempDir,
79    pub libp2p_keypair_i: usize,
80    pub libp2p_port: u16,
81    pub graphql_port: u16,
82    pub client_port: u16,
83    pub initial_peers: Vec<P2pConnectionOutgoingInitOpts>,
84    pub daemon_json: DaemonJson,
85    pub block_producer: Option<AccountSecretKey>,
86}
87
88/// OCaml node execution methods.
89///
90/// Supports multiple ways of running the OCaml Mina daemon,
91/// from local binaries to Docker containers with automatic
92/// detection and fallback behavior.
93#[derive(Serialize, Deserialize, Debug, Clone)]
94pub enum OcamlNodeExecutable {
95    /// Use locally installed Mina binary
96    ///
97    /// # Arguments
98    /// * `String` - Path to the mina executable
99    ///
100    /// # Example
101    /// ```
102    /// OcamlNodeExecutable::Installed("/usr/local/bin/mina".to_string())
103    /// ```
104    Installed(String),
105
106    /// Use specific Docker image
107    ///
108    /// # Arguments
109    /// * `String` - Docker image tag
110    ///
111    /// # Example
112    /// ```
113    /// OcamlNodeExecutable::Docker("minaprotocol/mina-daemon:3.0.0".to_string())
114    /// ```
115    Docker(String),
116
117    /// Use default Docker image
118    ///
119    /// Falls back to the predefined default image when no local
120    /// binary is available. See [`OcamlNodeExecutable::DEFAULT_DOCKER_IMAGE`] for the
121    /// current default.
122    DockerDefault,
123}
124
125#[derive(Serialize, Deserialize, Debug, Clone)]
126#[serde(rename_all = "camelCase")]
127pub struct OcamlVrfOutput {
128    pub vrf_output: String,
129    pub vrf_output_fractional: f64,
130    pub threshold_met: bool,
131    pub public_key: String,
132}
133
134impl OcamlNodeConfig {
135    /// Warning: All envs that needs to be set must be set here,
136    /// otherwise it won't work for docker executable because env needs
137    /// to be set from args.
138    pub fn cmd<I, K, V>(&self, envs: I) -> Command
139    where
140        I: IntoIterator<Item = (K, V)>,
141        K: AsRef<OsStr>,
142        V: AsRef<OsStr>,
143    {
144        match &self.executable {
145            OcamlNodeExecutable::Installed(program) => {
146                info!(system_time(); "Using local Mina binary: {}", program);
147                let mut cmd = Command::new(program);
148                cmd.envs(envs);
149                cmd
150            }
151            OcamlNodeExecutable::Docker(tag) => {
152                info!(system_time(); "Using custom Docker image: {}", tag);
153                self.docker_run_cmd(tag, envs)
154            }
155            OcamlNodeExecutable::DockerDefault => {
156                info!(
157                    system_time();
158                    "Using default Docker image: {}",
159                    OcamlNodeExecutable::DEFAULT_DOCKER_IMAGE
160                );
161                self.docker_run_cmd(OcamlNodeExecutable::DEFAULT_DOCKER_IMAGE, envs)
162            }
163        }
164    }
165
166    /// Create a Docker run command with proper configuration.
167    ///
168    /// Sets up a Docker container with appropriate networking, user mapping,
169    /// volume mounts, and environment variables for running OCaml Mina daemon.
170    ///
171    /// # Arguments
172    /// * `tag` - Docker image tag to use
173    /// * `envs` - Environment variables to pass to the container
174    ///
175    /// # Docker Configuration
176    /// - Uses host networking for P2P connectivity
177    /// - Maps host user ID to avoid permission issues
178    /// - Mounts node directory for persistent data
179    /// - Sets working directory to `/tmp` for key generation
180    fn docker_run_cmd<I, K, V>(&self, tag: &str, envs: I) -> Command
181    where
182        I: IntoIterator<Item = (K, V)>,
183        K: AsRef<OsStr>,
184        V: AsRef<OsStr>,
185    {
186        let mut cmd = Command::new("docker");
187        let dir_path = self.dir.path().display();
188
189        let uid = std::env::var("$UID").unwrap_or_else(|_| "1000".to_owned());
190        let container_name = OcamlNodeExecutable::docker_container_name(&self.dir);
191
192        info!(
193            system_time();
194            "Configuring Docker container: name={}, image={}, uid={}, mount={}",
195            container_name,
196            tag,
197            uid,
198            dir_path
199        );
200
201        // set docker opts
202        cmd.arg("run")
203            .args(["--name".to_owned(), container_name.clone()])
204            .args(["--network", "host"])
205            .args(["--user".to_owned(), format!("{uid}:{uid}")])
206            .args(["-v".to_owned(), format!("{dir_path}:{dir_path}")])
207            // set workdir to `/tmp`, otherwise generating libp2p keys
208            // using mina cmd might fail, if the user `$UID` doesn't
209            // have a write permission in the default workdir.
210            .args(["-w", "/tmp"]);
211
212        // set docker container envs
213        let mut env_count = 0;
214        for (key, value) in envs {
215            let arg: OsString = [key.as_ref(), value.as_ref()].join(OsStr::new("="));
216            cmd.args(["-e".as_ref(), arg.as_os_str()]);
217            env_count += 1;
218        }
219
220        info!(system_time(); "Added {} environment variables to Docker container", env_count);
221
222        // set docker image
223        cmd.arg(tag);
224
225        info!(system_time(); "Docker command configured for container: {}", container_name);
226        cmd
227    }
228}
229
230impl OcamlNodeExecutable {
231    pub const DEFAULT_DOCKER_IMAGE: &'static str =
232        "gcr.io/o1labs-192920/mina-daemon:3.3.0-alpha1-6929a7e-noble-devnet";
233    pub const DEFAULT_MINA_EXECUTABLE: &'static str = "mina";
234
235    fn docker_container_name(tmp_dir: &temp_dir::TempDir) -> String {
236        let path = tmp_dir.path().file_name().unwrap().to_str().unwrap();
237        format!("mina_testing_ocaml_{}", &path[1..])
238    }
239
240    /// Clean up resources when terminating an OCaml node.
241    ///
242    /// Handles cleanup logic specific to the execution method:
243    /// - Local binaries: No additional cleanup needed
244    /// - Docker containers: Stop and remove the container
245    ///
246    /// # Arguments
247    /// * `tmp_dir` - Temporary directory used by the node
248    pub fn kill(&self, tmp_dir: &temp_dir::TempDir) {
249        match self {
250            OcamlNodeExecutable::Installed(program) => {
251                info!(system_time(); "No additional cleanup needed for local binary: {}", program);
252            }
253            OcamlNodeExecutable::Docker(_) | OcamlNodeExecutable::DockerDefault => {
254                let name = Self::docker_container_name(tmp_dir);
255                let image_info = match self {
256                    OcamlNodeExecutable::Docker(img) => img.clone(),
257                    OcamlNodeExecutable::DockerDefault => Self::DEFAULT_DOCKER_IMAGE.to_string(),
258                    _ => unreachable!(),
259                };
260
261                info!(
262                    system_time();
263                    "Cleaning up Docker container: {} (image: {})",
264                    name,
265                    image_info
266                );
267
268                // stop container.
269                info!(system_time(); "Stopping Docker container: {}", name);
270                let mut cmd = Command::new("docker");
271                cmd.args(["stop".to_owned(), name.clone()]);
272                match cmd.status() {
273                    Ok(status) if status.success() => {
274                        info!(system_time(); "Successfully stopped Docker container: {}", name);
275                    }
276                    Ok(status) => {
277                        warn!(
278                            system_time();
279                            "Docker stop command failed for container {}: exit code {:?}",
280                            name,
281                            status.code()
282                        );
283                    }
284                    Err(e) => {
285                        warn!(system_time(); "Failed to stop Docker container {}: {}", name, e);
286                    }
287                }
288
289                // remove container.
290                info!(system_time(); "Removing Docker container: {}", name);
291                let mut cmd = Command::new("docker");
292                cmd.args(["rm".to_owned(), name.clone()]);
293                match cmd.status() {
294                    Ok(status) if status.success() => {
295                        info!(system_time(); "Successfully removed Docker container: {}", name);
296                    }
297                    Ok(status) => {
298                        warn!(
299                            system_time();
300                            "Docker rm command failed for container {}: exit code {:?}",
301                            name,
302                            status.code()
303                        );
304                    }
305                    Err(e) => {
306                        warn!(system_time(); "Failed to remove Docker container {}: {}", name, e);
307                    }
308                }
309            }
310        }
311    }
312
313    /// Automatically detect and return the best available OCaml executable.
314    ///
315    /// This method implements the auto-detection strategy:
316    /// 1. First, attempt to use locally installed `mina` binary
317    /// 2. If not found, fall back to Docker with default image
318    /// 3. Automatically pull the Docker image if needed
319    ///
320    /// # Returns
321    /// * `Ok(OcamlNodeExecutable)` - Best available execution method
322    /// * `Err(anyhow::Error)` - No usable execution method found
323    ///
324    /// # Docker Fallback
325    /// When falling back to Docker, this method will automatically
326    /// pull the default image if not already present locally.
327    pub fn find_working() -> anyhow::Result<Self> {
328        let program_name = Self::DEFAULT_MINA_EXECUTABLE;
329        info!(system_time(); "Attempting to find local Mina binary: {}", program_name);
330
331        match Command::new(program_name)
332            .stdout(Stdio::null())
333            .stderr(Stdio::null())
334            .spawn()
335        {
336            Ok(_) => {
337                info!(system_time(); "Found working local Mina binary: {}", program_name);
338                return Ok(Self::Installed(program_name.to_owned()));
339            }
340            Err(err) => match err.kind() {
341                std::io::ErrorKind::NotFound => {
342                    info!(system_time(); "Local Mina binary not found, falling back to Docker");
343                }
344                _ => anyhow::bail!("'{program_name}' returned an error: {err}"),
345            },
346        };
347
348        info!(
349            system_time();
350            "Pulling default Docker image: {}",
351            Self::DEFAULT_DOCKER_IMAGE
352        );
353        let mut cmd = Command::new("docker");
354
355        let status = cmd
356            .stdout(Stdio::inherit())
357            .stderr(Stdio::inherit())
358            .args(["pull", Self::DEFAULT_DOCKER_IMAGE])
359            .status()
360            .map_err(|err| anyhow::anyhow!("error pulling ocaml docker: {err}"))?;
361        if !status.success() {
362            anyhow::bail!("error status pulling ocaml node: {status:?}");
363        }
364
365        info!(system_time(); "Successfully pulled Docker image, using DockerDefault");
366        Ok(Self::DockerDefault)
367    }
368}
369
370impl DaemonJson {
371    pub fn load(
372        mut add_account_sec_key: impl FnMut(AccountSecretKey),
373        path: PathBuf,
374        set_timestamp: Option<&str>,
375    ) -> Self {
376        let mut deamon_json: serde_json::Value =
377            serde_json::from_str(&fs::read_to_string(path).unwrap()).unwrap();
378
379        if let Some(time_str) = set_timestamp {
380            deamon_json["genesis"]["genesis_state_timestamp"] = time_str.into();
381        }
382
383        deamon_json
384            .get("ledger")
385            .unwrap()
386            .get("accounts")
387            .unwrap()
388            .as_array()
389            .unwrap()
390            .iter()
391            .for_each(|val| {
392                let sec_key_str = val.get("sk").unwrap().as_str().unwrap();
393                add_account_sec_key(AccountSecretKey::from_str(sec_key_str).unwrap());
394            });
395
396        Self::InMem(deamon_json)
397    }
398
399    pub fn gen(
400        add_account_sec_key: impl FnMut(AccountSecretKey),
401        genesis_timestamp: &str,
402        config: DaemonJsonGenConfig,
403    ) -> Self {
404        match config {
405            DaemonJsonGenConfig::Counts { whales, fish } => {
406                Self::gen_with_counts(add_account_sec_key, genesis_timestamp, whales, fish)
407            }
408            DaemonJsonGenConfig::DelegateTable(delegate_table) => Self::gen_with_delegate_table(
409                add_account_sec_key,
410                genesis_timestamp,
411                delegate_table,
412            ),
413        }
414    }
415
416    pub fn gen_with_counts(
417        add_account_sec_key: impl FnMut(AccountSecretKey),
418        genesis_timestamp: &str,
419        whales_n: usize,
420        fish_n: usize,
421    ) -> Self {
422        let delegator_balance = |balance: u64| move |i| balance / i as u64;
423        let whales = (0..whales_n).map(|i| {
424            let balance = 8333_u64;
425            let delegators = (1..=(i + 1) * 2).map(delegator_balance(50_000_000));
426            (balance, delegators)
427        });
428        let fish = (0..fish_n).map(|i| {
429            let balance = 6333_u64;
430            let delegators = (1..=(i + 1) * 2).map(delegator_balance(5_000_000));
431            (balance, delegators)
432        });
433        let delegate_table = whales.chain(fish);
434        Self::gen_with_delegate_table(add_account_sec_key, genesis_timestamp, delegate_table)
435    }
436
437    pub fn gen_with_delegate_table(
438        mut add_account_sec_key: impl FnMut(AccountSecretKey),
439        genesis_timestamp: &str,
440        delegate_table: impl IntoIterator<Item = (u64, impl IntoIterator<Item = u64>)>,
441    ) -> Self {
442        let gen_bp = |balance: u64| {
443            let sec_key = AccountSecretKey::rand();
444            let pub_key = sec_key.public_key();
445            let account = serde_json::json!({
446                "sk": sec_key.to_string(),
447                "pk": pub_key.to_string(),
448                "balance": format!("{balance}.000000000"),
449                "delegate": pub_key.to_string(),
450            });
451            (sec_key, account)
452        };
453        let gen_account = |balance: u64, delegate: &str| {
454            let (sec_key, mut account) = gen_bp(balance);
455            account["delegate"] = delegate.into();
456            (sec_key, account)
457        };
458
459        let all_accounts = delegate_table
460            .into_iter()
461            .flat_map(|(bp_balance, delegate_balances)| {
462                let bp = gen_bp(bp_balance);
463                let bp_pub_key = bp.0.public_key().to_string();
464                let delegates = delegate_balances
465                    .into_iter()
466                    .map(move |balance| gen_account(balance, &bp_pub_key));
467                std::iter::once(bp).chain(delegates)
468            })
469            .map(|(sec_key, account)| {
470                add_account_sec_key(sec_key);
471                account
472            })
473            .collect::<Vec<_>>();
474
475        DaemonJson::InMem(serde_json::json!({
476            "genesis": {
477                "genesis_state_timestamp": genesis_timestamp,
478            },
479            "ledger": {
480                "name": "custom",
481                "accounts": all_accounts,
482            },
483        }))
484    }
485}
486
487impl DaemonJsonGenConfig {
488    pub fn block_producer_count(&self) -> usize {
489        match self {
490            Self::Counts { whales, fish } => whales + fish,
491            Self::DelegateTable(bps) => bps.len(),
492        }
493    }
494}