Skip to main content

a3s_box_runtime/local_execution/
router.rs

1//! Durable dispatch between the retained Box backend and the A3S OCI SDK.
2
3use std::sync::Arc;
4
5use a3s_box_core::{
6    pty::PtyRequest, ExecOutput, ExecRequest, ExecutionEventBatch, ExecutionEventsRequest,
7    ExecutionIsolation, ExecutionManagerError, ExecutionManagerResult, ExecutionProcess,
8    ExecutionProcessInventory, ExecutionResourceUpdate, ExecutionStats, FileRequest, FileResponse,
9    FilesystemRequest, FilesystemResponse, KillOutcome, OperationId,
10};
11use async_trait::async_trait;
12
13use super::{
14    LocalExecutionBackend, LocalExecutionHandle, LocalExecutionObservation,
15    LocalExecutionResourcePlan, LocalExecutionTermination,
16};
17use crate::{BoxRecord, ManagedRuntimeRoute};
18
19/// Explicit cutover policy applied only while creating a new Box record.
20///
21/// Once selected, the exact route is persisted in the record and this policy
22/// is no longer consulted for lifecycle, recovery, or cleanup operations.
23#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
24pub enum OciMigrationPolicy {
25    /// Keep both isolation choices on their current Box-owned implementation.
26    #[default]
27    LegacyOnly,
28    /// Route Sandbox to OCI while retaining the current MicroVM implementation.
29    SandboxViaOci,
30    /// Route both Sandbox and MicroVM through the public OCI SDK.
31    AllViaOci,
32}
33
34impl OciMigrationPolicy {
35    const fn route(self, isolation: ExecutionIsolation) -> ManagedRuntimeRoute {
36        match (self, isolation) {
37            (Self::LegacyOnly, _) | (Self::SandboxViaOci, ExecutionIsolation::Microvm) => {
38                ManagedRuntimeRoute::BoxVm
39            }
40            (Self::SandboxViaOci, ExecutionIsolation::Sandbox) | (Self::AllViaOci, _) => {
41                ManagedRuntimeRoute::OciSdk
42            }
43        }
44    }
45}
46
47/// One fail-closed backend router that supports mixed legacy and OCI records.
48///
49/// The router never attempts the alternate backend after an error. A policy
50/// selects only new records; explicit record metadata owns every later call.
51#[derive(Clone)]
52pub struct LocalExecutionBackendRouter {
53    legacy: Arc<dyn LocalExecutionBackend>,
54    oci: Arc<dyn LocalExecutionBackend>,
55    policy: OciMigrationPolicy,
56}
57
58impl LocalExecutionBackendRouter {
59    /// Compose the two implementations behind one immutable creation policy.
60    pub fn new(
61        legacy: Arc<dyn LocalExecutionBackend>,
62        oci: Arc<dyn LocalExecutionBackend>,
63        policy: OciMigrationPolicy,
64    ) -> Self {
65        Self {
66            legacy,
67            oci,
68            policy,
69        }
70    }
71
72    /// Policy used only for records created by this router.
73    #[must_use]
74    pub const fn policy(&self) -> OciMigrationPolicy {
75        self.policy
76    }
77
78    fn backend_for_isolation(
79        &self,
80        isolation: ExecutionIsolation,
81    ) -> &Arc<dyn LocalExecutionBackend> {
82        match self.policy.route(isolation) {
83            ManagedRuntimeRoute::BoxVm => &self.legacy,
84            ManagedRuntimeRoute::OciSdk => &self.oci,
85            ManagedRuntimeRoute::Unspecified => {
86                unreachable!("creation policy always selects a route")
87            }
88        }
89    }
90
91    fn route_for_record(&self, record: &BoxRecord) -> ExecutionManagerResult<ManagedRuntimeRoute> {
92        resolved_runtime_route(record)
93    }
94
95    fn backend_for_record(
96        &self,
97        record: &BoxRecord,
98    ) -> ExecutionManagerResult<&Arc<dyn LocalExecutionBackend>> {
99        match self.route_for_record(record)? {
100            ManagedRuntimeRoute::BoxVm => Ok(&self.legacy),
101            ManagedRuntimeRoute::OciSdk => Ok(&self.oci),
102            ManagedRuntimeRoute::Unspecified => unreachable!("route inference is exhaustive"),
103        }
104    }
105}
106
107/// Resolve records written before the route field was introduced using the
108/// same durable evidence for both router dispatch and concrete backend checks.
109pub(super) fn resolved_runtime_route(
110    record: &BoxRecord,
111) -> ExecutionManagerResult<ManagedRuntimeRoute> {
112    let metadata = record.managed_execution.as_ref().ok_or_else(|| {
113        ExecutionManagerError::Internal(format!(
114            "execution {} has no managed lifecycle metadata for backend routing",
115            record.id
116        ))
117    })?;
118    metadata
119        .validate()
120        .map_err(|error| ExecutionManagerError::Internal(error.to_string()))?;
121    match metadata.runtime_route {
122        ManagedRuntimeRoute::BoxVm => Ok(ManagedRuntimeRoute::BoxVm),
123        ManagedRuntimeRoute::OciSdk => Ok(ManagedRuntimeRoute::OciSdk),
124        ManagedRuntimeRoute::Unspecified if metadata.oci_runtime.is_some() => {
125            Ok(ManagedRuntimeRoute::OciSdk)
126        }
127        // OCI handles deliberately store no Box-owned exec socket. This
128        // preserves stopped pre-routing OCI records after their live binding
129        // has been cleared during teardown.
130        ManagedRuntimeRoute::Unspecified if record.exec_socket_path.as_os_str().is_empty() => {
131            Ok(ManagedRuntimeRoute::OciSdk)
132        }
133        ManagedRuntimeRoute::Unspecified => Ok(ManagedRuntimeRoute::BoxVm),
134    }
135}
136
137#[async_trait]
138impl LocalExecutionBackend for LocalExecutionBackendRouter {
139    async fn preflight_isolation(
140        &self,
141        isolation: ExecutionIsolation,
142    ) -> ExecutionManagerResult<()> {
143        self.backend_for_isolation(isolation)
144            .preflight_isolation(isolation)
145            .await
146    }
147
148    fn route_for_create(&self, record: &BoxRecord) -> ExecutionManagerResult<ManagedRuntimeRoute> {
149        if record.managed_execution.is_none() {
150            return Err(ExecutionManagerError::Internal(format!(
151                "new execution {} has no managed lifecycle metadata for backend routing",
152                record.id
153            )));
154        }
155        Ok(self.policy.route(record.isolation))
156    }
157
158    async fn preflight(&self, record: &BoxRecord) -> ExecutionManagerResult<()> {
159        self.backend_for_record(record)?.preflight(record).await
160    }
161
162    async fn plan_create_resources(
163        &self,
164        record: &BoxRecord,
165    ) -> ExecutionManagerResult<LocalExecutionResourcePlan> {
166        self.backend_for_record(record)?
167            .plan_create_resources(record)
168            .await
169    }
170
171    async fn start(&self, record: &BoxRecord) -> ExecutionManagerResult<LocalExecutionHandle> {
172        self.backend_for_record(record)?.start(record).await
173    }
174
175    async fn inspect(
176        &self,
177        record: &BoxRecord,
178    ) -> ExecutionManagerResult<LocalExecutionObservation> {
179        self.backend_for_record(record)?.inspect(record).await
180    }
181
182    async fn pause(
183        &self,
184        record: &BoxRecord,
185        keep_memory: bool,
186    ) -> ExecutionManagerResult<LocalExecutionHandle> {
187        self.backend_for_record(record)?
188            .pause(record, keep_memory)
189            .await
190    }
191
192    async fn resume(&self, record: &BoxRecord) -> ExecutionManagerResult<LocalExecutionHandle> {
193        self.backend_for_record(record)?.resume(record).await
194    }
195
196    async fn preflight_resource_update(
197        &self,
198        record: &BoxRecord,
199        update: &ExecutionResourceUpdate,
200    ) -> ExecutionManagerResult<()> {
201        self.backend_for_record(record)?
202            .preflight_resource_update(record, update)
203            .await
204    }
205
206    async fn update_resources(
207        &self,
208        record: &BoxRecord,
209        operation_id: &OperationId,
210        update: &ExecutionResourceUpdate,
211    ) -> ExecutionManagerResult<()> {
212        self.backend_for_record(record)?
213            .update_resources(record, operation_id, update)
214            .await
215    }
216
217    async fn list_processes(
218        &self,
219        record: &BoxRecord,
220    ) -> ExecutionManagerResult<ExecutionProcessInventory> {
221        self.backend_for_record(record)?
222            .list_processes(record)
223            .await
224    }
225
226    async fn stats(&self, record: &BoxRecord) -> ExecutionManagerResult<ExecutionStats> {
227        self.backend_for_record(record)?.stats(record).await
228    }
229
230    async fn events(
231        &self,
232        record: &BoxRecord,
233        request: ExecutionEventsRequest,
234    ) -> ExecutionManagerResult<ExecutionEventBatch> {
235        self.backend_for_record(record)?
236            .events(record, request)
237            .await
238    }
239
240    async fn execute(
241        &self,
242        record: &BoxRecord,
243        request: ExecRequest,
244    ) -> ExecutionManagerResult<ExecOutput> {
245        self.backend_for_record(record)?
246            .execute(record, request)
247            .await
248    }
249
250    async fn start_process(
251        &self,
252        record: &BoxRecord,
253        request: ExecRequest,
254    ) -> ExecutionManagerResult<ExecutionProcess> {
255        self.backend_for_record(record)?
256            .start_process(record, request)
257            .await
258    }
259
260    async fn start_pty(
261        &self,
262        record: &BoxRecord,
263        request: PtyRequest,
264    ) -> ExecutionManagerResult<ExecutionProcess> {
265        self.backend_for_record(record)?
266            .start_pty(record, request)
267            .await
268    }
269
270    async fn transfer_file(
271        &self,
272        record: &BoxRecord,
273        request: FileRequest,
274    ) -> ExecutionManagerResult<FileResponse> {
275        self.backend_for_record(record)?
276            .transfer_file(record, request)
277            .await
278    }
279
280    async fn filesystem(
281        &self,
282        record: &BoxRecord,
283        request: FilesystemRequest,
284    ) -> ExecutionManagerResult<FilesystemResponse> {
285        self.backend_for_record(record)?
286            .filesystem(record, request)
287            .await
288    }
289
290    async fn prepare_quiescent_rootfs(&self, record: &BoxRecord) -> ExecutionManagerResult<()> {
291        self.backend_for_record(record)?
292            .prepare_quiescent_rootfs(record)
293            .await
294    }
295
296    async fn cleanup_quiescent_rootfs(&self, record: &BoxRecord) -> ExecutionManagerResult<()> {
297        self.backend_for_record(record)?
298            .cleanup_quiescent_rootfs(record)
299            .await
300    }
301
302    async fn stop_for_restart(
303        &self,
304        record: &BoxRecord,
305        timeout_secs: Option<u64>,
306    ) -> ExecutionManagerResult<KillOutcome> {
307        self.backend_for_record(record)?
308            .stop_for_restart(record, timeout_secs)
309            .await
310    }
311
312    async fn kill(&self, record: &BoxRecord) -> ExecutionManagerResult<KillOutcome> {
313        self.backend_for_record(record)?.kill(record).await
314    }
315
316    async fn kill_with_status(
317        &self,
318        record: &BoxRecord,
319    ) -> ExecutionManagerResult<LocalExecutionTermination> {
320        self.backend_for_record(record)?
321            .kill_with_status(record)
322            .await
323    }
324}