feat(deploy): add colibri-deploy crate + MCP tools
New crate: colibri-deploy - run(target, command) → shell on host or Bastille jail - list_targets() → host + active Bastille jails (graceful sudo fallback) New MCP tools (2): - colibri_deploy_run: run a command on host or jail - colibri_deploy_targets: list available targets Tests: host echo, host fail, list_targets includes host. Tool count: 18 → 20
This commit is contained in:
parent
9e8e25d535
commit
cb124a014f
7 changed files with 147 additions and 2 deletions
8
Cargo.lock
generated
8
Cargo.lock
generated
|
|
@ -355,6 +355,13 @@ dependencies = [
|
|||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colibri-deploy"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colibri-glasspane"
|
||||
version = "0.12.0"
|
||||
|
|
@ -386,6 +393,7 @@ dependencies = [
|
|||
"clap",
|
||||
"colibri-client",
|
||||
"colibri-daemon",
|
||||
"colibri-deploy",
|
||||
"colibri-pf",
|
||||
"colibri-zfs",
|
||||
"serde",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
[workspace]
|
||||
members = ["crates/colibri-contracts", "crates/colibri-deepseek", "crates/colibri-runtime", "crates/colibri-glasspane", "crates/colibri-daemon", "crates/colibri-client", "crates/colibri-glasspane-tui", "crates/colibri-store", "crates/colibri-skills", "crates/colibri-mcp", "crates/colibri-vault", "crates/colibri-zfs", "crates/colibri-pf", "crates/clawdie"]
|
||||
members = ["crates/colibri-contracts", "crates/colibri-deepseek", "crates/colibri-runtime", "crates/colibri-glasspane", "crates/colibri-daemon", "crates/colibri-client", "crates/colibri-glasspane-tui", "crates/colibri-store", "crates/colibri-skills", "crates/colibri-mcp", "crates/colibri-vault", "crates/colibri-zfs", "crates/colibri-pf", "crates/colibri-deploy", "crates/clawdie"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.12.0"
|
||||
|
|
|
|||
8
crates/colibri-deploy/Cargo.toml
Normal file
8
crates/colibri-deploy/Cargo.toml
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
[package]
|
||||
name = "colibri-deploy"
|
||||
version.workspace = true
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
|
||||
[dependencies]
|
||||
thiserror = "2"
|
||||
92
crates/colibri-deploy/src/lib.rs
Normal file
92
crates/colibri-deploy/src/lib.rs
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
use std::process::Command;
|
||||
|
||||
/// Run a command on a target. Supports "host" (local shell) and Bastille jail names.
|
||||
pub fn run(target: &str, command: &str) -> Result<String, Error> {
|
||||
match target {
|
||||
"host" => run_host(command),
|
||||
jail => run_jail(jail, command),
|
||||
}
|
||||
}
|
||||
|
||||
/// List available deployment targets: "host" always present,
|
||||
/// plus any Bastille jails reachable via passwordless sudo.
|
||||
pub fn list_targets() -> Vec<String> {
|
||||
let mut targets = vec!["host".to_string()];
|
||||
if let Ok(output) = Command::new("sudo").args(["bastille", "list"]).output() {
|
||||
if output.status.success() {
|
||||
for line in String::from_utf8_lossy(&output.stdout).lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with("JID") || line.starts_with('-') {
|
||||
continue;
|
||||
}
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
if parts.len() >= 3 {
|
||||
targets.push(parts[0].to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
targets
|
||||
}
|
||||
|
||||
fn run_host(command: &str) -> Result<String, Error> {
|
||||
let output = Command::new("sh")
|
||||
.args(["-c", command])
|
||||
.output()
|
||||
.map_err(|e| Error::Command(format!("host command: {e}")))?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(Error::Command(format!(
|
||||
"exit {}: {}",
|
||||
output.status.code().unwrap_or(-1),
|
||||
if stderr.is_empty() { stdout } else { stderr }
|
||||
)));
|
||||
}
|
||||
Ok(if stdout.is_empty() { stderr } else { stdout })
|
||||
}
|
||||
|
||||
fn run_jail(jail: &str, command: &str) -> Result<String, Error> {
|
||||
let output = Command::new("sudo")
|
||||
.args(["bastille", "cmd", jail, command])
|
||||
.output()
|
||||
.map_err(|e| Error::Command(format!("bastille cmd {jail}: {e}")))?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
|
||||
let msg = if stderr.is_empty() { stdout } else { stderr };
|
||||
return Err(Error::Command(format!("jail {jail}: {msg}")));
|
||||
}
|
||||
Ok(stdout)
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("deploy error: {0}")]
|
||||
Command(String),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn run_host_echo() {
|
||||
let result = run("host", "echo hello").unwrap();
|
||||
assert_eq!(result.trim(), "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_host_fail() {
|
||||
assert!(run("host", "exit 1").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_targets_includes_host() {
|
||||
let targets = list_targets();
|
||||
assert!(targets.iter().any(|t| t == "host"));
|
||||
}
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@ colibri-client = { path = "../colibri-client" }
|
|||
colibri-daemon = { path = "../colibri-daemon" }
|
||||
colibri-pf = { path = "../colibri-pf" }
|
||||
colibri-zfs = { path = "../colibri-zfs" }
|
||||
colibri-deploy = { path = "../colibri-deploy" }
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
|
|
|||
|
|
@ -207,6 +207,25 @@ pub fn tool_list() -> Vec<Value> {
|
|||
"List active PF state table entries: protocol, source, destination, state.",
|
||||
None,
|
||||
),
|
||||
// ── Deploy tools ──
|
||||
json_tool(
|
||||
"colibrie_deploy_run",
|
||||
"Run a shell command on the host or in a Bastille jail. Use deploy_targets to list available targets.",
|
||||
Some(serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"target": { "type": "string", "description": "Target: host or jail name from deploy_targets" },
|
||||
"command": { "type": "string", "description": "Shell command to run" }
|
||||
},
|
||||
"required": ["target", "command"]
|
||||
})),
|
||||
),
|
||||
json_tool(
|
||||
"colibrie_deploy_targets",
|
||||
"List deploy targets: host and available Bastille jails.",
|
||||
None,
|
||||
),
|
||||
|
||||
// ── Wiki tools ──
|
||||
json_tool(
|
||||
"colibri_wiki_search",
|
||||
|
|
@ -433,6 +452,23 @@ pub async fn dispatch_tool(
|
|||
serde_json::json!({"page": page, "content": content}),
|
||||
))
|
||||
}
|
||||
// ── Deploy dispatch ──
|
||||
"colibri_deploy_run" => {
|
||||
let target = require_string(arguments, "target")?;
|
||||
let command = require_string(arguments, "command")?;
|
||||
let output = colibri_deploy::run(&target, &command)
|
||||
.map_err(|e| McpError::internal(format!("deploy: {e}")))?;
|
||||
Ok(tool_text(serde_json::json!({
|
||||
"target": target,
|
||||
"output": output,
|
||||
})))
|
||||
}
|
||||
"colibri_deploy_targets" => {
|
||||
let targets = colibri_deploy::list_targets();
|
||||
Ok(tool_text(
|
||||
serde_json::to_value(&targets).unwrap_or_default(),
|
||||
))
|
||||
}
|
||||
"colibri_external_mcp_servers" => {
|
||||
let registry = external::load_registry_if_present(&config.external_config_path).await?;
|
||||
Ok(tool_text(serde_json::json!({
|
||||
|
|
|
|||
|
|
@ -255,5 +255,5 @@ fn tool_list_has_all_phase1_tools() {
|
|||
assert!(names.contains(&"colibri_list_task_costs"));
|
||||
|
||||
assert!(names.contains(&"colibri_get_task"));
|
||||
assert_eq!(names.len(), 18);
|
||||
assert_eq!(names.len(), 20);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue