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.** 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}