use std::sync::Arc;
use async_trait::async_trait;
use serde_json::{Value, json};
use super::seed::SeedApiRuntime;
use crate::chain::{ChainManager, EVENT_KIND_WORKLOAD_REFUSE};
pub const INVENTORY_CHAIN_SOURCE: &str = "workload.inventory";
pub const EVENT_KIND_FLEET_INVENTORY: &str = "workload.fleet.inventory";
pub const READ_TOOLS: &[&str] = &["fleet_status"];
pub const MAX_DEVICES: usize = 1000;
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum FleetError {
#[error("MCP tool {0:?} is not a read tool; the inventory adapter is read-only")]
NonReadTool(String),
#[error("fleet transport: {0}")]
Transport(String),
#[error("fleet_status output not understood: {0}")]
Schema(String),
#[error("the Cognitum cloud refused the credential (HTTP {0})")]
Unauthorized(u16),
}
#[async_trait]
pub trait FleetMcp: Send + Sync {
async fn call_tool(&self, name: &str, args: Value) -> Result<Value, FleetError>;
}
pub struct ReadOnlyFleet {
inner: Arc<dyn FleetMcp>,
}
impl ReadOnlyFleet {
pub fn new(inner: Arc<dyn FleetMcp>) -> Self {
Self { inner }
}
pub fn http(
url: &str,
tokens: Box<dyn super::fleet_mcp::OAuthTokens>,
) -> Result<Self, FleetError> {
Ok(Self::new(Arc::new(super::fleet_mcp::HttpFleetMcp::new(
url, tokens,
)?)))
}
pub async fn call(&self, name: &str, args: Value) -> Result<Value, FleetError> {
if !READ_TOOLS.contains(&name) {
return Err(FleetError::NonReadTool(name.to_string()));
}
self.inner.call_tool(name, args).await
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FleetCandidate {
pub device_id: String,
pub firmware: Option<String>,
pub online: Option<bool>,
pub device_pubkey: Option<String>,
}
fn text(v: &Value, keys: &[&str]) -> Option<String> {
keys.iter()
.find_map(|k| v.get(*k).and_then(Value::as_str))
.filter(|s| !s.is_empty() && s.len() <= 256)
.map(str::to_string)
}
fn online_of(v: &Value) -> Option<bool> {
if let Some(b) = v.get("online").and_then(Value::as_bool) {
return Some(b);
}
let s = text(v, &["status", "state"])?.to_ascii_lowercase();
match s.as_str() {
"online" | "active" | "connected" | "up" => Some(true),
"offline" | "inactive" | "disconnected" | "down" => Some(false),
_ => None,
}
}
pub fn parse_fleet_status(out: &Value) -> Result<(Vec<FleetCandidate>, usize), FleetError> {
let list = match out {
Value::Array(a) => a,
Value::Object(_) => ["devices", "seeds", "fleet", "items"]
.iter()
.find_map(|k| out.get(*k).and_then(Value::as_array))
.ok_or_else(|| FleetError::Schema("no devices array".into()))?,
_ => return Err(FleetError::Schema("not an object or array".into())),
};
if list.len() > MAX_DEVICES {
return Err(FleetError::Schema(format!(
"more than {MAX_DEVICES} devices"
)));
}
let mut out = Vec::new();
let mut skipped = 0;
for d in list {
let Some(device_id) = text(d, &["device_id", "deviceId", "id"]) else {
skipped += 1;
continue;
};
out.push(FleetCandidate {
device_id,
firmware: text(
d,
&["firmware", "firmware_version", "firmwareVersion", "version"],
),
online: online_of(d),
device_pubkey: text(d, &["public_key", "publicKey", "pubkey"]),
});
}
Ok((out, skipped))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FleetCheck {
Verified,
NotListed,
KeyNotReported,
}
pub struct FleetInventory {
fleet: ReadOnlyFleet,
chain: Arc<ChainManager>,
label: String,
}
impl FleetInventory {
pub fn new(fleet: ReadOnlyFleet, chain: Arc<ChainManager>, label: &str) -> Self {
Self {
fleet,
chain,
label: label.chars().take(64).collect(),
}
}
pub async fn candidates(
&self,
region: Option<&str>,
) -> Result<Vec<FleetCandidate>, FleetError> {
let args = match region {
Some(r) => json!({ "region": r }),
None => json!({}),
};
let out = self.fleet.call("fleet_status", args).await?;
let (list, skipped) = parse_fleet_status(&out)?;
self.chain.append(
INVENTORY_CHAIN_SOURCE,
EVENT_KIND_FLEET_INVENTORY,
Some(json!({
"tool": "fleet_status", "credential": self.label, "region": region,
"candidates": list.iter().map(|c| json!({
"device_id": c.device_id, "firmware": c.firmware, "online": c.online,
})).collect::<Vec<_>>(),
"skipped_without_device_id": skipped,
})),
);
Ok(list)
}
pub async fn cross_check(
&self,
candidates: &[FleetCandidate],
rt: &SeedApiRuntime,
) -> Result<FleetCheck, FleetError> {
let (device_id, key) = rt
.identity()
.await
.map_err(|e| FleetError::Transport(e.to_string()))?;
let Some(c) = candidates.iter().find(|c| c.device_id == device_id) else {
return Ok(FleetCheck::NotListed);
};
match &c.device_pubkey {
None => Ok(FleetCheck::KeyNotReported),
Some(k) if *k == key => Ok(FleetCheck::Verified),
Some(_) => {
let e = FleetError::Schema(format!(
"the cloud's public key for {device_id} differs from the key the Seed reports locally"
));
self.chain.append(
INVENTORY_CHAIN_SOURCE,
EVENT_KIND_WORKLOAD_REFUSE,
Some(json!({
"phase": "fleet.inventory", "code": "key_mismatch",
"device_id": device_id, "node_id": rt.node_id(),
"credential": self.label,
})),
);
Err(e)
}
}
}
}