cellos-ctl 0.6.0-pre

cellctl — kubectl-style CLI for CellOS execution cells and formations. Thin HTTP client over cellos-server with apply/get/describe/logs/events/webui.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
//! `cellctl` — kubectl-style CLI for CellOS.
//!
//! Doctrine alignment (CHATROOM Session 16):
//!   * **Thin client.** Every subcommand corresponds to exactly one HTTP call
//!     against `cellos-server`. No client-side state, no caches, no projections.
//!   * **Events are the source of truth.** `cellctl logs` and `cellctl events`
//!     surface the CloudEvent stream verbatim; the state machine lives in the
//!     server-side projector.
//!   * **Exit codes are a contract.** Routed through pattern-11
//!     `axiom_exit::Exit`: 0=success, 2=usage, 3=preflight/validation,
//!     64=API/transport failure (tool-specific). Errors go to stderr;
//!     machine-readable output goes to stdout.
//!
//! See `crates/cellos-ctl/src/exit.rs` for the exit-code definitions.
//!
//! ## Public entry
//!
//! Most consumers run the `cellctl` binary directly. The `cellos` meta-crate
//! at `crates/cellos-meta/` re-exports this crate's [`run`] as one of its
//! three installable binaries so `cargo install cellos` ships cellctl,
//! cellos-server, and cellos-supervisor in one go.

pub mod client;
pub mod cmd;
pub mod config;
pub mod exit;
pub mod model;
pub mod output;

use std::path::PathBuf;

use clap::{Parser, Subcommand};

use crate::client::CellosClient;
use crate::exit::{CtlError, CtlResult};
use crate::output::OutputFormat;

/// kubectl-style CLI for CellOS.
#[derive(Parser, Debug)]
#[command(
    name = "cellctl",
    version,
    about = "kubectl-style CLI for CellOS",
    long_about = None,
)]
struct Cli {
    /// Override the server URL (otherwise read from config or $CELLCTL_SERVER).
    #[arg(long, global = true, env = "CELLCTL_SERVER")]
    server: Option<String>,

    /// Override the bearer token (otherwise read from config or $CELLCTL_TOKEN).
    #[arg(long, global = true, env = "CELLCTL_TOKEN", hide_env_values = true)]
    token: Option<String>,

    #[command(subcommand)]
    cmd: Cmd,
}

#[derive(Subcommand, Debug)]
enum Cmd {
    /// Submit a formation spec to the server (POST /v1/formations).
    Apply {
        /// Path to a formation YAML file.
        #[arg(short = 'f', long = "file")]
        file: PathBuf,
    },
    /// List resources.
    Get {
        #[command(subcommand)]
        what: GetWhat,
    },
    /// Show full state + recent events for a single resource.
    Describe {
        #[command(subcommand)]
        what: DescribeWhat,
    },
    /// Delete a resource.
    Delete {
        #[command(subcommand)]
        what: DeleteWhat,
    },
    /// Stream CloudEvents for a single cell.
    Logs {
        /// Cell name or id.
        cell: String,
        /// Keep the connection open and stream new events.
        #[arg(long, short = 'f')]
        follow: bool,
        /// Show only the last N events.
        #[arg(long)]
        tail: Option<usize>,
    },
    /// Stream global / formation-scoped CloudEvents.
    Events {
        /// Filter to a single formation.
        #[arg(long)]
        formation: Option<String>,
        /// Keep the connection open (uses WebSocket /ws/events).
        #[arg(long, short = 'f')]
        follow: bool,
        /// One-shot only: return events with `seq > since`. Pair with
        /// the `cursor` from a previous `cellctl events` response to
        /// page through history without duplicates.
        #[arg(long)]
        since: Option<u64>,
        /// One-shot only: cap the response page (default 100, server
        /// clamps at 1000). Ignored when `--follow` is set.
        #[arg(long)]
        limit: Option<usize>,
    },
    /// Poll a formation until it reaches a terminal state.
    Rollout {
        #[command(subcommand)]
        what: RolloutWhat,
    },
    /// Show what would change between local YAML and the server-side formation.
    Diff {
        /// Path to a formation YAML file.
        #[arg(short = 'f', long = "file")]
        file: PathBuf,
    },
    /// Read/write cellctl config (~/.cellctl/config).
    Config {
        #[command(subcommand)]
        what: ConfigWhat,
    },
    /// Print the cellctl client + server version.
    Version,
    /// Cross-domain audit bundle ops over a local spool (S19/S20, ADR-0029).
    ///
    /// These are LOCAL file operations — no server client. The export/import/
    /// verify logic lives in `cellos-sink-spool::bundle`; this is the operator
    /// surface.
    Audit {
        #[command(subcommand)]
        what: AuditWhat,
    },
    /// Accreditation evidence: lint the 800-53 catalog or emit OSCAL artifacts.
    ///
    /// LOCAL operations over the in-repo control-mapping catalog (logic lives in
    /// `cellos-evidence`). Emitted artifacts are CANDIDATE evidence, not a
    /// self-attestation of control satisfaction.
    Evidence {
        #[command(subcommand)]
        what: EvidenceWhat,
    },
    /// Spin up a localhost browser proxy for the cellctl web view (ADR-0017).
    Webui {
        /// Launch the system browser at the URL after binding.
        #[arg(long)]
        open: bool,
        /// Bind mode: `auto` (default; loopback in this MVP),
        /// `loopback` (force 127.0.0.1), or `unix` (planned).
        #[arg(long, value_enum, default_value = "auto")]
        bind: cmd::webui::BindMode,
    },
}

