Skip to main content

a3s_box_runtime/local_execution/
backend.rs

1//! Injectable process/runtime boundary for local execution orchestration.
2
3use 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/// Product resources derived by a backend before a new execution is reserved.
20///
21/// Planning may resolve immutable image metadata, but it must not create any
22/// execution-owned resource. The manager validates and persists this plan in
23/// the [`BoxRecord`] before `start` may materialize it, so restart recovery and
24/// removal never have to infer ownership from host artifacts.
25#[derive(Debug, Clone, Default, PartialEq, Eq)]
26pub struct LocalExecutionResourcePlan {
27    /// Exact Box-owned anonymous volume identities required by the image.
28    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/// Runtime evidence persisted after an execution becomes ready.
69#[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    /// Exact A3S OCI identity when lifecycle ownership is delegated through
78    /// the public SDK rather than a Box-owned VM process.
79    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/// One backend observation used during inspection and restart recovery.
107#[derive(Debug, Clone)]
108pub struct LocalExecutionObservation {
109    pub state: ExecutionState,
110    pub handle: Option<LocalExecutionHandle>,
111    pub exit_code: Option<i32>,
112}
113
114/// Result of a terminal backend operation, including authoritative status when
115/// the runtime was able to observe it before teardown.
116#[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/// Backend operations invoked outside the durable state lock.
146///
147/// Implementations must key all host/runtime paths by [`BoxRecord::id`]. The
148/// external sandbox ID in managed metadata is an untrusted diagnostic label.
149#[async_trait]
150pub trait LocalExecutionBackend: Send + Sync {
151    /// Perform any backend-only launch-readiness probe available before callers
152    /// execute external image operations. Backends without a context-free probe
153    /// may defer to record preflight. Mutable checks must be repeated there.
154    async fn preflight_isolation(
155        &self,
156        _isolation: ExecutionIsolation,
157    ) -> ExecutionManagerResult<()> {
158        Ok(())
159    }
160
161    /// Select the durable route for a new record before capability preflight.
162    ///
163    /// Custom test or embedding backends may leave the route unspecified. Box
164    /// production backends must return an exact route so a later policy change
165    /// cannot reinterpret a claimed or stopped generation.
166    fn route_for_create(&self, _record: &BoxRecord) -> ExecutionManagerResult<ManagedRuntimeRoute> {
167        Ok(ManagedRuntimeRoute::Unspecified)
168    }
169
170    /// Reject an unsupported execution before its durable reservation is
171    /// published. Backends must repeat mutable capability checks at launch.
172    async fn preflight(&self, _record: &BoxRecord) -> ExecutionManagerResult<()> {
173        Ok(())
174    }
175
176    /// Derive execution-owned resource identities without materializing them.
177    ///
178    /// The manager invokes this only after backend preflight and publishes the
179    /// returned identities as part of the initial durable reservation.
180    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    /// Validate a complete live resource contract before a durable mutation claim.
203    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    /// Apply the exact persisted resource update operation.
214    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    /// Return live init and exec processes for the record's exact runtime target.
226    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    /// Return normalized counters for the record's exact runtime target.
236    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    /// Poll ordered events for the record's exact runtime target.
243    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    /// Execute one captured process through a backend-owned, generation-fenced
254    /// session boundary. The legacy VM backend retains its socket transport;
255    /// SDK-backed implementations override this method.
256    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    /// Start one streaming non-terminal process through the backend boundary.
267    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    /// Start one interactive terminal process through the backend boundary.
278    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    /// Transfer one file through the backend's exact-generation session.
289    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    /// Inspect or mutate the exact generation's workload filesystem.
300    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    /// Make a stopped, storage-retained rootfs available for a filesystem
311    /// snapshot without starting the execution runtime.
312    async fn prepare_quiescent_rootfs(&self, _record: &BoxRecord) -> ExecutionManagerResult<()> {
313        Ok(())
314    }
315
316    /// Release any transient rootfs mount created by
317    /// [`Self::prepare_quiescent_rootfs`] while retaining guest data.
318    async fn cleanup_quiescent_rootfs(&self, _record: &BoxRecord) -> ExecutionManagerResult<()> {
319        Ok(())
320    }
321
322    /// Stop the current runtime while preserving execution-owned storage for
323    /// the replacement generation.
324    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    /// Kill the current execution and return its exact terminal status when the
335    /// backend provides one. Implementations that cannot observe status retain
336    /// the legacy `kill` behavior through this default.
337    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}