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