Skip to main content

a3s_box_runtime/local_execution/
remove.rs

1//! Durable removal and complete host-resource cleanup for managed executions.
2
3use std::path::Path;
4#[cfg(target_os = "linux")]
5use std::path::PathBuf;
6
7use a3s_box_core::{
8    ExecutionGeneration, ExecutionId, ExecutionManagerError, ExecutionManagerResult,
9};
10
11use super::record::execution_id;
12use super::store::run_store;
13use super::support::generation;
14use super::{BoxRecord, LocalExecutionManager};
15
16impl LocalExecutionManager {
17    /// Load one managed record without reconciling provider state.
18    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
19    pub(crate) async fn managed_record(
20        &self,
21        execution_id: &ExecutionId,
22    ) -> ExecutionManagerResult<Option<BoxRecord>> {
23        self.get(execution_id).await
24    }
25
26    /// Load the complete managed inventory without exposing legacy CLI boxes.
27    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
28    pub(crate) async fn managed_records(&self) -> ExecutionManagerResult<Vec<BoxRecord>> {
29        let store = self.store.clone();
30        run_store(move || store.list()).await
31    }
32
33    /// Remove one terminal generation after durably claiming its teardown.
34    ///
35    /// A failed cleanup deliberately leaves the record in `removing`. Retrying
36    /// the same generation resumes cleanup before the record is forgotten.
37    pub async fn remove_execution(
38        &self,
39        execution_id: &ExecutionId,
40        expected_generation: ExecutionGeneration,
41    ) -> ExecutionManagerResult<bool> {
42        let _lifecycle_lock =
43            super::lifecycle_lock::acquire(&self.home_dir, execution_id.as_str()).await?;
44        let store = self.store.clone();
45        let claimed_id = execution_id.clone();
46        let claimed =
47            run_store(move || store.begin_remove(&claimed_id, expected_generation)).await?;
48        let Some(record) = claimed else {
49            return Ok(false);
50        };
51        self.finish_remove(record).await
52    }
53
54    pub(super) async fn finish_remove(&self, record: BoxRecord) -> ExecutionManagerResult<bool> {
55        let execution_id = execution_id(&record)?;
56        let expected_generation = generation(&record, &execution_id)?;
57
58        // Detach shared stores before deleting execution-owned paths. Both the
59        // detach and the path cleanup are idempotent, so a crash can replay.
60        self.release_execution_resources(&record).await?;
61
62        let home_dir = self.home_dir.clone();
63        let cleanup_record = record.clone();
64        tokio::task::spawn_blocking(move || cleanup_execution_paths(&home_dir, &cleanup_record))
65            .await
66            .map_err(|error| {
67                ExecutionManagerError::Internal(format!(
68                    "managed removal task failed for {execution_id}: {error}"
69                ))
70            })??;
71
72        let store = self.store.clone();
73        let removed_id = execution_id.clone();
74        run_store(move || store.finish_remove(&removed_id, expected_generation)).await
75    }
76}
77
78fn cleanup_execution_paths(home_dir: &Path, record: &BoxRecord) -> ExecutionManagerResult<()> {
79    validate_owned_paths(home_dir, record)?;
80
81    if record.isolation.is_sandbox() {
82        crate::vm::reap::cleanup_recorded_sandbox_runtime_in(home_dir, &record.box_dir, &record.id)
83            .map_err(|error| cleanup_error(record, "delete the recorded Sandbox runtime", error))?;
84        crate::sandbox::cleanup_sandbox_mount_aliases(home_dir, &record.id)
85            .map_err(|error| cleanup_error(record, "detach Sandbox attachment aliases", error))?;
86    }
87
88    remove_anonymous_volumes(home_dir, record)?;
89
90    let socket_dir = crate::vm::runtime_socket_dir(home_dir, &record.id);
91    #[cfg(target_os = "linux")]
92    crate::network::terminate_passt(&socket_dir);
93
94    crate::rootfs::unmount_box_overlay(&record.box_dir.join("merged"));
95    crate::rootfs::unmount_box_rootfs(&record.box_dir.join("rootfs"));
96
97    remove_tree_if_present(&record.box_dir)
98        .map_err(|error| cleanup_error(record, "remove the execution directory", error))?;
99    remove_tree_if_present(&socket_dir)
100        .map_err(|error| cleanup_error(record, "remove the runtime socket directory", error))?;
101
102    for runtime_root in [
103        crate::vm::sandbox_runtime_root(home_dir, &record.id),
104        crate::vm::legacy_sandbox_runtime_root(home_dir, &record.id),
105    ] {
106        remove_tree_if_present(&runtime_root).map_err(|error| {
107            cleanup_error(record, "remove the Sandbox runtime state directory", error)
108        })?;
109    }
110
111    let bind_mount_dir = std::env::temp_dir().join(format!("a3s-fs-mount-{}", record.id));
112    remove_tree_if_present(&bind_mount_dir)
113        .map_err(|error| cleanup_error(record, "remove temporary bind-mount staging", error))?;
114
115    remove_host_cgroup(record)?;
116    Ok(())
117}
118
119fn validate_owned_paths(home_dir: &Path, record: &BoxRecord) -> ExecutionManagerResult<()> {
120    uuid::Uuid::parse_str(&record.id).map_err(|error| {
121        ExecutionManagerError::Internal(format!(
122            "managed execution has an invalid internal ID {}: {error}",
123            record.id
124        ))
125    })?;
126    let expected_box_dir = home_dir.join("boxes").join(&record.id);
127    if record.box_dir != expected_box_dir {
128        return Err(ExecutionManagerError::Internal(format!(
129            "managed execution {} has an unexpected host directory {}",
130            record.id,
131            record.box_dir.display()
132        )));
133    }
134
135    let internal_exec = expected_box_dir.join("sockets/exec.sock");
136    let external_exec = crate::vm::runtime_socket_dir(home_dir, &record.id).join("exec.sock");
137    if !record.exec_socket_path.as_os_str().is_empty()
138        && record.exec_socket_path != internal_exec
139        && record.exec_socket_path != external_exec
140    {
141        return Err(ExecutionManagerError::Internal(format!(
142            "managed execution {} has an unexpected exec endpoint {}",
143            record.id,
144            record.exec_socket_path.display()
145        )));
146    }
147    Ok(())
148}
149
150fn remove_anonymous_volumes(home_dir: &Path, record: &BoxRecord) -> ExecutionManagerResult<()> {
151    if record.anonymous_volumes.is_empty() {
152        return Ok(());
153    }
154    let store = crate::VolumeStore::new(home_dir.join("volumes.json"), home_dir.join("volumes"));
155    for name in &record.anonymous_volumes {
156        store
157            .remove_anonymous(name, &record.id)
158            .map_err(|error| cleanup_error(record, "remove an anonymous volume", error))?;
159    }
160    Ok(())
161}
162
163fn remove_tree_if_present(path: &Path) -> std::io::Result<()> {
164    match std::fs::remove_dir_all(path) {
165        Ok(()) => Ok(()),
166        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
167        Err(error) => Err(error),
168    }
169}
170
171fn remove_host_cgroup(record: &BoxRecord) -> ExecutionManagerResult<()> {
172    // A3S OCI Runtime owns and removes the complete Sandbox hierarchy. This
173    // compatibility cleanup is only for legacy MicroVM shim cgroups.
174    if record.isolation.is_sandbox() {
175        return Ok(());
176    }
177    #[cfg(target_os = "linux")]
178    {
179        let path = PathBuf::from("/sys/fs/cgroup/a3s-box").join(&record.id);
180        for attempt in 0..50 {
181            match std::fs::remove_dir(&path) {
182                Ok(()) => return Ok(()),
183                Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
184                Err(error) if attempt + 1 < 50 => {
185                    let _ = error;
186                    std::thread::sleep(std::time::Duration::from_millis(20));
187                }
188                Err(error) => {
189                    return Err(cleanup_error(record, "remove the host cgroup", error));
190                }
191            }
192        }
193    }
194    #[cfg(not(target_os = "linux"))]
195    let _ = record;
196    Ok(())
197}
198
199fn cleanup_error(
200    record: &BoxRecord,
201    operation: &str,
202    error: impl std::fmt::Display,
203) -> ExecutionManagerError {
204    ExecutionManagerError::Unavailable(format!(
205        "failed to {operation} for execution {}: {error}",
206        record.id
207    ))
208}
209
210#[cfg(test)]
211mod tests {
212    use std::sync::Arc;
213
214    use a3s_box_core::{
215        BoxConfig, CreateExecutionRequest, ExecutionIsolation, ExecutionManager,
216        ExecutionRecordPolicy, OperationId,
217    };
218    use async_trait::async_trait;
219
220    use super::*;
221    use crate::local_execution::{
222        LocalExecutionBackend, LocalExecutionHandle, LocalExecutionObservation,
223    };
224
225    struct UnusedBackend;
226
227    #[async_trait]
228    impl LocalExecutionBackend for UnusedBackend {
229        async fn start(&self, _record: &BoxRecord) -> ExecutionManagerResult<LocalExecutionHandle> {
230            unreachable!("removal test never starts a backend")
231        }
232
233        async fn inspect(
234            &self,
235            _record: &BoxRecord,
236        ) -> ExecutionManagerResult<LocalExecutionObservation> {
237            unreachable!("removal test never inspects a backend")
238        }
239
240        async fn pause(
241            &self,
242            _record: &BoxRecord,
243            _keep_memory: bool,
244        ) -> ExecutionManagerResult<LocalExecutionHandle> {
245            unreachable!("removal test never pauses a backend")
246        }
247
248        async fn resume(
249            &self,
250            _record: &BoxRecord,
251        ) -> ExecutionManagerResult<LocalExecutionHandle> {
252            unreachable!("removal test never resumes a backend")
253        }
254
255        async fn kill(
256            &self,
257            _record: &BoxRecord,
258        ) -> ExecutionManagerResult<a3s_box_core::KillOutcome> {
259            unreachable!("removal test never kills a backend")
260        }
261    }
262
263    #[tokio::test]
264    async fn removal_claim_cleans_owned_paths_before_forgetting_the_record() {
265        let temporary = tempfile::tempdir().unwrap();
266        let home_dir = temporary.path().join("home");
267        let manager = LocalExecutionManager::new(
268            home_dir.join("boxes.json"),
269            &home_dir,
270            Arc::new(UnusedBackend),
271        );
272        let reservation = manager
273            .create(
274                CreateExecutionRequest {
275                    external_sandbox_id: "runtime-unit-1".to_string(),
276                    config: BoxConfig {
277                        isolation: ExecutionIsolation::Sandbox,
278                        persistent: true,
279                        ..Default::default()
280                    },
281                    labels: Default::default(),
282                    policy: ExecutionRecordPolicy::default(),
283                    rootfs_snapshot_id: None,
284                },
285                &OperationId::new("runtime-create-1").unwrap(),
286            )
287            .await
288            .unwrap();
289
290        let box_dir = home_dir
291            .join("boxes")
292            .join(reservation.execution_id.as_str());
293        std::fs::create_dir_all(box_dir.join("logs")).unwrap();
294        std::fs::write(
295            box_dir.join("logs/container.json"),
296            b"retained until remove\n",
297        )
298        .unwrap();
299        let socket_dir =
300            crate::vm::runtime_socket_dir(&home_dir, reservation.execution_id.as_str());
301        std::fs::create_dir_all(&socket_dir).unwrap();
302
303        assert!(manager
304            .remove_execution(&reservation.execution_id, reservation.generation)
305            .await
306            .unwrap());
307        assert!(!box_dir.exists());
308        assert!(!socket_dir.exists());
309        assert!(manager
310            .managed_record(&reservation.execution_id)
311            .await
312            .unwrap()
313            .is_none());
314        assert!(!manager
315            .remove_execution(&reservation.execution_id, reservation.generation)
316            .await
317            .unwrap());
318    }
319}