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/// Server response envelope for list endpoints. Server emits several shapes
102/// historically and going forward:
103///
104/// 1. A bare JSON array: `[{...}, ...]` — early endpoints.
105/// 2. A generic wrapper: `{"items": [...]}` — never shipped on a real route,
106/// kept for forward compatibility.
107/// 3. The list-snapshot envelope: `{"formations": [...], "cursor": N}` (and
108/// by symmetry `{"cells": [...], "cursor": N}`) per ADR-0015 §D2. The
109/// `cursor` is the highest JetStream stream-sequence the server has
110/// applied; clients hand it back as `/ws/events?since=<cursor>` to resume
111/// the live stream without gaps between snapshot and WS open.
112///
113/// `List<T>` accepts every shape above via a custom deserialiser. When the
114/// server provides a cursor it is preserved on the deserialised value;
115/// otherwise `cursor` is `None`. The CTL-001 regression test in
116/// `tests/formations_list_envelope.rs` pins the `{formations, cursor}` shape.
117#[derive(Debug, Clone, Serialize)]
118pub struct List<T> {
119 pub items: Vec<T>,
120 /// JetStream cursor returned by `GET /v1/formations` (and similar
121 /// snapshot endpoints). `None` when the server emits a bare array or a
122 /// generic `{"items": [...]}` envelope.
123 #[serde(default, skip_serializing_if = "Option::is_none")]
124 pub cursor: Option<u64>,
125}
126
127impl<'de, T: serde::Deserialize<'de>> serde::Deserialize<'de> for List<T> {
128 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
129 // The `Keyed` variant accepts every server-side wrapper key the
130 // projector emits today (`items`, `formations`, `cells`) for the
131 // same field, plus an optional `cursor`. Serde's `untagged` enum
132 // picks the first variant whose shape matches, so a bare array
133 // falls through to `Bare` and any object with one of the recognised
134 // keys lands in `Keyed`.
135 #[derive(Deserialize)]
136 #[serde(untagged)]
137 enum Wire<T> {
138 Bare(Vec<T>),
139 Keyed {
140 #[serde(alias = "formations", alias = "cells")]
141 items: Vec<T>,
142 #[serde(default)]
143 cursor: Option<u64>,
144 },
145 }
146 Ok(match Wire::<T>::deserialize(d)? {
147 Wire::Bare(items) => List {
148 items,
149 cursor: None,
150 },
151 Wire::Keyed { items, cursor } => List { items, cursor },
152 })
153 }
154}