huskies: merge 1206 story fleet_identity MCP tool: read sled pins vs live signed identity, and re-pin via TOFU
This commit is contained in:
@@ -29,6 +29,8 @@ const GATEWAY_TOOLS: &[&str] = &[
|
||||
"prompt_permission",
|
||||
// One-shot container rebuild: build fresh image, swap container, preserve state.
|
||||
"project_rebuild",
|
||||
// Read sled identity pins vs. live signed identity, and TOFU re-pin.
|
||||
"fleet_identity",
|
||||
];
|
||||
|
||||
/// Gateway tool definitions.
|
||||
@@ -154,6 +156,24 @@ pub(crate) fn gateway_tool_definitions() -> Vec<Value> {
|
||||
"required": ["name"]
|
||||
}
|
||||
}),
|
||||
json!({
|
||||
"name": "fleet_identity",
|
||||
"description": "Read mode (default): for every registered sled, report project, url, connected, the recorded pin (expected_node_id), the live signature-verified node_id from a signed challenge-response (never the unsigned /identity display field), and whether they match. Repin mode: capture a sled's live verified identity via TOFU and persist it as the new pin, refusing when the signature is missing or does not verify.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["read", "repin"],
|
||||
"description": "\"read\" (default) reports every sled's pin vs. live identity. \"repin\" re-pins one sled via TOFU; requires `project`."
|
||||
},
|
||||
"project": {
|
||||
"type": "string",
|
||||
"description": "Required when action is \"repin\": the project/sled name to re-pin."
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -420,6 +440,7 @@ async fn handle_gateway_tool(
|
||||
"agents.list" => handle_agents_list_tool(id),
|
||||
"prompt_permission" => handle_prompt_permission_tool(params, state, id).await,
|
||||
"project_rebuild" => handle_project_rebuild_tool(params, state, id).await,
|
||||
"fleet_identity" => handle_fleet_identity_tool(params, state, id).await,
|
||||
_ => JsonRpcResponse::error(id, -32601, format!("Unknown gateway tool: {tool_name}")),
|
||||
}
|
||||
}
|
||||
@@ -922,6 +943,67 @@ async fn handle_project_rebuild_tool(
|
||||
)
|
||||
}
|
||||
|
||||
/// Handle the `fleet_identity` gateway tool.
|
||||
///
|
||||
/// Dispatches on the `action` argument: `"read"` (default) reports every
|
||||
/// sled's pin vs. live signed identity; `"repin"` captures and persists a
|
||||
/// single sled's live verified identity via TOFU.
|
||||
async fn handle_fleet_identity_tool(
|
||||
params: &Value,
|
||||
state: &GatewayState,
|
||||
id: Option<Value>,
|
||||
) -> JsonRpcResponse {
|
||||
let args = params.get("arguments").unwrap_or(params);
|
||||
let action = args
|
||||
.get("action")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("read");
|
||||
|
||||
match action {
|
||||
"read" => {
|
||||
let reports = gateway::fleet_identity_read(state).await;
|
||||
let text = gateway::format_identity_reports(&reports);
|
||||
let reports_json = serde_json::to_value(&reports).unwrap_or(json!([]));
|
||||
JsonRpcResponse::success(
|
||||
id,
|
||||
json!({
|
||||
"content": [{ "type": "text", "text": text }],
|
||||
"reports": reports_json,
|
||||
}),
|
||||
)
|
||||
}
|
||||
"repin" => {
|
||||
let project = args.get("project").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if project.is_empty() {
|
||||
return JsonRpcResponse::error(
|
||||
id,
|
||||
-32602,
|
||||
"missing required parameter for action=\"repin\": project".into(),
|
||||
);
|
||||
}
|
||||
match gateway::fleet_identity_repin(state, project).await {
|
||||
Ok(node_id) => JsonRpcResponse::success(
|
||||
id,
|
||||
json!({
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": format!(
|
||||
"Re-pinned `{project}` to verified node_id `{node_id}`."
|
||||
)
|
||||
}]
|
||||
}),
|
||||
),
|
||||
Err(e) => JsonRpcResponse::error(id, -32602, e.to_string()),
|
||||
}
|
||||
}
|
||||
other => JsonRpcResponse::error(
|
||||
id,
|
||||
-32602,
|
||||
format!("unknown fleet_identity action \"{other}\"; expected \"read\" or \"repin\""),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle the `pipeline.get` read-RPC — returns per-project item lists in the
|
||||
/// shape expected by the gateway web UI:
|
||||
/// `{ "active": "...", "projects": { "name": { "active": [...], "backlog_count": N } } }`.
|
||||
@@ -1111,4 +1193,59 @@ mod tests {
|
||||
"MCP path should report 'not a directory', got: {mcp_msg}"
|
||||
);
|
||||
}
|
||||
|
||||
// ── fleet_identity tool (story 1206) ─────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn fleet_identity_read_default_action_lists_projects() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let state = make_test_state(dir.path());
|
||||
let params = json!({ "arguments": {} });
|
||||
let resp = handle_fleet_identity_tool(¶ms, &state, Some(json!(1))).await;
|
||||
assert!(resp.error.is_none(), "expected success: {:?}", resp.error);
|
||||
let text = resp.result.unwrap()["content"][0]["text"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
assert!(text.contains("test-project"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fleet_identity_repin_missing_project_returns_error() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let state = make_test_state(dir.path());
|
||||
let params = json!({ "arguments": { "action": "repin" } });
|
||||
let resp = handle_fleet_identity_tool(¶ms, &state, Some(json!(1))).await;
|
||||
assert!(resp.error.is_some(), "expected error for missing project");
|
||||
let msg = resp.error.unwrap().message;
|
||||
assert!(
|
||||
msg.contains("project"),
|
||||
"expected 'project' in error, got: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fleet_identity_repin_unknown_project_returns_error() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let state = make_test_state(dir.path());
|
||||
let params = json!({ "arguments": { "action": "repin", "project": "nonexistent" } });
|
||||
let resp = handle_fleet_identity_tool(¶ms, &state, Some(json!(1))).await;
|
||||
assert!(resp.error.is_some(), "expected error for unknown project");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fleet_identity_unknown_action_returns_error() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let state = make_test_state(dir.path());
|
||||
let params = json!({ "arguments": { "action": "bogus" } });
|
||||
let resp = handle_fleet_identity_tool(¶ms, &state, Some(json!(1))).await;
|
||||
assert!(resp.error.is_some(), "expected error for unknown action");
|
||||
let msg = resp.error.unwrap().message;
|
||||
assert!(msg.contains("unknown fleet_identity action"), "got: {msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fleet_identity_is_in_gateway_tools() {
|
||||
assert!(GATEWAY_TOOLS.contains(&"fleet_identity"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user