clawft_kernel/workload_runtime/
fleet_inventory.rs1use std::sync::Arc;
19
20use async_trait::async_trait;
21use serde_json::{Value, json};
22
23use super::seed::SeedApiRuntime;
24use crate::chain::{ChainManager, EVENT_KIND_WORKLOAD_REFUSE};
25
26pub const INVENTORY_CHAIN_SOURCE: &str = "workload.inventory";
28pub const EVENT_KIND_FLEET_INVENTORY: &str = "workload.fleet.inventory";
30pub const READ_TOOLS: &[&str] = &["fleet_status"];
32pub const MAX_DEVICES: usize = 1000;
34
35#[non_exhaustive]
37#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
38pub enum FleetError {
39 #[error("MCP tool {0:?} is not a read tool; the inventory adapter is read-only")]
41 NonReadTool(String),
42 #[error("fleet transport: {0}")]
44 Transport(String),
45 #[error("fleet_status output not understood: {0}")]
47 Schema(String),
48 #[error("the Cognitum cloud refused the credential (HTTP {0})")]
50 Unauthorized(u16),
51}
52
53#[async_trait]
55pub trait FleetMcp: Send + Sync {
56 async fn call_tool(&self, name: &str, args: Value) -> Result<Value, FleetError>;
58}
59
60pub struct ReadOnlyFleet {
62 inner: Arc<dyn FleetMcp>,
63}
64
65impl ReadOnlyFleet {
66 pub fn new(inner: Arc<dyn FleetMcp>) -> Self {
68 Self { inner }
69 }
70
71 pub fn http(
74 url: &str,
75 tokens: Box<dyn super::fleet_mcp::OAuthTokens>,
76 ) -> Result<Self, FleetError> {
77 Ok(Self::new(Arc::new(super::fleet_mcp::HttpFleetMcp::new(
78 url, tokens,
79 )?)))
80 }
81
82 pub async fn call(&self, name: &str, args: Value) -> Result<Value, FleetError> {
84 if !READ_TOOLS.contains(&name) {
85 return Err(FleetError::NonReadTool(name.to_string()));
86 }
87 self.inner.call_tool(name, args).await
88 }
89}
90
91#[derive(Debug, Clone, PartialEq, Eq)]
93pub struct FleetCandidate {
94 pub device_id: String,
96 pub firmware: Option<String>,
98 pub online: Option<bool>,
100 pub device_pubkey: Option<String>,
102}
103
104fn text(v: &Value, keys: &[&str]) -> Option<String> {
105 keys.iter()
106 .find_map(|k| v.get(*k).and_then(Value::as_str))
107 .filter(|s| !s.is_empty() && s.len() <= 256)
108 .map(str::to_string)
109}
110
111fn online_of(v: &Value) -> Option<bool> {
112 if let Some(b) = v.get("online").and_then(Value::as_bool) {
113 return Some(b);
114 }
115 let s = text(v, &["status", "state"])?.to_ascii_lowercase();
116 match s.as_str() {
117 "online" | "active" | "connected" | "up" => Some(true),
118 "offline" | "inactive" | "disconnected" | "down" => Some(false),
119 _ => None,
120 }
121}
122
123pub fn parse_fleet_status(out: &Value) -> Result<(Vec<FleetCandidate>, usize), FleetError> {
126 let list = match out {
127 Value::Array(a) => a,
128 Value::Object(_) => ["devices", "seeds", "fleet", "items"]
129 .iter()
130 .find_map(|k| out.get(*k).and_then(Value::as_array))
131 .ok_or_else(|| FleetError::Schema("no devices array".into()))?,
132 _ => return Err(FleetError::Schema("not an object or array".into())),
133 };
134 if list.len() > MAX_DEVICES {
135 return Err(FleetError::Schema(format!(
136 "more than {MAX_DEVICES} devices"
137 )));
138 }
139 let mut out = Vec::new();
140 let mut skipped = 0;
141 for d in list {
142 let Some(device_id) = text(d, &["device_id", "deviceId", "id"]) else {
143 skipped += 1;
144 continue;
145 };
146 out.push(FleetCandidate {
147 device_id,
148 firmware: text(
149 d,
150 &["firmware", "firmware_version", "firmwareVersion", "version"],
151 ),
152 online: online_of(d),
153 device_pubkey: text(d, &["public_key", "publicKey", "pubkey"]),
154 });
155 }
156 Ok((out, skipped))
157}
158
159#[derive(Debug, Clone, PartialEq, Eq)]
161pub enum FleetCheck {
162 Verified,
164 NotListed,
166 KeyNotReported,
168}
169
170pub struct FleetInventory {
172 fleet: ReadOnlyFleet,
173 chain: Arc<ChainManager>,
174 label: String,
175}
176
177impl FleetInventory {
178 pub fn new(fleet: ReadOnlyFleet, chain: Arc<ChainManager>, label: &str) -> Self {
181 Self {
182 fleet,
183 chain,
184 label: label.chars().take(64).collect(),
185 }
186 }
187
188 pub async fn candidates(
190 &self,
191 region: Option<&str>,
192 ) -> Result<Vec<FleetCandidate>, FleetError> {
193 let args = match region {
194 Some(r) => json!({ "region": r }),
195 None => json!({}),
196 };
197 let out = self.fleet.call("fleet_status", args).await?;
198 let (list, skipped) = parse_fleet_status(&out)?;
199 self.chain.append(
200 INVENTORY_CHAIN_SOURCE,
201 EVENT_KIND_FLEET_INVENTORY,
202 Some(json!({
203 "tool": "fleet_status", "credential": self.label, "region": region,
204 "candidates": list.iter().map(|c| json!({
205 "device_id": c.device_id, "firmware": c.firmware, "online": c.online,
206 })).collect::<Vec<_>>(),
207 "skipped_without_device_id": skipped,
208 })),
209 );
210 Ok(list)
211 }
212
213 pub async fn cross_check(
217 &self,
218 candidates: &[FleetCandidate],
219 rt: &SeedApiRuntime,
220 ) -> Result<FleetCheck, FleetError> {
221 let (device_id, key) = rt
222 .identity()
223 .await
224 .map_err(|e| FleetError::Transport(e.to_string()))?;
225 let Some(c) = candidates.iter().find(|c| c.device_id == device_id) else {
226 return Ok(FleetCheck::NotListed);
227 };
228 match &c.device_pubkey {
229 None => Ok(FleetCheck::KeyNotReported),
230 Some(k) if *k == key => Ok(FleetCheck::Verified),
231 Some(_) => {
232 let e = FleetError::Schema(format!(
233 "the cloud's public key for {device_id} differs from the key the Seed reports locally"
234 ));
235 self.chain.append(
236 INVENTORY_CHAIN_SOURCE,
237 EVENT_KIND_WORKLOAD_REFUSE,
238 Some(json!({
239 "phase": "fleet.inventory", "code": "key_mismatch",
240 "device_id": device_id, "node_id": rt.node_id(),
241 "credential": self.label,
242 })),
243 );
244 Err(e)
245 }
246 }
247 }
248}