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