1use std::path::PathBuf;
2use std::time::Duration;
3
4use serde::{Deserialize, Serialize};
5use soma_fleet::{FanoutPolicy, FanoutScheduler, HostId, HostRecord, TargetOutcomeKind};
6use soma_ops::MutationSendState;
7use tokio_util::sync::CancellationToken;
8
9use crate::{
10 HostExecMutator, HostExecReceipt, HostExecRequest, InfraError, MutationFailure, MutationResult,
11};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "snake_case")]
16pub enum HostExecTargetStatus {
17 Succeeded,
19 Failed,
21 Cancelled,
23 TimedOut,
25}
26
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29pub struct HostExecTargetResult {
30 pub host: HostId,
32 pub working_dir: Option<PathBuf>,
34 pub status: HostExecTargetStatus,
36 pub receipt: Option<HostExecReceipt>,
38 pub error: Option<String>,
40 pub send_state: MutationSendState,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
46pub struct HostExecManyOutcome {
47 pub results: Vec<HostExecTargetResult>,
49 pub succeeded: usize,
51 pub failed: usize,
53 pub cancelled: usize,
55 pub timed_out: usize,
57 pub send_state: MutationSendState,
59}
60
61impl HostExecManyOutcome {
62 #[must_use]
64 pub fn all_succeeded(&self) -> bool {
65 self.succeeded == self.results.len()
66 }
67}
68
69#[derive(Debug, Clone, Copy)]
71pub struct HostExecManyEngine {
72 scheduler: FanoutScheduler,
73}
74
75impl HostExecManyEngine {
76 pub fn new(max_concurrency: usize, per_target_timeout: Duration) -> Result<Self, InfraError> {
78 let policy = FanoutPolicy::new(max_concurrency, per_target_timeout).map_err(|error| {
79 InfraError::InvalidRequest {
80 domain: "host-exec-many",
81 message: error.to_string(),
82 }
83 })?;
84 Ok(Self {
85 scheduler: FanoutScheduler::new(policy),
86 })
87 }
88
89 pub async fn execute(
91 &self,
92 client: &dyn HostExecMutator,
93 targets: Vec<(HostRecord, HostExecRequest)>,
94 cancellation: CancellationToken,
95 ) -> MutationResult<HostExecManyOutcome> {
96 if targets.is_empty() {
97 return Err(MutationFailure::new(
98 MutationSendState::NotSent,
99 InfraError::InvalidRequest {
100 domain: "host-exec-many",
101 message: "at least one target is required".into(),
102 },
103 ));
104 }
105 if cancellation.is_cancelled() {
106 return Err(MutationFailure::new(
107 MutationSendState::NotSent,
108 soma_fleet::FleetError::Cancelled.into(),
109 ));
110 }
111 let descriptors = targets
112 .iter()
113 .map(|(host, request)| {
114 (
115 host.id().clone(),
116 request.working_dir().map(ToOwned::to_owned),
117 )
118 })
119 .collect::<Vec<_>>();
120 let report = self
121 .scheduler
122 .run_with_payload(targets, cancellation, |host, request, child| async move {
123 client.exec_host(&host, &request, &child).await
124 })
125 .await;
126 let mut results = Vec::with_capacity(descriptors.len());
127 for outcome in report.into_outcomes() {
128 let (index, host, kind) = outcome.into_parts();
129 let working_dir = descriptors[index].1.clone();
130 results.push(normalize_target(host, working_dir, kind));
131 }
132 let succeeded = count(&results, HostExecTargetStatus::Succeeded);
133 let failed = count(&results, HostExecTargetStatus::Failed);
134 let cancelled = count(&results, HostExecTargetStatus::Cancelled);
135 let timed_out = count(&results, HostExecTargetStatus::TimedOut);
136 let send_state = aggregate_send_state(&results);
137 Ok(HostExecManyOutcome {
138 results,
139 succeeded,
140 failed,
141 cancelled,
142 timed_out,
143 send_state,
144 })
145 }
146}
147
148fn normalize_target(
149 host: HostId,
150 working_dir: Option<PathBuf>,
151 kind: TargetOutcomeKind<HostExecReceipt, MutationFailure>,
152) -> HostExecTargetResult {
153 match kind {
154 TargetOutcomeKind::Succeeded(receipt) => {
155 let succeeded = receipt.exit_code == Some(0);
156 let send_state = receipt.send_state;
157 HostExecTargetResult {
158 host,
159 working_dir,
160 status: if succeeded {
161 HostExecTargetStatus::Succeeded
162 } else {
163 HostExecTargetStatus::Failed
164 },
165 error: if succeeded {
166 None
167 } else {
168 Some(format!(
169 "command exited with status {:?}",
170 receipt.exit_code
171 ))
172 },
173 receipt: Some(receipt),
174 send_state,
175 }
176 }
177 TargetOutcomeKind::Failed(failure) => HostExecTargetResult {
178 host,
179 working_dir,
180 status: HostExecTargetStatus::Failed,
181 receipt: None,
182 error: Some(failure.error().to_string()),
183 send_state: failure.send_state(),
184 },
185 TargetOutcomeKind::Cancelled => HostExecTargetResult {
186 host,
187 working_dir,
188 status: HostExecTargetStatus::Cancelled,
189 receipt: None,
190 error: Some("target was cancelled after fanout admission".into()),
191 send_state: MutationSendState::Unknown,
192 },
193 TargetOutcomeKind::TimedOut => HostExecTargetResult {
194 host,
195 working_dir,
196 status: HostExecTargetStatus::TimedOut,
197 receipt: None,
198 error: Some("target exceeded its bounded execution timeout".into()),
199 send_state: MutationSendState::Unknown,
200 },
201 }
202}
203
204fn count(results: &[HostExecTargetResult], status: HostExecTargetStatus) -> usize {
205 results
206 .iter()
207 .filter(|result| result.status == status)
208 .count()
209}
210
211fn aggregate_send_state(results: &[HostExecTargetResult]) -> MutationSendState {
212 if results
213 .iter()
214 .any(|result| result.send_state == MutationSendState::Unknown)
215 {
216 MutationSendState::Unknown
217 } else if results
218 .iter()
219 .any(|result| result.send_state == MutationSendState::Sent)
220 {
221 MutationSendState::Sent
222 } else {
223 MutationSendState::NotSent
224 }
225}
226
227#[cfg(test)]
228#[path = "host_exec_many_tests.rs"]
229mod tests;