//! Docker 容器执行封装:在预热池容器中执行命令、构建容器配置、镜像管理。 //! //! 与 executor.rs 共享 ExecOutcome / ExecError 类型。 //! 调用方(warm_pool)先 acquire 容器句柄,再调用 exec_in_container 执行。 use std::collections::HashMap; use std::time::{Duration, Instant}; use bollard::container::Config; use bollard::exec::{CreateExecOptions, StartExecResults}; use bollard::image::CreateImageOptions; use bollard::container::LogOutput; use bollard::models::HostConfig; use bollard::{Docker, API_DEFAULT_VERSION}; use futures_util::StreamExt; use crate::config::WarmPoolConfig; use crate::executor::{ExecError, ExecOutcome, StreamEvent}; /// 在已运行的容器中执行一条命令,返回 ExecOutcome(与进程模式相同的接口)。 /// /// # 参数 /// - `argv`:命令及参数数组 /// - `stdin`:可选标准输入 /// - `env`:环境变量键值对 /// - `timeout`:执行超时 /// - `max_output`:单流最大捕获字节数 pub async fn exec_in_container( docker: &Docker, container_id: &str, argv: &[String], stdin: Option, env: &[(String, String)], timeout: Duration, max_output: usize, ) -> Result { let start = Instant::now(); // 1) 创建 exec 实例 let exec = docker .create_exec( container_id, CreateExecOptions { cmd: Some(argv.to_vec()), env: Some( env.iter() .map(|(k, v)| format!("{k}={v}")) .collect::>(), ), attach_stdout: Some(true), attach_stderr: Some(true), attach_stdin: Some(stdin.is_some()), ..Default::default() }, ) .await .map_err(|e| ExecError::Internal { user_msg: "创建容器 exec 失败", detail: format!("create_exec 失败: {e}"), })?; // 2) 启动 exec 并捕获输出 let mut stdout = Vec::new(); let mut stderr = Vec::new(); let mut stdout_truncated = false; let mut stderr_truncated = false; let mut timeout_hit = false; let cancelled = false; let exec_result = tokio::time::timeout(timeout, async { match docker.start_exec(&exec.id, None).await { Ok(StartExecResults::Attached { mut output, mut input, }) => { // 先写入 stdin(若有),然后关闭输入管道 if let Some(data) = &stdin { use tokio::io::AsyncWriteExt; let _ = input.write_all(data.as_bytes()).await; let _ = input.shutdown().await; } drop(input); // 关闭 stdin 触发命令的 EOF while let Some(chunk) = output.next().await { match chunk { Ok(LogOutput::StdOut { message }) => { let data = message.as_ref(); if stdout.len() < max_output { let room = max_output - stdout.len(); let take = data.len().min(room); stdout.extend_from_slice(&data[..take]); if take < data.len() { stdout_truncated = true; } } else { stdout_truncated = true; } } Ok(LogOutput::StdErr { message }) => { let data = message.as_ref(); if stderr.len() < max_output { let room = max_output - stderr.len(); let take = data.len().min(room); stderr.extend_from_slice(&data[..take]); if take < data.len() { stderr_truncated = true; } } else { stderr_truncated = true; } } Ok(_) => {} Err(e) => { tracing::warn!(error = %e, exec_id = %exec.id, "容器 exec 流错误"); break; } } } } Ok(_) => { // Detached(无输出者适用),直接检查退出码 } Err(e) => { tracing::warn!(error = %e, exec_id = %exec.id, "启动容器 exec 失败"); } } }) .await; if exec_result.is_err() { timeout_hit = true; // Docker exec 无 kill 端点;exec 会在容器中继续运行, // 但容器会在 release 时被销毁,无需额外清理。 } // 4) 获取退出码 let exit_code = docker .inspect_exec(&exec.id) .await .ok() .and_then(|i| i.exit_code) .map(|c| c as i32); Ok(ExecOutcome { exit_code, stdout: String::from_utf8_lossy(&stdout).into_owned(), stderr: String::from_utf8_lossy(&stderr).into_owned(), duration_ms: start.elapsed().as_millis(), timeout: timeout_hit, cancelled, stdout_truncated, stderr_truncated, }) } /// 流式执行:在容器中执行命令并通过 channel 实时推送输出事件。 pub async fn exec_in_container_stream( docker: &Docker, container_id: &str, argv: &[String], stdin: Option, env: &[(String, String)], timeout: Duration, max_output: usize, ) -> Result, ExecError> { let start = Instant::now(); let exec = docker .create_exec( container_id, CreateExecOptions { cmd: Some(argv.to_vec()), env: Some( env.iter() .map(|(k, v)| format!("{k}={v}")) .collect::>(), ), attach_stdout: Some(true), attach_stderr: Some(true), attach_stdin: Some(stdin.is_some()), ..Default::default() }, ) .await .map_err(|e| ExecError::Internal { user_msg: "创建容器 exec 失败", detail: format!("create_exec 失败: {e}"), })?; let (tx, rx) = tokio::sync::mpsc::channel::(64); // 消费 exec 输出并转发到 channel let docker_inner = docker.clone(); tokio::spawn(async move { let mut out_written = 0usize; let mut err_written = 0usize; let mut out_trunc = false; let mut err_trunc = false; let exec_result = tokio::time::timeout(timeout, async { match docker_inner.start_exec(&exec.id, None).await { Ok(StartExecResults::Attached { mut output, mut input, }) => { // 先写入 stdin(若有),然后关闭输入管道 if let Some(data) = &stdin { use tokio::io::AsyncWriteExt; let _ = input.write_all(data.as_bytes()).await; let _ = input.shutdown().await; } drop(input); while let Some(chunk) = output.next().await { match chunk { Ok(log_output) => { match log_output { LogOutput::StdOut { message } => { let data = message.as_ref(); if out_written < max_output { let room = max_output - out_written; let take = data.len().min(room); if take > 0 { let _ = tx .send(StreamEvent::Stdout(data[..take].to_vec())) .await; } out_written += take; if take < data.len() { out_trunc = true; } } else { out_trunc = true; } } LogOutput::StdErr { message } => { let data = message.as_ref(); if err_written < max_output { let room = max_output - err_written; let take = data.len().min(room); if take > 0 { let _ = tx .send(StreamEvent::Stderr(data[..take].to_vec())) .await; } err_written += take; if take < data.len() { err_trunc = true; } } else { err_trunc = true; } } _ => {} } } Err(e) => { tracing::warn!(error = %e, "容器 exec 流错误"); break; } } } } Ok(_) => {} Err(e) => { tracing::warn!(error = %e, "启动容器 exec 失败"); } } }) .await; let timeout_hit = exec_result.is_err(); if timeout_hit { // Docker exec 无 kill 端点;exec 在容器内继续运行, // 容器会在 release 时被销毁,无需额外清理。 } let exit_code = docker_inner .inspect_exec(&exec.id) .await .ok() .and_then(|i| i.exit_code) .map(|c| c as i32); let _ = tx .send(StreamEvent::Done(ExecOutcome { exit_code, stdout: String::new(), stderr: String::new(), duration_ms: start.elapsed().as_millis(), timeout: timeout_hit, cancelled: false, stdout_truncated: out_trunc, stderr_truncated: err_trunc, })) .await; }); Ok(rx) } /// 为预热容器构建 Docker Config。 /// /// 预热容器使用 `sleep infinity` 待命,实际执行通过 `exec_in_container` 注入命令。 pub fn build_container_config( config: &WarmPoolConfig, image: &str, base_workdir: &str, ) -> Config { // Docker bind mount 要求绝对路径;相对路径返回 400 错误。 if !base_workdir.starts_with('/') { tracing::warn!( base_workdir, "base_workdir 不是绝对路径,Docker bind mount 将失败" ); } let network_mode = if config.container_network.is_empty() { None } else { Some(config.container_network.clone()) }; Config { image: Some(image.to_string()), cmd: Some(vec!["sleep".into(), "infinity".into()]), host_config: Some(HostConfig { network_mode, readonly_rootfs: Some(true), cap_drop: Some(vec!["ALL".into()]), security_opt: Some(vec!["no-new-privileges:true".into()]), memory: Some(config.memory_per_container), memory_swap: Some(config.memory_per_container), pids_limit: Some(config.pids_limit), nano_cpus: Some(config.nano_cpus_per_container), tmpfs: Some(HashMap::from([( "/tmp".into(), "size=67108864,mode=1777".into(), )])), binds: Some(vec![format!("{base_workdir}:/app/sandbox-work:rw")]), ..Default::default() }), ..Default::default() } } /// 检查镜像是否存在,不存在则拉取。 pub async fn ensure_image(docker: &Docker, image: &str) -> Result<(), String> { let images = docker .list_images::(None) .await .map_err(|e| format!("列出镜像失败: {e}"))?; if images .iter() .any(|i| i.repo_tags.contains(&image.to_string())) { return Ok(()); } tracing::info!(image, "拉取容器镜像(首次使用)"); docker .create_image( Some(CreateImageOptions { from_image: image, ..Default::default() }), None, None, ) .for_each(|_| async {}) // 静默消费拉取流 .await; tracing::info!(image, "镜像拉取完成"); Ok(()) } /// 连接 Docker 守护进程。 pub fn connect_docker(socket: &str) -> Result { if socket.starts_with("unix://") { Docker::connect_with_unix(socket.trim_start_matches("unix://"), 120, API_DEFAULT_VERSION) .map_err(|e| format!("连接 Docker Unix socket 失败: {e}")) } else if socket.starts_with("tcp://") { Docker::connect_with_http(socket, 120, API_DEFAULT_VERSION) .map_err(|e| format!("连接 Docker TCP 失败: {e}")) } else { Docker::connect_with_local_defaults() .map_err(|e| format!("连接本地 Docker 失败: {e}")) } } /// 从 argv 首元素推断语言(用于选择预热池分桶)。 pub fn detect_language_from_argv(argv: &[String]) -> &str { let cmd = argv.first().map(|s| s.as_str()).unwrap_or(""); if cmd.ends_with("python3") || cmd == "python" || cmd.contains("python") { "python" } else if cmd.ends_with("node") || cmd.contains("node") { "node" } else if cmd.ends_with("java") || cmd.contains("java") { "java" } else { "generic" } } #[cfg(test)] mod tests { use super::*; #[test] fn detect_language_from_argv_python() { assert_eq!( detect_language_from_argv(&["python3".into(), "script.py".into()]), "python" ); assert_eq!( detect_language_from_argv(&["python".into(), "-c".into(), "print(1)".into()]), "python" ); assert_eq!( detect_language_from_argv(&["/usr/bin/python3".into(), "script.py".into()]), "python" ); } #[test] fn detect_language_from_argv_node() { assert_eq!( detect_language_from_argv(&["node".into(), "index.js".into()]), "node" ); assert_eq!( detect_language_from_argv(&["/usr/local/bin/node".into(), "app.js".into()]), "node" ); } #[test] fn detect_language_from_argv_java() { assert_eq!( detect_language_from_argv(&["java".into(), "-jar".into(), "app.jar".into()]), "java" ); } #[test] fn detect_language_from_argv_fallback() { assert_eq!( detect_language_from_argv(&["cat".into(), "/etc/passwd".into()]), "generic" ); assert_eq!( detect_language_from_argv(&["sh".into(), "-c".into(), "echo hi".into()]), "generic" ); } #[test] fn detect_language_from_argv_empty() { assert_eq!(detect_language_from_argv(&[]), "generic"); } #[test] fn build_container_config_sets_basic_fields() { let mut cfg = WarmPoolConfig::default(); cfg.images.insert( "python".into(), "python:3.12-slim".into(), ); let image = cfg.images.get("python").unwrap(); let config = build_container_config(&cfg, image, "/app/sandbox-work"); assert_eq!(config.image.as_deref(), Some("python:3.12-slim")); assert!(config.host_config.is_some()); let hc = config.host_config.unwrap(); assert_eq!(hc.network_mode, None, "默认容器网络应为 None(bridge)"); assert_eq!(hc.readonly_rootfs, Some(true)); assert_eq!(hc.cap_drop, Some(vec!["ALL".into()])); assert_eq!(hc.memory, Some(512 * 1024 * 1024)); } } /// Docker 集成测试(需要在运行中的 Docker 守护进程)。 /// cargo test --features docker-tests #[cfg(feature = "docker-tests")] #[cfg(test)] mod docker_integration_tests { use super::*; use crate::warm_pool::WarmPool; fn test_image() -> &'static str { "alpine:3.20" } async fn setup_docker() -> Option<(Docker, String)> { let docker = connect_docker("unix:///var/run/docker.sock").ok()?; ensure_image(&docker, test_image()).await.ok()?; Some((docker, test_image().to_string())) } #[tokio::test] async fn exec_echo_captures_stdout() { let (docker, image) = match setup_docker().await { Some(v) => v, None => { eprintln!("跳过:Docker 不可用"); return; } }; let mut cfg = WarmPoolConfig::default(); cfg.images.insert("generic".into(), image); let pool = WarmPool::new(cfg, "/tmp".into(), docker.clone()); let config = build_container_config(pool.config(), &image, "/tmp"); let container = docker .create_container::(None, config) .await .expect("创建容器"); docker .start_container::(&container.id, None) .await .expect("启动容器"); let outcome = exec_in_container( &docker, &container.id, &["echo".into(), "hello-docker".into()], None, &[], Duration::from_secs(10), 4096, ) .await .expect("exec_in_container"); assert_eq!(outcome.exit_code, Some(0)); assert!(outcome.stdout.contains("hello-docker")); assert!(!outcome.timeout); assert!(!outcome.cancelled); docker .remove_container( &container.id, Some(RemoveContainerOptions { force: true, v: true, ..Default::default() }), ) .await .ok(); } #[tokio::test] async fn exec_timeout_kills() { let (docker, image) = match setup_docker().await { Some(v) => v, None => { eprintln!("跳过:Docker 不可用"); return; } }; let mut cfg = WarmPoolConfig::default(); cfg.images.insert("generic".into(), image); let pool = WarmPool::new(cfg, "/tmp".into(), docker.clone()); let config = build_container_config(pool.config(), &image, "/tmp"); let container = docker .create_container::(None, config) .await .expect("创建容器"); docker .start_container::(&container.id, None) .await .expect("启动容器"); let outcome = exec_in_container( &docker, &container.id, &["sleep".into(), "30".into()], None, &[], Duration::from_millis(200), 4096, ) .await .expect("exec_in_container"); assert!(outcome.timeout, "应超时返回"); assert!(outcome.duration_ms < 5000); docker .remove_container( &container.id, Some(RemoveContainerOptions { force: true, v: true, ..Default::default() }), ) .await .ok(); } #[tokio::test] async fn exec_in_container_stream_emits_events() { let (docker, image) = match setup_docker().await { Some(v) => v, None => { eprintln!("跳过:Docker 不可用"); return; } }; let mut cfg = WarmPoolConfig::default(); cfg.images.insert("generic".into(), image); let pool = WarmPool::new(cfg, "/tmp".into(), docker.clone()); let config = build_container_config(pool.config(), &image, "/tmp"); let container = docker .create_container::(None, config) .await .expect("创建容器"); docker .start_container::(&container.id, None) .await .expect("启动容器"); let mut rx = exec_in_container_stream( &docker, &container.id, &["echo".into(), "stream-test".into()], None, &[], Duration::from_secs(10), 4096, ) .await .expect("exec_in_container_stream"); use futures_util::StreamExt; let mut found_stdout = false; let mut found_done = false; while let Some(event) = rx.recv().await { match event { StreamEvent::Stdout(data) => { if String::from_utf8_lossy(&data).contains("stream-test") { found_stdout = true; } } StreamEvent::Done(o) => { assert_eq!(o.exit_code, Some(0)); found_done = true; } _ => {} } } assert!(found_stdout, "应收到 stdout 事件"); assert!(found_done, "应以 Done 结束"); docker .remove_container( &container.id, Some(RemoveContainerOptions { force: true, v: true, ..Default::default() }), ) .await .ok(); } #[tokio::test] async fn warm_pool_acquire_release_cycle() { let (docker, image) = match setup_docker().await { Some(v) => v, None => { eprintln!("跳过:Docker 不可用"); return; } }; let mut cfg = WarmPoolConfig::default(); cfg.images.insert("generic".into(), image); cfg.max_idle_per_lang = 1; cfg.global_max_containers = 5; let pool = WarmPool::new(cfg, "/tmp".into(), docker); pool.warmup_all().await; let stats = pool.collect_metrics().await; let (idle, ..) = stats.get("generic").copied().unwrap_or((0, 0, 0)); assert!(idle >= 1, "预热后应有至少 1 个空闲容器"); let handle = pool.clone().acquire("generic").await.expect("acquire"); assert_eq!(handle.language, "generic"); pool.release(handle).await; tokio::time::sleep(Duration::from_millis(500)).await; let stats2 = pool.collect_metrics().await; let (idle2, ..) = stats2.get("generic").copied().unwrap_or((0, 0, 0)); assert!(idle2 >= 1, "release 后应有替补容器"); pool.shutdown().await; } }