cellos-ctl 0.5.5

cellctl — kubectl-style CLI for CellOS execution cells and formations. Thin HTTP client over cellos-server with apply/get/describe/logs/events/webui.
Documentation
//! Wire-level types for the cellos-server HTTP API.
//!
//! These mirror the projector's response shape. cellctl is a thin client — these
//! types exist only to deserialize responses and re-serialize for `--output json`.
//! No client-side derivations, no state caching.
//!
//! Fields are flexible (`#[serde(default)]`) so that adding fields server-side
//! does not break older clients. This is the same compatibility contract kubectl
//! has with the kube-apiserver.

use serde::{Deserialize, Serialize};
use serde_json::Value;

/// Cell view returned by `GET /v1/cells` / `GET /v1/cells/<id>`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Cell {
    #[serde(default)]
    pub id: String,
    #[serde(default)]
    pub name: String,
    #[serde(default)]
    pub formation_id: Option<String>,
    /// PENDING | ADMITTED | RUNNING | COMPLETED | FAILED | KILLED
    #[serde(default)]
    pub state: String,
    #[serde(default)]
    pub image: Option<String>,
    #[serde(default)]
    pub critical: Option<bool>,
    #[serde(default)]
    pub created_at: Option<String>,
    #[serde(default)]
    pub started_at: Option<String>,
    #[serde(default)]
    pub finished_at: Option<String>,
    /// Most-recent CloudEvent outcome, if known.
    #[serde(default)]
    pub outcome: Option<String>,
    /// Arbitrary extra fields the server may attach.
    #[serde(flatten, default)]
    pub extra: serde_json::Map<String, Value>,
}

/// Formation view returned by `GET /v1/formations` / `GET /v1/formations/<id>`.
///
/// Wire contract drift note (red-team wave 2, HIGH-W2D-2): the server's
/// [`cellos_server::state::FormationRecord`] serialises its lifecycle
/// field as `status` (UPPERCASE enum: `PENDING` / `RUNNING` / `SUCCEEDED`
/// / `FAILED` / `CANCELLED`), while older clients and the table-render
/// code in `output.rs` expect `state` (PascalCase string:
/// `PENDING` / `LAUNCHING` / …). The `state` field below carries
/// `#[serde(alias = "status")]` so cellctl deserialises both shapes
/// without a wire break — until cellos-server is renamed to expose
/// `state` directly, this alias is load-bearing.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Formation {
    #[serde(default)]
    pub id: String,
    #[serde(default)]
    pub name: String,
    /// PENDING | LAUNCHING | RUNNING | DEGRADED | COMPLETED | FAILED
    ///
    /// Accepts both `state` (forward-looking canonical) and `status` (the
    /// shape cellos-server emits today) on the wire.
    #[serde(default, alias = "status")]
    pub state: String,
    #[serde(default)]
    pub tenant: Option<String>,
    #[serde(default)]
    pub cells: Vec<Cell>,
    #[serde(default)]
    pub created_at: Option<String>,
    #[serde(default)]
    pub updated_at: Option<String>,
    #[serde(flatten, default)]
    pub extra: serde_json::Map<String, Value>,
}

/// CloudEvent envelope — projector emits these on the stream and on
/// `GET /v1/cells/<id>/events` plus the `/ws/events` socket.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CloudEvent {
    #[serde(default, alias = "specversion")]
    pub spec_version: Option<String>,
    #[serde(default)]
    pub id: Option<String>,
    #[serde(default)]
    pub source: Option<String>,
    #[serde(default, alias = "type")]
    pub event_type: Option<String>,
    #[serde(default)]
    pub time: Option<String>,
    #[serde(default)]
    pub subject: Option<String>,
    #[serde(default)]
    pub data: Option<Value>,
    #[serde(flatten, default)]
    pub extra: serde_json::Map<String, Value>,
}

/// Snapshot envelope for `GET /v1/formations`.
///
/// Wave-3 fix (MED-CTL-001-A): the previous `List<T>` generic unified
/// `formations`, `cells`, and `items` into a single keyed variant via
/// `#[serde(alias = ...)]`. That unification was clever but fragile: if a
/// server response (or a test fixture, or a future projector evolution)
/// ever emitted BOTH `formations` and `cells` (or `items`) in the same
/// envelope, serde's alias resolution would silently pick exactly one and
/// drop the other half of the payload. The collapse was invisible at
/// every call site because the dropped half just became "an empty list".
///
/// The fix is explicit, type-per-endpoint structs. `FormationsSnapshot`
/// carries the canonical key `formations`; the deserializer below accepts
/// the historical shapes the projector emitted (bare array, generic
/// `{items, cursor?}` wrapper, canonical `{formations, cursor?}` envelope
/// per ADR-0015 §D2) but never simultaneously — an envelope that contains
/// more than one recognised list key is REJECTED as ambiguous.
///
/// The `cursor` is the highest JetStream stream-sequence the server has
/// applied; clients hand it back as `/ws/events?since=<cursor>` to resume
/// the live stream without gaps between snapshot and WS open. It is
/// `None` for bare-array and generic-`items` shapes (which have no
/// cursor on the wire).
#[derive(Debug, Clone, Serialize)]
pub struct FormationsSnapshot {
    pub formations: Vec<Formation>,
    /// JetStream cursor; preserved verbatim from the server envelope when
    /// present, `None` when the server emits a bare array or the legacy
    /// generic-`items` envelope.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub cursor: Option<u64>,
}

impl<'de> serde::Deserialize<'de> for FormationsSnapshot {
    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
        let raw = serde_json::Value::deserialize(d)?;
        let (formations, cursor) = decode_snapshot::<D, Formation>(raw, "formations")?;
        Ok(FormationsSnapshot { formations, cursor })
    }
}

/// Snapshot envelope for `GET /v1/cells`. Mirror of
/// [`FormationsSnapshot`] — see that type's docs for the rationale behind
/// splitting the unified `List<T>` into endpoint-specific snapshots
/// (MED-CTL-001-A). The canonical key on the wire is `cells`; the
/// deserializer also accepts a bare array and the legacy generic
/// `{items, cursor?}` wrapper, but rejects mixed envelopes that contain
/// more than one recognised list key.
#[derive(Debug, Clone, Serialize)]
pub struct CellsSnapshot {
    pub cells: Vec<Cell>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub cursor: Option<u64>,
}

impl<'de> serde::Deserialize<'de> for CellsSnapshot {
    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
        let raw = serde_json::Value::deserialize(d)?;
        let (cells, cursor) = decode_snapshot::<D, Cell>(raw, "cells")?;
        Ok(CellsSnapshot { cells, cursor })
    }
}

/// Shared snapshot-envelope decoder.
///
/// Accepts exactly one of:
///
/// - bare JSON array `[T, T, ...]` (cursor = `None`)
/// - object with the canonical key `{canonical: [T, ...], cursor?: N}`
/// - object with the legacy generic key `{items: [T, ...], cursor?: N}`
///
/// REJECTS any object envelope whose key set contains MORE than one of
/// the recognised list keys (`formations`, `cells`, `items`). This is
/// the wave-3 fix: previously `List<T>` aliased all three onto one
/// field and silently dropped half a mixed payload. Refusing to decode
/// surfaces the contract drift instead of pretending it didn't happen.
fn decode_snapshot<'de, D, T>(
    raw: serde_json::Value,
    canonical: &'static str,
) -> Result<(Vec<T>, Option<u64>), D::Error>
where
    D: serde::Deserializer<'de>,
    T: serde::de::DeserializeOwned,
{
    use serde::de::Error as _;

    // Bare-array form: `[T, T, ...]`. No cursor on the wire.
    if let serde_json::Value::Array(_) = &raw {
        let items: Vec<T> = serde_json::from_value(raw).map_err(D::Error::custom)?;
        return Ok((items, None));
    }

    let obj = raw.as_object().ok_or_else(|| {
        D::Error::custom(format!(
            "expected array or object envelope for {canonical} snapshot",
        ))
    })?;

    // Detect every recognised list key present in the envelope. If MORE
    // than one is present (e.g. server returns `{formations: [...],
    // cells: [...]}` in a single response), we refuse to silently pick
    // one and drop the rest — that was the MED-CTL-001-A failure mode.
    const LIST_KEYS: &[&str] = &["formations", "cells", "items"];
    let present: Vec<&&str> = LIST_KEYS.iter().filter(|k| obj.contains_key(**k)).collect();
    if present.len() > 1 {
        let keys: Vec<&str> = present.iter().map(|k| **k).collect();
        return Err(D::Error::custom(format!(
            "ambiguous {canonical} snapshot envelope: multiple list keys present ({keys:?}); \
             refusing to silently pick one (MED-CTL-001-A)",
        )));
    }

    // Pick the one list key that's present. `canonical` is preferred
    // when both `canonical` and `items` would be accepted; the `len() > 1`
    // gate above rules out the both-present case before we get here.
    let key = if obj.contains_key(canonical) {
        canonical
    } else if obj.contains_key("items") {
        "items"
    } else {
        // Empty envelope is acceptable iff the server returned exactly
        // `{cursor: N}` — but in practice the server always emits the
        // list key (possibly empty). Treat missing list key as an empty
        // list to remain back-compat with `{}`.
        return Ok((Vec::new(), obj.get("cursor").and_then(|v| v.as_u64())));
    };

    let list_value = obj.get(key).cloned().unwrap_or(serde_json::Value::Null);
    let items: Vec<T> = serde_json::from_value(list_value).map_err(D::Error::custom)?;
    let cursor = obj.get("cursor").and_then(|v| v.as_u64());
    Ok((items, cursor))
}