1use std::collections::HashMap;
12use std::path::{Path, PathBuf};
13use std::sync::Arc;
14use std::time::{Duration, Instant};
15
16use tokio::sync::{Mutex, RwLock};
17
18use bamboo_broker::{
19 ask_agent, AgentDeployment, BrokerClient, Deployer, LocalProcessDeployer, RusshAuth,
20 RusshDeployer, SshDeployer, UploadSpec, ORCHESTRATOR_ID,
21};
22use bamboo_config::cluster_fabric::{
23 Node, NodePlacement, NodeState, NodeStatus, SshAuth, SshTarget,
24};
25use bamboo_config::{BrokerClientConfig, Config};
26use bamboo_subagent::{AgentRef, AskMode};
27
28use crate::deploy_agent::{Deployed, DeployedRegistry};
29
30#[derive(Debug)]
32pub enum FabricError {
33 NotFound(String),
34 BadRequest(String),
35 Internal(String),
36}
37
38impl std::fmt::Display for FabricError {
39 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40 match self {
41 FabricError::NotFound(m) | FabricError::BadRequest(m) | FabricError::Internal(m) => {
42 write!(f, "{m}")
43 }
44 }
45 }
46}
47
48type FabricResult<T> = Result<T, FabricError>;
49
50pub struct FabricDeployer {
53 config: Arc<RwLock<Config>>,
54 config_io_lock: Arc<Mutex<()>>,
57 data_dir: PathBuf,
58 registry: DeployedRegistry,
61 bamboo_bin: PathBuf,
62 recovery: Arc<Mutex<HashMap<String, RecoveryState>>>,
65}
66
67#[derive(Default)]
69struct RecoveryState {
70 consecutive_unreachable: u32,
72 attempts: u32,
74 next_eligible: Option<tokio::time::Instant>,
76 gave_up: bool,
78 in_flight: bool,
81}
82
83const RECOVERY_DEBOUNCE: u32 = 2;
85const RECOVERY_MAX_ATTEMPTS: u32 = 3;
87
88impl FabricDeployer {
89 pub fn new(
90 config: Arc<RwLock<Config>>,
91 config_io_lock: Arc<Mutex<()>>,
92 data_dir: impl Into<PathBuf>,
93 registry: DeployedRegistry,
94 bamboo_bin: impl Into<PathBuf>,
95 ) -> Self {
96 Self {
97 config,
98 config_io_lock,
99 data_dir: data_dir.into(),
100 registry,
101 bamboo_bin: bamboo_bin.into(),
102 recovery: Arc::new(Mutex::new(HashMap::new())),
103 }
104 }
105
106 pub fn registry(&self) -> DeployedRegistry {
108 self.registry.clone()
109 }
110
111 fn node_snapshot(&self, cfg: &Config, node_id: &str) -> FabricResult<Node> {
112 cfg.cluster_fabric
113 .node(node_id)
114 .cloned()
115 .ok_or_else(|| FabricError::NotFound(format!("Node '{node_id}'")))
116 }
117
118 pub async fn deploy(&self, node_id: &str, echo: bool) -> FabricResult<NodeState> {
123 let (mut node, broker) = {
124 let cfg = self.config.read().await;
125 (
126 self.node_snapshot(&cfg, node_id)?,
127 cfg.subagents.broker.clone(),
128 )
129 };
130 if !node.enabled {
131 return Err(FabricError::BadRequest(format!(
132 "Node '{node_id}' is disabled"
133 )));
134 }
135 let broker = broker
136 .filter(|b| !b.endpoint.trim().is_empty())
137 .ok_or_else(|| {
138 FabricError::BadRequest(
139 "No broker configured (subagents.broker) — a worker has nowhere to dial home"
140 .to_string(),
141 )
142 })?;
143
144 if node.deploy.artifact_path.is_none() && matches!(node.placement, NodePlacement::Ssh(_)) {
151 let build = build_deployer(&node, &self.bamboo_bin).map_err(FabricError::BadRequest)?;
152 let uname = build.deployer.preflight().await.map_err(|e| {
153 FabricError::Internal(format!("preflight for '{node_id}' failed: {e}"))
154 })?;
155 if remote_matches_orchestrator(&uname) {
156 node.deploy.artifact_path = Some(self.bamboo_bin.to_string_lossy().into_owned());
157 tracing::info!(
158 node = node_id,
159 %uname,
160 "no artifact_path set — auto-uploading orchestrator binary (arch match)"
161 );
162 } else {
163 return Err(FabricError::BadRequest(format!(
164 "node '{node_id}': remote is '{uname}' but the orchestrator binary is \
165 {}/{} — set deploy.artifact_path to a bamboo binary built for the remote arch",
166 std::env::consts::OS,
167 std::env::consts::ARCH,
168 )));
169 }
170 }
171
172 let worker_id = worker_id_for(&node);
173 let build = build_deployer(&node, &self.bamboo_bin).map_err(FabricError::BadRequest)?;
174 let log_path = log_path_for(&node);
175
176 let spec_json = {
181 let cfg = self.config.read().await;
182 build_resident_spec(
183 &node,
184 &broker.endpoint,
185 &broker.token,
186 &cfg,
187 echo,
188 &worker_id,
189 )
190 };
191
192 let deployment = AgentDeployment {
193 id: worker_id.clone(),
194 role: node.deploy.default_role.clone(),
195 broker_endpoint: broker.endpoint.clone(),
196 token: broker.token.clone(),
197 model: node.deploy.model.clone(),
198 workspace: node.deploy.workspace.clone(),
199 echo,
200 mcp_proxy: Some(ORCHESTRATOR_ID.to_string()),
201 log_path: Some(log_path.clone()),
202 spec_json,
203 };
204
205 if let Some(prev) = self
208 .registry
209 .lock()
210 .await
211 .remove(&crate::registry_keys::node_key(node_id))
212 {
213 prev.handle.shutdown().await;
214 }
215
216 let handle = match build.deployer.deploy(&deployment).await {
217 Ok(h) => h,
218 Err(e) => {
219 let failed = NodeState {
220 status: NodeStatus::Failed,
221 last_error: Some(e.to_string()),
222 ..Default::default()
223 };
224 let _ = self.persist_state(node_id, Some(failed)).await;
225 tracing::warn!(
226 audit = "cluster_fabric.deploy",
227 node = node_id,
228 placement = placement_env(&node),
229 outcome = "failed",
230 error = %e,
231 );
232 return Err(FabricError::Internal(format!(
233 "deploy node '{node_id}' failed: {e}"
234 )));
235 }
236 };
237 let pid = handle.pid();
238 tracing::info!(
239 audit = "cluster_fabric.deploy",
240 node = node_id,
241 placement = placement_env(&node),
242 worker_id = %worker_id,
243 echo,
244 outcome = "deployed",
245 );
246
247 self.registry.lock().await.insert(
248 crate::registry_keys::node_key(node_id),
249 Deployed {
250 env: placement_env(&node).to_string(),
251 handle,
252 },
253 );
254
255 if let Some(cell) = build.observed_fp {
257 if let Some(fp) = cell.lock().await.clone() {
258 self.pin_fingerprint_if_absent(node_id, &fp).await;
259 }
260 }
261
262 let verify = if echo {
272 verify_echo_worker(&broker, &worker_id).await
273 } else {
274 let role = node
275 .deploy
276 .default_role
277 .clone()
278 .unwrap_or_else(|| "general-purpose".to_string());
279 verify_worker_connected(&broker, &worker_id, &role, Duration::from_secs(30)).await
280 };
281 if let Err(e) = verify {
282 if let Some(d) = self
284 .registry
285 .lock()
286 .await
287 .remove(&crate::registry_keys::node_key(node_id))
288 {
289 d.handle.shutdown().await;
290 }
291 let msg = format!(
292 "worker deployed but never came up on the bus (verify failed): {e} — \
293 check that `bamboo` runs on the remote (arch/deps) and see the node log"
294 );
295 let failed = NodeState {
296 status: NodeStatus::Failed,
297 worker_id: Some(worker_id.clone()),
298 log_path: Some(log_path.clone()),
299 last_error: Some(msg.clone()),
300 ..Default::default()
301 };
302 let _ = self.persist_state(node_id, Some(failed)).await;
303 tracing::warn!(
304 audit = "cluster_fabric.deploy",
305 node = node_id,
306 worker_id = %worker_id,
307 echo,
308 outcome = "verify_failed",
309 error = %e,
310 );
311 return Err(FabricError::Internal(format!(
312 "deploy node '{node_id}': {msg}"
313 )));
314 }
315 tracing::info!(
316 node = node_id,
317 worker_id = %worker_id,
318 echo,
319 "deploy verify ok — worker is reachable on the bus"
320 );
321
322 let state = NodeState {
323 status: NodeStatus::Running,
324 worker_id: Some(worker_id),
325 remote_pid: pid,
326 log_path: Some(log_path),
327 deployed_at: Some(chrono::Utc::now().to_rfc3339()),
328 ..Default::default()
329 };
330 self.persist_state(node_id, Some(state.clone())).await?;
331 Ok(state)
332 }
333
334 pub async fn stop(&self, node_id: &str) -> FabricResult<NodeState> {
336 {
337 let cfg = self.config.read().await;
338 self.node_snapshot(&cfg, node_id)?;
339 }
340 if let Some(d) = self
341 .registry
342 .lock()
343 .await
344 .remove(&crate::registry_keys::node_key(node_id))
345 {
346 d.handle.shutdown().await;
347 }
348 tracing::info!(
349 audit = "cluster_fabric.stop",
350 node = node_id,
351 outcome = "stopped"
352 );
353 let state = NodeState {
354 status: NodeStatus::Stopped,
355 ..Default::default()
356 };
357 self.persist_state(node_id, Some(state.clone())).await?;
358 Ok(state)
359 }
360
361 pub async fn test(&self, node_id: &str) -> FabricResult<String> {
363 let node = {
364 let cfg = self.config.read().await;
365 self.node_snapshot(&cfg, node_id)?
366 };
367 let build = build_deployer(&node, &self.bamboo_bin).map_err(FabricError::BadRequest)?;
368 let result = build.deployer.preflight().await;
369 tracing::info!(
370 audit = "cluster_fabric.test",
371 node = node_id,
372 placement = placement_env(&node),
373 outcome = if result.is_ok() { "ok" } else { "failed" },
374 );
375 result.map_err(|e| FabricError::Internal(format!("preflight failed: {e}")))
376 }
377
378 pub async fn read_logs(&self, node_id: &str, lines: usize) -> FabricResult<String> {
380 let node = {
381 let cfg = self.config.read().await;
382 self.node_snapshot(&cfg, node_id)?
383 };
384 let log_path = node
385 .state
386 .as_ref()
387 .and_then(|s| s.log_path.clone())
388 .unwrap_or_else(|| log_path_for(&node));
389 let build = build_deployer(&node, &self.bamboo_bin).map_err(FabricError::BadRequest)?;
390 build
391 .deployer
392 .tail_log(&log_path, lines)
393 .await
394 .map_err(|e| FabricError::Internal(format!("read logs failed: {e}")))
395 }
396
397 const HEALTH_PROBE_TIMEOUT: Duration = Duration::from_secs(5);
400
401 pub async fn health_check(&self, node_id: &str) -> FabricResult<NodeState> {
403 self.health_check_within(node_id, Self::HEALTH_PROBE_TIMEOUT)
404 .await
405 }
406
407 async fn health_check_within(
416 &self,
417 node_id: &str,
418 probe_timeout: Duration,
419 ) -> FabricResult<NodeState> {
420 let (node, broker) = {
421 let cfg = self.config.read().await;
422 (
423 self.node_snapshot(&cfg, node_id)?,
424 cfg.subagents.broker.clone(),
425 )
426 };
427 let current = node.state.clone().unwrap_or_default();
428 if !matches!(
429 current.status,
430 NodeStatus::Running | NodeStatus::Unreachable
431 ) {
432 return Ok(current); }
434 let worker_id = current
435 .worker_id
436 .clone()
437 .unwrap_or_else(|| worker_id_for(&node));
438 let role = node
439 .deploy
440 .default_role
441 .clone()
442 .unwrap_or_else(|| "general-purpose".to_string());
443 let Some(broker) = broker.filter(|b| !b.endpoint.trim().is_empty()) else {
444 return Ok(current); };
446
447 let alive = verify_worker_connected(&broker, &worker_id, &role, probe_timeout)
451 .await
452 .is_ok();
453
454 let new_status = if alive {
455 NodeStatus::Running
456 } else {
457 NodeStatus::Unreachable
458 };
459 let new_error = if alive {
460 None
461 } else {
462 Some(format!(
463 "worker '{worker_id}' not present on the bus under role '{role}'"
464 ))
465 };
466
467 let _io = self.config_io_lock.lock().await;
474 let (next, from, snapshot) = {
475 let mut cfg = self.config.write().await;
476 let Some(node) = cfg.cluster_fabric.node_mut(node_id) else {
477 return Ok(current);
478 };
479 let live = node.state.clone().unwrap_or_default();
480 if !matches!(live.status, NodeStatus::Running | NodeStatus::Unreachable) {
481 return Ok(live); }
483 let from = live.status;
484 let next = NodeState {
485 status: new_status,
486 last_health: Some(chrono::Utc::now().to_rfc3339()),
487 last_error: new_error,
488 ..live
489 };
490 node.state = Some(next.clone());
491 (next, from, (from != new_status).then(|| cfg.clone()))
494 };
495 if let Some(snapshot) = snapshot {
496 let data_dir = self.data_dir.clone();
497 tokio::task::spawn_blocking(move || snapshot.save_to_dir(data_dir))
498 .await
499 .map_err(|e| FabricError::Internal(format!("persist task: {e}")))?
500 .map_err(|e| FabricError::Internal(format!("save config: {e}")))?;
501 tracing::info!(
502 audit = "cluster_fabric.health",
503 node = node_id,
504 worker_id = %worker_id,
505 from = ?from,
506 to = ?next.status,
507 "node health changed",
508 );
509 }
510 Ok(next)
511 }
512
513 async fn monitored_node_ids(&self) -> Vec<String> {
516 let cfg = self.config.read().await;
517 cfg.cluster_fabric
518 .nodes
519 .iter()
520 .filter(|n| {
521 n.state.as_ref().is_some_and(|s| {
522 matches!(s.status, NodeStatus::Running | NodeStatus::Unreachable)
523 })
524 })
525 .map(|n| n.id.clone())
526 .collect()
527 }
528
529 async fn recovery_decision(&self, node_id: &str, state: &NodeState) -> Option<u32> {
537 if state.status != NodeStatus::Unreachable {
538 self.recovery.lock().await.remove(node_id);
539 return None;
540 }
541 let auto = {
542 let cfg = self.config.read().await;
543 cfg.cluster_fabric
544 .node(node_id)
545 .map(|n| n.deploy.auto_recover)
546 .unwrap_or(false)
547 };
548 if !auto {
549 return None;
550 }
551
552 let mut map = self.recovery.lock().await;
553 let rs = map.entry(node_id.to_string()).or_default();
554 rs.consecutive_unreachable = rs.consecutive_unreachable.saturating_add(1);
555 if rs.consecutive_unreachable < RECOVERY_DEBOUNCE {
556 return None; }
558 if rs.in_flight {
559 return None; }
561 if rs.attempts >= RECOVERY_MAX_ATTEMPTS {
562 let first_give_up = !rs.gave_up;
563 rs.gave_up = true;
564 drop(map);
565 if first_give_up {
566 self.mark_failed(
567 node_id,
568 &format!("auto-recover gave up after {RECOVERY_MAX_ATTEMPTS} attempts"),
569 )
570 .await;
571 }
572 return None;
573 }
574 if let Some(t) = rs.next_eligible {
575 if tokio::time::Instant::now() < t {
576 return None; }
578 }
579 rs.attempts += 1;
580 rs.in_flight = true;
581 let attempt = rs.attempts;
582 rs.next_eligible = Some(tokio::time::Instant::now() + Self::recovery_backoff(attempt));
583 Some(attempt)
584 }
585
586 async fn clear_recovery_in_flight(&self, node_id: &str) {
589 if let Some(rs) = self.recovery.lock().await.get_mut(node_id) {
590 rs.in_flight = false;
591 }
592 }
593
594 fn recovery_backoff(attempt: u32) -> Duration {
596 let shift = attempt.saturating_sub(1).min(5);
597 Duration::from_secs((10u64 << shift).min(300))
598 }
599
600 async fn mark_failed(&self, node_id: &str, reason: &str) {
602 let current = {
603 let cfg = self.config.read().await;
604 cfg.cluster_fabric
605 .node(node_id)
606 .and_then(|n| n.state.clone())
607 .unwrap_or_default()
608 };
609 let failed = NodeState {
610 status: NodeStatus::Failed,
611 last_error: Some(reason.to_string()),
612 ..current
613 };
614 if let Err(e) = self.persist_state(node_id, Some(failed)).await {
615 tracing::warn!(node = node_id, error = %e, "failed to persist Failed state");
616 }
617 tracing::warn!(
618 audit = "cluster_fabric.recover",
619 node = node_id,
620 reason,
621 "auto-recover exhausted → Failed",
622 );
623 }
624
625 pub async fn spawn_health_monitor(self: Arc<Self>) -> Option<tokio::task::JoinHandle<()>> {
630 let interval = {
631 let cfg = self.config.read().await;
632 cfg.cluster_fabric.health_interval()?
633 };
634 tracing::info!(
635 interval_secs = interval.as_secs(),
636 "cluster health monitor started"
637 );
638 Some(tokio::spawn(async move {
639 let mut tick = tokio::time::interval(interval);
640 tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
641 tick.tick().await; loop {
643 tick.tick().await;
644 for id in self.monitored_node_ids().await {
645 match self.health_check(&id).await {
646 Ok(state) => {
647 if let Some(attempt) = self.recovery_decision(&id, &state).await {
648 let this = self.clone();
653 let node = id.clone();
654 tokio::spawn(async move {
655 tracing::warn!(
656 audit = "cluster_fabric.recover",
657 node = %node,
658 attempt,
659 "auto-recovering unreachable node",
660 );
661 match this.deploy(&node, false).await {
662 Ok(_) => tracing::info!(
663 node = %node,
664 attempt,
665 "auto-recover redeploy succeeded"
666 ),
667 Err(e) => tracing::warn!(
668 node = %node,
669 attempt,
670 error = %e,
671 "auto-recover redeploy failed"
672 ),
673 }
674 this.clear_recovery_in_flight(&node).await;
675 });
676 }
677 }
678 Err(e) => tracing::warn!(node = %id, error = %e, "health check failed"),
679 }
680 }
681 }
682 }))
683 }
684
685 async fn persist_state(&self, node_id: &str, state: Option<NodeState>) -> FabricResult<()> {
688 let _io = self.config_io_lock.lock().await;
689 let snapshot = {
690 let mut cfg = self.config.write().await;
691 let node = cfg
692 .cluster_fabric
693 .node_mut(node_id)
694 .ok_or_else(|| FabricError::NotFound(format!("Node '{node_id}'")))?;
695 node.state = state;
696 cfg.clone()
697 };
698 let data_dir = self.data_dir.clone();
699 tokio::task::spawn_blocking(move || snapshot.save_to_dir(data_dir))
700 .await
701 .map_err(|e| FabricError::Internal(format!("persist task: {e}")))?
702 .map_err(|e| FabricError::Internal(format!("save config: {e}")))?;
703 Ok(())
704 }
705
706 async fn pin_fingerprint_if_absent(&self, node_id: &str, fp: &str) {
708 let _io = self.config_io_lock.lock().await;
709 let snapshot = {
710 let mut cfg = self.config.write().await;
711 if let Some(node) = cfg.cluster_fabric.node_mut(node_id) {
712 if let NodePlacement::Ssh(target) = &mut node.placement {
713 if target.host_key_fingerprint.is_some() {
714 return;
715 }
716 target.host_key_fingerprint = Some(fp.to_string());
717 }
718 }
719 cfg.clone()
720 };
721 let data_dir = self.data_dir.clone();
722 let _ = tokio::task::spawn_blocking(move || snapshot.save_to_dir(data_dir)).await;
723 }
724}
725
726pub type FingerprintCell = Arc<Mutex<Option<String>>>;
728
729pub struct DeployerBuild {
731 pub deployer: Box<dyn Deployer>,
732 pub observed_fp: Option<FingerprintCell>,
733}
734
735pub fn worker_id_for(node: &Node) -> String {
737 let short: String = node.id.chars().filter(|c| *c != '-').take(8).collect();
738 format!("node-{short}")
739}
740
741fn build_resident_spec(
748 node: &Node,
749 broker_endpoint: &str,
750 broker_token: &str,
751 config: &Config,
752 echo: bool,
753 worker_id: &str,
754) -> Option<String> {
755 use bamboo_subagent::provision::{
756 BusEndpoint, ChildIdentity, ExecutorSpec, McpProxyConfig, ModelRefSpec, ProvisionSpec,
757 };
758
759 let credentials = bamboo_engine::external_agents::runtime::extract_provider_credentials(config);
760 if credentials.is_empty() && !echo {
761 return None;
762 }
763
764 let role = node
765 .deploy
766 .default_role
767 .clone()
768 .unwrap_or_else(|| "general-purpose".to_string());
769 let mut spec = ProvisionSpec::new(
770 ChildIdentity {
771 child_id: worker_id.to_string(),
772 parent_id: None,
773 project_key: None,
774 role,
775 depth: 0,
776 },
777 if echo {
778 ExecutorSpec::Echo
779 } else {
780 ExecutorSpec::BambooRuntime
781 },
782 std::env::temp_dir()
783 .join("bamboo-fabric-agents")
784 .join(worker_id)
785 .to_string_lossy()
786 .into_owned(),
787 );
788 spec.bus = Some(BusEndpoint {
789 endpoint: broker_endpoint.to_string(),
790 token: broker_token.to_string(),
791 });
792 spec.model = node
795 .deploy
796 .model
797 .as_deref()
798 .and_then(parse_provider_model)
799 .or_else(|| {
800 config.defaults.as_ref().and_then(|d| {
801 let r = d.sub_agent.as_ref().unwrap_or(&d.chat);
805 (!r.provider.trim().is_empty() && !r.model.trim().is_empty()).then(|| {
806 ModelRefSpec {
807 provider: r.provider.clone(),
808 model: r.model.clone(),
809 }
810 })
811 })
812 });
813 spec.workspace = node.deploy.workspace.clone();
814 spec.secrets.provider_credentials = credentials;
815 spec.capabilities.mcp_proxy = Some(McpProxyConfig {
817 orchestrator: ORCHESTRATOR_ID.to_string(),
818 endpoint: broker_endpoint.to_string(),
819 token: broker_token.to_string(),
820 });
821 spec.to_json().ok()
823}
824
825fn parse_provider_model(s: &str) -> Option<bamboo_subagent::provision::ModelRefSpec> {
828 let s = s.trim();
829 s.split_once(':').and_then(|(p, m)| {
830 (!p.is_empty() && !m.is_empty()).then(|| bamboo_subagent::provision::ModelRefSpec {
831 provider: p.to_string(),
832 model: m.to_string(),
833 })
834 })
835}
836
837pub fn placement_env(node: &Node) -> &'static str {
839 match &node.placement {
840 NodePlacement::Local => "local",
841 NodePlacement::Ssh(_) => "ssh",
842 }
843}
844
845pub fn log_path_for(node: &Node) -> String {
849 let worker = worker_id_for(node);
850 match &node.placement {
851 NodePlacement::Local => bamboo_config::paths::resolve_bamboo_dir()
852 .join("fabric-logs")
853 .join(format!("{worker}.log"))
854 .to_string_lossy()
855 .into_owned(),
856 NodePlacement::Ssh(_) => {
857 let dir = node
858 .deploy
859 .remote_dir
860 .clone()
861 .unwrap_or_else(|| ".bamboo-deploy".to_string());
862 format!("{dir}/{worker}.log")
863 }
864 }
865}
866
867pub fn remote_artifact_path(node: &Node) -> String {
870 let dir = node
871 .deploy
872 .remote_dir
873 .clone()
874 .unwrap_or_else(|| ".bamboo-deploy".to_string());
875 let name = node
876 .deploy
877 .artifact_sha256
878 .as_deref()
879 .filter(|h| h.len() >= 8)
880 .map(|h| format!("bamboo-{}", &h[..8]))
881 .unwrap_or_else(|| "bamboo".to_string());
882 format!("{dir}/{name}")
883}
884
885fn remote_matches_orchestrator(uname: &str) -> bool {
888 let os = match std::env::consts::OS {
889 "macos" => "Darwin",
890 "linux" => "Linux",
891 other => other,
892 };
893 let arch = match std::env::consts::ARCH {
894 "aarch64" => "arm64",
895 "x86_64" => "x86_64",
896 other => other,
897 };
898 uname.contains(os) && uname.contains(arch)
899}
900
901async fn verify_echo_worker(broker: &BrokerClientConfig, worker_id: &str) -> Result<(), String> {
905 let me = AgentRef {
906 session_id: format!("{ORCHESTRATOR_ID}-deploy-verify"),
907 role: Some("orchestrator".to_string()),
908 };
909 ask_agent(
910 &broker.endpoint,
911 me,
912 &broker.token,
913 worker_id,
914 "ping",
915 AskMode::Query,
916 Duration::from_secs(30),
917 )
918 .await
919 .map(|_| ())
920 .map_err(|e| e.to_string())
921}
922
923async fn verify_worker_connected(
932 broker: &BrokerClientConfig,
933 worker_id: &str,
934 role: &str,
935 timeout: Duration,
936) -> Result<(), String> {
937 let me = AgentRef {
938 session_id: format!("{ORCHESTRATOR_ID}-deploy-presence"),
939 role: Some("orchestrator".to_string()),
940 };
941 let mut client = BrokerClient::connect(&broker.endpoint, me, &broker.token)
942 .await
943 .map_err(|e| e.to_string())?;
944 let deadline = Instant::now() + timeout;
945 loop {
946 match client.list_connected(role).await {
947 Ok(ids) if ids.iter().any(|id| id == worker_id) => return Ok(()),
948 Ok(_) => {}
949 Err(e) => return Err(e.to_string()),
950 }
951 if Instant::now() >= deadline {
952 return Err(format!(
953 "worker '{worker_id}' never registered on the bus under role \
954 '{role}' within {}s",
955 timeout.as_secs()
956 ));
957 }
958 tokio::time::sleep(Duration::from_millis(500)).await;
959 }
960}
961
962pub fn build_deployer(node: &Node, bamboo_bin: &Path) -> Result<DeployerBuild, String> {
966 match &node.placement {
967 NodePlacement::Local => Ok(DeployerBuild {
968 deployer: Box::new(LocalProcessDeployer::new(bamboo_bin.to_path_buf())),
969 observed_fp: None,
970 }),
971 NodePlacement::Ssh(target) => match &target.auth {
972 SshAuth::SystemSshConfig => Ok(DeployerBuild {
974 deployer: Box::new(build_system_ssh(node, target)),
975 observed_fp: None,
976 }),
977 SshAuth::Password { .. } | SshAuth::PrivateKey { .. } => {
979 let russh = build_russh(node, target)?;
980 let observed_fp = Some(russh.observed_cell());
981 Ok(DeployerBuild {
982 deployer: Box::new(russh),
983 observed_fp,
984 })
985 }
986 },
987 }
988}
989
990fn build_system_ssh(node: &Node, target: &SshTarget) -> SshDeployer {
991 let host = format!("{}@{}", target.username, target.host);
992 let upload = node.deploy.artifact_path.as_ref().map(|local| UploadSpec {
993 local_path: local.clone(),
994 remote_path: remote_artifact_path(node),
995 });
996 SshDeployer::new(host)
997 .with_port(Some(target.port))
998 .with_upload(upload)
999}
1000
1001fn build_russh(node: &Node, target: &SshTarget) -> Result<RusshDeployer, String> {
1002 let auth = match &target.auth {
1003 SshAuth::Password { password, .. } => {
1004 if password.trim().is_empty() {
1005 return Err("node has no stored SSH password".to_string());
1006 }
1007 RusshAuth::Password(password.clone())
1008 }
1009 SshAuth::PrivateKey {
1010 private_key,
1011 private_key_path,
1012 passphrase,
1013 ..
1014 } => {
1015 let pem = if !private_key.trim().is_empty() {
1016 private_key.clone()
1017 } else if let Some(path) = private_key_path {
1018 std::fs::read_to_string(path)
1019 .map_err(|e| format!("read private key '{path}': {e}"))?
1020 } else {
1021 return Err("node has neither an inline private key nor a key path".to_string());
1022 };
1023 RusshAuth::PrivateKey {
1024 pem,
1025 passphrase: Some(passphrase.clone()).filter(|p| !p.trim().is_empty()),
1026 }
1027 }
1028 SshAuth::SystemSshConfig => {
1029 return Err("build_russh called for SystemSshConfig".to_string());
1030 }
1031 };
1032
1033 let upload = node.deploy.artifact_path.as_ref().map(|local| UploadSpec {
1034 local_path: local.clone(),
1035 remote_path: remote_artifact_path(node),
1036 });
1037
1038 Ok(RusshDeployer::new(
1039 target.host.clone(),
1040 target.port,
1041 target.username.clone(),
1042 auth,
1043 )
1044 .with_fingerprint(target.host_key_fingerprint.clone())
1045 .with_upload(upload))
1046}
1047
1048#[cfg(test)]
1049mod resident_spec_tests {
1050 use super::parse_provider_model;
1051
1052 #[test]
1053 fn parse_provider_model_splits_and_guards() {
1054 let r = parse_provider_model("anthropic:claude-opus-4-8").unwrap();
1055 assert_eq!(r.provider, "anthropic");
1056 assert_eq!(r.model, "claude-opus-4-8");
1057 assert!(parse_provider_model("just-a-model").is_none());
1059 assert!(parse_provider_model(":m").is_none());
1060 assert!(parse_provider_model("p:").is_none());
1061 assert!(parse_provider_model(" ").is_none());
1062 }
1063}
1064
1065#[cfg(test)]
1066mod presence_verify_tests {
1067 use super::verify_worker_connected;
1071 use bamboo_config::BrokerClientConfig;
1072 use std::sync::Arc;
1073 use std::time::Duration;
1074
1075 async fn start_broker() -> (String, tempfile::TempDir) {
1076 let dir = tempfile::tempdir().unwrap();
1077 let core = Arc::new(bamboo_broker::BrokerCore::new(dir.path()));
1078 let server = Arc::new(bamboo_broker::BrokerServer::new(core, "t"));
1079 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1080 let addr = listener.local_addr().unwrap();
1081 tokio::spawn(async move {
1082 let _ = server.serve(listener).await;
1083 });
1084 (format!("ws://{addr}"), dir)
1085 }
1086
1087 async fn join(endpoint: &str, id: &str, role: &str) -> bamboo_broker::BrokerClient {
1090 let mut c = bamboo_broker::BrokerClient::connect(
1091 endpoint,
1092 bamboo_subagent::AgentRef {
1093 session_id: id.into(),
1094 role: Some(role.into()),
1095 },
1096 "t",
1097 )
1098 .await
1099 .unwrap();
1100 c.subscribe().await.unwrap();
1101 c
1102 }
1103
1104 fn cfg(endpoint: &str) -> BrokerClientConfig {
1105 BrokerClientConfig {
1106 endpoint: endpoint.to_string(),
1107 token: "t".into(),
1108 token_encrypted: None,
1109 }
1110 }
1111
1112 #[tokio::test]
1113 async fn ok_when_worker_registered_under_role() {
1114 let (endpoint, _dir) = start_broker().await;
1115 let _worker = join(&endpoint, "w-mon", "monitor").await;
1116 let out =
1117 verify_worker_connected(&cfg(&endpoint), "w-mon", "monitor", Duration::from_secs(3))
1118 .await;
1119 assert!(out.is_ok(), "present worker should verify: {out:?}");
1120 }
1121
1122 #[tokio::test]
1123 async fn times_out_when_worker_absent() {
1124 let (endpoint, _dir) = start_broker().await;
1125 let out = verify_worker_connected(
1127 &cfg(&endpoint),
1128 "w-mon",
1129 "monitor",
1130 Duration::from_millis(400),
1131 )
1132 .await;
1133 assert!(out.is_err(), "absent worker should fail verify");
1134 assert!(out.unwrap_err().contains("never registered"));
1135 }
1136
1137 #[tokio::test]
1138 async fn role_scoped_ignores_worker_under_other_role() {
1139 let (endpoint, _dir) = start_broker().await;
1140 let _other = join(&endpoint, "w-mon", "builder").await;
1142 let out = verify_worker_connected(
1143 &cfg(&endpoint),
1144 "w-mon",
1145 "monitor",
1146 Duration::from_millis(400),
1147 )
1148 .await;
1149 assert!(
1150 out.is_err(),
1151 "a worker under a different role must not count"
1152 );
1153 }
1154}
1155
1156#[cfg(test)]
1157mod health_check_tests {
1158 use super::*;
1161 use bamboo_config::cluster_fabric::{
1162 DeployProfile, Node, NodePlacement, NodeState, NodeStatus, TrustLevel,
1163 };
1164 use bamboo_config::{BrokerClientConfig, Config};
1165 use std::collections::HashMap;
1166 use std::sync::Arc;
1167 use tokio::sync::{Mutex, RwLock};
1168
1169 async fn start_broker() -> (String, tempfile::TempDir) {
1170 let dir = tempfile::tempdir().unwrap();
1171 let core = Arc::new(bamboo_broker::BrokerCore::new(dir.path()));
1172 let server = Arc::new(bamboo_broker::BrokerServer::new(core, "t"));
1173 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1174 let addr = listener.local_addr().unwrap();
1175 tokio::spawn(async move {
1176 let _ = server.serve(listener).await;
1177 });
1178 (format!("ws://{addr}"), dir)
1179 }
1180
1181 async fn join(endpoint: &str, id: &str, role: &str) -> bamboo_broker::BrokerClient {
1182 let mut c = bamboo_broker::BrokerClient::connect(
1183 endpoint,
1184 bamboo_subagent::AgentRef {
1185 session_id: id.into(),
1186 role: Some(role.into()),
1187 },
1188 "t",
1189 )
1190 .await
1191 .unwrap();
1192 c.subscribe().await.unwrap();
1193 c
1194 }
1195
1196 fn running_node(id: &str, worker_id: &str, role: &str) -> Node {
1197 Node {
1198 id: id.into(),
1199 label: id.into(),
1200 placement: NodePlacement::Local,
1201 trust_level: TrustLevel::Trusted,
1202 deploy: DeployProfile {
1203 default_role: Some(role.into()),
1204 ..Default::default()
1205 },
1206 state: Some(NodeState {
1207 status: NodeStatus::Running,
1208 worker_id: Some(worker_id.into()),
1209 ..Default::default()
1210 }),
1211 enabled: true,
1212 }
1213 }
1214
1215 fn deployer_with(nodes: Vec<Node>, endpoint: &str) -> Arc<FabricDeployer> {
1216 let mut cfg = Config::default();
1217 cfg.cluster_fabric.nodes = nodes;
1218 cfg.subagents.broker = Some(BrokerClientConfig {
1219 endpoint: endpoint.into(),
1220 token: "t".into(),
1221 token_encrypted: None,
1222 });
1223 Arc::new(FabricDeployer::new(
1224 Arc::new(RwLock::new(cfg)),
1225 Arc::new(Mutex::new(())),
1226 std::env::temp_dir(),
1227 Arc::new(Mutex::new(HashMap::new())),
1228 "/usr/bin/true",
1229 ))
1230 }
1231
1232 #[tokio::test]
1233 async fn keeps_running_when_worker_present() {
1234 let (endpoint, _dir) = start_broker().await;
1235 let _w = join(&endpoint, "node-a", "mon").await;
1236 let d = deployer_with(vec![running_node("a", "node-a", "mon")], &endpoint);
1237 let st = d
1238 .health_check_within("a", Duration::from_secs(3))
1239 .await
1240 .unwrap();
1241 assert_eq!(st.status, NodeStatus::Running);
1242 assert!(st.last_health.is_some(), "heartbeat stamped");
1243 assert!(st.last_error.is_none());
1244 }
1245
1246 #[tokio::test]
1247 async fn flips_to_unreachable_when_worker_gone() {
1248 let (endpoint, _dir) = start_broker().await;
1249 let d = deployer_with(vec![running_node("a", "node-a", "mon")], &endpoint);
1251 let st = d
1252 .health_check_within("a", Duration::from_millis(400))
1253 .await
1254 .unwrap();
1255 assert_eq!(st.status, NodeStatus::Unreachable);
1256 assert!(st.last_error.is_some());
1257 }
1258
1259 #[tokio::test]
1260 async fn leaves_non_deployed_node_untouched() {
1261 let (endpoint, _dir) = start_broker().await;
1262 let mut node = running_node("a", "node-a", "mon");
1263 node.state = Some(NodeState {
1264 status: NodeStatus::Stopped,
1265 ..Default::default()
1266 });
1267 let d = deployer_with(vec![node], &endpoint);
1268 let st = d
1269 .health_check_within("a", Duration::from_millis(400))
1270 .await
1271 .unwrap();
1272 assert_eq!(
1273 st.status,
1274 NodeStatus::Stopped,
1275 "a stopped node is not probed"
1276 );
1277 assert!(st.last_health.is_none(), "no probe → no heartbeat");
1278 }
1279
1280 #[tokio::test]
1281 async fn does_not_clobber_a_concurrent_status_change() {
1282 let (endpoint, _dir) = start_broker().await;
1285 let d = deployer_with(vec![running_node("a", "node-a", "mon")], &endpoint);
1287 let probe = {
1288 let d = d.clone();
1289 tokio::spawn(async move {
1290 d.health_check_within("a", Duration::from_millis(1500))
1291 .await
1292 })
1293 };
1294 tokio::time::sleep(Duration::from_millis(200)).await;
1296 {
1297 let mut cfg = d.config.write().await;
1298 cfg.cluster_fabric.node_mut("a").unwrap().state = Some(NodeState {
1299 status: NodeStatus::Stopped,
1300 ..Default::default()
1301 });
1302 }
1303 let observed = probe.await.unwrap().unwrap();
1304 assert_eq!(
1305 observed.status,
1306 NodeStatus::Stopped,
1307 "health_check yields to the stop"
1308 );
1309 let cfg = d.config.read().await;
1310 let st = cfg
1311 .cluster_fabric
1312 .node("a")
1313 .unwrap()
1314 .state
1315 .as_ref()
1316 .unwrap();
1317 assert_eq!(
1318 st.status,
1319 NodeStatus::Stopped,
1320 "Stopped not overwritten to Unreachable"
1321 );
1322 }
1323}
1324
1325#[cfg(test)]
1326mod recovery_tests {
1327 use super::*;
1330 use bamboo_config::cluster_fabric::{
1331 DeployProfile, Node, NodePlacement, NodeState, NodeStatus, TrustLevel,
1332 };
1333 use bamboo_config::Config;
1334 use tokio::sync::{Mutex, RwLock};
1335
1336 fn recoverable_node(id: &str) -> Node {
1337 Node {
1338 id: id.into(),
1339 label: id.into(),
1340 placement: NodePlacement::Local,
1341 trust_level: TrustLevel::Trusted,
1342 deploy: DeployProfile {
1343 default_role: Some("mon".into()),
1344 auto_recover: true,
1345 ..Default::default()
1346 },
1347 state: Some(NodeState {
1348 status: NodeStatus::Unreachable,
1349 ..Default::default()
1350 }),
1351 enabled: true,
1352 }
1353 }
1354
1355 fn deployer(nodes: Vec<Node>) -> (Arc<FabricDeployer>, tempfile::TempDir) {
1356 let dir = tempfile::tempdir().unwrap();
1357 let mut cfg = Config::default();
1358 cfg.cluster_fabric.nodes = nodes;
1359 let d = Arc::new(FabricDeployer::new(
1360 Arc::new(RwLock::new(cfg)),
1361 Arc::new(Mutex::new(())),
1362 dir.path().to_path_buf(),
1363 Arc::new(Mutex::new(HashMap::new())),
1364 "/usr/bin/true",
1365 ));
1366 (d, dir)
1367 }
1368
1369 fn unreachable() -> NodeState {
1370 NodeState {
1371 status: NodeStatus::Unreachable,
1372 ..Default::default()
1373 }
1374 }
1375
1376 #[tokio::test(start_paused = true)]
1377 async fn debounces_backs_off_then_caps_to_failed() {
1378 let (d, _dir) = deployer(vec![recoverable_node("a")]);
1379 let un = unreachable();
1380
1381 assert_eq!(d.recovery_decision("a", &un).await, None);
1383 assert_eq!(d.recovery_decision("a", &un).await, Some(1));
1384 assert_eq!(d.recovery_decision("a", &un).await, None);
1386 tokio::time::advance(Duration::from_secs(11)).await;
1387 assert_eq!(d.recovery_decision("a", &un).await, None, "still in-flight");
1388 d.clear_recovery_in_flight("a").await;
1390 assert_eq!(d.recovery_decision("a", &un).await, Some(2));
1391 d.clear_recovery_in_flight("a").await;
1392 tokio::time::advance(Duration::from_secs(21)).await;
1393 assert_eq!(d.recovery_decision("a", &un).await, Some(3));
1394 d.clear_recovery_in_flight("a").await;
1396 tokio::time::advance(Duration::from_secs(41)).await;
1397 assert_eq!(d.recovery_decision("a", &un).await, None);
1398 let cfg = d.config.read().await;
1399 let st = cfg
1400 .cluster_fabric
1401 .node("a")
1402 .unwrap()
1403 .state
1404 .as_ref()
1405 .unwrap();
1406 assert_eq!(st.status, NodeStatus::Failed);
1407 assert!(st
1408 .last_error
1409 .as_deref()
1410 .unwrap_or_default()
1411 .contains("gave up"));
1412 }
1413
1414 #[tokio::test]
1415 async fn no_recovery_when_flag_off() {
1416 let mut node = recoverable_node("a");
1417 node.deploy.auto_recover = false;
1418 let (d, _dir) = deployer(vec![node]);
1419 let un = unreachable();
1420 assert_eq!(d.recovery_decision("a", &un).await, None);
1421 assert_eq!(
1422 d.recovery_decision("a", &un).await,
1423 None,
1424 "opt-out never redeploys"
1425 );
1426 }
1427
1428 #[tokio::test]
1429 async fn recovered_node_clears_progress() {
1430 let (d, _dir) = deployer(vec![recoverable_node("a")]);
1431 let un = unreachable();
1432 let ok = NodeState {
1433 status: NodeStatus::Running,
1434 ..Default::default()
1435 };
1436 assert_eq!(d.recovery_decision("a", &un).await, None); assert_eq!(d.recovery_decision("a", &ok).await, None); assert_eq!(d.recovery_decision("a", &un).await, None);
1440 assert_eq!(d.recovery_decision("a", &un).await, Some(1));
1441 }
1442}