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}