Skip to content

Commit 998142e

Browse files
authored
Merge branch 'main' into build/leanvm-track-main
2 parents c959352 + d54044c commit 998142e

3 files changed

Lines changed: 273 additions & 12 deletions

File tree

bin/ethlambda/src/cli.rs

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,8 @@ use ethlambda_p2p::discovery::DEFAULT_DISCOVERY_TARGET_PEERS;
44
use std::net::IpAddr;
55
use std::path::PathBuf;
66

7-
use crate::version;
8-
9-
#[derive(Debug, clap::Parser)]
10-
#[command(name = "ethlambda", author = "LambdaClass", version = version::CLIENT_VERSION, about = "ethlambda consensus client")]
11-
pub(crate) struct CliOptions {
7+
#[derive(Debug, clap::Args)]
8+
pub(crate) struct NodeOptions {
129
/// Path to the chain genesis config (e.g., config.yaml).
1310
#[arg(long)]
1411
pub(crate) genesis: PathBuf,
@@ -173,7 +170,7 @@ pub(crate) struct DiscoveryConfig {
173170
pub(crate) target_peers: usize,
174171
}
175172

176-
impl CliOptions {
173+
impl NodeOptions {
177174
/// Reject a discovery port that collides with the QUIC port.
178175
///
179176
/// Both are UDP. Without this the collision surfaces at bind time as an
@@ -231,10 +228,13 @@ pub(crate) struct ShadowOptions {
231228
#[cfg(test)]
232229
mod tests {
233230
use super::*;
234-
use clap::Parser as _;
231+
use crate::command::{Command, try_parse_from};
235232

236233
/// The required flags, so a test can vary only what it cares about.
237-
fn parse(extra: &[&str]) -> CliOptions {
234+
///
235+
/// `NodeOptions` is a `clap::Args` group rather than a parser of its own,
236+
/// so this parses through the real dispatch, as the binary does.
237+
fn parse(extra: &[&str]) -> NodeOptions {
238238
let mut argv = vec![
239239
"ethlambda",
240240
"--genesis",
@@ -253,7 +253,8 @@ mod tests {
253253
"ethlambda_0",
254254
];
255255
argv.extend_from_slice(extra);
256-
CliOptions::parse_from(argv)
256+
let Command::Node(options) = try_parse_from(argv).expect("node options parse");
257+
options
257258
}
258259

259260
/// `--discovery.enable` on its own has to work: a default that is never

bin/ethlambda/src/command.rs

Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
//! Sub-command definition and dispatch.
2+
//!
3+
//! `node` is an ordinary clap sub-command, so clap owns its help, usage lines
4+
//! and error messages. The one thing clap cannot express is a *default*
5+
//! sub-command, and the node needs one: the Dockerfile,
6+
//! lean-quickstart, the hive shim and the devnet skills all invoke the binary as
7+
//! a bare list of node flags, from before there was anything else to run. That
8+
//! form keeps working because a missing sub-command is filled in as `node`
9+
//! before parsing — see [`default_subcommand`].
10+
11+
use std::ffi::OsString;
12+
13+
use clap::Parser;
14+
15+
use crate::cli::NodeOptions;
16+
use crate::version;
17+
18+
/// Tokens that already say what to run, so no default is inserted ahead of
19+
/// them. `help` is clap's own generated sub-command (`ethlambda help node`).
20+
const EXPLICIT: &[&str] = &[NODE, "help", "-h", "--help", "-V", "--version"];
21+
22+
const NODE: &str = "node";
23+
24+
#[derive(Debug, clap::Parser)]
25+
#[command(
26+
name = "ethlambda",
27+
author = "LambdaClass",
28+
version = version::CLIENT_VERSION,
29+
about = "ethlambda consensus client",
30+
// `--version` used to sit on the node options, so it was accepted after
31+
// node flags; `ethereum/hive` builds its image that way. Propagating it to
32+
// the sub-commands keeps those invocations working.
33+
propagate_version = true
34+
)]
35+
struct Cli {
36+
#[command(subcommand)]
37+
command: Command,
38+
}
39+
40+
/// What the command line asked the binary to do.
41+
#[derive(Debug, clap::Subcommand)]
42+
pub(crate) enum Command {
43+
/// Run the consensus node (default when no sub-command is given).
44+
///
45+
/// `ethlambda --genesis ...` and `ethlambda node --genesis ...` are the
46+
/// same invocation.
47+
//
48+
// Deliberately not part of the doc comment above, which clap renders as
49+
// this sub-command's `long_about`: `display_name` keeps `--version`
50+
// printing `ethlambda <version>` after node flags, as it did when the node
51+
// flags were the whole command line. It is still listed and invoked as
52+
// `node`.
53+
#[command(display_name = "ethlambda")]
54+
Node(NodeOptions),
55+
}
56+
57+
/// Parse the process arguments, exiting the way clap does on a parse error,
58+
/// `--help` or `--version`.
59+
pub(crate) fn parse() -> Command {
60+
try_parse_from(std::env::args_os()).unwrap_or_else(|err| err.exit())
61+
}
62+
63+
pub(crate) fn try_parse_from<I>(args: I) -> Result<Command, clap::Error>
64+
where
65+
I: IntoIterator,
66+
I::Item: Into<OsString>,
67+
{
68+
let mut args: Vec<OsString> = args.into_iter().map(Into::into).collect();
69+
if let Some(token) = default_subcommand(&args) {
70+
args.insert(1, token.into());
71+
}
72+
Cli::try_parse_from(args).map(|cli| cli.command)
73+
}
74+
75+
/// The sub-command to insert, if the arguments do not name one.
76+
///
77+
/// `NodeOptions` declares no positional arguments, so the first token after
78+
/// the program name is either a flag or a sub-command — a flag *value* never
79+
/// lands there and is never mistaken for one. A leading flag therefore means
80+
/// the flat node form, and gets `node` inserted ahead of it; a bare invocation
81+
/// is left alone so clap prints its own "requires a subcommand" help.
82+
fn default_subcommand(args: &[OsString]) -> Option<&'static str> {
83+
let first = args.get(1)?.to_str()?;
84+
(!EXPLICIT.contains(&first)).then_some(NODE)
85+
}
86+
87+
#[cfg(test)]
88+
mod tests {
89+
use std::path::PathBuf;
90+
91+
use clap::error::ErrorKind;
92+
93+
use super::*;
94+
95+
/// The flat invocation shape used by the Dockerfile, lean-quickstart, the
96+
/// hive shim and the devnet skills. It must keep parsing unchanged.
97+
const FLAT: &[&str] = &[
98+
"ethlambda",
99+
"--genesis",
100+
"config.yaml",
101+
"--validators",
102+
"annotated_validators.yaml",
103+
"--bootnodes",
104+
"nodes.yaml",
105+
"--validator-config",
106+
"validator-config.yaml",
107+
"--hash-sig-keys-dir",
108+
"hash-sig-keys/",
109+
"--node-key",
110+
"node.key",
111+
"--node-id",
112+
"ethlambda_0",
113+
"--gossipsub-port",
114+
"9001",
115+
"--is-aggregator",
116+
];
117+
118+
/// `FLAT` with an explicit `node` sub-command token.
119+
fn with_node_token() -> Vec<&'static str> {
120+
let mut args = vec!["ethlambda", NODE];
121+
args.extend_from_slice(&FLAT[1..]);
122+
args
123+
}
124+
125+
fn node_options(args: &[&str]) -> NodeOptions {
126+
let Command::Node(options) =
127+
try_parse_from(args.iter().map(OsString::from)).expect("invocation parses");
128+
options
129+
}
130+
131+
#[test]
132+
fn flat_invocation_parses_unchanged() {
133+
let options = node_options(FLAT);
134+
assert_eq!(options.genesis, PathBuf::from("config.yaml"));
135+
assert_eq!(options.hash_sig_keys_dir, PathBuf::from("hash-sig-keys/"));
136+
assert_eq!(options.node_id, "ethlambda_0");
137+
assert_eq!(options.gossipsub_port, 9001);
138+
assert!(options.is_aggregator);
139+
}
140+
141+
#[test]
142+
fn node_sub_command_accepts_the_same_flags_as_the_flat_form() {
143+
let flat = node_options(FLAT);
144+
let scoped = node_options(&with_node_token());
145+
// Compared through `Debug`, which the derive prints field by field,
146+
// because `NodeOptions` derives no `PartialEq` — and deriving one for
147+
// a test would touch the parser this module deliberately leaves alone.
148+
assert_eq!(format!("{flat:?}"), format!("{scoped:?}"));
149+
}
150+
151+
#[test]
152+
fn a_flag_value_of_node_is_not_taken_for_the_sub_command() {
153+
let mut args: Vec<&str> = FLAT.to_vec();
154+
let value = args
155+
.iter()
156+
.position(|arg| *arg == "ethlambda_0")
157+
.expect("node id value present");
158+
args[value] = NODE;
159+
assert_eq!(node_options(&args).node_id, NODE);
160+
}
161+
162+
#[test]
163+
fn a_node_token_after_the_flags_is_still_rejected() {
164+
// The default is inserted at the front or not at all, so a later token
165+
// stays the stray positional argument it has always been.
166+
let mut args: Vec<&str> = FLAT.to_vec();
167+
args.push(NODE);
168+
let err = try_parse_from(args.iter().map(OsString::from))
169+
.expect_err("a trailing token must not be swallowed");
170+
assert_eq!(err.kind(), ErrorKind::UnknownArgument);
171+
}
172+
173+
#[test]
174+
fn a_second_node_token_is_rejected_by_clap() {
175+
let mut args = with_node_token();
176+
args.insert(1, NODE);
177+
let err = try_parse_from(args.iter().map(OsString::from))
178+
.expect_err("only one sub-command is accepted");
179+
assert_eq!(err.kind(), ErrorKind::UnknownArgument);
180+
}
181+
182+
#[test]
183+
fn missing_required_flag_keeps_the_clap_error_in_both_forms() {
184+
// `--genesis config.yaml` dropped from the front of the flag list.
185+
let flat: Vec<&str> = std::iter::once("ethlambda")
186+
.chain(FLAT[3..].iter().copied())
187+
.collect();
188+
let mut scoped = vec!["ethlambda", NODE];
189+
scoped.extend_from_slice(&flat[1..]);
190+
191+
for args in [flat, scoped] {
192+
let err = try_parse_from(args.iter().map(OsString::from))
193+
.expect_err("a missing required flag must error");
194+
assert_eq!(err.kind(), ErrorKind::MissingRequiredArgument);
195+
}
196+
}
197+
198+
#[test]
199+
fn bare_invocation_asks_for_a_sub_command() {
200+
// Nothing to default: clap prints the top-level help, which lists the
201+
// sub-commands, rather than a missing-argument list for one of them.
202+
let err = try_parse_from(["ethlambda"].iter().map(OsString::from))
203+
.expect_err("an argument-less invocation must not start a node");
204+
assert_eq!(
205+
err.kind(),
206+
ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
207+
);
208+
assert_ne!(err.exit_code(), 0, "a bare invocation must not exit 0");
209+
}
210+
211+
#[test]
212+
fn help_and_version_stay_top_level_flags() {
213+
// `ethereum/hive` builds its ethlambda image by piping
214+
// `ethlambda --version` into a file, with and without flags in front.
215+
let mut version_after_flags: Vec<&str> = FLAT.to_vec();
216+
version_after_flags.push("--version");
217+
218+
for (args, expected) in [
219+
(vec!["ethlambda", "--help"], ErrorKind::DisplayHelp),
220+
(vec!["ethlambda", "--version"], ErrorKind::DisplayVersion),
221+
(version_after_flags, ErrorKind::DisplayVersion),
222+
] {
223+
let err = try_parse_from(args.iter().map(OsString::from))
224+
.expect_err("help and version short-circuit parsing");
225+
assert_eq!(err.kind(), expected);
226+
}
227+
}
228+
229+
#[test]
230+
fn version_output_is_identical_for_every_form() {
231+
// `--version` moved from the node options to the top-level command, so
232+
// pin that it still prints one string: `ethereum/hive` records this
233+
// output as the client version.
234+
let mut after_flags: Vec<&str> = FLAT.to_vec();
235+
after_flags.push("--version");
236+
let printed: Vec<String> = [
237+
vec!["ethlambda", "--version"],
238+
vec!["ethlambda", NODE, "--version"],
239+
after_flags,
240+
]
241+
.into_iter()
242+
.map(|args| {
243+
try_parse_from(args.iter().map(OsString::from))
244+
.expect_err("--version short-circuits parsing")
245+
.to_string()
246+
})
247+
.collect();
248+
assert_eq!(printed[0], printed[1]);
249+
assert_eq!(printed[0], printed[2]);
250+
}
251+
252+
#[test]
253+
fn help_lists_the_sub_commands() {
254+
// Listed by clap itself, because they are real sub-commands.
255+
let err = try_parse_from(["ethlambda", "--help"].iter().map(OsString::from))
256+
.expect_err("--help short-circuits parsing");
257+
assert!(err.to_string().contains(NODE), "{err}");
258+
}
259+
}

bin/ethlambda/src/main.rs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
mod checkpoint_sync;
22
mod cli;
3+
mod command;
34
mod fd_limit;
45
mod version;
56

@@ -31,8 +32,8 @@ use std::{
3132
};
3233
use tokio_util::sync::CancellationToken;
3334

34-
use clap::Parser;
35-
use cli::CliOptions;
35+
use command::Command;
36+
3637
use ethlambda_blockchain::MILLISECONDS_PER_SLOT;
3738
use ethlambda_blockchain::block_builder::ProposerConfig;
3839
use ethlambda_blockchain::key_manager::ValidatorKeyPair;
@@ -81,7 +82,7 @@ async fn main() -> eyre::Result<()> {
8182
tracing::subscriber::set_global_default(subscriber)
8283
.wrap_err("failed to set global tracing subscriber")?;
8384

84-
let options = CliOptions::parse();
85+
let Command::Node(options) = command::parse();
8586
options.validate_discovery()?;
8687

8788
#[cfg(feature = "shadow-integration")]

0 commit comments

Comments
 (0)