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 /// One-shot only: return events with `seq > since`. Pair with
100 /// the `cursor` from a previous `cellctl events` response to
101 /// page through history without duplicates.
102 #[arg(long)]
103 since: Option<u64>,
104 /// One-shot only: cap the response page (default 100, server
105 /// clamps at 1000). Ignored when `--follow` is set.
106 #[arg(long)]
107 limit: Option<usize>,
108 },
109 /// Poll a formation until it reaches a terminal state.
110 Rollout {
111 #[command(subcommand)]
112 what: RolloutWhat,
113 },
114 /// Show what would change between local YAML and the server-side formation.
115 Diff {
116 /// Path to a formation YAML file.
117 #[arg(short = 'f', long = "file")]
118 file: PathBuf,
119 },
120 /// Read/write cellctl config (~/.cellctl/config).
121 Config {
122 #[command(subcommand)]
123 what: ConfigWhat,
124 },
125 /// Print the cellctl client + server version.
126 Version,
127 /// Spin up a localhost browser proxy for the cellctl web view (ADR-0017).
128 Webui {
129 /// Launch the system browser at the URL after binding.
130 #[arg(long)]
131 open: bool,
132 /// Bind mode: `auto` (default; loopback in this MVP),
133 /// `loopback` (force 127.0.0.1), or `unix` (planned).
134 #[arg(long, value_enum, default_value = "auto")]
135 bind: cmd::webui::BindMode,
136 },
137}
138
139#[derive(Subcommand, Debug)]
140enum GetWhat {
141 /// List formations.
142 Formations {
143 #[arg(long, short = 'o', default_value = "table")]
144 output: String,
145 },
146 /// List cells (optionally filtered to a single formation).
147 Cells {
148 #[arg(long)]
149 formation: Option<String>,
150 #[arg(long, short = 'o', default_value = "table")]
151 output: String,
152 },
153}
154
155#[derive(Subcommand, Debug)]
156enum DescribeWhat {
157 /// Describe a formation by name or id.
158 Formation {
159 /// Formation name or id to describe.
160 name: String,
161 },
162 /// Describe a cell by name or id.
163 Cell {
164 /// Cell name or id to describe.
165 name: String,
166 },
167}
168
169#[derive(Subcommand, Debug)]
170enum DeleteWhat {
171 /// Delete a formation (also tears down its cells server-side).
172 Formation {
173 /// Formation name or id to delete.
174 name: String,
175 /// Skip interactive confirmation.
176 #[arg(long, short = 'y')]
177 yes: bool,
178 },
179}
180
181#[derive(Subcommand, Debug)]
182enum RolloutWhat {
183 /// Poll a formation until it reaches COMPLETED or FAILED.
184 Status {
185 /// Formation name or id to poll.
186 name: String,
187 /// Give up after N seconds (default: no timeout).
188 #[arg(long)]
189 timeout: Option<u64>,
190 },
191}
192
193#[derive(Subcommand, Debug)]
194enum ConfigWhat {
195 /// Set the server URL persistently.
196 SetServer {
197 /// Server base URL (e.g. http://127.0.0.1:8080).
198 url: String,
199 },
200 /// Set the bearer token persistently.
201 SetToken {
202 /// Bearer token to send as `Authorization: Bearer <TOKEN>`.
203 token: String,
204 },
205 /// Print the resolved config.
206 Show,
207}
208
209/// Run the `cellctl` CLI. Returns when the command completes or exits the
210/// process on error via [`CtlError::exit`]. This is the entry point both
211/// the standalone `cellctl` binary and the `cellos` meta-crate's `cellctl`
212/// shim call into.
213pub fn run() {
214 // Tracing is opt-in via $RUST_LOG so noisy debug output never goes to stderr
215 // by default — that would muddy the doctrine error contract.
216 //
217 // HIGH-B5: when tracing IS enabled, the redacted filter on the fmt
218 // layer suppresses reqwest/hyper TRACE events that would otherwise dump
219 // bearer tokens (cellctl makes authenticated reqwest calls to
220 // cellos-server's API; `RUST_LOG=reqwest=trace` is exactly the failure
221 // mode this fixes).
222 if std::env::var_os("RUST_LOG").is_some() {
223 use tracing_subscriber::layer::SubscriberExt;
224 use tracing_subscriber::util::SubscriberInitExt;
225 use tracing_subscriber::Layer;
226
227 let fmt_layer = tracing_subscriber::fmt::layer()
228 .with_writer(std::io::stderr)
229 .with_filter(cellos_core::observability::redacted_filter());
230
231 let _ = tracing_subscriber::registry()
232 .with(tracing_subscriber::EnvFilter::from_default_env())
233 .with(fmt_layer)
234 .try_init();
235 }
236
237 let cli = Cli::parse();
238
239 // Build a single-threaded current-thread runtime; cellctl is I/O bound and
240 // doesn't benefit from a multi-thread scheduler.
241 let rt = match tokio::runtime::Builder::new_current_thread()
242 .enable_all()
243 .build()
244 {
245 Ok(rt) => rt,
246 Err(e) => CtlError::usage(format!("init tokio runtime: {e}")).exit(),
247 };
248
249 match rt.block_on(dispatch(cli)) {
250 Ok(()) => {}
251 Err(e) => e.exit(),
252 }
253}
254
255async fn dispatch(cli: Cli) -> CtlResult<()> {
256 // Effective config = on-disk config overridden by CLI flags / env vars.
257 let mut cfg = config::load().unwrap_or_default();
258 if let Some(s) = cli.server {
259 cfg.server_url = Some(s);
260 }
261 if let Some(t) = cli.token {
262 cfg.api_token = Some(t);
263 }
264
265 // Config + Version + Webui don't go through the normal CellosClient
266 // dispatch — Webui takes the raw config to drive its reverse proxy.
267 match cli.cmd {
268 Cmd::Config { what } => return run_config(what),
269 Cmd::Version => {
270 let client = CellosClient::new(&cfg)?;
271 return cmd::version::run(&client).await;
272 }
273 Cmd::Webui { open, bind } => {
274 return cmd::webui::run(&cfg, open, bind).await;
275 }
276 _ => {}
277 }
278
279 let client = CellosClient::new(&cfg)?;
280
281 match cli.cmd {
282 Cmd::Apply { file } => cmd::apply::run(&client, &file).await,
283 Cmd::Get { what } => match what {
284 GetWhat::Formations { output } => {
285 let fmt: OutputFormat = output.parse()?;
286 cmd::get::formations(&client, fmt).await
287 }
288 GetWhat::Cells { formation, output } => {
289 let fmt: OutputFormat = output.parse()?;
290 cmd::get::cells(&client, formation.as_deref(), fmt).await
291 }
292 },
293 Cmd::Describe { what } => match what {
294 DescribeWhat::Formation { name } => cmd::describe::formation(&client, &name).await,
295 DescribeWhat::Cell { name } => cmd::describe::cell(&client, &name).await,
296 },
297 Cmd::Delete { what } => match what {
298 DeleteWhat::Formation { name, yes } => {
299 cmd::delete::formation(&client, &name, yes).await
300 }
301 },
302 Cmd::Logs { cell, follow, tail } => cmd::logs::run(&client, &cell, follow, tail).await,
303 Cmd::Events {
304 formation,
305 follow,
306 since,
307 limit,
308 } => cmd::events::run(&client, formation.as_deref(), follow, since, limit).await,
309 Cmd::Rollout { what } => match what {
310 RolloutWhat::Status { name, timeout } => {
311 cmd::rollout::status(&client, &name, timeout).await
312 }
313 },
314 Cmd::Diff { file } => cmd::diff::run(&client, &file).await,
315 Cmd::Config { .. } | Cmd::Version | Cmd::Webui { .. } => {
316 unreachable!("handled above")
317 }
318 }
319}
320
321fn run_config(what: ConfigWhat) -> CtlResult<()> {
322 match what {
323 ConfigWhat::SetServer { url } => cmd::config_cmd::set_server(&url),
324 ConfigWhat::SetToken { token } => cmd::config_cmd::set_token(&token),
325 ConfigWhat::Show => cmd::config_cmd::show(),
326 }
327}