Skip to main content

cellos_ctl/
lib.rs

1//! `cellctl` — kubectl-style CLI for CellOS.
2//!
3//! Doctrine alignment (CHATROOM Session 16):
4//!   * **Thin client.** Every subcommand corresponds to exactly one HTTP call
5//!     against `cellos-server`. No client-side state, no caches, no projections.
6//!   * **Events are the source of truth.** `cellctl logs` and `cellctl events`
7//!     surface the CloudEvent stream verbatim; the state machine lives in the
8//!     server-side projector.
9//!   * **Exit codes are a contract.** 0=success, 1=usage, 2=API, 3=validation.
10//!     Errors go to stderr; machine-readable output goes to stdout.
11//!
12//! See `crates/cellos-ctl/src/exit.rs` for the exit-code definitions.
13//!
14//! ## Public entry
15//!
16//! Most consumers run the `cellctl` binary directly. The `cellos` meta-crate
17//! at `crates/cellos-meta/` re-exports this crate's [`run`] as one of its
18//! three installable binaries so `cargo install cellos` ships cellctl,
19//! cellos-server, and cellos-supervisor in one go.
20
21pub mod client;
22pub mod cmd;
23pub mod config;
24pub mod exit;
25pub mod model;
26pub mod output;
27
28use std::path::PathBuf;
29
30use clap::{Parser, Subcommand};
31
32use crate::client::CellosClient;
33use crate::exit::{CtlError, CtlResult};
34use crate::output::OutputFormat;
35
36/// kubectl-style CLI for CellOS.
37#[derive(Parser, Debug)]
38#[command(
39    name = "cellctl",
40    version,
41    about = "kubectl-style CLI for CellOS",
42    long_about = None,
43)]
44struct Cli {
45    /// Override the server URL (otherwise read from config or $CELLCTL_SERVER).
46    #[arg(long, global = true, env = "CELLCTL_SERVER")]
47    server: Option<String>,
48
49    /// Override the bearer token (otherwise read from config or $CELLCTL_TOKEN).
50    #[arg(long, global = true, env = "CELLCTL_TOKEN", hide_env_values = true)]
51    token: Option<String>,
52
53    #[command(subcommand)]
54    cmd: Cmd,
55}
56
57#[derive(Subcommand, Debug)]
58enum Cmd {
59    /// Submit a formation spec to the server (POST /v1/formations).
60    Apply {
61        /// Path to a formation YAML file.
62        #[arg(short = 'f', long = "file")]
63        file: PathBuf,
64    },
65    /// List resources.
66    Get {
67        #[command(subcommand)]
68        what: GetWhat,
69    },
70    /// Show full state + recent events for a single resource.
71    Describe {
72        #[command(subcommand)]
73        what: DescribeWhat,
74    },
75    /// Delete a resource.
76    Delete {
77        #[command(subcommand)]
78        what: DeleteWhat,
79    },
80    /// Stream CloudEvents for a single cell.
81    Logs {
82        /// Cell name or id.
83        cell: String,
84        /// Keep the connection open and stream new events.
85        #[arg(long, short = 'f')]
86        follow: bool,
87        /// Show only the last N events.
88        #[arg(long)]
89        tail: Option<usize>,
90    },
91    /// Stream global / formation-scoped CloudEvents.
92    Events {
93        /// Filter to a single formation.
94        #[arg(long)]
95        formation: Option<String>,
96        /// Keep the connection open (uses WebSocket /ws/events).
97        #[arg(long, short = 'f')]
98        follow: bool,
99    },
100    /// Poll a formation until it reaches a terminal state.
101    Rollout {
102        #[command(subcommand)]
103        what: RolloutWhat,
104    },
105    /// Show what would change between local YAML and the server-side formation.
106    Diff {
107        /// Path to a formation YAML file.
108        #[arg(short = 'f', long = "file")]
109        file: PathBuf,
110    },
111    /// Read/write cellctl config (~/.cellctl/config).
112    Config {
113        #[command(subcommand)]
114        what: ConfigWhat,
115    },
116    /// Print the cellctl client + server version.
117    Version,
118    /// Spin up a localhost browser proxy for the cellctl web view (ADR-0017).
119    Webui {
120        /// Launch the system browser at the URL after binding.
121        #[arg(long)]
122        open: bool,
123        /// Bind mode: `auto` (default; loopback in this MVP),
124        /// `loopback` (force 127.0.0.1), or `unix` (planned).
125        #[arg(long, value_enum, default_value = "auto")]
126        bind: cmd::webui::BindMode,
127    },
128}
129
130#[derive(Subcommand, Debug)]
131enum GetWhat {
132    /// List formations.
133    Formations {
134        #[arg(long, short = 'o', default_value = "table")]
135        output: String,
136    },
137    /// List cells (optionally filtered to a single formation).
138    Cells {
139        #[arg(long)]
140        formation: Option<String>,
141        #[arg(long, short = 'o', default_value = "table")]
142        output: String,
143    },
144}
145
146#[derive(Subcommand, Debug)]
147enum DescribeWhat {
148    /// Describe a formation by name or id.
149    Formation {
150        /// Formation name or id to describe.
151        name: String,
152    },
153    /// Describe a cell by name or id.
154    Cell {
155        /// Cell name or id to describe.
156        name: String,
157    },
158}
159
160#[derive(Subcommand, Debug)]
161enum DeleteWhat {
162    /// Delete a formation (also tears down its cells server-side).
163    Formation {
164        /// Formation name or id to delete.
165        name: String,
166        /// Skip interactive confirmation.
167        #[arg(long, short = 'y')]
168        yes: bool,
169    },
170}
171
172#[derive(Subcommand, Debug)]
173enum RolloutWhat {
174    /// Poll a formation until it reaches COMPLETED or FAILED.
175    Status {
176        /// Formation name or id to poll.
177        name: String,
178        /// Give up after N seconds (default: no timeout).
179        #[arg(long)]
180        timeout: Option<u64>,
181    },
182}
183
184#[derive(Subcommand, Debug)]
185enum ConfigWhat {
186    /// Set the server URL persistently.
187    SetServer {
188        /// Server base URL (e.g. http://127.0.0.1:8080).
189        url: String,
190    },
191    /// Set the bearer token persistently.
192    SetToken {
193        /// Bearer token to send as `Authorization: Bearer <TOKEN>`.
194        token: String,
195    },
196    /// Print the resolved config.
197    Show,
198}
199
200/// Run the `cellctl` CLI. Returns when the command completes or exits the
201/// process on error via [`CtlError::exit`]. This is the entry point both
202/// the standalone `cellctl` binary and the `cellos` meta-crate's `cellctl`
203/// shim call into.
204pub fn run() {
205    // Tracing is opt-in via $RUST_LOG so noisy debug output never goes to stderr
206    // by default — that would muddy the doctrine error contract.
207    //
208    // HIGH-B5: when tracing IS enabled, the redacted filter on the fmt
209    // layer suppresses reqwest/hyper TRACE events that would otherwise dump
210    // bearer tokens (cellctl makes authenticated reqwest calls to
211    // cellos-server's API; `RUST_LOG=reqwest=trace` is exactly the failure
212    // mode this fixes).
213    if std::env::var_os("RUST_LOG").is_some() {
214        use tracing_subscriber::layer::SubscriberExt;
215        use tracing_subscriber::util::SubscriberInitExt;
216        use tracing_subscriber::Layer;
217
218        let fmt_layer = tracing_subscriber::fmt::layer()
219            .with_writer(std::io::stderr)
220            .with_filter(cellos_core::observability::redacted_filter());
221
222        let _ = tracing_subscriber::registry()
223            .with(tracing_subscriber::EnvFilter::from_default_env())
224            .with(fmt_layer)
225            .try_init();
226    }
227
228    let cli = Cli::parse();
229
230    // Build a single-threaded current-thread runtime; cellctl is I/O bound and
231    // doesn't benefit from a multi-thread scheduler.
232    let rt = match tokio::runtime::Builder::new_current_thread()
233        .enable_all()
234        .build()
235    {
236        Ok(rt) => rt,
237        Err(e) => CtlError::usage(format!("init tokio runtime: {e}")).exit(),
238    };
239
240    match rt.block_on(dispatch(cli)) {
241        Ok(()) => {}
242        Err(e) => e.exit(),
243    }
244}
245
246async fn dispatch(cli: Cli) -> CtlResult<()> {
247    // Effective config = on-disk config overridden by CLI flags / env vars.
248    let mut cfg = config::load().unwrap_or_default();
249    if let Some(s) = cli.server {
250        cfg.server_url = Some(s);
251    }
252    if let Some(t) = cli.token {
253        cfg.api_token = Some(t);
254    }
255
256    // Config + Version + Webui don't go through the normal CellosClient
257    // dispatch — Webui takes the raw config to drive its reverse proxy.
258    match cli.cmd {
259        Cmd::Config { what } => return run_config(what),
260        Cmd::Version => {
261            let client = CellosClient::new(&cfg)?;
262            return cmd::version::run(&client).await;
263        }
264        Cmd::Webui { open, bind } => {
265            return cmd::webui::run(&cfg, open, bind).await;
266        }
267        _ => {}
268    }
269
270    let client = CellosClient::new(&cfg)?;
271
272    match cli.cmd {
273        Cmd::Apply { file } => cmd::apply::run(&client, &file).await,
274        Cmd::Get { what } => match what {
275            GetWhat::Formations { output } => {
276                let fmt: OutputFormat = output.parse()?;
277                cmd::get::formations(&client, fmt).await
278            }
279            GetWhat::Cells { formation, output } => {
280                let fmt: OutputFormat = output.parse()?;
281                cmd::get::cells(&client, formation.as_deref(), fmt).await
282            }
283        },
284        Cmd::Describe { what } => match what {
285            DescribeWhat::Formation { name } => cmd::describe::formation(&client, &name).await,
286            DescribeWhat::Cell { name } => cmd::describe::cell(&client, &name).await,
287        },
288        Cmd::Delete { what } => match what {
289            DeleteWhat::Formation { name, yes } => {
290                cmd::delete::formation(&client, &name, yes).await
291            }
292        },
293        Cmd::Logs { cell, follow, tail } => cmd::logs::run(&client, &cell, follow, tail).await,
294        Cmd::Events { formation, follow } => {
295            cmd::events::run(&client, formation.as_deref(), follow).await
296        }
297        Cmd::Rollout { what } => match what {
298            RolloutWhat::Status { name, timeout } => {
299                cmd::rollout::status(&client, &name, timeout).await
300            }
301        },
302        Cmd::Diff { file } => cmd::diff::run(&client, &file).await,
303        Cmd::Config { .. } | Cmd::Version | Cmd::Webui { .. } => {
304            unreachable!("handled above")
305        }
306    }
307}
308
309fn run_config(what: ConfigWhat) -> CtlResult<()> {
310    match what {
311        ConfigWhat::SetServer { url } => cmd::config_cmd::set_server(&url),
312        ConfigWhat::SetToken { token } => cmd::config_cmd::set_token(&token),
313        ConfigWhat::Show => cmd::config_cmd::show(),
314    }
315}