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 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 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 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 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}