#[derive(Subcommand, Debug)]
enum GetWhat {
    /// List formations.
    Formations {
        #[arg(long, short = 'o', default_value = "table")]
        output: String,
    },
    /// List cells (optionally filtered to a single formation).
    Cells {
        #[arg(long)]
        formation: Option<String>,
        #[arg(long, short = 'o', default_value = "table")]
        output: String,
    },
}

#[derive(Subcommand, Debug)]
enum DescribeWhat {
    /// Describe a formation by name or id.
    Formation {
        /// Formation name or id to describe.
        name: String,
    },
    /// Describe a cell by name or id.
    Cell {
        /// Cell name or id to describe.
        name: String,
    },
}

#[derive(Subcommand, Debug)]
enum DeleteWhat {
    /// Delete a formation (also tears down its cells server-side).
    Formation {
        /// Formation name or id to delete.
        name: String,
        /// Skip interactive confirmation.
        #[arg(long, short = 'y')]
        yes: bool,
    },
}

#[derive(Subcommand, Debug)]
enum RolloutWhat {
    /// Poll a formation until it reaches COMPLETED or FAILED.
    Status {
        /// Formation name or id to poll.
        name: String,
        /// Give up after N seconds (default: no timeout).
        #[arg(long)]
        timeout: Option<u64>,
    },
}

#[derive(Subcommand, Debug)]
enum AuditWhat {
    /// Export a contiguous chain range `[from, to]` as a bundle file.
    ExportBundle {
        /// Spool directory holding the audit chain.
        #[arg(long)]
        dir: PathBuf,
        /// Operator-declared chain id (routing label) recorded in the manifest.
        #[arg(long)]
        chain_id: String,
        /// First row seq to include.
        #[arg(long)]
        from: u64,
        /// Last row seq to include (defaults to the chain head).
        #[arg(long)]
        to: Option<u64>,
        /// Output bundle file to write.
        #[arg(long)]
        out: PathBuf,
    },
    /// Import a bundle file into the local spool (fail-closed).
    ImportBundle {
        /// Spool directory to import into.
        #[arg(long)]
        dir: PathBuf,
        /// Bundle file to read.
        #[arg(long = "in")]
        input: PathBuf,
    },
    /// Verify the on-disk chain and report status (exits 1 on a break).
    VerifyChain {
        /// Spool directory holding the audit chain.
        #[arg(long)]
        dir: PathBuf,
    },
}

#[derive(Subcommand, Debug)]
enum EvidenceWhat {
    /// Lint the 800-53 control-mapping catalog (schema + on-disk seam/test refs).
    Lint,
    /// Emit the OSCAL component-definition + SSP-fragment to a directory.
    Emit {
        /// Output directory for the OSCAL artifacts.
        #[arg(long)]
        out: PathBuf,
    },
}

#[derive(Subcommand, Debug)]
enum ConfigWhat {
    /// Set the server URL persistently.
    SetServer {
        /// Server base URL (e.g. http://127.0.0.1:8080).
        url: String,
    },
    /// Set the bearer token persistently.
    SetToken {
        /// Bearer token to send as `Authorization: Bearer <TOKEN>`.
        token: String,
    },
    /// Print the resolved config.
    Show,
}

