Skip to main content

cellos_ctl/
model.rs

1//! Wire-level types for the cellos-server HTTP API.
2//!
3//! These mirror the projector's response shape. cellctl is a thin client — these
4//! types exist only to deserialize responses and re-serialize for `--output json`.
5//! No client-side derivations, no state caching.
6//!
7//! Fields are flexible (`#[serde(default)]`) so that adding fields server-side
8//! does not break older clients. This is the same compatibility contract kubectl
9//! has with the kube-apiserver.
10
11use serde::{Deserialize, Serialize};
12use serde_json::Value;
13
14/// Cell view returned by `GET /v1/cells` / `GET /v1/cells/<id>`.
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct Cell {
17    #[serde(default)]
18    pub id: String,
19    #[serde(default)]
20    pub name: String,
21    #[serde(default)]
22    pub formation_id: Option<String>,
23    /// PENDING | ADMITTED | RUNNING | COMPLETED | FAILED | KILLED
24    #[serde(default)]
25    pub state: String,
26    #[serde(default)]
27    pub image: Option<String>,
28    #[serde(default)]
29    pub critical: Option<bool>,
30    #[serde(default)]
31    pub created_at: Option<String>,
32    #[serde(default)]
33    pub started_at: Option<String>,
34    #[serde(default)]
35    pub finished_at: Option<String>,
36    /// Most-recent CloudEvent outcome, if known.
37    #[serde(default)]
38    pub outcome: Option<String>,
39    /// Arbitrary extra fields the server may attach.
40    #[serde(flatten, default)]
41    pub extra: serde_json::Map<String, Value>,
42}
43
44/// Formation view returned by `GET /v1/formations` / `GET /v1/formations/<id>`.
45///
46/// Wire contract drift note (red-team wave 2, HIGH-W2D-2): the server's
47/// [`cellos_server::state::FormationRecord`] serialises its lifecycle
48/// field as `status` (UPPERCASE enum: `PENDING` / `RUNNING` / `SUCCEEDED`
49/// / `FAILED` / `CANCELLED`), while older clients and the table-render
50/// code in `output.rs` expect `state` (PascalCase string:
51/// `PENDING` / `LAUNCHING` / …). The `state` field below carries
52/// `#[serde(alias = "status")]` so cellctl deserialises both shapes
53/// without a wire break — until cellos-server is renamed to expose
54/// `state` directly, this alias is load-bearing.
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct Formation {
57    #[serde(default)]
58    pub id: String,
59    #[serde(default)]
60    pub name: String,
61    /// PENDING | LAUNCHING | RUNNING | DEGRADED | COMPLETED | FAILED
62    ///
63    /// Accepts both `state` (forward-looking canonical) and `status` (the
64    /// shape cellos-server emits today) on the wire.
65    #[serde(default, alias = "status")]
66    pub state: String,
67    #[serde(default)]
68    pub tenant: Option<String>,
69    #[serde(default)]
70    pub cells: Vec<Cell>,
71    #[serde(default)]
72    pub created_at: Option<String>,
73    #[serde(default)]
74    pub updated_at: Option<String>,
75    #[serde(flatten, default)]
76    pub extra: serde_json::Map<String, Value>,
77}
78
79/// CloudEvent envelope — projector emits these on the stream and on
80/// `GET /v1/cells/<id>/events` plus the `/ws/events` socket.
81#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct CloudEvent {
83    #[serde(default, alias = "specversion")]
84    pub spec_version: Option<String>,
85    #[serde(default)]
86    pub id: Option<String>,
87    #[serde(default)]
88    pub source: Option<String>,
89    #[serde(default, alias = "type")]
90    pub event_type: Option<String>,
91    #[serde(default)]
92    pub time: Option<String>,
93    #[serde(default)]
94    pub subject: Option<String>,
95    #[serde(default)]
96    pub data: Option<Value>,
97    #[serde(flatten, default)]
98    pub extra: serde_json::Map<String, Value>,
99}
100
101/// Snapshot envelope for `GET /v1/formations`.
102///
103/// Wave-3 fix (MED-CTL-001-A): the previous `List<T>` generic unified
104/// `formations`, `cells`, and `items` into a single keyed variant via
105/// `#[serde(alias = ...)]`. That unification was clever but fragile: if a
106/// server response (or a test fixture, or a future projector evolution)
107/// ever emitted BOTH `formations` and `cells` (or `items`) in the same
108/// envelope, serde's alias resolution would silently pick exactly one and
109/// drop the other half of the payload. The collapse was invisible at
110/// every call site because the dropped half just became "an empty list".
111///
112/// The fix is explicit, type-per-endpoint structs. `FormationsSnapshot`
113/// carries the canonical key `formations`; the deserializer below accepts
114/// the historical shapes the projector emitted (bare array, generic
115/// `{items, cursor?}` wrapper, canonical `{formations, cursor?}` envelope
116/// per ADR-0015 §D2) but never simultaneously — an envelope that contains
117/// more than one recognised list key is REJECTED as ambiguous.
118///
119/// The `cursor` is the highest JetStream stream-sequence the server has
120/// applied; clients hand it back as `/ws/events?since=<cursor>` to resume
121/// the live stream without gaps between snapshot and WS open. It is
122/// `None` for bare-array and generic-`items` shapes (which have no
123/// cursor on the wire).
124#[derive(Debug, Clone, Serialize)]
125pub struct FormationsSnapshot {
126    pub formations: Vec<Formation>,
127    /// JetStream cursor; preserved verbatim from the server envelope when
128    /// present, `None` when the server emits a bare array or the legacy
129    /// generic-`items` envelope.
130    #[serde(default, skip_serializing_if = "Option::is_none")]
131    pub cursor: Option<u64>,
132}
133
134impl<'de> serde::Deserialize<'de> for FormationsSnapshot {
135    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
136        let raw = serde_json::Value::deserialize(d)?;
137        let (formations, cursor) = decode_snapshot::<D, Formation>(raw, "formations")?;
138        Ok(FormationsSnapshot { formations, cursor })
139    }
140}
141
142/// Snapshot envelope for `GET /v1/cells`. Mirror of
143/// [`FormationsSnapshot`] — see that type's docs for the rationale behind
144/// splitting the unified `List<T>` into endpoint-specific snapshots
145/// (MED-CTL-001-A). The canonical key on the wire is `cells`; the
146/// deserializer also accepts a bare array and the legacy generic
147/// `{items, cursor?}` wrapper, but rejects mixed envelopes that contain
148/// more than one recognised list key.
149#[derive(Debug, Clone, Serialize)]
150pub struct CellsSnapshot {
151    pub cells: Vec<Cell>,
152    #[serde(default, skip_serializing_if = "Option::is_none")]
153    pub cursor: Option<u64>,
154}
155
156impl<'de> serde::Deserialize<'de> for CellsSnapshot {
157    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
158        let raw = serde_json::Value::deserialize(d)?;
159        let (cells, cursor) = decode_snapshot::<D, Cell>(raw, "cells")?;
160        Ok(CellsSnapshot { cells, cursor })
161    }
162}
163
164/// Shared snapshot-envelope decoder.
165///
166/// Accepts exactly one of:
167///
168/// - bare JSON array `[T, T, ...]` (cursor = `None`)
169/// - object with the canonical key `{canonical: [T, ...], cursor?: N}`
170/// - object with the legacy generic key `{items: [T, ...], cursor?: N}`
171///
172/// REJECTS any object envelope whose key set contains MORE than one of
173/// the recognised list keys (`formations`, `cells`, `items`). This is
174/// the wave-3 fix: previously `List<T>` aliased all three onto one
175/// field and silently dropped half a mixed payload. Refusing to decode
176/// surfaces the contract drift instead of pretending it didn't happen.
177fn decode_snapshot<'de, D, T>(
178    raw: serde_json::Value,
179    canonical: &'static str,
180) -> Result<(Vec<T>, Option<u64>), D::Error>
181where
182    D: serde::Deserializer<'de>,
183    T: serde::de::DeserializeOwned,
184{
185    use serde::de::Error as _;
186
187    // Bare-array form: `[T, T, ...]`. No cursor on the wire.
188    if let serde_json::Value::Array(_) = &raw {
189        let items: Vec<T> = serde_json::from_value(raw).map_err(D::Error::custom)?;
190        return Ok((items, None));
191    }
192
193    let obj = raw.as_object().ok_or_else(|| {
194        D::Error::custom(format!(
195            "expected array or object envelope for {canonical} snapshot",
196        ))
197    })?;
198
199    // Detect every recognised list key present in the envelope. If MORE
200    // than one is present (e.g. server returns `{formations: [...],
201    // cells: [...]}` in a single response), we refuse to silently pick
202    // one and drop the rest — that was the MED-CTL-001-A failure mode.
203    const LIST_KEYS: &[&str] = &["formations", "cells", "items"];
204    let present: Vec<&&str> = LIST_KEYS.iter().filter(|k| obj.contains_key(**k)).collect();
205    if present.len() > 1 {
206        let keys: Vec<&str> = present.iter().map(|k| **k).collect();
207        return Err(D::Error::custom(format!(
208            "ambiguous {canonical} snapshot envelope: multiple list keys present ({keys:?}); \
209             refusing to silently pick one (MED-CTL-001-A)",
210        )));
211    }
212
213    // Pick the one list key that's present. `canonical` is preferred
214    // when both `canonical` and `items` would be accepted; the `len() > 1`
215    // gate above rules out the both-present case before we get here.
216    let key = if obj.contains_key(canonical) {
217        canonical
218    } else if obj.contains_key("items") {
219        "items"
220    } else {
221        // Empty envelope is acceptable iff the server returned exactly
222        // `{cursor: N}` — but in practice the server always emits the
223        // list key (possibly empty). Treat missing list key as an empty
224        // list to remain back-compat with `{}`.
225        return Ok((Vec::new(), obj.get("cursor").and_then(|v| v.as_u64())));
226    };
227
228    let list_value = obj.get(key).cloned().unwrap_or(serde_json::Value::Null);
229    let items: Vec<T> = serde_json::from_value(list_value).map_err(D::Error::custom)?;
230    let cursor = obj.get("cursor").and_then(|v| v.as_u64());
231    Ok((items, cursor))
232}