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