Skip to main content

a3s_box_runtime/sandbox/
controller.rs

1//! Shared launch utilities for the A3S OCI Runtime Sandbox owner.
2
3#[cfg(target_os = "linux")]
4use std::fs::File;
5use std::fs::OpenOptions;
6#[cfg(target_os = "linux")]
7use std::io::{Read, Seek, SeekFrom};
8use std::path::{Path, PathBuf};
9#[cfg(target_os = "linux")]
10use std::process::{Command, Stdio};
11#[cfg(target_os = "linux")]
12use std::time::{Duration, Instant};
13
14use a3s_box_core::error::{BoxError, Result};
15use a3s_box_core::execution::ResolvedExecutionPlan;
16use a3s_box_core::log::LogConfig;
17#[cfg(target_os = "linux")]
18use a3s_box_core::log::{SandboxLogWorkerSpec, SANDBOX_LOG_WORKER_SCHEMA};
19use oci_spec::runtime::Spec;
20use serde::Serialize;
21
22use super::capability::SandboxCapabilitySnapshot;
23
24#[cfg(target_os = "linux")]
25pub(crate) const EXEC_LISTENER_FD: i32 = 3;
26#[cfg(target_os = "linux")]
27pub(crate) const PTY_LISTENER_FD: i32 = 4;
28#[cfg(target_os = "linux")]
29pub(crate) const INIT_LOG_FD: i32 = 5;
30#[cfg(target_os = "linux")]
31pub(crate) const START_TIMEOUT: Duration = Duration::from_secs(10);
32#[cfg(target_os = "linux")]
33const START_FAILURE_LOG_LIMIT_BYTES: u64 = 4 * 1024;
34
35/// Files and sockets required to launch a generated OCI bundle.
36pub struct SandboxLaunchSpec {
37    pub container_id: String,
38    pub bundle_dir: PathBuf,
39    pub runtime_root: PathBuf,
40    pub runtime_record: PathBuf,
41    pub exec_socket_path: PathBuf,
42    pub pty_socket_path: PathBuf,
43    pub stdout_path: PathBuf,
44    pub stderr_path: PathBuf,
45    pub init_log_path: PathBuf,
46    pub log_config: LogConfig,
47    pub log_worker_path: PathBuf,
48    pub log_worker_log_path: PathBuf,
49    pub log_worker_ready_path: PathBuf,
50}
51
52/// Persist generated artifacts without accepting user-supplied OCI JSON.
53pub fn write_bundle(
54    bundle_dir: &Path,
55    spec: &Spec,
56    execution_plan: &ResolvedExecutionPlan,
57    capabilities: &SandboxCapabilitySnapshot,
58) -> Result<()> {
59    create_private_dir(bundle_dir)?;
60    write_json_atomic(&bundle_dir.join("config.json"), spec)?;
61    write_json_atomic(&bundle_dir.join("execution-plan.json"), execution_plan)?;
62    write_json_atomic(&bundle_dir.join("capabilities.json"), capabilities)?;
63    Ok(())
64}
65
66pub(crate) fn write_json_atomic(path: &Path, value: &impl Serialize) -> Result<()> {
67    use std::io::Write;
68    #[cfg(unix)]
69    use std::os::unix::fs::OpenOptionsExt;
70
71    let parent = path.parent().ok_or_else(|| {
72        BoxError::ConfigError(format!(
73            "Sandbox artifact has no parent: {}",
74            path.display()
75        ))
76    })?;
77    create_private_dir(parent)?;
78    let temporary = path.with_extension(format!("tmp-{}", uuid::Uuid::new_v4()));
79    let bytes = serde_json::to_vec_pretty(value).map_err(|error| {
80        BoxError::SerializationError(format!("Failed to encode Sandbox artifact: {error}"))
81    })?;
82    let mut options = OpenOptions::new();
83    options.create_new(true).write(true);
84    #[cfg(unix)]
85    options.mode(0o600);
86    let mut file = options.open(&temporary).map_err(BoxError::IoError)?;
87    file.write_all(&bytes).map_err(BoxError::IoError)?;
88    file.write_all(b"\n").map_err(BoxError::IoError)?;
89    file.sync_all().map_err(BoxError::IoError)?;
90    std::fs::rename(&temporary, path).map_err(BoxError::IoError)?;
91    Ok(())
92}
93
94pub(crate) fn create_private_dir(path: &Path) -> Result<()> {
95    std::fs::create_dir_all(path).map_err(BoxError::IoError)?;
96    #[cfg(unix)]
97    {
98        use std::os::unix::fs::PermissionsExt;
99        std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))
100            .map_err(BoxError::IoError)?;
101    }
102    Ok(())
103}
104
105#[cfg(target_os = "linux")]
106pub(crate) fn open_log(path: &Path) -> Result<File> {
107    use std::os::unix::fs::OpenOptionsExt;
108
109    let parent = path.parent().ok_or_else(|| {
110        BoxError::ConfigError(format!("Sandbox log has no parent: {}", path.display()))
111    })?;
112    create_private_dir(parent)?;
113    let mut options = OpenOptions::new();
114    options.create(true).truncate(true).write(true).mode(0o600);
115    options.open(path).map_err(BoxError::IoError)
116}
117
118#[cfg(target_os = "linux")]
119pub(crate) fn start_log_worker(
120    launch: &SandboxLaunchSpec,
121    watched_pid: u32,
122    watched_pid_start_time: u64,
123) -> Result<std::process::Child> {
124    let _ = std::fs::remove_file(&launch.log_worker_ready_path);
125    let worker_spec = SandboxLogWorkerSpec {
126        schema: SANDBOX_LOG_WORKER_SCHEMA.to_string(),
127        box_id: launch.container_id.clone(),
128        console_log: launch.stdout_path.clone(),
129        log_config: launch.log_config.clone(),
130        watched_pid,
131        watched_pid_start_time,
132        ready_file: launch.log_worker_ready_path.clone(),
133    };
134    let config = serde_json::to_string(&worker_spec).map_err(|error| {
135        BoxError::SerializationError(format!(
136            "Failed to encode Sandbox log worker configuration: {error}"
137        ))
138    })?;
139    let stdout = open_log(&launch.log_worker_log_path)?;
140    let stderr = stdout.try_clone().map_err(BoxError::IoError)?;
141    let mut worker = Command::new(&launch.log_worker_path)
142        .arg("--sandbox-log-worker-config")
143        .arg(config)
144        .env("LC_ALL", "C")
145        .stdin(Stdio::null())
146        .stdout(Stdio::from(stdout))
147        .stderr(Stdio::from(stderr))
148        .spawn()
149        .map_err(|error| BoxError::BoxBootError {
150            message: format!("Failed to start Sandbox log worker: {error}"),
151            hint: None,
152        })?;
153
154    let deadline = Instant::now() + Duration::from_secs(3);
155    loop {
156        if launch.log_worker_ready_path.is_file() {
157            return Ok(worker);
158        }
159        match worker.try_wait() {
160            Ok(Some(status)) => {
161                let diagnostics =
162                    read_log_tail(&launch.log_worker_log_path, START_FAILURE_LOG_LIMIT_BYTES)
163                        .map(|excerpt| format!(": {excerpt}"))
164                        .unwrap_or_default();
165                return Err(BoxError::BoxBootError {
166                    message: format!(
167                        "Sandbox log worker exited before readiness with {status}{diagnostics}"
168                    ),
169                    hint: None,
170                });
171            }
172            Ok(None) => {}
173            Err(error) => return Err(BoxError::IoError(error)),
174        }
175        if Instant::now() >= deadline {
176            reap_failed_log_worker(&mut worker);
177            return Err(BoxError::BoxBootError {
178                message: "Timed out waiting for Sandbox log worker readiness".to_string(),
179                hint: None,
180            });
181        }
182        std::thread::sleep(Duration::from_millis(5));
183    }
184}
185
186#[cfg(target_os = "linux")]
187pub(crate) fn reap_failed_log_worker(worker: &mut std::process::Child) {
188    let deadline = Instant::now() + Duration::from_secs(1);
189    loop {
190        match worker.try_wait() {
191            Ok(Some(_)) => return,
192            Ok(None) if Instant::now() < deadline => {
193                std::thread::sleep(Duration::from_millis(10));
194            }
195            _ => break,
196        }
197    }
198    let _ = worker.kill();
199    let _ = worker.wait();
200}
201
202#[cfg(target_os = "linux")]
203pub(crate) fn bind_control_listener(path: &Path) -> Result<std::os::unix::net::UnixListener> {
204    use std::os::unix::fs::{FileTypeExt, PermissionsExt};
205
206    let parent = path.parent().ok_or_else(|| {
207        BoxError::ConfigError(format!("Sandbox socket has no parent: {}", path.display()))
208    })?;
209    create_private_dir(parent)?;
210    match std::fs::symlink_metadata(path) {
211        Ok(metadata) if metadata.file_type().is_socket() => {
212            std::fs::remove_file(path).map_err(BoxError::IoError)?;
213        }
214        Ok(_) => {
215            return Err(BoxError::BoxBootError {
216                message: format!(
217                    "Refusing to replace non-socket Sandbox control path {}",
218                    path.display()
219                ),
220                hint: None,
221            });
222        }
223        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
224        Err(error) => return Err(BoxError::IoError(error)),
225    }
226    let listener = std::os::unix::net::UnixListener::bind(path).map_err(BoxError::IoError)?;
227    std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
228        .map_err(BoxError::IoError)?;
229    Ok(listener)
230}
231
232#[cfg(target_os = "linux")]
233pub(crate) fn duplicate_for_inheritance(fd: i32) -> Result<std::os::fd::OwnedFd> {
234    use std::os::fd::{FromRawFd, OwnedFd};
235
236    let duplicate = unsafe { libc::fcntl(fd, libc::F_DUPFD_CLOEXEC, 10) };
237    if duplicate < 0 {
238        return Err(BoxError::IoError(std::io::Error::last_os_error()));
239    }
240    // SAFETY: F_DUPFD_CLOEXEC returned a new descriptor owned by this process.
241    Ok(unsafe { OwnedFd::from_raw_fd(duplicate) })
242}
243
244#[cfg(target_os = "linux")]
245pub(crate) fn read_log_tail(path: &Path, limit: u64) -> Option<String> {
246    let mut file = File::open(path).ok()?;
247    let length = file.metadata().ok()?.len();
248    let offset = length.saturating_sub(limit);
249    file.seek(SeekFrom::Start(offset)).ok()?;
250
251    let mut bytes = Vec::with_capacity((length - offset) as usize);
252    file.take(limit).read_to_end(&mut bytes).ok()?;
253    let excerpt = String::from_utf8_lossy(&bytes).trim().to_string();
254    if excerpt.is_empty() {
255        None
256    } else if offset > 0 {
257        Some(format!("...{excerpt}"))
258    } else {
259        Some(excerpt)
260    }
261}
262
263#[cfg(all(test, target_os = "linux"))]
264mod tests {
265    use super::*;
266
267    #[test]
268    fn startup_log_excerpt_is_bounded_and_keeps_the_tail() {
269        let temporary = tempfile::tempdir().unwrap();
270        let path = temporary.path().join("runtime.stderr.log");
271        let mut contents = "x".repeat(START_FAILURE_LOG_LIMIT_BYTES as usize + 512);
272        contents.push_str("\nseccomp unknown architecture `NATIVE`\n");
273        std::fs::write(&path, contents).unwrap();
274
275        let excerpt = read_log_tail(&path, START_FAILURE_LOG_LIMIT_BYTES).unwrap();
276        assert!(excerpt.starts_with("..."));
277        assert!(excerpt.contains("seccomp unknown architecture `NATIVE`"));
278        assert!(excerpt.len() <= START_FAILURE_LOG_LIMIT_BYTES as usize + 3);
279    }
280}