Skip to content

Commit 67ee7d6

Browse files
committed
feat(cli): accept a node sub-command for running the node
The binary has only ever run the node, so an invocation is a bare list of node flags. An upcoming offline block-building benchmark adds a second entry point, which means the node first needs a name of its own. `node` is the default sub-command: `ethlambda node --genesis ...` and the existing flat `ethlambda --genesis ...` both run the node. A leading `node` token is stripped before parsing and the same CliOptions parser then sees exactly the arguments it saw before, so for the flat form the help text, error messages, exit codes and --version are unchanged by construction rather than by convention. That form is what the Dockerfile, lean-quickstart, the hive shim and the devnet skills all use, and none of them has to move. Tests pin the flat parse, the two forms agreeing field for field, a --node-id value that is literally "node", a trailing `node` token still being rejected, missing required flags in both forms, the bare invocation, and --help/--version staying top-level flags.
1 parent 68e51cd commit 67ee7d6

2 files changed

Lines changed: 182 additions & 3 deletions

File tree

bin/ethlambda/src/command.rs

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
//! Sub-command dispatch.
2+
//!
3+
//! `node` is the default sub-command: it can be named explicitly
4+
//! (`ethlambda node --genesis ...`) or left out entirely
5+
//! (`ethlambda --genesis ...`). Leaving it out is what the Dockerfile,
6+
//! lean-quickstart, the hive shim and the devnet skills all do, so that form
7+
//! stays the one this module is careful about: the token is simply removed
8+
//! before parsing, and the very same [`CliOptions`] parser then sees the very
9+
//! same arguments it saw before this module existed. Help text, error
10+
//! messages, exit codes and `--version` are therefore unchanged for it, by
11+
//! construction rather than by test.
12+
13+
use std::ffi::OsString;
14+
15+
use clap::Parser;
16+
17+
use crate::cli::CliOptions;
18+
19+
/// The sub-command token accepted in first position.
20+
///
21+
/// `CliOptions` declares no positional arguments, so the first token after the
22+
/// program name is either a flag or this sub-command: a flag *value* never
23+
/// lands there and is never mistaken for it.
24+
const NODE: &str = "node";
25+
26+
/// What the command line asked the binary to do.
27+
#[derive(Debug)]
28+
pub(crate) enum Invocation {
29+
/// Run the consensus node.
30+
Node(CliOptions),
31+
}
32+
33+
/// Parse the process arguments, exiting the way clap does on a parse error,
34+
/// `--help` or `--version`.
35+
pub(crate) fn parse() -> Invocation {
36+
match try_parse_from(std::env::args_os()) {
37+
Ok(invocation) => invocation,
38+
Err(err) => err.exit(),
39+
}
40+
}
41+
42+
fn try_parse_from<I>(args: I) -> Result<Invocation, clap::Error>
43+
where
44+
I: IntoIterator,
45+
I::Item: Into<OsString>,
46+
{
47+
let mut args: Vec<OsString> = args.into_iter().map(Into::into).collect();
48+
if args.get(1).is_some_and(|arg| arg == NODE) {
49+
args.remove(1);
50+
}
51+
CliOptions::try_parse_from(args).map(Invocation::Node)
52+
}
53+
54+
#[cfg(test)]
55+
mod tests {
56+
use std::path::PathBuf;
57+
58+
use clap::error::ErrorKind;
59+
60+
use super::*;
61+
62+
/// The flat invocation shape used by the Dockerfile, lean-quickstart, the
63+
/// hive shim and the devnet skills. It must keep parsing unchanged.
64+
const FLAT: &[&str] = &[
65+
"ethlambda",
66+
"--genesis",
67+
"config.yaml",
68+
"--validators",
69+
"annotated_validators.yaml",
70+
"--bootnodes",
71+
"nodes.yaml",
72+
"--validator-config",
73+
"validator-config.yaml",
74+
"--hash-sig-keys-dir",
75+
"hash-sig-keys/",
76+
"--node-key",
77+
"node.key",
78+
"--node-id",
79+
"ethlambda_0",
80+
"--gossipsub-port",
81+
"9001",
82+
"--is-aggregator",
83+
];
84+
85+
/// `FLAT` with an explicit `node` sub-command token.
86+
fn with_node_token() -> Vec<&'static str> {
87+
let mut args = vec!["ethlambda", NODE];
88+
args.extend_from_slice(&FLAT[1..]);
89+
args
90+
}
91+
92+
fn node_options(args: &[&str]) -> CliOptions {
93+
let Invocation::Node(options) =
94+
try_parse_from(args.iter().map(OsString::from)).expect("invocation parses");
95+
options
96+
}
97+
98+
#[test]
99+
fn flat_invocation_parses_unchanged() {
100+
let options = node_options(FLAT);
101+
assert_eq!(options.genesis, PathBuf::from("config.yaml"));
102+
assert_eq!(options.hash_sig_keys_dir, PathBuf::from("hash-sig-keys/"));
103+
assert_eq!(options.node_id, "ethlambda_0");
104+
assert_eq!(options.gossipsub_port, 9001);
105+
assert!(options.is_aggregator);
106+
}
107+
108+
#[test]
109+
fn node_sub_command_accepts_the_same_flags_as_the_flat_form() {
110+
let flat = node_options(FLAT);
111+
let scoped = node_options(&with_node_token());
112+
assert_eq!(format!("{flat:?}"), format!("{scoped:?}"));
113+
}
114+
115+
#[test]
116+
fn a_flag_value_of_node_is_not_taken_for_the_sub_command() {
117+
let mut args: Vec<&str> = FLAT.to_vec();
118+
let value = args
119+
.iter()
120+
.position(|arg| *arg == "ethlambda_0")
121+
.expect("node id value present");
122+
args[value] = NODE;
123+
assert_eq!(node_options(&args).node_id, NODE);
124+
}
125+
126+
#[test]
127+
fn a_node_token_after_the_flags_is_still_rejected() {
128+
// Only a leading token is a sub-command; anywhere else it stays the
129+
// stray positional argument it has always been.
130+
let mut args: Vec<&str> = FLAT.to_vec();
131+
args.push(NODE);
132+
let err = try_parse_from(args.iter().map(OsString::from))
133+
.expect_err("a trailing token must not be swallowed");
134+
assert_eq!(err.kind(), ErrorKind::UnknownArgument);
135+
}
136+
137+
#[test]
138+
fn missing_required_flag_keeps_the_clap_error_in_both_forms() {
139+
// `--genesis config.yaml` dropped from the front of the flag list.
140+
let flat: Vec<&str> = std::iter::once("ethlambda")
141+
.chain(FLAT[3..].iter().copied())
142+
.collect();
143+
let mut scoped = vec!["ethlambda", NODE];
144+
scoped.extend_from_slice(&flat[1..]);
145+
146+
for args in [flat, scoped] {
147+
let err = try_parse_from(args.iter().map(OsString::from))
148+
.expect_err("a missing required flag must error");
149+
assert_eq!(err.kind(), ErrorKind::MissingRequiredArgument);
150+
}
151+
}
152+
153+
#[test]
154+
fn bare_invocation_still_errors_on_the_required_flags() {
155+
for args in [vec!["ethlambda"], vec!["ethlambda", NODE]] {
156+
let err = try_parse_from(args.iter().map(OsString::from))
157+
.expect_err("an argument-less invocation must not start a node");
158+
assert_eq!(err.kind(), ErrorKind::MissingRequiredArgument);
159+
}
160+
}
161+
162+
#[test]
163+
fn help_and_version_stay_top_level_flags() {
164+
// `ethereum/hive` builds its ethlambda image by piping
165+
// `ethlambda --version` into a file, with and without flags in front.
166+
let mut version_after_flags: Vec<&str> = FLAT.to_vec();
167+
version_after_flags.push("--version");
168+
169+
for (args, expected) in [
170+
(vec!["ethlambda", "--help"], ErrorKind::DisplayHelp),
171+
(vec!["ethlambda", "--version"], ErrorKind::DisplayVersion),
172+
(version_after_flags, ErrorKind::DisplayVersion),
173+
] {
174+
let err = try_parse_from(args.iter().map(OsString::from))
175+
.expect_err("help and version short-circuit parsing");
176+
assert_eq!(err.kind(), expected);
177+
}
178+
}
179+
}

bin/ethlambda/src/main.rs

Lines changed: 3 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,7 @@ use std::{
3132
};
3233
use tokio_util::sync::CancellationToken;
3334

34-
use clap::Parser;
35-
use cli::CliOptions;
35+
use command::Invocation;
3636
use ethlambda_blockchain::MILLISECONDS_PER_SLOT;
3737
use ethlambda_blockchain::block_builder::ProposerConfig;
3838
use ethlambda_blockchain::key_manager::ValidatorKeyPair;
@@ -80,7 +80,7 @@ async fn main() -> eyre::Result<()> {
8080
tracing::subscriber::set_global_default(subscriber)
8181
.wrap_err("failed to set global tracing subscriber")?;
8282

83-
let options = CliOptions::parse();
83+
let Invocation::Node(options) = command::parse();
8484
options.validate_discovery()?;
8585

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

0 commit comments

Comments
 (0)