a3s_box_runtime/local_execution/
backend.rs1use std::path::PathBuf;
4
5use a3s_box_core::{
6 pty::PtyRequest, ExecOutput, ExecRequest, ExecutionEventBatch, ExecutionEventsRequest,
7 ExecutionId, ExecutionIsolation, ExecutionManagerError, ExecutionManagerResult,
8 ExecutionProcess, ExecutionProcessInventory, ExecutionResourceUpdate, ExecutionState,
9 ExecutionStats, FileRequest, FileResponse, FilesystemRequest, FilesystemResponse, KillOutcome,
10 OperationId,
11};
12use async_trait::async_trait;
13use chrono::{DateTime, Utc};
14use std::collections::HashSet;
15
16use crate::local_execution::OciRuntimeBinding;
17use crate::{BoxRecord, ManagedRuntimeRoute};
18
19#[derive(Debug, Clone, Default, PartialEq, Eq)]
26pub struct LocalExecutionResourcePlan {
27 pub anonymous_volumes: Vec<String>,
29}
30
31impl LocalExecutionResourcePlan {
32 pub(crate) fn apply_to(self, record: &mut BoxRecord) -> ExecutionManagerResult<()> {
33 if !record.anonymous_volumes.is_empty() {
34 return Err(ExecutionManagerError::Internal(format!(
35 "new execution {} already contains anonymous-volume ownership",
36 record.id
37 )));
38 }
39
40 let mut seen = HashSet::with_capacity(self.anonymous_volumes.len());
41 for name in &self.anonymous_volumes {
42 if !valid_planned_volume_name(name) {
43 return Err(ExecutionManagerError::Internal(format!(
44 "backend planned an invalid anonymous-volume identity for {}: {name:?}",
45 record.id
46 )));
47 }
48 if !seen.insert(name) {
49 return Err(ExecutionManagerError::Internal(format!(
50 "backend planned duplicate anonymous-volume identity {name:?} for {}",
51 record.id
52 )));
53 }
54 }
55 record.anonymous_volumes = self.anonymous_volumes;
56 Ok(())
57 }
58}
59
60fn valid_planned_volume_name(name: &str) -> bool {
61 name.starts_with("anon_")
62 && name.len() <= 128
63 && name
64 .bytes()
65 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
66}
67
68#[derive(Debug, Clone)]
70pub struct LocalExecutionHandle {
71 pub started_at: DateTime<Utc>,
72 pub pid: Option<u32>,
73 pub pid_start_time: Option<u64>,
74 pub exec_socket_path: PathBuf,
75 pub console_log: PathBuf,
76 pub anonymous_volumes: Vec<String>,
77 pub oci_runtime: Option<OciRuntimeBinding>,
80}
81
82impl LocalExecutionHandle {
83 pub(crate) fn validate(&self, execution_id: &ExecutionId) -> ExecutionManagerResult<()> {
84 if self.pid.is_none() && self.pid_start_time.is_some() {
85 return Err(ExecutionManagerError::Internal(format!(
86 "backend returned a PID start time without a PID for {execution_id}"
87 )));
88 }
89 if let Some(binding) = &self.oci_runtime {
90 binding.validate_for(execution_id)?;
91 }
92 if self.exec_socket_path.as_os_str().is_empty() && self.oci_runtime.is_none() {
93 return Err(ExecutionManagerError::Internal(format!(
94 "backend returned an empty exec socket path for {execution_id}"
95 )));
96 }
97 if self.console_log.as_os_str().is_empty() {
98 return Err(ExecutionManagerError::Internal(format!(
99 "backend returned an empty console log path for {execution_id}"
100 )));
101 }
102 Ok(())
103 }
104}
105
106#[derive(Debug, Clone)]
108pub struct LocalExecutionObservation {
109 pub state: ExecutionState,
110 pub handle: Option<LocalExecutionHandle>,
111 pub exit_code: Option<i32>,
112}
113
114#[derive(Debug, Clone, Copy, PartialEq, Eq)]
117pub struct LocalExecutionTermination {
118 pub outcome: KillOutcome,
119 pub exit_code: Option<i32>,
120}
121
122impl LocalExecutionObservation {
123 pub(crate) fn validate(&self, execution_id: &ExecutionId) -> ExecutionManagerResult<()> {
124 match self.state {
125 ExecutionState::Running | ExecutionState::Paused => {
126 self.handle.as_ref().ok_or_else(|| {
127 ExecutionManagerError::Internal(format!(
128 "backend returned {:?} without runtime evidence for {execution_id}",
129 self.state
130 ))
131 })?;
132 }
133 ExecutionState::Created
134 | ExecutionState::Creating
135 | ExecutionState::Stopped
136 | ExecutionState::Failed => {}
137 }
138 if let Some(handle) = &self.handle {
139 handle.validate(execution_id)?;
140 }
141 Ok(())
142 }
143}
144
145#[async_trait]
150pub trait LocalExecutionBackend: Send + Sync {
151 async fn preflight_isolation(
155 &self,
156 _isolation: ExecutionIsolation,
157 ) -> ExecutionManagerResult<()> {
158 Ok(())
159 }
160
161 fn route_for_create(&self, _record: &BoxRecord) -> ExecutionManagerResult<ManagedRuntimeRoute> {
167 Ok(ManagedRuntimeRoute::Unspecified)
168 }
169
170 async fn preflight(&self, _record: &BoxRecord) -> ExecutionManagerResult<()> {
173 Ok(())
174 }
175
176 async fn plan_create_resources(
181 &self,
182 _record: &BoxRecord,
183 ) -> ExecutionManagerResult<LocalExecutionResourcePlan> {
184 Ok(LocalExecutionResourcePlan::default())
185 }
186
187 async fn start(&self, record: &BoxRecord) -> ExecutionManagerResult<LocalExecutionHandle>;
188
189 async fn inspect(
190 &self,
191 record: &BoxRecord,
192 ) -> ExecutionManagerResult<LocalExecutionObservation>;
193
194 async fn pause(
195 &self,
196 record: &BoxRecord,
197 keep_memory: bool,
198 ) -> ExecutionManagerResult<LocalExecutionHandle>;
199
200 async fn resume(&self, record: &BoxRecord) -> ExecutionManagerResult<LocalExecutionHandle>;
201
202 async fn preflight_resource_update(
204 &self,
205 _record: &BoxRecord,
206 _update: &ExecutionResourceUpdate,
207 ) -> ExecutionManagerResult<()> {
208 Err(ExecutionManagerError::Unavailable(
209 "this execution backend does not support live resource updates".to_string(),
210 ))
211 }
212
213 async fn update_resources(
215 &self,
216 _record: &BoxRecord,
217 _operation_id: &OperationId,
218 _update: &ExecutionResourceUpdate,
219 ) -> ExecutionManagerResult<()> {
220 Err(ExecutionManagerError::Unavailable(
221 "this execution backend does not support live resource updates".to_string(),
222 ))
223 }
224
225 async fn list_processes(
227 &self,
228 _record: &BoxRecord,
229 ) -> ExecutionManagerResult<ExecutionProcessInventory> {
230 Err(ExecutionManagerError::Unavailable(
231 "this execution backend does not expose process inventory".to_string(),
232 ))
233 }
234
235 async fn stats(&self, _record: &BoxRecord) -> ExecutionManagerResult<ExecutionStats> {
237 Err(ExecutionManagerError::Unavailable(
238 "this execution backend does not expose runtime stats".to_string(),
239 ))
240 }
241
242 async fn events(
244 &self,
245 _record: &BoxRecord,
246 _request: ExecutionEventsRequest,
247 ) -> ExecutionManagerResult<ExecutionEventBatch> {
248 Err(ExecutionManagerError::Unavailable(
249 "this execution backend does not expose runtime events".to_string(),
250 ))
251 }
252
253 async fn execute(
257 &self,
258 _record: &BoxRecord,
259 _request: ExecRequest,
260 ) -> ExecutionManagerResult<ExecOutput> {
261 Err(ExecutionManagerError::Unavailable(
262 "this execution backend does not expose captured process sessions".to_string(),
263 ))
264 }
265
266 async fn start_process(
268 &self,
269 _record: &BoxRecord,
270 _request: ExecRequest,
271 ) -> ExecutionManagerResult<ExecutionProcess> {
272 Err(ExecutionManagerError::Unavailable(
273 "this execution backend does not expose streaming process sessions".to_string(),
274 ))
275 }
276
277 async fn start_pty(
279 &self,
280 _record: &BoxRecord,
281 _request: PtyRequest,
282 ) -> ExecutionManagerResult<ExecutionProcess> {
283 Err(ExecutionManagerError::Unavailable(
284 "this execution backend does not expose PTY sessions".to_string(),
285 ))
286 }
287
288 async fn transfer_file(
290 &self,
291 _record: &BoxRecord,
292 _request: FileRequest,
293 ) -> ExecutionManagerResult<FileResponse> {
294 Err(ExecutionManagerError::Unavailable(
295 "this execution backend does not expose file transfer sessions".to_string(),
296 ))
297 }
298
299 async fn filesystem(
301 &self,
302 _record: &BoxRecord,
303 _request: FilesystemRequest,
304 ) -> ExecutionManagerResult<FilesystemResponse> {
305 Err(ExecutionManagerError::Unavailable(
306 "this execution backend does not expose filesystem sessions".to_string(),
307 ))
308 }
309
310 async fn prepare_quiescent_rootfs(&self, _record: &BoxRecord) -> ExecutionManagerResult<()> {
313 Ok(())
314 }
315
316 async fn cleanup_quiescent_rootfs(&self, _record: &BoxRecord) -> ExecutionManagerResult<()> {
319 Ok(())
320 }
321
322 async fn stop_for_restart(
325 &self,
326 record: &BoxRecord,
327 _timeout_secs: Option<u64>,
328 ) -> ExecutionManagerResult<KillOutcome> {
329 self.kill(record).await
330 }
331
332 async fn kill(&self, record: &BoxRecord) -> ExecutionManagerResult<KillOutcome>;
333
334 async fn kill_with_status(
338 &self,
339 record: &BoxRecord,
340 ) -> ExecutionManagerResult<LocalExecutionTermination> {
341 Ok(LocalExecutionTermination {
342 outcome: self.kill(record).await?,
343 exit_code: None,
344 })
345 }
346}