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::cleanup_bounded_writable_layer_for_removal(&record.box_dir)
96        .map_err(|error| cleanup_error(record, "detach bounded writable-layer mounts", error))?;
97    crate::rootfs::unmount_box_rootfs(&record.box_dir.join("rootfs"));
98
99    remove_tree_if_present(&record.box_dir)
100        .map_err(|error| cleanup_error(record, "remove the execution directory", error))?;
101    remove_tree_if_present(&socket_dir)
102        .map_err(|error| cleanup_error(record, "remove the runtime socket directory", error))?;
103
104    for runtime_root in [
105        crate::vm::sandbox_runtime_root(home_dir, &record.id),
106        crate::vm::legacy_sandbox_runtime_root(home_dir, &record.id),
107    ] {
108        remove_tree_if_present(&runtime_root).map_err(|error| {
109            cleanup_error(record, "remove the Sandbox runtime state directory", error)
110        })?;
111    }
112
113    let bind_mount_dir = std::env::temp_dir().join(format!("a3s-fs-mount-{}", record.id));
114    remove_tree_if_present(&bind_mount_dir)
115        .map_err(|error| cleanup_error(record, "remove temporary bind-mount staging", error))?;
116
117    remove_host_cgroup(record)?;
118    Ok(())
119}
120
121fn validate_owned_paths(home_dir: &Path, record: &BoxRecord) -> ExecutionManagerResult<()> {
122    uuid::Uuid::parse_str(&record.id).map_err(|error| {
123        ExecutionManagerError::Internal(format!(
124            "managed execution has an invalid internal ID {}: {error}",
125            record.id
126        ))
127    })?;
128    let expected_box_dir = home_dir.join("boxes").join(&record.id);
129    if record.box_dir != expected_box_dir {
130        return Err(ExecutionManagerError::Internal(format!(
131            "managed execution {} has an unexpected host directory {}",
132            record.id,
133            record.box_dir.display()
134        )));
135    }
136
137    let internal_exec = expected_box_dir.join("sockets/exec.sock");
138    let external_exec = crate::vm::runtime_socket_dir(home_dir, &record.id).join("exec.sock");
139    if !record.exec_socket_path.as_os_str().is_empty()
140        && record.exec_socket_path != internal_exec
141        && record.exec_socket_path != external_exec
142    {
143        return Err(ExecutionManagerError::Internal(format!(
144            "managed execution {} has an unexpected exec endpoint {}",
145            record.id,
146            record.exec_socket_path.display()
147        )));
148    }
149    Ok(())
150}
151
152fn remove_anonymous_volumes(home_dir: &Path, record: &BoxRecord) -> ExecutionManagerResult<()> {
153    if record.anonymous_volumes.is_empty() {
154        return Ok(());
155    }
156    let store = crate::VolumeStore::new(home_dir.join("volumes.json"), home_dir.join("volumes"));
157    for name in &record.anonymous_volumes {
158        store
159            .remove_anonymous(name, &record.id)
160            .map_err(|error| cleanup_error(record, "remove an anonymous volume", error))?;
161    }
162    Ok(())
163}
164
165fn remove_tree_if_present(path: &Path) -> std::io::Result<()> {
166    match std::fs::remove_dir_all(path) {
167        Ok(()) => Ok(()),
168        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
169        Err(error) => Err(error),
170    }
171}
172
173fn remove_host_cgroup(record: &BoxRecord) -> ExecutionManagerResult<()> {
174    // A3S OCI Runtime owns and removes the complete Sandbox hierarchy. This
175    // compatibility cleanup is only for legacy MicroVM shim cgroups.
176    if record.isolation.is_sandbox() {
177        return Ok(());
178    }
179    #[cfg(target_os = "linux")]
180    {
181        let path = PathBuf::from("/sys/fs/cgroup/a3s-box").join(&record.id);
182        for attempt in 0..50 {
183            match std::fs::remove_dir(&path) {
184                Ok(()) => return Ok(()),
185                Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
186                Err(error) if attempt + 1 < 50 => {
187                    let _ = error;
188                    std::thread::sleep(std::time::Duration::from_millis(20));
189                }
190                Err(error) => {
191                    return Err(cleanup_error(record, "remove the host cgroup", error));
192                }
193            }
194        }
195    }
196    #[cfg(not(target_os = "linux"))]
197    let _ = record;
198    Ok(())
199}
200
201fn cleanup_error(
202    record: &BoxRecord,
203    operation: &str,
204    error: impl std::fmt::Display,
205) -> ExecutionManagerError {
206    ExecutionManagerError::Unavailable(format!(
207        "failed to {operation} for execution {}: {error}",
208        record.id
209    ))
210}
211
212#[cfg(test)]
213mod tests {
214    use std::sync::Arc;
215
216    use a3s_box_core::{
217        BoxConfig, CreateExecutionRequest, ExecutionIsolation, ExecutionManager,
218        ExecutionRecordPolicy, OperationId,
219    };
220    use async_trait::async_trait;
221
222    use super::*;
223    use crate::local_execution::{
224        LocalExecutionBackend, LocalExecutionHandle, LocalExecutionObservation,
225    };
226
227    struct UnusedBackend;
228
229    #[async_trait]
230    impl LocalExecutionBackend for UnusedBackend {
231        async fn start(&self, _record: &BoxRecord) -> ExecutionManagerResult<LocalExecutionHandle> {
232            unreachable!("removal test never starts a backend")
233        }
234
235        async fn inspect(
236            &self,
237            _record: &BoxRecord,
238        ) -> ExecutionManagerResult<LocalExecutionObservation> {
239            unreachable!("removal test never inspects a backend")
240        }
241
242        async fn pause(
243            &self,
244            _record: &BoxRecord,
245            _keep_memory: bool,
246        ) -> ExecutionManagerResult<LocalExecutionHandle> {
247            unreachable!("removal test never pauses a backend")
248        }
249
250        async fn resume(
251            &self,
252            _record: &BoxRecord,
253        ) -> ExecutionManagerResult<LocalExecutionHandle> {
254            unreachable!("removal test never resumes a backend")
255        }
256
257        async fn kill(
258            &self,
259            _record: &BoxRecord,
260        ) -> ExecutionManagerResult<a3s_box_core::KillOutcome> {
261            unreachable!("removal test never kills a backend")
262        }
263    }
264
265    #[tokio::test]
266    async fn removal_claim_cleans_owned_paths_before_forgetting_the_record() {
267        let temporary = tempfile::tempdir().unwrap();
268        let home_dir = temporary.path().join("home");
269        let manager = LocalExecutionManager::new(
270            home_dir.join("boxes.json"),
271            &home_dir,
272            Arc::new(UnusedBackend),
273        );
274        let reservation = manager
275            .create(
276                CreateExecutionRequest {
277                    external_sandbox_id: "runtime-unit-1".to_string(),
278                    config: BoxConfig {
279                        isolation: ExecutionIsolation::Sandbox,
280                        persistent: true,
281                        ..Default::default()
282                    },
283                    labels: Default::default(),
284                    policy: ExecutionRecordPolicy::default(),
285                    rootfs_snapshot_id: None,
286                },
287                &OperationId::new("runtime-create-1").unwrap(),
288            )
289            .await
290            .unwrap();
291
292        let box_dir = home_dir
293            .join("boxes")
294            .join(reservation.execution_id.as_str());
295        std::fs::create_dir_all(box_dir.join("logs")).unwrap();
296        std::fs::write(
297            box_dir.join("logs/container.json"),
298            b"retained until remove\n",
299        )
300        .unwrap();
301        let socket_dir =
302            crate::vm::runtime_socket_dir(&home_dir, reservation.execution_id.as_str());
303        std::fs::create_dir_all(&socket_dir).unwrap();
304
305        assert!(manager
306            .remove_execution(&reservation.execution_id, reservation.generation)
307            .await
308            .unwrap());
309        assert!(!box_dir.exists());
310        assert!(!socket_dir.exists());
311        assert!(manager
312            .managed_record(&reservation.execution_id)
313            .await
314            .unwrap()
315            .is_none());
316        assert!(!manager
317            .remove_execution(&reservation.execution_id, reservation.generation)
318            .await
319            .unwrap());
320    }
321}