a3s_box_runtime/local_execution/
remove.rs1use 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#[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 #[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 #[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 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 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 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}