use async_trait::async_trait;
use std::path::PathBuf;
use std::time::Duration;
use tokio::process::Command;
use crate::error::{BrokerError, BrokerResult};
pub const DEFAULT_GRACEFUL_STOP_TIMEOUT: Duration = Duration::from_secs(30);
#[derive(Debug, Clone)]
pub struct AgentDeployment {
pub id: String,
pub role: Option<String>,
pub broker_endpoint: String,
pub token: String,
pub model: Option<String>,
pub workspace: Option<String>,
pub echo: bool,
pub mcp_proxy: Option<String>,
pub log_path: Option<String>,
pub spec_json: Option<String>,
pub tls_ca_cert: Option<String>,
}
#[async_trait]
pub trait Deployer: Send + Sync {
async fn deploy(&self, agent: &AgentDeployment) -> BrokerResult<DeployedAgent>;
async fn preflight(&self) -> BrokerResult<String> {
Ok("ok".to_string())
}
async fn tail_log(&self, _log_path: &str, _lines: usize) -> BrokerResult<String> {
Err(BrokerError::Transport(
"log tail is not supported for this deployer".to_string(),
))
}
}
#[async_trait]
pub trait RemoteDeployment: Send + Sync {
fn remote_pid(&self) -> Option<u32> {
None
}
async fn shutdown(&self) {
self.shutdown_with_timeout(DEFAULT_GRACEFUL_STOP_TIMEOUT)
.await;
}
async fn shutdown_with_timeout(&self, timeout: Duration);
}
pub struct DeployedAgent {
pub id: String,
inner: DeployedInner,
}
enum DeployedInner {
Process {
child: tokio::process::Child,
cleanup: Option<Vec<String>>,
},
Remote(Box<dyn RemoteDeployment>),
}
impl DeployedAgent {
pub fn from_parts(
id: impl Into<String>,
child: tokio::process::Child,
cleanup: Option<Vec<String>>,
) -> Self {
Self {
id: id.into(),
inner: DeployedInner::Process { child, cleanup },
}
}
pub fn from_remote(id: impl Into<String>, handle: Box<dyn RemoteDeployment>) -> Self {
Self {
id: id.into(),
inner: DeployedInner::Remote(handle),
}
}
pub fn pid(&self) -> Option<u32> {
match &self.inner {
DeployedInner::Process { child, .. } => child.id(),
DeployedInner::Remote(h) => h.remote_pid(),
}
}
pub async fn shutdown(self) {
self.shutdown_with_timeout(DEFAULT_GRACEFUL_STOP_TIMEOUT)
.await
}
pub async fn shutdown_with_timeout(self, timeout: Duration) {
match self.inner {
DeployedInner::Process { mut child, cleanup } => {
if let Some(pid) = child.id() {
send_sigterm(pid).await;
let deadline = tokio::time::Instant::now() + timeout;
loop {
match child.try_wait() {
Ok(Some(_)) => break,
Ok(None) => {
if tokio::time::Instant::now() >= deadline {
break;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
Err(_) => break,
}
}
}
let _ = child.start_kill();
let _ = child.wait().await;
if let Some(args) = cleanup {
if let Some((bin, rest)) = args.split_first() {
let _ = Command::new(bin).args(rest).status().await;
}
}
}
DeployedInner::Remote(h) => h.shutdown_with_timeout(timeout).await,
}
}
}
#[cfg(not(target_os = "windows"))]
async fn send_sigterm(pid: u32) {
let _ = Command::new("kill")
.args(["-TERM", &pid.to_string()])
.status()
.await;
}
#[cfg(target_os = "windows")]
async fn send_sigterm(_pid: u32) {}
pub(crate) fn agent_argv(d: &AgentDeployment) -> Vec<String> {
let mut a = vec![
"broker-agent".to_string(),
"serve".to_string(),
"--broker".to_string(),
d.broker_endpoint.clone(),
"--id".to_string(),
d.id.clone(),
];
if let Some(r) = &d.role {
a.push("--role".into());
a.push(r.clone());
}
if let Some(m) = &d.model {
a.push("--model".into());
a.push(m.clone());
}
if let Some(w) = &d.workspace {
a.push("--workspace".into());
a.push(w.clone());
}
if d.echo {
a.push("--echo".into());
}
if let Some(orchestrator) = &d.mcp_proxy {
a.push("--mcp-proxy".into());
a.push(orchestrator.clone());
}
if let Some(ca_cert) = &d.tls_ca_cert {
a.push("--tls-ca-cert".into());
a.push(ca_cert.clone());
}
a
}
fn spawn_err(e: std::io::Error) -> BrokerError {
BrokerError::Transport(format!("spawn: {e}"))
}
pub struct LocalProcessDeployer {
pub bamboo_bin: PathBuf,
}
impl LocalProcessDeployer {
pub fn new(bamboo_bin: impl Into<PathBuf>) -> Self {
Self {
bamboo_bin: bamboo_bin.into(),
}
}
}
#[async_trait]
impl Deployer for LocalProcessDeployer {
async fn deploy(&self, d: &AgentDeployment) -> BrokerResult<DeployedAgent> {
let mut cmd = Command::new(&self.bamboo_bin);
let mut argv = agent_argv(d);
if d.spec_json.is_some() {
argv.push("--spec-stdin".to_string());
cmd.stdin(std::process::Stdio::piped());
}
cmd.args(argv)
.env("BAMBOO_BROKER_TOKEN", &d.token)
.kill_on_drop(true);
if let Some(log_path) = &d.log_path {
if let Some(dir) = std::path::Path::new(log_path).parent() {
let _ = std::fs::create_dir_all(dir);
}
if let Ok(file) = std::fs::File::create(log_path) {
if let Ok(err_file) = file.try_clone() {
cmd.stdout(std::process::Stdio::from(file))
.stderr(std::process::Stdio::from(err_file));
}
}
}
let mut child = cmd.spawn().map_err(spawn_err)?;
if let Some(spec_json) = &d.spec_json {
use tokio::io::AsyncWriteExt;
if let Some(mut stdin) = child.stdin.take() {
stdin
.write_all(spec_json.as_bytes())
.await
.map_err(|e| BrokerError::Transport(format!("write spec to stdin: {e}")))?;
stdin
.shutdown()
.await
.map_err(|e| BrokerError::Transport(format!("close worker stdin: {e}")))?;
}
}
Ok(DeployedAgent::from_parts(d.id.clone(), child, None))
}
async fn tail_log(&self, log_path: &str, lines: usize) -> BrokerResult<String> {
tail_local_file(log_path, lines).await
}
}
async fn tail_local_file(path: &str, lines: usize) -> BrokerResult<String> {
let content = tokio::fs::read_to_string(path)
.await
.map_err(|e| BrokerError::Transport(format!("read log '{path}': {e}")))?;
let tail: Vec<&str> = content.lines().rev().take(lines).collect();
Ok(tail.into_iter().rev().collect::<Vec<_>>().join("\n"))
}
pub struct DockerDeployer {
pub image: String,
pub docker_bin: String,
pub bamboo_in_image: String,
pub network: Option<String>,
pub mount_home: Option<PathBuf>,
}
impl DockerDeployer {
pub fn new(image: impl Into<String>) -> Self {
Self {
image: image.into(),
docker_bin: "docker".into(),
bamboo_in_image: "bamboo".into(),
network: None,
mount_home: None,
}
}
pub fn network(mut self, net: impl Into<String>) -> Self {
self.network = Some(net.into());
self
}
pub fn mount_home(mut self, host_bamboo_dir: impl Into<PathBuf>) -> Self {
self.mount_home = Some(host_bamboo_dir.into());
self
}
fn argv(&self, d: &AgentDeployment, container: &str) -> Vec<String> {
let mut a = vec![
"run".to_string(),
"--rm".to_string(),
"--name".to_string(),
container.to_string(),
"-e".to_string(),
format!("BAMBOO_BROKER_TOKEN={}", d.token),
"--add-host".to_string(),
"host.docker.internal:host-gateway".to_string(),
];
if let Some(net) = &self.network {
a.push("--network".into());
a.push(net.clone());
}
if d.spec_json.is_some() {
a.push("-i".into());
a.push("--entrypoint".into());
a.push(self.bamboo_in_image.clone());
a.push(self.image.clone());
let mut argv = agent_argv(d);
argv.push("--spec-stdin".to_string());
a.extend(argv);
} else if let Some(home) = &self.mount_home {
a.push("-v".into());
a.push(format!("{}:/seed:ro", home.display()));
a.push("--entrypoint".into());
a.push("/bin/sh".into());
a.push(self.image.clone());
let mut script = String::from(
"BAMBOO_DATA_DIR=\"${BAMBOO_DATA_DIR:-/data}\"; export BAMBOO_DATA_DIR; \
mkdir -p \"$BAMBOO_DATA_DIR\"; \
for f in config.json .bamboo_encryption_key; do \
[ -e \"/seed/$f\" ] && cp -f \"/seed/$f\" \"$BAMBOO_DATA_DIR/\"; \
done; \
[ -d /seed/skills ] && cp -rf /seed/skills \"$BAMBOO_DATA_DIR/\"; \
exec ",
);
script.push_str(&sh_quote(&self.bamboo_in_image));
for arg in agent_argv(d) {
script.push(' ');
script.push_str(&sh_quote(&arg));
}
a.push("-c".into());
a.push(script);
} else {
a.push("--entrypoint".to_string());
a.push(self.bamboo_in_image.clone());
a.push(self.image.clone());
a.extend(agent_argv(d));
}
a
}
}
#[async_trait]
impl Deployer for DockerDeployer {
async fn deploy(&self, d: &AgentDeployment) -> BrokerResult<DeployedAgent> {
let container = format!("bamboo-agent-{}", d.id);
let mut cmd = Command::new(&self.docker_bin);
cmd.args(self.argv(d, &container)).kill_on_drop(true);
if d.spec_json.is_some() {
cmd.stdin(std::process::Stdio::piped());
}
let mut child = cmd.spawn().map_err(spawn_err)?;
if let Some(spec_json) = &d.spec_json {
use tokio::io::AsyncWriteExt;
if let Some(mut stdin) = child.stdin.take() {
stdin
.write_all(spec_json.as_bytes())
.await
.map_err(|e| BrokerError::Transport(format!("write spec to stdin: {e}")))?;
stdin
.shutdown()
.await
.map_err(|e| BrokerError::Transport(format!("close worker stdin: {e}")))?;
}
}
Ok(DeployedAgent::from_parts(
d.id.clone(),
child,
Some(vec![
self.docker_bin.clone(),
"rm".into(),
"-f".into(),
container,
]),
))
}
}
#[derive(Debug, Clone)]
pub struct UploadSpec {
pub local_path: String,
pub remote_path: String,
}
pub struct SshDeployer {
pub host: String,
pub ssh_bin: String,
pub scp_bin: String,
pub bamboo_on_remote: String,
pub port: Option<u16>,
pub identity_file: Option<String>,
pub upload: Option<UploadSpec>,
}
impl SshDeployer {
pub fn new(host: impl Into<String>) -> Self {
Self {
host: host.into(),
ssh_bin: "ssh".into(),
scp_bin: "scp".into(),
bamboo_on_remote: "bamboo".into(),
port: None,
identity_file: None,
upload: None,
}
}
pub fn with_port(mut self, port: Option<u16>) -> Self {
self.port = port.filter(|p| *p != 22);
self
}
pub fn with_identity(mut self, identity: Option<String>) -> Self {
self.identity_file = identity.filter(|s| !s.trim().is_empty());
self
}
pub fn with_upload(mut self, upload: Option<UploadSpec>) -> Self {
if let Some(u) = &upload {
self.bamboo_on_remote = u.remote_path.clone();
}
self.upload = upload;
self
}
fn ssh_conn_flags(&self) -> Vec<String> {
let mut a = vec![
"-o".to_string(),
"StrictHostKeyChecking=accept-new".to_string(),
];
if let Some(p) = self.port {
a.push("-p".into());
a.push(p.to_string());
}
if let Some(id) = &self.identity_file {
a.push("-i".into());
a.push(id.clone());
}
a
}
async fn ssh_capture(&self, remote_cmd: &str) -> BrokerResult<String> {
let mut args = self.ssh_conn_flags();
args.push(self.host.clone());
args.push(remote_cmd.to_string());
let out = Command::new(&self.ssh_bin)
.args(args)
.output()
.await
.map_err(spawn_err)?;
Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
}
async fn upload_if_needed(&self) -> BrokerResult<()> {
let Some(spec) = &self.upload else {
return Ok(());
};
let local_hash = file_sha256(&spec.local_path).await;
let remote_hash = self
.ssh_capture(&format!(
"sha256sum {p} 2>/dev/null || shasum -a 256 {p} 2>/dev/null || true",
p = sh_quote(&spec.remote_path)
))
.await
.unwrap_or_default();
let remote_hash = remote_hash.split_whitespace().next().unwrap_or("");
if let Some(local) = &local_hash {
if remote_hash == local && !remote_hash.is_empty() {
return Ok(()); }
}
if let Some(dir) = spec.remote_path.rsplit_once('/').map(|(d, _)| d) {
if !dir.is_empty() {
let _ = self
.ssh_capture(&format!("mkdir -p {}", sh_quote(dir)))
.await;
}
}
let tmp = format!("{}.upload", spec.remote_path);
let mut scp_args = vec![
"-o".to_string(),
"StrictHostKeyChecking=accept-new".to_string(),
];
if let Some(p) = self.port {
scp_args.push("-P".into());
scp_args.push(p.to_string());
}
if let Some(id) = &self.identity_file {
scp_args.push("-i".into());
scp_args.push(id.clone());
}
scp_args.push(spec.local_path.clone());
scp_args.push(format!("{}:{}", self.host, tmp));
let status = Command::new(&self.scp_bin)
.args(scp_args)
.status()
.await
.map_err(spawn_err)?;
if !status.success() {
return Err(BrokerError::Transport(format!(
"scp upload to {} failed (status {status})",
self.host
)));
}
self.ssh_capture(&format!(
"chmod +x {tmp} && mv -f {tmp} {dst}",
tmp = sh_quote(&tmp),
dst = sh_quote(&spec.remote_path)
))
.await?;
Ok(())
}
fn argv(&self, d: &AgentDeployment, spec_file: Option<&str>) -> Vec<String> {
let host_only = self.host.rsplit('@').next().unwrap_or(self.host.as_str());
let same_host = matches!(host_only, "localhost" | "127.0.0.1" | "::1");
let port = if same_host {
None
} else {
broker_port(&d.broker_endpoint)
};
let mut a = vec![
"-tt".to_string(),
"-o".to_string(),
"StrictHostKeyChecking=accept-new".to_string(),
];
if let Some(p) = self.port {
a.push("-p".to_string());
a.push(p.to_string());
}
if let Some(id) = &self.identity_file {
a.push("-i".to_string());
a.push(id.clone());
}
if let Some(p) = port {
a.push("-R".to_string());
a.push(format!("{p}:127.0.0.1:{p}"));
}
a.push(self.host.clone());
let mut tunneled = d.clone();
if let Some(p) = port {
let scheme = broker_scheme(&d.broker_endpoint);
tunneled.broker_endpoint = format!("{scheme}://127.0.0.1:{p}");
}
let mut remote = format!("BAMBOO_BROKER_TOKEN={}", sh_quote(&d.token));
remote.push(' ');
remote.push_str(&sh_quote(&self.bamboo_on_remote));
for arg in agent_argv(&tunneled) {
remote.push(' ');
remote.push_str(&sh_quote(&arg));
}
if let Some(path) = spec_file {
remote.push_str(" --spec-file ");
remote.push_str(&sh_quote(path));
}
if let Some(log_path) = &d.log_path {
remote.push_str(&format!(" > {} 2>&1", sh_quote(log_path)));
}
a.push(remote);
a
}
async fn upload_spec_file(&self, spec_json: &str, id: &str) -> BrokerResult<String> {
let local = std::env::temp_dir().join(format!("bamboo-spec-{id}.json"));
tokio::fs::write(&local, spec_json)
.await
.map_err(|e| BrokerError::Transport(format!("write local spec temp: {e}")))?;
let remote_path = format!("/tmp/bamboo-spec-{id}.json");
let tmp = format!("{remote_path}.upload");
let mut scp_args = vec![
"-o".to_string(),
"StrictHostKeyChecking=accept-new".to_string(),
];
if let Some(p) = self.port {
scp_args.push("-P".into());
scp_args.push(p.to_string());
}
if let Some(idf) = &self.identity_file {
scp_args.push("-i".into());
scp_args.push(idf.clone());
}
scp_args.push(local.to_string_lossy().into_owned());
scp_args.push(format!("{}:{}", self.host, tmp));
let status = Command::new(&self.scp_bin)
.args(scp_args)
.status()
.await
.map_err(spawn_err)?;
let _ = tokio::fs::remove_file(&local).await;
if !status.success() {
return Err(BrokerError::Transport(format!(
"scp spec upload to {} failed (status {status})",
self.host
)));
}
self.ssh_capture(&format!(
"mv -f {tmp} {dst}",
tmp = sh_quote(&tmp),
dst = sh_quote(&remote_path)
))
.await?;
Ok(remote_path)
}
}
#[async_trait]
impl Deployer for SshDeployer {
async fn deploy(&self, d: &AgentDeployment) -> BrokerResult<DeployedAgent> {
self.upload_if_needed().await?;
let remote_spec_path = match &d.spec_json {
Some(spec_json) => Some(self.upload_spec_file(spec_json, &d.id).await?),
None => None,
};
let mut cmd = Command::new(&self.ssh_bin);
cmd.args(self.argv(d, remote_spec_path.as_deref()))
.kill_on_drop(true);
let child = cmd.spawn().map_err(spawn_err)?;
Ok(DeployedAgent::from_parts(d.id.clone(), child, None))
}
async fn preflight(&self) -> BrokerResult<String> {
let out = self.ssh_capture("uname -s -m").await?;
if out.trim().is_empty() {
return Err(BrokerError::Transport(format!(
"ssh preflight to {} produced no output (unreachable or auth failed)",
self.host
)));
}
Ok(out)
}
async fn tail_log(&self, log_path: &str, lines: usize) -> BrokerResult<String> {
self.ssh_capture(&format!(
"tail -n {lines} {} 2>/dev/null || true",
sh_quote(log_path)
))
.await
}
}
async fn file_sha256(path: &str) -> Option<String> {
let out = Command::new("sh")
.arg("-c")
.arg(format!(
"sha256sum {p} 2>/dev/null || shasum -a 256 {p} 2>/dev/null",
p = sh_quote(path)
))
.output()
.await
.ok()?;
String::from_utf8_lossy(&out.stdout)
.split_whitespace()
.next()
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
}
pub(crate) fn sh_quote(s: &str) -> String {
format!("'{}'", s.replace('\'', "'\\''"))
}
pub(crate) fn broker_port(endpoint: &str) -> Option<u16> {
let after_host = endpoint.rsplit_once(':')?.1;
after_host.split(['/', '?']).next()?.parse().ok()
}
pub(crate) fn broker_scheme(endpoint: &str) -> &'static str {
if endpoint.starts_with("wss://") {
"wss"
} else {
"ws"
}
}
#[cfg(test)]
mod tests {
use super::*;
fn dep() -> AgentDeployment {
AgentDeployment {
id: "w1".into(),
role: Some("researcher".into()),
broker_endpoint: "ws://broker:9600".into(),
token: "tok".into(),
model: None,
workspace: None,
echo: true,
mcp_proxy: None,
log_path: None,
spec_json: None,
tls_ca_cert: None,
}
}
#[test]
fn agent_argv_includes_flags_but_not_token() {
let a = agent_argv(&dep());
assert_eq!(&a[0..2], &["broker-agent", "serve"]);
assert!(a.contains(&"--broker".to_string()));
assert!(a.contains(&"ws://broker:9600".to_string()));
assert!(a.contains(&"--id".to_string()) && a.contains(&"w1".to_string()));
assert!(a.contains(&"--role".to_string()) && a.contains(&"researcher".to_string()));
assert!(a.contains(&"--echo".to_string()));
assert!(!a.iter().any(|x| x.contains("tok")));
}
#[test]
fn docker_argv_wraps_with_run_rm_name_env_and_network() {
let d = DockerDeployer::new("bamboo:latest").network("host");
let a = d.argv(&dep(), "bamboo-agent-w1");
assert_eq!(&a[0..4], &["run", "--rm", "--name", "bamboo-agent-w1"]);
assert!(a.contains(&"-e".to_string()));
assert!(a.contains(&"BAMBOO_BROKER_TOKEN=tok".to_string()));
assert!(a.contains(&"--network".to_string()) && a.contains(&"host".to_string()));
assert!(a.contains(&"bamboo:latest".to_string()));
assert!(a
.windows(2)
.any(|w| w == ["--entrypoint".to_string(), "bamboo".to_string()]));
let img = a.iter().position(|x| x == "bamboo:latest").unwrap();
assert_eq!(a[img + 1], "broker-agent");
}
#[test]
fn ssh_argv_reverse_tunnels_broker_and_quotes_remote() {
let s = SshDeployer::new("gpu-host");
let a = s.argv(&dep(), None); assert_eq!(a[0], "-tt");
assert!(a.windows(2).any(|w| w
== [
"-o".to_string(),
"StrictHostKeyChecking=accept-new".to_string()
]));
assert!(a
.windows(2)
.any(|w| w == ["-R".to_string(), "9600:127.0.0.1:9600".to_string()]));
assert!(a.contains(&"gpu-host".to_string()));
let remote = a.last().unwrap();
assert!(remote.starts_with("BAMBOO_BROKER_TOKEN='tok'"));
assert!(remote.contains("broker-agent"));
assert!(remote.contains("ws://127.0.0.1:9600"));
assert!(!remote.contains("ws://broker:9600"));
}
#[test]
fn ssh_argv_skips_reverse_tunnel_for_same_host() {
let s = SshDeployer::new("localhost");
let a = s.argv(&dep(), None);
assert_eq!(a[0], "-tt");
assert!(a.windows(2).any(|w| w
== [
"-o".to_string(),
"StrictHostKeyChecking=accept-new".to_string()
]));
assert!(a.contains(&"localhost".to_string()));
assert!(!a.iter().any(|x| x == "-R"));
let remote = a.last().unwrap();
assert!(remote.contains("ws://broker:9600"));
}
#[test]
fn sh_quote_escapes_single_quotes() {
assert_eq!(sh_quote("a'b"), "'a'\\''b'");
}
#[test]
fn ssh_argv_includes_port_and_identity_when_set() {
let s = SshDeployer::new("user@gpu-host")
.with_port(Some(2222))
.with_identity(Some("/keys/id_ed25519".into()));
let a = s.argv(&dep(), None);
assert!(a
.windows(2)
.any(|w| w == ["-p".to_string(), "2222".to_string()]));
assert!(a
.windows(2)
.any(|w| w == ["-i".to_string(), "/keys/id_ed25519".to_string()]));
}
#[test]
fn with_port_omits_default_22() {
let s = SshDeployer::new("h").with_port(Some(22));
assert_eq!(s.port, None, "port 22 is the ssh default; not passed");
let a = s.argv(&dep(), None);
assert!(!a.iter().any(|x| x == "-p"));
}
#[test]
fn with_upload_points_remote_binary_at_uploaded_path() {
let s = SshDeployer::new("user@box").with_upload(Some(UploadSpec {
local_path: "/local/bamboo".into(),
remote_path: ".bamboo-deploy/bamboo".into(),
}));
assert_eq!(s.bamboo_on_remote, ".bamboo-deploy/bamboo");
let remote = s.argv(&dep(), None).last().unwrap().clone();
assert!(remote.contains("'.bamboo-deploy/bamboo'"));
}
#[test]
fn with_identity_ignores_blank() {
let s = SshDeployer::new("h").with_identity(Some(" ".into()));
assert_eq!(s.identity_file, None);
}
#[test]
fn ssh_argv_appends_log_redirect_when_set() {
let s = SshDeployer::new("user@box");
let mut d = dep();
d.log_path = Some(".bamboo-deploy/node-x.log".into());
let remote = s.argv(&d, Some("/tmp/spec.json")).last().unwrap().clone();
assert!(
remote.contains("--spec-file '/tmp/spec.json'"),
"spec-file must be on the remote command: {remote}"
);
let remote = s.argv(&d, None).last().unwrap().clone();
assert!(
remote
.trim_end()
.ends_with("> '.bamboo-deploy/node-x.log' 2>&1"),
"got: {remote}"
);
}
#[cfg(unix)]
#[tokio::test]
async fn shutdown_sends_sigterm_before_hard_kill() {
let marker = std::env::temp_dir().join(format!(
"bamboo_deploy_sigterm_{}_{:?}.marker",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let ready = marker.with_extension("ready");
let _ = std::fs::remove_file(&marker);
let _ = std::fs::remove_file(&ready);
let child = Command::new("sh")
.arg("-c")
.arg(format!(
"trap 'touch {m}; exit 0' TERM; touch {r}; while :; do sleep 0.05; done",
m = marker.display(),
r = ready.display()
))
.kill_on_drop(true)
.spawn()
.expect("spawn TERM-trapping child");
let agent = DeployedAgent::from_parts("graceful", child, None);
let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
while !ready.exists() {
assert!(
tokio::time::Instant::now() < deadline,
"child never signalled readiness"
);
tokio::time::sleep(Duration::from_millis(10)).await;
}
agent.shutdown_with_timeout(Duration::from_secs(5)).await;
assert!(
marker.exists(),
"child must have received SIGTERM and exited gracefully (marker written by its TERM trap)"
);
let _ = std::fs::remove_file(&marker);
let _ = std::fs::remove_file(&ready);
}
#[cfg(unix)]
#[tokio::test]
async fn shutdown_hard_kills_after_grace_window_when_sigterm_ignored() {
let child = Command::new("sh")
.arg("-c")
.arg("trap '' TERM; while :; do sleep 0.05; done")
.kill_on_drop(true)
.spawn()
.expect("spawn TERM-ignoring child");
let pid = child.id().expect("child has a pid");
let agent = DeployedAgent::from_parts("wedged", child, None);
tokio::time::timeout(
Duration::from_secs(10),
agent.shutdown_with_timeout(Duration::from_millis(300)),
)
.await
.expect("shutdown must be bounded even when SIGTERM is ignored");
let alive = std::process::Command::new("kill")
.args(["-0", &pid.to_string()])
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false);
assert!(!alive, "TERM-ignoring worker must be hard-killed");
}
#[tokio::test]
async fn tail_local_file_returns_last_lines() {
let path = std::env::temp_dir().join("bamboo-tail-test.log");
tokio::fs::write(&path, "l1\nl2\nl3\nl4\nl5\n")
.await
.unwrap();
let out = tail_local_file(path.to_str().unwrap(), 2).await.unwrap();
assert_eq!(out, "l4\nl5");
let _ = tokio::fs::remove_file(&path).await;
}
#[test]
fn docker_argv_mounts_home_when_set() {
let d = DockerDeployer::new("img").mount_home("/home/u/.bamboo");
let a = d.argv(&dep(), "c");
assert!(a.contains(&"-v".to_string()));
assert!(a.iter().any(|x| x == "/home/u/.bamboo:/seed:ro"));
assert!(a
.windows(2)
.any(|w| w == ["--entrypoint".to_string(), "/bin/sh".to_string()]));
let script = a.last().unwrap();
assert!(script.contains("/seed/config.json") || script.contains("config.json"));
assert!(script.contains("cp -rf /seed/skills"));
assert!(script.contains("exec 'bamboo' 'broker-agent' 'serve'"));
assert!(!a
.iter()
.any(|x| x.contains("tok") && !x.starts_with("BAMBOO_BROKER_TOKEN=")));
}
#[test]
fn docker_argv_uses_spec_stdin_and_never_mounts_home() {
let mut d = dep();
d.spec_json = Some(r#"{"version":1}"#.to_string());
let deployer = DockerDeployer::new("img");
let a = deployer.argv(&d, "c");
assert!(!a.contains(&"-v".to_string()));
assert!(!a.iter().any(|x| x.contains("/seed")));
assert!(a
.windows(2)
.any(|w| w == ["--entrypoint".to_string(), "bamboo".to_string()]));
assert!(!a.iter().any(|x| x == "/bin/sh"));
assert!(!a.iter().any(|x| x.contains("config.json")));
assert!(!a.iter().any(|x| x.contains(".bamboo_encryption_key")));
assert!(a.contains(&"-i".to_string()));
assert!(a.contains(&"--spec-stdin".to_string()));
assert!(!a
.iter()
.any(|x| x.contains("tok") && !x.starts_with("BAMBOO_BROKER_TOKEN=")));
}
#[test]
fn docker_argv_spec_json_takes_precedence_over_mount_home() {
let mut d = dep();
d.spec_json = Some(r#"{"version":1}"#.to_string());
let deployer = DockerDeployer::new("img").mount_home("/home/u/.bamboo");
let a = deployer.argv(&d, "c");
assert!(!a.iter().any(|x| x.contains("/seed")));
assert!(a.contains(&"--spec-stdin".to_string()));
}
#[test]
fn docker_argv_spec_json_still_wires_host_docker_internal() {
let mut d = dep();
d.spec_json = Some(r#"{"version":1}"#.to_string());
let deployer = DockerDeployer::new("img");
let a = deployer.argv(&d, "c");
assert!(a.windows(2).any(|w| w
== [
"--add-host".to_string(),
"host.docker.internal:host-gateway".to_string()
]));
}
#[tokio::test]
async fn docker_deploy_pipes_spec_json_to_stdin() {
let marker = std::env::temp_dir().join(format!(
"bamboo_docker_spec_stdin_{}_{:?}.json",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let _ = std::fs::remove_file(&marker);
let fake_docker = std::env::temp_dir().join(format!(
"bamboo_fake_docker_{}_{:?}.sh",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::write(
&fake_docker,
format!("#!/bin/sh\ncat > {}\n", marker.display()),
)
.unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&fake_docker, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let mut d = dep();
d.spec_json = Some(r#"{"version":1,"secret":"do-not-mount-whole-home"}"#.to_string());
let mut deployer = DockerDeployer::new("img");
deployer.docker_bin = fake_docker.to_string_lossy().into_owned();
let agent = deployer.deploy(&d).await.expect("fake docker deploy");
let expected = r#"{"version":1,"secret":"do-not-mount-whole-home"}"#;
let deadline = std::time::Instant::now() + Duration::from_secs(5);
let mut written = String::new();
while std::time::Instant::now() < deadline {
if let Ok(s) = std::fs::read_to_string(&marker) {
if s == expected {
written = s;
break;
}
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
assert_eq!(written, expected, "fake docker never wrote the piped spec");
let _ = std::fs::remove_file(&marker);
let _ = std::fs::remove_file(&fake_docker);
drop(agent);
}
}