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.** Routed through pattern-11
10//! `axiom_exit::Exit`: 0=success, 2=usage, 3=preflight/validation,
11//! 64=API/transport failure (tool-specific). Errors go to stderr;
12//! machine-readable output goes to stdout.
13//!
14//! See `crates/cellos-ctl/src/exit.rs` for the exit-code definitions.
15//!
16//! ## Public entry
17//!
18//! Most consumers run the `cellctl` binary directly. The `cellos` meta-crate
19//! at `crates/cellos-meta/` re-exports this crate's [`run`] as one of its
20//! three installable binaries so `cargo install cellos` ships cellctl,
21//! cellos-server, and cellos-supervisor in one go.
22
23pub mod client;
24pub mod cmd;
25pub mod config;
26pub mod exit;
27pub mod model;
28pub mod output;
29
30use std::path::PathBuf;
31
32use clap::{Parser, Subcommand};
33
34use crate::client::CellosClient;
35use crate::exit::{CtlError, CtlResult};
36use crate::output::OutputFormat;
37
38/// kubectl-style CLI for CellOS.
39#[derive(Parser, Debug)]
40#[command(
41 name = "cellctl",
42 version,
43 about = "kubectl-style CLI for CellOS",
44 long_about = None,
45)]
46struct Cli {
47 /// Override the server URL (otherwise read from config or $CELLCTL_SERVER).
48 #[arg(long, global = true, env = "CELLCTL_SERVER")]
49 server: Option<String>,
50
51 /// Override the bearer token (otherwise read from config or $CELLCTL_TOKEN).
52 #[arg(long, global = true, env = "CELLCTL_TOKEN", hide_env_values = true)]
53 token: Option<String>,
54
55 #[command(subcommand)]
56 cmd: Cmd,
57}
58
59#[derive(Subcommand, Debug)]
60enum Cmd {
61 /// Submit a formation spec to the server (POST /v1/formations).
62 Apply {
63 /// Path to a formation YAML file.
64 #[arg(short = 'f', long = "file")]
65 file: PathBuf,
66 },
67 /// List resources.
68 Get {
69 #[command(subcommand)]
70 what: GetWhat,
71 },
72 /// Show full state + recent events for a single resource.
73 Describe {
74 #[command(subcommand)]
75 what: DescribeWhat,
76 },
77 /// Delete a resource.
78 Delete {
79 #[command(subcommand)]
80 what: DeleteWhat,
81 },
82 /// Stream CloudEvents for a single cell.
83 Logs {
84 /// Cell name or id.
85 cell: String,
86 /// Keep the connection open and stream new events.
87 #[arg(long, short = 'f')]
88 follow: bool,
89 /// Show only the last N events.
90 #[arg(long)]
91 tail: Option<usize>,
92 },
93 /// Stream global / formation-scoped CloudEvents.
94 Events {
95 /// Filter to a single formation.
96 #[arg(long)]
97 formation: Option<String>,
98 /// Keep the connection open (uses WebSocket /ws/events).
99 #[arg(long, short = 'f')]
100 follow: bool,
101 /// One-shot only: return events with `seq > since`. Pair with
102 /// the `cursor` from a previous `cellctl events` response to
103 /// page through history without duplicates.
104 #[arg(long)]
105 since: Option<u64>,
106 /// One-shot only: cap the response page (default 100, server
107 /// clamps at 1000). Ignored when `--follow` is set.
108 #[arg(long)]
109 limit: Option<usize>,
110 },
111 /// Poll a formation until it reaches a terminal state.
112 Rollout {
113 #[command(subcommand)]
114 what: RolloutWhat,
115 },
116 /// Show what would change between local YAML and the server-side formation.
117 Diff {
118 /// Path to a formation YAML file.
119 #[arg(short = 'f', long = "file")]
120 file: PathBuf,
121 },
122 /// Read/write cellctl config (~/.cellctl/config).
123 Config {
124 #[command(subcommand)]
125 what: ConfigWhat,
126 },
127 /// Print the cellctl client + server version.
128 Version,
129 /// Cross-domain audit bundle ops over a local spool (S19/S20, ADR-0029).
130 ///
131 /// These are LOCAL file operations — no server client. The export/import/
132 /// verify logic lives in `cellos-sink-spool::bundle`; this is the operator
133 /// surface.
134 Audit {
135 #[command(subcommand)]
136 what: AuditWhat,
137 },
138 /// Accreditation evidence: lint the 800-53 catalog or emit OSCAL artifacts.
139 ///
140 /// LOCAL operations over the in-repo control-mapping catalog (logic lives in
141 /// `cellos-evidence`). Emitted artifacts are CANDIDATE evidence, not a
142 /// self-attestation of control satisfaction.
143 Evidence {
144 #[command(subcommand)]
145 what: EvidenceWhat,
146 },
147 /// Spin up a localhost browser proxy for the cellctl web view (ADR-0017).
148 Webui {
149 /// Launch the system browser at the URL after binding.
150 #[arg(long)]
151 open: bool,
152 /// Bind mode: `auto` (default; loopback in this MVP),
153 /// `loopback` (force 127.0.0.1), or `unix` (planned).
154 #[arg(long, value_enum, default_value = "auto")]
155 bind: cmd::webui::BindMode,
156 },
157}
158
159#[derive(Subcommand, Debug)]
160enum GetWhat {
161 /// List formations.
162 Formations {
163 #[arg(long, short = 'o', default_value = "table")]
164 output: String,
165 },
166 /// List cells (optionally filtered to a single formation).
167 Cells {
168 #[arg(long)]
169 formation: Option<String>,
170 #[arg(long, short = 'o', default_value = "table")]
171 output: String,
172 },
173}
174
175#[derive(Subcommand, Debug)]
176enum DescribeWhat {
177 /// Describe a formation by name or id.
178 Formation {
179 /// Formation name or id to describe.
180 name: String,
181 },
182 /// Describe a cell by name or id.
183 Cell {
184 /// Cell name or id to describe.
185 name: String,
186 },
187}
188
189#[derive(Subcommand, Debug)]
190enum DeleteWhat {
191 /// Delete a formation (also tears down its cells server-side).
192 Formation {
193 /// Formation name or id to delete.
194 name: String,
195 /// Skip interactive confirmation.
196 #[arg(long, short = 'y')]
197 yes: bool,
198 },
199}
200
201#[derive(Subcommand, Debug)]
202enum RolloutWhat {
203 /// Poll a formation until it reaches COMPLETED or FAILED.
204 Status {
205 /// Formation name or id to poll.
206 name: String,
207 /// Give up after N seconds (default: no timeout).
208 #[arg(long)]
209 timeout: Option<u64>,
210 },
211}
212
213#[derive(Subcommand, Debug)]
214enum AuditWhat {
215 /// Export a contiguous chain range `[from, to]` as a bundle file.
216 ExportBundle {
217 /// Spool directory holding the audit chain.
218 #[arg(long)]
219 dir: PathBuf,
220 /// Operator-declared chain id (routing label) recorded in the manifest.
221 #[arg(long)]
222 chain_id: String,
223 /// First row seq to include.
224 #[arg(long)]
225 from: u64,
226 /// Last row seq to include (defaults to the chain head).
227 #[arg(long)]
228 to: Option<u64>,
229 /// Output bundle file to write.
230 #[arg(long)]
231 out: PathBuf,
232 },
233 /// Import a bundle file into the local spool (fail-closed).
234 ImportBundle {
235 /// Spool directory to import into.
236 #[arg(long)]
237 dir: PathBuf,
238 /// Bundle file to read.
239 #[arg(long = "in")]
240 input: PathBuf,
241 },
242 /// Verify the on-disk chain and report status (exits 1 on a break).
243 VerifyChain {
244 /// Spool directory holding the audit chain.
245 #[arg(long)]
246 dir: PathBuf,
247 },
248}
249
250#[derive(Subcommand, Debug)]
251enum EvidenceWhat {
252 /// Lint the 800-53 control-mapping catalog (schema + on-disk seam/test refs).
253 Lint,
254 /// Emit the OSCAL component-definition + SSP-fragment to a directory.
255 Emit {
256 /// Output directory for the OSCAL artifacts.
257 #[arg(long)]
258 out: PathBuf,
259 },
260}
261
262#[derive(Subcommand, Debug)]
263enum ConfigWhat {
264 /// Set the server URL persistently.
265 SetServer {
266 /// Server base URL (e.g. http://127.0.0.1:8080).
267 url: String,
268 },
269 /// Set the bearer token persistently.
270 SetToken {
271 /// Bearer token to send as `Authorization: Bearer <TOKEN>`.
272 token: String,
273 },
274 /// Print the resolved config.
275 Show,
276}
277
278/// Run the `cellctl` CLI. Returns when the command completes or exits the
279/// process on error via [`CtlError::exit`]. This is the entry point both
280/// the standalone `cellctl` binary and the `cellos` meta-crate's `cellctl`
281/// shim call into.
282pub fn run() {
283 // Tracing is opt-in via $RUST_LOG so noisy debug output never goes to stderr
284 // by default — that would muddy the doctrine error contract.
285 //
286 // HIGH-B5: when tracing IS enabled, the redacted filter on the fmt
287 // layer suppresses reqwest/hyper TRACE events that would otherwise dump
288 // bearer tokens (cellctl makes authenticated reqwest calls to
289 // cellos-server's API; `RUST_LOG=reqwest=trace` is exactly the failure
290 // mode this fixes).
291 if std::env::var_os("RUST_LOG").is_some() {
292 use tracing_subscriber::layer::SubscriberExt;
293 use tracing_subscriber::util::SubscriberInitExt;
294 use tracing_subscriber::Layer;
295
296 let fmt_layer = tracing_subscriber::fmt::layer()
297 .with_writer(std::io::stderr)
298 .with_filter(cellos_core::observability::redacted_filter());
299
300 let _ = tracing_subscriber::registry()
301 .with(tracing_subscriber::EnvFilter::from_default_env())
302 .with(fmt_layer)
303 .try_init();
304 }
305
306 let cli = Cli::parse();
307
308 // Build a single-threaded current-thread runtime; cellctl is I/O bound and
309 // doesn't benefit from a multi-thread scheduler.
310 let rt = match tokio::runtime::Builder::new_current_thread()
311 .enable_all()
312 .build()
313 {
314 Ok(rt) => rt,
315 Err(e) => CtlError::usage(format!("init tokio runtime: {e}")).exit(),
316 };
317
318 // Both arms route the process exit byte through the pattern-11
319 // `axiom_exit::Exit` FSM (success -> `Exit::Ok`; failure -> the variant
320 // mapped by `CtlError::exit`).
321 match rt.block_on(dispatch(cli)) {
322 Ok(()) => std::process::exit(axiom_exit::Exit::Ok.code().into()),
323 Err(e) => e.exit(),
324 }
325}
326
327async fn dispatch(cli: Cli) -> CtlResult<()> {
328 // Effective config = on-disk config overridden by CLI flags / env vars.
329 let mut cfg = config::load().unwrap_or_default();
330 if let Some(s) = cli.server {
331 cfg.server_url = Some(s);
332 }
333 if let Some(t) = cli.token {
334 cfg.api_token = Some(t);
335 }
336
337 // Config + Version + Webui don't go through the normal CellosClient
338 // dispatch — Webui takes the raw config to drive its reverse proxy.
339 match cli.cmd {
340 Cmd::Config { what } => return run_config(what),
341 // Audit is a pure-local file op (like Config/Webui): no server client.
342 Cmd::Audit { what } => return run_audit(what),
343 Cmd::Evidence { what } => return run_evidence(what),
344 Cmd::Version => {
345 let client = CellosClient::new(&cfg)?;
346 return cmd::version::run(&client).await;
347 }
348 Cmd::Webui { open, bind } => {
349 return cmd::webui::run(&cfg, open, bind).await;
350 }
351 _ => {}
352 }
353
354 let client = CellosClient::new(&cfg)?;
355
356 match cli.cmd {
357 Cmd::Apply { file } => cmd::apply::run(&client, &file).await,
358 Cmd::Get { what } => match what {
359 GetWhat::Formations { output } => {
360 let fmt: OutputFormat = output.parse()?;
361 cmd::get::formations(&client, fmt).await
362 }
363 GetWhat::Cells { formation, output } => {
364 let fmt: OutputFormat = output.parse()?;
365 cmd::get::cells(&client, formation.as_deref(), fmt).await
366 }
367 },
368 Cmd::Describe { what } => match what {
369 DescribeWhat::Formation { name } => cmd::describe::formation(&client, &name).await,
370 DescribeWhat::Cell { name } => cmd::describe::cell(&client, &name).await,
371 },
372 Cmd::Delete { what } => match what {
373 DeleteWhat::Formation { name, yes } => {
374 cmd::delete::formation(&client, &name, yes).await
375 }
376 },
377 Cmd::Logs { cell, follow, tail } => cmd::logs::run(&client, &cell, follow, tail).await,
378 Cmd::Events {
379 formation,
380 follow,
381 since,
382 limit,
383 } => cmd::events::run(&client, formation.as_deref(), follow, since, limit).await,
384 Cmd::Rollout { what } => match what {
385 RolloutWhat::Status { name, timeout } => {
386 cmd::rollout::status(&client, &name, timeout).await
387 }
388 },
389 Cmd::Diff { file } => cmd::diff::run(&client, &file).await,
390 Cmd::Config { .. }
391 | Cmd::Audit { .. }
392 | Cmd::Evidence { .. }
393 | Cmd::Version
394 | Cmd::Webui { .. } => {
395 unreachable!("handled above")
396 }
397 }
398}
399
400fn run_audit(what: AuditWhat) -> CtlResult<()> {
401 match what {
402 AuditWhat::ExportBundle {
403 dir,
404 chain_id,
405 from,
406 to,
407 out,
408 } => cmd::audit::export_bundle(&dir, &chain_id, from, to, &out),
409 AuditWhat::ImportBundle { dir, input } => cmd::audit::import_bundle(&dir, &input),
410 AuditWhat::VerifyChain { dir } => cmd::audit::verify_chain(&dir),
411 }
412}
413
414fn run_evidence(what: EvidenceWhat) -> CtlResult<()> {
415 match what {
416 EvidenceWhat::Lint => cmd::evidence::lint(),
417 EvidenceWhat::Emit { out } => cmd::evidence::emit(&out),
418 }
419}
420
421fn run_config(what: ConfigWhat) -> CtlResult<()> {
422 match what {
423 ConfigWhat::SetServer { url } => cmd::config_cmd::set_server(&url),
424 ConfigWhat::SetToken { token } => cmd::config_cmd::set_token(&token),
425 ConfigWhat::Show => cmd::config_cmd::show(),
426 }
427}