Skip to main content

palladium_cli/commands/
consensus.rs

1use clap::{Args, Subcommand};
2use schemars::JsonSchema;
3use serde::Serialize;
4use serde_json::{json, Value};
5
6use crate::client::{ControlPlaneClient, Endpoint};
7use crate::CliResult;
8
9#[derive(Subcommand, Debug, Serialize, JsonSchema)]
10#[serde(rename_all = "kebab-case")]
11pub enum ConsensusCommand {
12    /// Manage consensus groups.
13    #[command(subcommand)]
14    Group(ConsensusGroupCommand),
15    /// Inspect consensus bindings.
16    #[command(subcommand)]
17    Binding(ConsensusBindingCommand),
18    /// Propose a write via the consensus engine.
19    Propose(ConsensusProposeArgs),
20    /// Perform a linearizable read via the consensus engine.
21    Read(ConsensusReadArgs),
22    /// Show consensus engine info.
23    #[command(subcommand)]
24    Engine(ConsensusEngineCommand),
25}
26
27#[derive(Subcommand, Debug, Serialize, JsonSchema)]
28#[serde(rename_all = "kebab-case")]
29pub enum ConsensusGroupCommand {
30    /// Create a consensus group.
31    Create(ConsensusGroupCreateArgs),
32    /// Attach an actor path to a consensus group.
33    Attach(ConsensusGroupAttachArgs),
34    /// List consensus groups and attached actors.
35    List(ConsensusGroupListArgs),
36    /// Detach an actor path from a consensus group.
37    Detach(ConsensusGroupDetachArgs),
38    /// Delete a consensus group.
39    Delete(ConsensusGroupDeleteArgs),
40    /// Show members of a consensus group.
41    Members(ConsensusGroupMembersArgs),
42    /// Show consensus group status summary.
43    Status(ConsensusGroupStatusArgs),
44}
45
46#[derive(Subcommand, Debug, Serialize, JsonSchema)]
47#[serde(rename_all = "kebab-case")]
48pub enum ConsensusEngineCommand {
49    /// Show whether a consensus engine is configured.
50    Status(ConsensusEngineStatusArgs),
51    /// Show consensus engine configuration (redacted TLS).
52    Config(ConsensusEngineConfigArgs),
53}
54
55#[derive(Subcommand, Debug, Serialize, JsonSchema)]
56#[serde(rename_all = "kebab-case")]
57pub enum ConsensusBindingCommand {
58    /// List actor bindings by consensus group.
59    List(ConsensusBindingListArgs),
60}
61
62#[derive(Args, Debug, Serialize, JsonSchema)]
63#[serde(rename_all = "kebab-case")]
64pub struct ConsensusGroupCreateArgs {
65    /// Group name.
66    #[arg(long)]
67    pub name: String,
68    /// Member engine IDs (repeatable).
69    #[arg(long = "member")]
70    pub members: Vec<String>,
71    /// Heartbeat interval in milliseconds.
72    #[arg(long)]
73    pub heartbeat_interval_ms: Option<u64>,
74    /// Election timeout minimum in milliseconds.
75    #[arg(long)]
76    pub election_timeout_min_ms: Option<u64>,
77    /// Election timeout maximum in milliseconds.
78    #[arg(long)]
79    pub election_timeout_max_ms: Option<u64>,
80    /// Snapshot interval (log entries).
81    #[arg(long)]
82    pub snapshot_interval: Option<u64>,
83    /// Output in JSON format.
84    #[arg(long)]
85    pub json: bool,
86}
87
88#[derive(Args, Debug, Serialize, JsonSchema)]
89#[serde(rename_all = "kebab-case")]
90pub struct ConsensusGroupAttachArgs {
91    /// Group name.
92    #[arg(long)]
93    pub group: String,
94    /// Actor path to attach.
95    #[arg(long)]
96    pub path: String,
97    /// Output in JSON format.
98    #[arg(long)]
99    pub json: bool,
100}
101
102#[derive(Args, Debug, Serialize, JsonSchema)]
103#[serde(rename_all = "kebab-case")]
104pub struct ConsensusGroupListArgs {
105    /// Output in JSON format.
106    #[arg(long)]
107    pub json: bool,
108}
109
110#[derive(Args, Debug, Serialize, JsonSchema)]
111#[serde(rename_all = "kebab-case")]
112pub struct ConsensusGroupDetachArgs {
113    /// Group name.
114    #[arg(long)]
115    pub group: String,
116    /// Actor path to detach.
117    #[arg(long)]
118    pub path: String,
119    /// Output in JSON format.
120    #[arg(long)]
121    pub json: bool,
122}
123
124#[derive(Args, Debug, Serialize, JsonSchema)]
125#[serde(rename_all = "kebab-case")]
126pub struct ConsensusGroupDeleteArgs {
127    /// Group name.
128    #[arg(long)]
129    pub name: String,
130    /// Output in JSON format.
131    #[arg(long)]
132    pub json: bool,
133}
134
135#[derive(Args, Debug, Serialize, JsonSchema)]
136#[serde(rename_all = "kebab-case")]
137pub struct ConsensusGroupMembersArgs {
138    /// Group name.
139    #[arg(long)]
140    pub group: String,
141    /// Output in JSON format.
142    #[arg(long)]
143    pub json: bool,
144}
145
146#[derive(Args, Debug, Serialize, JsonSchema)]
147#[serde(rename_all = "kebab-case")]
148pub struct ConsensusGroupStatusArgs {
149    /// Group name.
150    #[arg(long)]
151    pub group: String,
152    /// Output in JSON format.
153    #[arg(long)]
154    pub json: bool,
155}
156
157#[derive(Args, Debug, Serialize, JsonSchema)]
158#[serde(rename_all = "kebab-case")]
159pub struct ConsensusProposeArgs {
160    /// Group name.
161    #[arg(long)]
162    pub group: String,
163    /// JSON payload to propose.
164    pub payload: String,
165    /// Output in JSON format.
166    #[arg(long)]
167    pub json: bool,
168}
169
170#[derive(Args, Debug, Serialize, JsonSchema)]
171#[serde(rename_all = "kebab-case")]
172pub struct ConsensusReadArgs {
173    /// Group name.
174    #[arg(long)]
175    pub group: String,
176    /// JSON query payload.
177    pub query: String,
178    /// Output in JSON format.
179    #[arg(long)]
180    pub json: bool,
181}
182
183#[derive(Args, Debug, Serialize, JsonSchema)]
184#[serde(rename_all = "kebab-case")]
185pub struct ConsensusEngineStatusArgs {
186    /// Output in JSON format.
187    #[arg(long)]
188    pub json: bool,
189}
190
191#[derive(Args, Debug, Serialize, JsonSchema)]
192#[serde(rename_all = "kebab-case")]
193pub struct ConsensusEngineConfigArgs {
194    /// Output in JSON format.
195    #[arg(long)]
196    pub json: bool,
197}
198
199#[derive(Args, Debug, Serialize, JsonSchema)]
200#[serde(rename_all = "kebab-case")]
201pub struct ConsensusBindingListArgs {
202    /// Output in JSON format.
203    #[arg(long)]
204    pub json: bool,
205}
206
207fn print_pretty_json(value: &Value) -> CliResult {
208    println!("{}", serde_json::to_string_pretty(value)?);
209    Ok(())
210}
211
212fn value_str<'a>(value: &'a Value, key: &str, default: &'a str) -> &'a str {
213    value.get(key).and_then(|v| v.as_str()).unwrap_or(default)
214}
215
216fn value_u64(value: &Value, key: &str) -> u64 {
217    value.get(key).and_then(|v| v.as_u64()).unwrap_or(0)
218}
219
220fn string_array_joined(value: &Value, key: &str) -> String {
221    value
222        .get(key)
223        .and_then(|v| v.as_array())
224        .map(|list| {
225            list.iter()
226                .filter_map(|v| v.as_str())
227                .collect::<Vec<_>>()
228                .join(", ")
229        })
230        .unwrap_or_default()
231}
232
233fn render_group_actor_rows(rows: &[Value], empty_msg: &str) {
234    if rows.is_empty() {
235        println!("{empty_msg}");
236        return;
237    }
238    for entry in rows {
239        let group = value_str(entry, "group", "unknown");
240        let actors = string_array_joined(entry, "actors");
241        if actors.is_empty() {
242            println!("{group}: (no actors)");
243        } else {
244            println!("{group}: {actors}");
245        }
246    }
247}
248
249pub fn run(cmd: ConsensusCommand, endpoint: &Endpoint) -> CliResult {
250    let mut client = ControlPlaneClient::connect_endpoint(endpoint)?;
251    match cmd {
252        ConsensusCommand::Group(group_cmd) => match group_cmd {
253            ConsensusGroupCommand::Create(args) => {
254                let mut params = serde_json::Map::new();
255                params.insert("name".into(), Value::String(args.name.clone()));
256                if !args.members.is_empty() {
257                    params.insert(
258                        "members".into(),
259                        Value::Array(args.members.iter().cloned().map(Value::String).collect()),
260                    );
261                }
262                if let Some(v) = args.heartbeat_interval_ms {
263                    params.insert("heartbeat_interval_ms".into(), Value::Number(v.into()));
264                }
265                if let Some(v) = args.election_timeout_min_ms {
266                    params.insert("election_timeout_min_ms".into(), Value::Number(v.into()));
267                }
268                if let Some(v) = args.election_timeout_max_ms {
269                    params.insert("election_timeout_max_ms".into(), Value::Number(v.into()));
270                }
271                if let Some(v) = args.snapshot_interval {
272                    params.insert("snapshot_interval".into(), Value::Number(v.into()));
273                }
274                let result = client.call("consensus.group.create", Value::Object(params))?;
275                if args.json {
276                    print_pretty_json(&result)?;
277                } else {
278                    println!("Consensus group {} created.", args.name);
279                }
280            }
281            ConsensusGroupCommand::Attach(args) => {
282                let params = json!({
283                    "group": args.group,
284                    "path": args.path,
285                });
286                let result = client.call("consensus.group.attach", params)?;
287                if args.json {
288                    print_pretty_json(&result)?;
289                } else {
290                    let group = value_str(&result, "group", "unknown");
291                    let path = value_str(&result, "path", "unknown");
292                    println!("Attached {path} to consensus group {group}.");
293                }
294            }
295            ConsensusGroupCommand::List(args) => {
296                let result = client.call("consensus.group.list", Value::Null)?;
297                let groups = result.as_array().cloned().unwrap_or_default();
298                if args.json {
299                    print_pretty_json(&Value::Array(groups))?;
300                } else {
301                    render_group_actor_rows(&groups, "No consensus groups.");
302                }
303            }
304            ConsensusGroupCommand::Detach(args) => {
305                let params = json!({
306                    "group": args.group,
307                    "path": args.path,
308                });
309                let result = client.call("consensus.group.detach", params)?;
310                if args.json {
311                    print_pretty_json(&result)?;
312                } else {
313                    let group = value_str(&result, "group", "unknown");
314                    let path = value_str(&result, "path", "unknown");
315                    println!("Detached {path} from consensus group {group}.");
316                }
317            }
318            ConsensusGroupCommand::Delete(args) => {
319                let result = client.call("consensus.group.delete", json!({"name": args.name}))?;
320                if args.json {
321                    print_pretty_json(&result)?;
322                } else {
323                    println!("Consensus group {} deleted.", args.name);
324                }
325            }
326            ConsensusGroupCommand::Members(args) => {
327                let result =
328                    client.call("consensus.group.members", json!({"group": args.group}))?;
329                if args.json {
330                    print_pretty_json(&result)?;
331                } else {
332                    let members = string_array_joined(&result, "members");
333                    println!(
334                        "Members: {}",
335                        if members.is_empty() {
336                            "(none)".to_string()
337                        } else {
338                            members
339                        }
340                    );
341                }
342            }
343            ConsensusGroupCommand::Status(args) => {
344                let result = client.call("consensus.group.status", json!({"group": args.group}))?;
345                if args.json {
346                    print_pretty_json(&result)?;
347                } else {
348                    let member_count = value_u64(&result, "member_count");
349                    let attached = value_u64(&result, "attached_actor_count");
350                    println!("Members: {member_count}, attached actors: {attached}");
351                }
352            }
353        },
354        ConsensusCommand::Binding(binding_cmd) => match binding_cmd {
355            ConsensusBindingCommand::List(args) => {
356                let result = client.call("consensus.binding.list", Value::Null)?;
357                let bindings = result.as_array().cloned().unwrap_or_default();
358                if args.json {
359                    print_pretty_json(&Value::Array(bindings))?;
360                } else {
361                    render_group_actor_rows(&bindings, "No consensus bindings.");
362                }
363            }
364        },
365        ConsensusCommand::Propose(args) => {
366            let payload: Value = serde_json::from_str(&args.payload)?;
367            let result = client.call(
368                "consensus.propose",
369                json!({"group": args.group, "payload": payload}),
370            )?;
371            print_pretty_json(&result)?;
372        }
373        ConsensusCommand::Read(args) => {
374            let query: Value = serde_json::from_str(&args.query)?;
375            let result = client.call(
376                "consensus.read",
377                json!({"group": args.group, "query": query}),
378            )?;
379            print_pretty_json(&result)?;
380        }
381        ConsensusCommand::Engine(cmd) => match cmd {
382            ConsensusEngineCommand::Status(args) => {
383                let result = client.call("consensus.engine.status", Value::Null)?;
384                if args.json {
385                    print_pretty_json(&result)?;
386                } else {
387                    let kind = value_str(&result, "kind", "unknown");
388                    let engine_id = value_str(&result, "engine_id", "unknown");
389                    println!("Consensus engine: {kind} ({engine_id})");
390                }
391            }
392            ConsensusEngineCommand::Config(args) => {
393                let result = client.call("consensus.engine.config", Value::Null)?;
394                if args.json {
395                    print_pretty_json(&result)?;
396                } else {
397                    let kind = value_str(&result, "kind", "unknown");
398                    let tcp = value_str(&result, "tcp_addr", "unknown");
399                    let quic = value_str(&result, "quic_addr", "-");
400                    let prefer_quic = result
401                        .get("prefer_quic")
402                        .and_then(|v| v.as_bool())
403                        .unwrap_or(false);
404                    let tls_enabled = result
405                        .get("tls_enabled")
406                        .and_then(|v| v.as_bool())
407                        .unwrap_or(false);
408                    println!("Consensus engine: {kind}");
409                    println!("tcp: {tcp}");
410                    println!("quic: {quic}");
411                    println!("prefer_quic: {prefer_quic}");
412                    println!("tls_enabled: {tls_enabled}");
413                }
414            }
415        },
416    }
417    Ok(())
418}