/// Run the `cellctl` CLI. Returns when the command completes or exits the
/// process on error via [`CtlError::exit`]. This is the entry point both
/// the standalone `cellctl` binary and the `cellos` meta-crate's `cellctl`
/// shim call into.
pub fn run() {
    // Tracing is opt-in via $RUST_LOG so noisy debug output never goes to stderr
    // by default — that would muddy the doctrine error contract.
    //
    // HIGH-B5: when tracing IS enabled, the redacted filter on the fmt
    // layer suppresses reqwest/hyper TRACE events that would otherwise dump
    // bearer tokens (cellctl makes authenticated reqwest calls to
    // cellos-server's API; `RUST_LOG=reqwest=trace` is exactly the failure
    // mode this fixes).
    if std::env::var_os("RUST_LOG").is_some() {
        use tracing_subscriber::layer::SubscriberExt;
        use tracing_subscriber::util::SubscriberInitExt;
        use tracing_subscriber::Layer;

        let fmt_layer = tracing_subscriber::fmt::layer()
            .with_writer(std::io::stderr)
            .with_filter(cellos_core::observability::redacted_filter());

        let _ = tracing_subscriber::registry()
            .with(tracing_subscriber::EnvFilter::from_default_env())
            .with(fmt_layer)
            .try_init();
    }

    let cli = Cli::parse();

    // Build a single-threaded current-thread runtime; cellctl is I/O bound and
    // doesn't benefit from a multi-thread scheduler.
    let rt = match tokio::runtime::Builder::new_current_thread()
        .enable_all()
        .build()
    {
        Ok(rt) => rt,
        Err(e) => CtlError::usage(format!("init tokio runtime: {e}")).exit(),
    };

    // Both arms route the process exit byte through the pattern-11
    // `axiom_exit::Exit` FSM (success -> `Exit::Ok`; failure -> the variant
    // mapped by `CtlError::exit`).
    match rt.block_on(dispatch(cli)) {
        Ok(()) => std::process::exit(axiom_exit::Exit::Ok.code().into()),
        Err(e) => e.exit(),
    }
}

async fn dispatch(cli: Cli) -> CtlResult<()> {
    // Effective config = on-disk config overridden by CLI flags / env vars.
    let mut cfg = config::load().unwrap_or_default();
    if let Some(s) = cli.server {
        cfg.server_url = Some(s);
    }
    if let Some(t) = cli.token {
        cfg.api_token = Some(t);
    }

    // Config + Version + Webui don't go through the normal CellosClient
    // dispatch — Webui takes the raw config to drive its reverse proxy.
    match cli.cmd {
        Cmd::Config { what } => return run_config(what),
        // Audit is a pure-local file op (like Config/Webui): no server client.
        Cmd::Audit { what } => return run_audit(what),
        Cmd::Evidence { what } => return run_evidence(what),
        Cmd::Version => {
            let client = CellosClient::new(&cfg)?;
            return cmd::version::run(&client).await;
        }
        Cmd::Webui { open, bind } => {
            return cmd::webui::run(&cfg, open, bind).await;
        }
        _ => {}
    }

    let client = CellosClient::new(&cfg)?;

    match cli.cmd {
        Cmd::Apply { file } => cmd::apply::run(&client, &file).await,
        Cmd::Get { what } => match what {
            GetWhat::Formations { output } => {
                let fmt: OutputFormat = output.parse()?;
                cmd::get::formations(&client, fmt).await
            }
            GetWhat::Cells { formation, output } => {
                let fmt: OutputFormat = output.parse()?;
                cmd::get::cells(&client, formation.as_deref(), fmt).await
            }
        },
        Cmd::Describe { what } => match what {
            DescribeWhat::Formation { name } => cmd::describe::formation(&client, &name).await,
            DescribeWhat::Cell { name } => cmd::describe::cell(&client, &name).await,
        },
        Cmd::Delete { what } => match what {
            DeleteWhat::Formation { name, yes } => {
                cmd::delete::formation(&client, &name, yes).await
            }
        },
        Cmd::Logs { cell, follow, tail } => cmd::logs::run(&client, &cell, follow, tail).await,
        Cmd::Events {
            formation,
            follow,
            since,
            limit,
        } => cmd::events::run(&client, formation.as_deref(), follow, since, limit).await,
        Cmd::Rollout { what } => match what {
            RolloutWhat::Status { name, timeout } => {
                cmd::rollout::status(&client, &name, timeout).await
            }
        },
        Cmd::Diff { file } => cmd::diff::run(&client, &file).await,
        Cmd::Config { .. }
        | Cmd::Audit { .. }
        | Cmd::Evidence { .. }
        | Cmd::Version
        | Cmd::Webui { .. } => {
            unreachable!("handled above")
        }
    }
}

fn run_audit(what: AuditWhat) -> CtlResult<()> {
    match what {
        AuditWhat::ExportBundle {
            dir,
            chain_id,
            from,
            to,
            out,
        } => cmd::audit::export_bundle(&dir, &chain_id, from, to, &out),
        AuditWhat::ImportBundle { dir, input } => cmd::audit::import_bundle(&dir, &input),
        AuditWhat::VerifyChain { dir } => cmd::audit::verify_chain(&dir),
    }
}

fn run_evidence(what: EvidenceWhat) -> CtlResult<()> {
    match what {
        EvidenceWhat::Lint => cmd::evidence::lint(),
        EvidenceWhat::Emit { out } => cmd::evidence::emit(&out),
    }
}

fn run_config(what: ConfigWhat) -> CtlResult<()> {
    match what {
        ConfigWhat::SetServer { url } => cmd::config_cmd::set_server(&url),
        ConfigWhat::SetToken { token } => cmd::config_cmd::set_token(&token),
        ConfigWhat::Show => cmd::config_cmd::show(),
    }
}