Skip to main content

hotshot_orchestrator/
lib.rs

1// Copyright (c) 2021-2024 Espresso Systems (espressosys.com)
2// This file is part of the HotShot repository.
3
4// You should have received a copy of the MIT License
5// along with the HotShot repository. If not, see <https://mit-license.org/>.
6
7//! Orchestrator for manipulating nodes and recording results during a run of `HotShot` tests
8
9/// The orchestrator's clients
10pub mod client;
11
12use std::{
13    collections::{HashMap, HashSet},
14    fs,
15    fs::OpenOptions,
16    io,
17    sync::Arc,
18    time::Duration,
19};
20
21use alloy::primitives::U256;
22use async_lock::RwLock;
23use axum::{
24    Router,
25    body::Bytes,
26    extract::{Path, State},
27    http::{HeaderMap, StatusCode},
28    response::Response,
29    routing::{get, post},
30};
31use client::{BenchResults, BenchResultsDownloadConfig};
32use csv::Writer;
33use futures::{StreamExt, stream::FuturesUnordered};
34use hotshot_types::{
35    PeerConfig,
36    network::{BuilderType, NetworkConfig, PublicKeysFile},
37    traits::{
38        node_implementation::NodeType,
39        signature_key::{SignatureKey, StakeTableEntryType},
40    },
41};
42use http_client::{Url, error::ClientErr};
43use http_wire::{self as wire, ServerError, WireVersion, cors_layer, healthcheck_response};
44use libp2p_identity::{
45    Keypair, PeerId,
46    ed25519::{Keypair as EdKeypair, SecretKey},
47};
48use multiaddr::Multiaddr;
49use serde::de::DeserializeOwned;
50use tokio::net::TcpListener;
51use vbs::{BinarySerializer, Serializer};
52
53/// VBS framing version for orchestrator request bodies and the client in `client.rs`.
54///
55/// Responses go through [`wire::respond`], which always encodes at [`WireVersion`]; aliasing
56/// keeps request decoding and the client in lockstep with the responses.
57pub type OrchestratorVersion = WireVersion;
58
59/// Generate an keypair based on a `seed` and an `index`
60/// # Panics
61/// This panics if libp2p is unable to generate a secret key from the seed
62#[must_use]
63pub fn libp2p_generate_indexed_identity(seed: [u8; 32], index: u64) -> Keypair {
64    let mut hasher = blake3::Hasher::new();
65    hasher.update(&seed);
66    hasher.update(&index.to_le_bytes());
67    let new_seed = *hasher.finalize().as_bytes();
68    let sk_bytes = SecretKey::try_from_bytes(new_seed).unwrap();
69    <EdKeypair as From<SecretKey>>::from(sk_bytes).into()
70}
71
72/// The state of the orchestrator
73#[derive(Default, Clone)]
74#[allow(clippy::struct_excessive_bools)]
75struct OrchestratorState<TYPES: NodeType> {
76    /// Tracks the latest node index we have generated a configuration for
77    latest_index: u16,
78    /// Tracks the latest temporary index we have generated for init validator's key pair
79    tmp_latest_index: u16,
80    /// The network configuration
81    config: NetworkConfig<TYPES>,
82    /// Whether the network configuration has been updated with all the peer's public keys/configs
83    peer_pub_ready: bool,
84    /// A map from public keys to `(node_index, is_da)`.
85    pub_posted: HashMap<Vec<u8>, (u64, bool)>,
86    /// Whether nodes should start their HotShot instances
87    /// Will be set to true once all nodes post they are ready to start
88    start: bool,
89    /// The total nodes that have posted they are ready to start
90    nodes_connected: HashSet<PeerConfig<TYPES>>,
91    /// The results of the benchmarks
92    bench_results: BenchResults,
93    /// The number of nodes that have posted their results
94    nodes_post_results: u64,
95    /// Whether the orchestrator can be started manually
96    manual_start_allowed: bool,
97    /// Whether we are still accepting new keys for registration
98    accepting_new_keys: bool,
99    /// Builder address pool
100    builders: Vec<Url>,
101    /// whether we are using a fixed stake table, disabling public key registration
102    fixed_stake_table: bool,
103}
104
105impl<TYPES: NodeType> OrchestratorState<TYPES> {
106    /// create a new [`OrchestratorState`]
107    pub fn new(network_config: NetworkConfig<TYPES>) -> Self {
108        let mut peer_pub_ready = false;
109        let mut fixed_stake_table = false;
110
111        if network_config.config.known_nodes_with_stake.is_empty() {
112            println!(
113                "No nodes were loaded from the config file. Nodes will be allowed to register \
114                 dynamically."
115            );
116        } else {
117            println!("Initializing orchestrator with fixed stake table.");
118            peer_pub_ready = true;
119            fixed_stake_table = true;
120        }
121
122        let builders = if matches!(network_config.builder, BuilderType::External) {
123            network_config.config.builder_urls.clone().into()
124        } else {
125            vec![]
126        };
127
128        OrchestratorState {
129            latest_index: 0,
130            tmp_latest_index: 0,
131            config: network_config,
132            peer_pub_ready,
133            pub_posted: HashMap::new(),
134            nodes_connected: HashSet::new(),
135            start: false,
136            bench_results: BenchResults::default(),
137            nodes_post_results: 0,
138            manual_start_allowed: true,
139            accepting_new_keys: true,
140            builders,
141            fixed_stake_table,
142        }
143    }
144
145    /// Output the results to a csv file according to orchestrator state
146    pub fn output_to_csv(&self) {
147        let output_csv = BenchResultsDownloadConfig {
148            commit_sha: self.config.commit_sha.clone(),
149            total_nodes: self.config.config.num_nodes_with_stake.into(),
150            da_committee_size: self.config.config.da_staked_committee_size,
151            fixed_leader_for_gpuvid: self.config.config.fixed_leader_for_gpuvid,
152            transactions_per_round: self.config.transactions_per_round,
153            transaction_size: self.bench_results.transaction_size_in_bytes,
154            rounds: self.config.rounds,
155            partial_results: self.bench_results.partial_results.clone(),
156            avg_latency_in_sec: self.bench_results.avg_latency_in_sec,
157            minimum_latency_in_sec: self.bench_results.minimum_latency_in_sec,
158            maximum_latency_in_sec: self.bench_results.maximum_latency_in_sec,
159            throughput_bytes_per_sec: self.bench_results.throughput_bytes_per_sec,
160            total_transactions_committed: self.bench_results.total_transactions_committed,
161            total_time_elapsed_in_sec: self.bench_results.total_time_elapsed_in_sec,
162            total_num_views: self.bench_results.total_num_views,
163            failed_num_views: self.bench_results.failed_num_views,
164            committee_type: self.bench_results.committee_type.clone(),
165        };
166        // Open the CSV file in append mode
167        let results_csv_file = OpenOptions::new()
168            .create(true)
169            .append(true) // Open in append mode
170            .open("scripts/benchmarks_results/results.csv")
171            .unwrap();
172        // Open a file for writing
173        let mut wtr = Writer::from_writer(results_csv_file);
174        let _ = wtr.serialize(output_csv);
175        let _ = wtr.flush();
176        println!("Results successfully saved in scripts/benchmarks_results/results.csv");
177    }
178}
179
180/// An api exposed by the orchestrator
181pub trait OrchestratorApi<TYPES: NodeType> {
182    /// Post an identity to the orchestrator. Takes in optional
183    /// arguments so others can identify us on the Libp2p network.
184    /// # Errors
185    /// If we were unable to serve the request
186    fn post_identity(
187        &mut self,
188        libp2p_address: Option<Multiaddr>,
189        libp2p_public_key: Option<PeerId>,
190    ) -> Result<u16, ServerError>;
191    /// post endpoint for each node's config
192    /// # Errors
193    /// if unable to serve
194    fn post_getconfig(&mut self, _node_index: u16) -> Result<NetworkConfig<TYPES>, ServerError>;
195    /// get endpoint for the next available temporary node index
196    /// # Errors
197    /// if unable to serve
198    fn get_tmp_node_index(&mut self) -> Result<u16, ServerError>;
199    /// post endpoint for each node's public key
200    /// # Errors
201    /// if unable to serve
202    fn register_public_key(
203        &mut self,
204        pubkey: &mut Vec<u8>,
205        is_da: bool,
206        libp2p_address: Option<Multiaddr>,
207        libp2p_public_key: Option<PeerId>,
208    ) -> Result<(u64, bool), ServerError>;
209    /// post endpoint for whether or not all peers public keys are ready
210    /// # Errors
211    /// if unable to serve
212    fn peer_pub_ready(&self) -> Result<bool, ServerError>;
213    /// get endpoint for the network config after all peers public keys are collected
214    /// # Errors
215    /// if unable to serve
216    fn post_config_after_peer_collected(&mut self) -> Result<NetworkConfig<TYPES>, ServerError>;
217    /// get endpoint for whether or not the run has started
218    /// # Errors
219    /// if unable to serve
220    fn get_start(&self) -> Result<bool, ServerError>;
221    /// post endpoint for the results of the run
222    /// # Errors
223    /// if unable to serve
224    fn post_run_results(&mut self, metrics: BenchResults) -> Result<(), ServerError>;
225    /// A node POSTs its public key to let the orchestrator know that it is ready
226    /// # Errors
227    /// if unable to serve
228    fn post_ready(&mut self, peer_config: &PeerConfig<TYPES>) -> Result<(), ServerError>;
229    /// post endpoint for manually starting the orchestrator
230    /// # Errors
231    /// if unable to serve
232    fn post_manual_start(&mut self, password_bytes: Vec<u8>) -> Result<(), ServerError>;
233    /// post endpoint for registering a builder with the orchestrator
234    /// # Errors
235    /// if unable to serve
236    fn post_builder(&mut self, builder: Url) -> Result<(), ServerError>;
237    /// get endpoints for builders
238    /// # Errors
239    /// if not all builders are registered yet
240    fn get_builders(&self) -> Result<Vec<Url>, ServerError>;
241}
242
243impl<TYPES: NodeType> OrchestratorState<TYPES>
244where
245    TYPES::SignatureKey: serde::Serialize + Clone + SignatureKey + 'static,
246{
247    /// register a node with an unknown public key.
248    /// this method should be used when we don't have a fixed stake table
249    fn register_unknown(
250        &mut self,
251        pubkey: &mut Vec<u8>,
252        da_requested: bool,
253        libp2p_address: Option<Multiaddr>,
254        libp2p_public_key: Option<PeerId>,
255    ) -> Result<(u64, bool), ServerError> {
256        if let Some((node_index, is_da)) = self.pub_posted.get(pubkey) {
257            return Ok((*node_index, *is_da));
258        }
259
260        if !self.accepting_new_keys {
261            return Err(ServerError {
262                status: StatusCode::FORBIDDEN,
263                message: "Network has been started manually, and is no longer registering new \
264                          keys."
265                    .to_string(),
266            });
267        }
268
269        let node_index = self.pub_posted.len() as u64;
270
271        // Deserialize the public key
272        let staked_pubkey = PeerConfig::<TYPES>::from_bytes(pubkey).unwrap();
273
274        self.config
275            .config
276            .known_nodes_with_stake
277            .push(staked_pubkey.clone());
278
279        let mut added_to_da = false;
280
281        let da_full =
282            self.config.config.known_da_nodes.len() >= self.config.config.da_staked_committee_size;
283
284        #[allow(clippy::nonminimal_bool)]
285        // We add the node to the DA committee depending on either its node index or whether it requested membership.
286        //
287        // Since we issue `node_index` incrementally, if we are deciding DA membership by node_index
288        // we only need to check that the DA committee is not yet full.
289        //
290        // Note: this logically simplifies to (self.config.indexed_da || da_requested) && !da_full,
291        // but writing it that way makes it a little less clear to me.
292        if (self.config.indexed_da || (!self.config.indexed_da && da_requested)) && !da_full {
293            self.config.config.known_da_nodes.push(staked_pubkey);
294            added_to_da = true;
295        }
296
297        self.pub_posted
298            .insert(pubkey.clone(), (node_index, added_to_da));
299
300        // If the orchestrator is set up for libp2p and we have supplied the proper
301        // Libp2p data, add our node to the list of bootstrap nodes.
302        if self.config.libp2p_config.clone().is_some()
303            && let (Some(libp2p_public_key), Some(libp2p_address)) =
304                (libp2p_public_key, libp2p_address)
305        {
306            // Push to our bootstrap nodes
307            self.config
308                .libp2p_config
309                .as_mut()
310                .unwrap()
311                .bootstrap_nodes
312                .push((libp2p_public_key, libp2p_address));
313        }
314
315        tracing::error!("Posted public key for node_index {node_index}");
316
317        // node_index starts at 0, so once it matches `num_nodes_with_stake`
318        // we will have registered one node too many. hence, we want `node_index + 1`.
319        if node_index + 1 >= (self.config.config.num_nodes_with_stake.get() as u64) {
320            self.peer_pub_ready = true;
321            self.accepting_new_keys = false;
322        }
323        Ok((node_index, added_to_da))
324    }
325
326    /// register a node on the fixed stake table, which was loaded at startup
327    fn register_from_list(
328        &mut self,
329        pubkey: &mut Vec<u8>,
330        da_requested: bool,
331        libp2p_address: Option<Multiaddr>,
332        libp2p_public_key: Option<PeerId>,
333    ) -> Result<(u64, bool), ServerError> {
334        // if we've already registered this node before, we just retrieve its info from `pub_posted`
335        if let Some((node_index, is_da)) = self.pub_posted.get(pubkey) {
336            return Ok((*node_index, *is_da));
337        }
338
339        // Deserialize the public key
340        let staked_pubkey = PeerConfig::<TYPES>::from_bytes(pubkey).unwrap();
341
342        // Check if the node is allowed to connect, returning its index and config entry if so.
343        let Some((node_index, node_config)) =
344            self.config.public_keys.iter().enumerate().find(|keys| {
345                keys.1.stake_table_key == staked_pubkey.stake_table_entry.public_key()
346            })
347        else {
348            return Err(ServerError {
349                status: StatusCode::FORBIDDEN,
350                message: "You are unauthorized to register with the orchestrator".to_string(),
351            });
352        };
353
354        // Check that our recorded DA status for the node matches what the node actually requested
355        if node_config.da != da_requested {
356            return Err(ServerError {
357                status: StatusCode::BAD_REQUEST,
358                message: format!(
359                    "Mismatch in DA status in registration for node {}. DA requested: {}, \
360                     expected: {}",
361                    node_index, da_requested, node_config.da
362                ),
363            });
364        }
365
366        let added_to_da = node_config.da;
367
368        self.pub_posted
369            .insert(pubkey.clone(), (node_index as u64, added_to_da));
370
371        // If the orchestrator is set up for libp2p and we have supplied the proper
372        // Libp2p data, add our node to the list of bootstrap nodes.
373        if self.config.libp2p_config.clone().is_some()
374            && let (Some(libp2p_public_key), Some(libp2p_address)) =
375                (libp2p_public_key, libp2p_address)
376        {
377            // Push to our bootstrap nodes
378            self.config
379                .libp2p_config
380                .as_mut()
381                .unwrap()
382                .bootstrap_nodes
383                .push((libp2p_public_key, libp2p_address));
384        }
385
386        tracing::error!("Node {node_index} has registered.");
387
388        Ok((node_index as u64, added_to_da))
389    }
390}
391
392impl<TYPES: NodeType> OrchestratorApi<TYPES> for OrchestratorState<TYPES>
393where
394    TYPES::SignatureKey: serde::Serialize + Clone + SignatureKey + 'static,
395{
396    /// Post an identity to the orchestrator. Takes in optional
397    /// arguments so others can identify us on the Libp2p network.
398    /// # Errors
399    /// If we were unable to serve the request
400    fn post_identity(
401        &mut self,
402        libp2p_address: Option<Multiaddr>,
403        libp2p_public_key: Option<PeerId>,
404    ) -> Result<u16, ServerError> {
405        let node_index = self.latest_index;
406        self.latest_index += 1;
407
408        if usize::from(node_index) >= self.config.config.num_nodes_with_stake.get() {
409            return Err(ServerError {
410                status: StatusCode::BAD_REQUEST,
411                message: "Network has reached capacity".to_string(),
412            });
413        }
414
415        // If the orchestrator is set up for libp2p and we have supplied the proper
416        // Libp2p data, add our node to the list of bootstrap nodes.
417        if self.config.libp2p_config.clone().is_some()
418            && let (Some(libp2p_public_key), Some(libp2p_address)) =
419                (libp2p_public_key, libp2p_address)
420        {
421            // Push to our bootstrap nodes
422            self.config
423                .libp2p_config
424                .as_mut()
425                .unwrap()
426                .bootstrap_nodes
427                .push((libp2p_public_key, libp2p_address));
428        }
429        Ok(node_index)
430    }
431
432    // Assumes nodes will set their own index that they received from the
433    // 'identity' endpoint
434    fn post_getconfig(&mut self, _node_index: u16) -> Result<NetworkConfig<TYPES>, ServerError> {
435        Ok(self.config.clone())
436    }
437
438    // Assumes one node do not get twice
439    fn get_tmp_node_index(&mut self) -> Result<u16, ServerError> {
440        let tmp_node_index = self.tmp_latest_index;
441        self.tmp_latest_index += 1;
442
443        if usize::from(tmp_node_index) >= self.config.config.num_nodes_with_stake.get() {
444            return Err(ServerError {
445                status: StatusCode::BAD_REQUEST,
446                message: "Node index getter for key pair generation has reached capacity"
447                    .to_string(),
448            });
449        }
450        Ok(tmp_node_index)
451    }
452
453    fn register_public_key(
454        &mut self,
455        pubkey: &mut Vec<u8>,
456        da_requested: bool,
457        libp2p_address: Option<Multiaddr>,
458        libp2p_public_key: Option<PeerId>,
459    ) -> Result<(u64, bool), ServerError> {
460        if self.fixed_stake_table {
461            self.register_from_list(pubkey, da_requested, libp2p_address, libp2p_public_key)
462        } else {
463            self.register_unknown(pubkey, da_requested, libp2p_address, libp2p_public_key)
464        }
465    }
466
467    fn peer_pub_ready(&self) -> Result<bool, ServerError> {
468        if !self.peer_pub_ready {
469            return Err(ServerError {
470                status: StatusCode::BAD_REQUEST,
471                message: "Peer's public configs are not ready".to_string(),
472            });
473        }
474        Ok(self.peer_pub_ready)
475    }
476
477    fn post_config_after_peer_collected(&mut self) -> Result<NetworkConfig<TYPES>, ServerError> {
478        if !self.peer_pub_ready {
479            return Err(ServerError {
480                status: StatusCode::BAD_REQUEST,
481                message: "Peer's public configs are not ready".to_string(),
482            });
483        }
484
485        Ok(self.config.clone())
486    }
487
488    fn get_start(&self) -> Result<bool, ServerError> {
489        // println!("{}", self.start);
490        if !self.start {
491            return Err(ServerError {
492                status: StatusCode::BAD_REQUEST,
493                message: "Network is not ready to start".to_string(),
494            });
495        }
496        Ok(self.start)
497    }
498
499    // Assumes nodes do not post 'ready' twice
500    fn post_ready(&mut self, peer_config: &PeerConfig<TYPES>) -> Result<(), ServerError> {
501        // If we have not disabled registration verification.
502        // Is this node allowed to connect?
503        if !self
504            .config
505            .config
506            .known_nodes_with_stake
507            .contains(peer_config)
508        {
509            return Err(ServerError {
510                status: StatusCode::FORBIDDEN,
511                message: "You are unauthorized to register with the orchestrator".to_string(),
512            });
513        }
514
515        // `HashSet::insert()` returns whether the node was newly inserted (true) or not
516        if self.nodes_connected.insert(peer_config.clone()) {
517            tracing::error!(
518                "Node {peer_config} connected. Total nodes connected: {}",
519                self.nodes_connected.len()
520            );
521        }
522
523        // i.e. nodes_connected >= num_nodes_with_stake * (start_threshold.0 / start_threshold.1)
524        if self.nodes_connected.len() as u64 * self.config.config.start_threshold.1
525            >= (self.config.config.num_nodes_with_stake.get() as u64)
526                * self.config.config.start_threshold.0
527        {
528            self.accepting_new_keys = false;
529            self.manual_start_allowed = false;
530            self.start = true;
531        }
532
533        Ok(())
534    }
535
536    /// Manually start the network
537    fn post_manual_start(&mut self, password_bytes: Vec<u8>) -> Result<(), ServerError> {
538        if !self.manual_start_allowed {
539            return Err(ServerError {
540                status: StatusCode::FORBIDDEN,
541                message: "Configs have already been distributed to nodes, and the network can no \
542                          longer be started manually."
543                    .to_string(),
544            });
545        }
546
547        let password = String::from_utf8(password_bytes)
548            .expect("Failed to decode raw password as UTF-8 string.");
549
550        // Check that the password matches
551        if self.config.manual_start_password != Some(password) {
552            return Err(ServerError {
553                status: StatusCode::FORBIDDEN,
554                message: "Incorrect password.".to_string(),
555            });
556        }
557
558        let registered_nodes_with_stake = self.config.config.known_nodes_with_stake.len();
559        let registered_da_nodes = self.config.config.known_da_nodes.len();
560
561        if registered_da_nodes > 1 {
562            self.config.config.num_nodes_with_stake =
563                std::num::NonZeroUsize::new(registered_nodes_with_stake)
564                    .expect("Failed to convert to NonZeroUsize; this should be impossible.");
565
566            self.config.config.da_staked_committee_size = registered_da_nodes;
567        } else {
568            return Err(ServerError {
569                status: StatusCode::FORBIDDEN,
570                message: format!(
571                    "We cannot manually start the network, because we only have \
572                     {registered_nodes_with_stake} nodes with stake registered, with \
573                     {registered_da_nodes} DA nodes."
574                ),
575            });
576        }
577
578        self.accepting_new_keys = false;
579        self.manual_start_allowed = false;
580        self.peer_pub_ready = true;
581        self.start = true;
582
583        Ok(())
584    }
585
586    // Aggregates results of the run from all nodes
587    fn post_run_results(&mut self, metrics: BenchResults) -> Result<(), ServerError> {
588        if metrics.total_transactions_committed != 0 {
589            // Deal with the bench results
590            if self.bench_results.total_transactions_committed == 0 {
591                self.bench_results = metrics;
592            } else {
593                // Deal with the bench results from different nodes
594                let cur_metrics = self.bench_results.clone();
595                self.bench_results.avg_latency_in_sec = (metrics.avg_latency_in_sec
596                    * metrics.num_latency
597                    + cur_metrics.avg_latency_in_sec * cur_metrics.num_latency)
598                    / (metrics.num_latency + cur_metrics.num_latency);
599                self.bench_results.num_latency += metrics.num_latency;
600                self.bench_results.minimum_latency_in_sec = metrics
601                    .minimum_latency_in_sec
602                    .min(cur_metrics.minimum_latency_in_sec);
603                self.bench_results.maximum_latency_in_sec = metrics
604                    .maximum_latency_in_sec
605                    .max(cur_metrics.maximum_latency_in_sec);
606                self.bench_results.throughput_bytes_per_sec = metrics
607                    .throughput_bytes_per_sec
608                    .max(cur_metrics.throughput_bytes_per_sec);
609                self.bench_results.total_transactions_committed = metrics
610                    .total_transactions_committed
611                    .max(cur_metrics.total_transactions_committed);
612                self.bench_results.total_time_elapsed_in_sec = metrics
613                    .total_time_elapsed_in_sec
614                    .max(cur_metrics.total_time_elapsed_in_sec);
615                self.bench_results.total_num_views =
616                    metrics.total_num_views.min(cur_metrics.total_num_views);
617                self.bench_results.failed_num_views =
618                    metrics.failed_num_views.max(cur_metrics.failed_num_views);
619            }
620        }
621        self.nodes_post_results += 1;
622        if self.bench_results.partial_results == "Unset" {
623            self.bench_results.partial_results = "One".to_string();
624            self.bench_results.printout();
625            self.output_to_csv();
626        }
627        if self.bench_results.partial_results == "One"
628            && self.nodes_post_results >= (self.config.config.da_staked_committee_size as u64 / 2)
629        {
630            self.bench_results.partial_results = "HalfDA".to_string();
631            self.bench_results.printout();
632            self.output_to_csv();
633        }
634        if self.bench_results.partial_results == "HalfDA"
635            && self.nodes_post_results >= (self.config.config.num_nodes_with_stake.get() as u64 / 2)
636        {
637            self.bench_results.partial_results = "Half".to_string();
638            self.bench_results.printout();
639            self.output_to_csv();
640        }
641        if self.bench_results.partial_results != "Full"
642            && self.nodes_post_results >= (self.config.config.num_nodes_with_stake.get() as u64)
643        {
644            self.bench_results.partial_results = "Full".to_string();
645            self.bench_results.printout();
646            self.output_to_csv();
647        }
648        Ok(())
649    }
650
651    fn post_builder(&mut self, builder: Url) -> Result<(), ServerError> {
652        self.builders.push(builder);
653        Ok(())
654    }
655
656    fn get_builders(&self) -> Result<Vec<Url>, ServerError> {
657        if !matches!(self.config.builder, BuilderType::External)
658            && self.builders.len() != self.config.config.da_staked_committee_size
659        {
660            return Err(ServerError {
661                status: StatusCode::NOT_FOUND,
662                message: "Not all builders are registered yet".to_string(),
663            });
664        }
665        Ok(self.builders.clone())
666    }
667}
668
669/// Shared, lock-guarded orchestrator state, cloned into every axum handler via `State`.
670type SharedOrchestratorState<TYPES> = Arc<RwLock<OrchestratorState<TYPES>>>;
671
672fn malformed_body() -> ServerError {
673    ServerError {
674        status: StatusCode::BAD_REQUEST,
675        message: "Malformed body".to_string(),
676    }
677}
678
679/// Decode a POST body from `OrchestratorClient`.
680///
681/// `client.rs` builds these bodies by calling `body_binary` on an already vbs-serialized
682/// `Vec<u8>`, so the wire body is `[4-byte version][8-byte bincode `Vec<u8>` length][inner
683/// vbs-serialized payload]`; the first 12 bytes are an artifact of that double encoding and must
684/// be stripped before decoding the inner payload.
685fn decode_wrapped_body<T: DeserializeOwned>(body: &[u8]) -> Result<T, ServerError> {
686    body.get(12..)
687        .and_then(|inner| Serializer::<OrchestratorVersion>::deserialize(inner).ok())
688        .ok_or_else(malformed_body)
689}
690
691/// Like [`decode_wrapped_body`], but for the `ready` route, whose inner payload is a raw
692/// [`PeerConfig::to_bytes`] blob rather than a second vbs-serialized value.
693fn decode_wrapped_peer_config<TYPES: NodeType>(
694    body: &[u8],
695) -> Result<PeerConfig<TYPES>, ServerError> {
696    body.get(12..)
697        .and_then(PeerConfig::<TYPES>::from_bytes)
698        .ok_or_else(malformed_body)
699}
700
701async fn healthcheck(headers: HeaderMap) -> Response {
702    healthcheck_response(&headers)
703}
704
705async fn post_identity<TYPES: NodeType>(
706    State(state): State<SharedOrchestratorState<TYPES>>,
707    headers: HeaderMap,
708    body: Bytes,
709) -> Response {
710    let result = match decode_wrapped_body::<(Option<Multiaddr>, Option<PeerId>)>(&body) {
711        Ok((libp2p_address, libp2p_public_key)) => state
712            .write()
713            .await
714            .post_identity(libp2p_address, libp2p_public_key),
715        Err(err) => Err(err),
716    };
717    wire::respond(&headers, result)
718}
719
720async fn post_getconfig<TYPES: NodeType>(
721    State(state): State<SharedOrchestratorState<TYPES>>,
722    Path(node_index): Path<u16>,
723    headers: HeaderMap,
724) -> Response {
725    let result = state.write().await.post_getconfig(node_index);
726    wire::respond(&headers, result)
727}
728
729async fn get_tmp_node_index<TYPES: NodeType>(
730    State(state): State<SharedOrchestratorState<TYPES>>,
731    headers: HeaderMap,
732) -> Response {
733    let result = state.write().await.get_tmp_node_index();
734    wire::respond(&headers, result)
735}
736
737async fn post_pubkey<TYPES: NodeType>(
738    State(state): State<SharedOrchestratorState<TYPES>>,
739    Path(is_da): Path<bool>,
740    headers: HeaderMap,
741    body: Bytes,
742) -> Response {
743    let result = match decode_wrapped_body::<(Vec<u8>, Option<Multiaddr>, Option<PeerId>)>(&body) {
744        Ok((mut pubkey, libp2p_address, libp2p_public_key)) => state
745            .write()
746            .await
747            .register_public_key(&mut pubkey, is_da, libp2p_address, libp2p_public_key),
748        Err(err) => Err(err),
749    };
750    wire::respond(&headers, result)
751}
752
753async fn peer_pubconfig_ready<TYPES: NodeType>(
754    State(state): State<SharedOrchestratorState<TYPES>>,
755    headers: HeaderMap,
756) -> Response {
757    let result = state.read().await.peer_pub_ready();
758    wire::respond(&headers, result)
759}
760
761async fn post_config_after_peer_collected<TYPES: NodeType>(
762    State(state): State<SharedOrchestratorState<TYPES>>,
763    headers: HeaderMap,
764) -> Response {
765    let result = state.write().await.post_config_after_peer_collected();
766    wire::respond(&headers, result)
767}
768
769async fn post_ready<TYPES: NodeType>(
770    State(state): State<SharedOrchestratorState<TYPES>>,
771    headers: HeaderMap,
772    body: Bytes,
773) -> Response {
774    let result = match decode_wrapped_peer_config::<TYPES>(&body) {
775        Ok(peer_config) => state.write().await.post_ready(&peer_config),
776        Err(err) => Err(err),
777    };
778    wire::respond(&headers, result)
779}
780
781async fn post_manual_start<TYPES: NodeType>(
782    State(state): State<SharedOrchestratorState<TYPES>>,
783    headers: HeaderMap,
784    body: Bytes,
785) -> Response {
786    // The raw body is used as-is, with no framing.
787    let result = state.write().await.post_manual_start(body.to_vec());
788    wire::respond(&headers, result)
789}
790
791async fn get_start<TYPES: NodeType>(
792    State(state): State<SharedOrchestratorState<TYPES>>,
793    headers: HeaderMap,
794) -> Response {
795    let result = state.read().await.get_start();
796    wire::respond(&headers, result)
797}
798
799async fn post_results<TYPES: NodeType>(
800    State(state): State<SharedOrchestratorState<TYPES>>,
801    headers: HeaderMap,
802    body: Bytes,
803) -> Response {
804    // Panics on a malformed body: the only production
805    // caller (`client.rs`'s `post_bench_results`) always sends well-formed JSON.
806    let metrics: BenchResults = serde_json::from_slice(&body).unwrap();
807    let result = state.write().await.post_run_results(metrics);
808    wire::respond(&headers, result)
809}
810
811async fn post_builder<TYPES: NodeType>(
812    State(state): State<SharedOrchestratorState<TYPES>>,
813    headers: HeaderMap,
814    body: Bytes,
815) -> Response {
816    let result = match decode_wrapped_body::<Vec<Url>>(&body) {
817        Ok(urls) => {
818            let mut reachable = urls
819                .into_iter()
820                .map(|url| async {
821                    let client: http_client::Client<ClientErr, OrchestratorVersion> =
822                        http_client::Client::builder(url.clone()).build();
823                    client
824                        .connect(Some(Duration::from_secs(2)))
825                        .await
826                        .then_some(url)
827                })
828                .collect::<FuturesUnordered<_>>()
829                .filter_map(futures::future::ready);
830            match reachable.next().await {
831                Some(url) => state.write().await.post_builder(url),
832                None => Err(ServerError {
833                    status: StatusCode::BAD_REQUEST,
834                    message: "No reachable addresses".to_string(),
835                }),
836            }
837        },
838        Err(err) => Err(err),
839    };
840    wire::respond(&headers, result)
841}
842
843async fn get_builders<TYPES: NodeType>(
844    State(state): State<SharedOrchestratorState<TYPES>>,
845    headers: HeaderMap,
846) -> Response {
847    let result = state.read().await.get_builders();
848    wire::respond(&headers, result)
849}
850
851/// Builds the `api` module's routes. Existing clients call these both directly (e.g.
852/// `api/identity`) and under a major-version prefix (`v0/api/identity`), so the `/api` tree is
853/// mounted at the root and under `/v0`.
854fn api_router<TYPES: NodeType>() -> Router<SharedOrchestratorState<TYPES>> {
855    Router::new()
856        .route("/healthcheck", get(healthcheck))
857        .route("/identity", post(post_identity::<TYPES>))
858        .route("/config/{node_index}", post(post_getconfig::<TYPES>))
859        .route("/get_tmp_node_index", post(get_tmp_node_index::<TYPES>))
860        .route("/pubkey/{is_da}", post(post_pubkey::<TYPES>))
861        .route("/peer_pub_ready", get(peer_pubconfig_ready::<TYPES>))
862        .route(
863            "/post_config_after_peer_collected",
864            post(post_config_after_peer_collected::<TYPES>),
865        )
866        .route("/ready", post(post_ready::<TYPES>))
867        .route("/start", get(get_start::<TYPES>))
868        .route("/results", post(post_results::<TYPES>))
869        .route("/manual_start", post(post_manual_start::<TYPES>))
870        .route("/builders", get(get_builders::<TYPES>))
871        .route("/builder", post(post_builder::<TYPES>))
872}
873
874/// Builds the full router: the app-level `healthcheck`, plus the `api` module served both
875/// unversioned and under `/v0`. Every response carries permissive CORS headers.
876fn app<TYPES: NodeType>(state: SharedOrchestratorState<TYPES>) -> Router {
877    let api = Router::new().nest("/api", api_router::<TYPES>());
878    Router::new()
879        .route("/healthcheck", get(healthcheck))
880        .merge(api.clone())
881        .nest("/v0", api)
882        .with_state(state)
883        .layer(cors_layer())
884}
885
886/// Runs the orchestrator
887/// # Errors
888/// This errors if axum runs into an issue during serving
889pub async fn run_orchestrator<TYPES: NodeType>(
890    mut network_config: NetworkConfig<TYPES>,
891    url: Url,
892) -> io::Result<()> {
893    let env_password = std::env::var("ORCHESTRATOR_MANUAL_START_PASSWORD");
894
895    if env_password.is_ok() {
896        tracing::warn!(
897            "Took orchestrator manual start password from the environment variable: \
898             ORCHESTRATOR_MANUAL_START_PASSWORD={:?}",
899            env_password
900        );
901        network_config.manual_start_password = env_password.ok();
902    }
903
904    // Try to overwrite the network_config public keys
905    // from the file the env var points to, or panic.
906    {
907        let env_public_keys = std::env::var("ORCHESTRATOR_PUBLIC_KEYS");
908
909        if let Ok(filepath) = env_public_keys {
910            #[allow(clippy::panic)]
911            let config_file_as_string: String = fs::read_to_string(filepath.clone())
912                .unwrap_or_else(|_| panic!("Could not read config file located at {filepath}"));
913
914            let file: PublicKeysFile<TYPES> =
915                toml::from_str::<PublicKeysFile<TYPES>>(&config_file_as_string)
916                    .expect("Unable to convert config file to TOML");
917
918            network_config.public_keys = file.public_keys;
919        }
920    }
921
922    network_config.config.known_nodes_with_stake = network_config
923        .public_keys
924        .iter()
925        .map(|keys| PeerConfig {
926            stake_table_entry: keys
927                .stake_table_key
928                .stake_table_entry(U256::from(keys.stake)),
929            state_ver_key: keys.state_ver_key.clone(),
930            connect_info: keys.connect_info.clone(),
931        })
932        .collect();
933
934    network_config.config.known_da_nodes = network_config
935        .public_keys
936        .iter()
937        .filter(|keys| keys.da)
938        .map(|keys| PeerConfig {
939            stake_table_entry: keys
940                .stake_table_key
941                .stake_table_entry(U256::from(keys.stake)),
942            state_ver_key: keys.state_ver_key.clone(),
943            connect_info: keys.connect_info.clone(),
944        })
945        .collect();
946
947    let state: SharedOrchestratorState<TYPES> =
948        Arc::new(RwLock::new(OrchestratorState::new(network_config)));
949    let app = app::<TYPES>(state);
950
951    let host = url
952        .host_str()
953        .ok_or_else(|| io::Error::other(format!("orchestrator url missing host: {url}")))?;
954    let port = url
955        .port_or_known_default()
956        .ok_or_else(|| io::Error::other(format!("orchestrator url missing port: {url}")))?;
957    let listener = TcpListener::bind((host, port)).await?;
958
959    tracing::error!("listening on {url:?}");
960    axum::serve(listener, app).await
961}
962
963#[cfg(test)]
964mod tests {
965    use axum::http::{Request, header};
966    use hotshot_example_types::node_types::TestTypes;
967
968    use super::*;
969
970    fn test_app() -> Router {
971        app::<TestTypes>(Arc::new(RwLock::new(OrchestratorState::new(
972            NetworkConfig::default(),
973        ))))
974    }
975
976    /// Every response must carry permissive CORS headers.
977    #[tokio::test]
978    async fn responses_carry_cors_headers() {
979        for uri in ["/healthcheck", "/api/peer_pub_ready", "/no/such/route"] {
980            let req = Request::builder()
981                .uri(uri)
982                .header(header::ORIGIN, "https://example.com")
983                .body(axum::body::Body::empty())
984                .unwrap();
985            let resp = tower::ServiceExt::oneshot(test_app(), req).await.unwrap();
986            assert_eq!(
987                resp.headers()
988                    .get(header::ACCESS_CONTROL_ALLOW_ORIGIN)
989                    .unwrap_or_else(|| panic!("no CORS header on {uri}")),
990                "*",
991                "{uri}"
992            );
993        }
994    }
995
996    /// The `api` module is served both unversioned and under `/v0`; both forms must route.
997    #[tokio::test]
998    async fn versioned_and_unversioned_api_paths_route() {
999        for uri in ["/api/peer_pub_ready", "/v0/api/peer_pub_ready"] {
1000            let req = Request::builder()
1001                .uri(uri)
1002                .body(axum::body::Body::empty())
1003                .unwrap();
1004            let resp = tower::ServiceExt::oneshot(test_app(), req).await.unwrap();
1005            assert_ne!(resp.status(), StatusCode::NOT_FOUND, "{uri} did not route");
1006        }
1007    }
1008}