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        #[arg(short = 'f', long = "file")]
108        file: PathBuf,
109    },
110    /// Read/write cellctl config (~/.cellctl/config).
111    Config {
112        #[command(subcommand)]
113        what: ConfigWhat,
114    },
115    /// Print the cellctl client + server version.
116    Version,
117    /// Spin up a localhost browser proxy for the cellctl web view (ADR-0017).
118    Webui {
119        /// Launch the system browser at the URL after binding.
120        #[arg(long)]
121        open: bool,
122        /// Bind mode: `auto` (default; loopback in this MVP),
123        /// `loopback` (force 127.0.0.1), or `unix` (planned).
124        #[arg(long, value_enum, default_value = "auto")]
125        bind: cmd::webui::BindMode,
126    },
127}
128
129#[derive(Subcommand, Debug)]
130enum GetWhat {
131    /// List formations.
132    Formations {
133        #[arg(long, short = 'o', default_value = "table")]
134        output: String,
135    },
136    /// List cells (optionally filtered to a single formation).
137    Cells {
138        #[arg(long)]
139        formation: Option<String>,
140        #[arg(long, short = 'o', default_value = "table")]
141        output: String,
142    },
143}
144
145#[derive(Subcommand, Debug)]
146enum DescribeWhat {
147    /// Describe a formation by name or id.
148    Formation { name: String },
149    /// Describe a cell by name or id.
150    Cell { name: String },
151}
152
153#[derive(Subcommand, Debug)]
154enum DeleteWhat {
155    /// Delete a formation (also tears down its cells server-side).
156    Formation {
157        name: String,
158        /// Skip interactive confirmation.
159        #[arg(long, short = 'y')]
160        yes: bool,
161    },
162}
163
164#[derive(Subcommand, Debug)]
165enum RolloutWhat {
166    /// Poll a formation until it reaches COMPLETED or FAILED.
167    Status {
168        name: String,
169        /// Give up after N seconds (default: no timeout).
170        #[arg(long)]
171        timeout: Option<u64>,
172    },
173}
174
175#[derive(Subcommand, Debug)]
176enum ConfigWhat {
177    /// Set the server URL persistently.
178    SetServer { url: String },
179    /// Set the bearer token persistently.
180    SetToken { token: String },
181    /// Print the resolved config.
182    Show,
183}
184
185/// Run the `cellctl` CLI. Returns when the command completes or exits the
186/// process on error via [`CtlError::exit`]. This is the entry point both
187/// the standalone `cellctl` binary and the `cellos` meta-crate's `cellctl`
188/// shim call into.
189pub fn run() {
190    // Tracing is opt-in via $RUST_LOG so noisy debug output never goes to stderr
191    // by default — that would muddy the doctrine error contract.
192    if std::env::var_os("RUST_LOG").is_some() {
193        let _ = tracing_subscriber::fmt()
194            .with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
195            .with_writer(std::io::stderr)
196            .try_init();
197    }
198
199    let cli = Cli::parse();
200
201    // Build a single-threaded current-thread runtime; cellctl is I/O bound and
202    // doesn't benefit from a multi-thread scheduler.
203    let rt = match tokio::runtime::Builder::new_current_thread()
204        .enable_all()
205        .build()
206    {
207        Ok(rt) => rt,
208        Err(e) => CtlError::usage(format!("init tokio runtime: {e}")).exit(),
209    };
210
211    match rt.block_on(dispatch(cli)) {
212        Ok(()) => {}
213        Err(e) => e.exit(),
214    }
215}
216
217async fn dispatch(cli: Cli) -> CtlResult<()> {
218    // Effective config = on-disk config overridden by CLI flags / env vars.
219    let mut cfg = config::load().unwrap_or_default();
220    if let Some(s) = cli.server {
221        cfg.server_url = Some(s);
222    }
223    if let Some(t) = cli.token {
224        cfg.api_token = Some(t);
225    }
226
227    // Config + Version + Webui don't go through the normal CellosClient
228    // dispatch — Webui takes the raw config to drive its reverse proxy.
229    match cli.cmd {
230        Cmd::Config { what } => return run_config(what),
231        Cmd::Version => {
232            let client = CellosClient::new(&cfg)?;
233            return cmd::version::run(&client).await;
234        }
235        Cmd::Webui { open, bind } => {
236            return cmd::webui::run(&cfg, open, bind).await;
237        }
238        _ => {}
239    }
240
241    let client = CellosClient::new(&cfg)?;
242
243    match cli.cmd {
244        Cmd::Apply { file } => cmd::apply::run(&client, &file).await,
245        Cmd::Get { what } => match what {
246            GetWhat::Formations { output } => {
247                let fmt: OutputFormat = output.parse()?;
248                cmd::get::formations(&client, fmt).await
249            }
250            GetWhat::Cells { formation, output } => {
251                let fmt: OutputFormat = output.parse()?;
252                cmd::get::cells(&client, formation.as_deref(), fmt).await
253            }
254        },
255        Cmd::Describe { what } => match what {
256            DescribeWhat::Formation { name } => cmd::describe::formation(&client, &name).await,
257            DescribeWhat::Cell { name } => cmd::describe::cell(&client, &name).await,
258        },
259        Cmd::Delete { what } => match what {
260            DeleteWhat::Formation { name, yes } => {
261                cmd::delete::formation(&client, &name, yes).await
262            }
263        },
264        Cmd::Logs { cell, follow, tail } => cmd::logs::run(&client, &cell, follow, tail).await,
265        Cmd::Events { formation, follow } => {
266            cmd::events::run(&client, formation.as_deref(), follow).await
267        }
268        Cmd::Rollout { what } => match what {
269            RolloutWhat::Status { name, timeout } => {
270                cmd::rollout::status(&client, &name, timeout).await
271            }
272        },
273        Cmd::Diff { file } => cmd::diff::run(&client, &file).await,
274        Cmd::Config { .. } | Cmd::Version | Cmd::Webui { .. } => {
275            unreachable!("handled above")
276        }
277    }
278}
279
280fn run_config(what: ConfigWhat) -> CtlResult<()> {
281    match what {
282        ConfigWhat::SetServer { url } => cmd::config_cmd::set_server(&url),
283        ConfigWhat::SetToken { token } => cmd::config_cmd::set_token(&token),
284        ConfigWhat::Show => cmd::config_cmd::show(),
285    }
286}