Skip to main content

yggdrasil/infra/
docker.rs

1use futures::StreamExt;
2use std::collections::HashMap;
3use std::sync::LazyLock;
4use std::time::Duration;
5use tokio::time::timeout;
6
7use crate::infra::runner_config::{ResourceLimits, RUNNER_CONFIG};
8use bollard::container::LogOutput;
9use bollard::models::{ContainerCreateBody, HostConfig, ResourcesUlimits};
10use bollard::query_parameters::{
11    AttachContainerOptions, CreateContainerOptions, RemoveContainerOptions, StartContainerOptions,
12    WaitContainerOptions,
13};
14use bollard::Docker;
15
16/// 共享的 Docker 客户端。
17///
18/// `connect_with_unix` 会立即探测 socket 是否存在:缺失(未安装 / 未运行 Docker,
19/// 或 `DOCKER_SOCKET_PATH` 指错)时返回 `SocketNotFoundError`。
20///
21/// 连接失败不 panic:release profile 是 `panic = "abort"`,会让整个服务进程一起挂掉,
22/// 而博客本身并不依赖 Docker。改为记 error 日志后返回 `None`,代码运行器把
23/// `None` 转成普通 bollard 错误向上冒泡,最终在 execute.rs 的错误脱敏层统一映射为
24/// 「系统暂时不可用」,其余功能不受影响。
25pub static DOCKER_CLIENT: LazyLock<Option<Docker>> = LazyLock::new(|| {
26    match Docker::connect_with_unix(
27        &RUNNER_CONFIG.docker_socket_path,
28        120,
29        bollard::API_DEFAULT_VERSION,
30    ) {
31        Ok(d) => Some(d),
32        Err(e) => {
33            tracing::error!(
34                error = ?e,
35                socket = %RUNNER_CONFIG.docker_socket_path,
36                "无法连接 Docker daemon,代码运行功能将不可用。\
37                 请确认 Docker 已安装并运行,或设置正确的 DOCKER_SOCKET_PATH",
38            );
39            None
40        }
41    }
42});
43
44/// 取共享 Docker 客户端;daemon 不可用时返回 IOError(NotFound)。
45///
46/// `NotFound` 既不命中 `TimedOut` 也不命中超时判断,会走 execute.rs 的通用失败路径
47/// (`ExecStatus::Failed` + 「系统暂时不可用」),不会误报成超时。
48fn get_docker() -> Result<&'static Docker, bollard::errors::Error> {
49    DOCKER_CLIENT
50        .as_ref()
51        .ok_or_else(|| bollard::errors::Error::IOError {
52            err: std::io::Error::new(
53                std::io::ErrorKind::NotFound,
54                "Docker daemon 不可用(未安装或未运行)",
55            ),
56        })
57}
58
59pub fn build_host_config(limits: &ResourceLimits) -> HostConfig {
60    let mut tmpfs = HashMap::new();
61    // /code 用 mode=1777(sticky + all-rwx)让容器内 1000:1000 用户可写。
62    // 不用 `uid=1000,gid=1000`:那是 Docker 的 tmpfs 扩展选项,Podman 报
63    // `unknown mount option "uid=1000"`。mode=1777 是 POSIX 标准 tmpfs 选项,
64    // Docker 与 Podman 都支持,语义等价(任意 UID 可写 /code)。
65    tmpfs.insert("/code".to_string(), "size=16m,mode=1777".to_string());
66    // /tmp 必须 exec:编译型语言(go/rust)把编译产物落在 /tmp 后再 exec,
67    // Docker tmpfs 默认 noexec 会让执行二进制时报 EACCES(permission denied)。
68    // 解释型语言(python/node)执行根文件系统的解释器,不受影响。
69    tmpfs.insert("/tmp".to_string(), "size=64m,mode=1777,exec".to_string());
70    tmpfs.insert("/run".to_string(), "size=16m,mode=1777".to_string());
71
72    let memory = (limits.memory_mb * 1024 * 1024) as i64;
73
74    HostConfig {
75        cpu_quota: Some((limits.cpu_cores * 100_000.0) as i64),
76        cpu_period: Some(100_000),
77        memory: Some(memory),
78        memory_swap: Some(memory), // = memory, disable swap
79        network_mode: Some(if limits.allow_network {
80            "bridge".to_string()
81        } else {
82            "none".to_string()
83        }),
84        readonly_rootfs: Some(true),
85        tmpfs: Some(tmpfs),
86        pids_limit: Some(64),
87        // 只保留 nofile(fd 数上限,语义正常)。
88        // 不设 nproc:RLIMIT_NPROC 在 setrlimit 时按 UID 计数,配合 non-root 用户会让
89        // 容器初始 exec /bin/sh 直接 EAGAIN("exec: resource temporarily unavailable"),
90        // 与容器内实际进程数无关。pids_limit 已在 cgroup 层兜底,nproc 是冗余且有害的双重约束。
91        ulimits: Some(vec![ResourcesUlimits {
92            name: Some("nofile".to_string()),
93            soft: Some(64),
94            hard: Some(64),
95        }]),
96        cap_drop: Some(vec!["ALL".to_string()]),
97        security_opt: Some(vec!["no-new-privileges".to_string()]),
98        auto_remove: Some(false), // must be false to avoid premature removal before getting logs
99        ..Default::default()
100    }
101}
102
103struct ContainerGuard {
104    container_id: String,
105    docker: Docker,
106}
107
108impl Drop for ContainerGuard {
109    fn drop(&mut self) {
110        let docker = self.docker.clone();
111        let container_id = self.container_id.clone();
112        // 容器清理是 fire-and-forget:调用方已返回,无法把错误回传给业务层。
113        // 因此重试几次以抵抗瞬时故障(daemon 繁忙 / socket 抖动),
114        // 仍失败则记录 error 级日志并带上 container_id,便于运维手动 `docker rm -f` 清理,
115        // 避免容器静默泄漏、长期堆积。
116        tokio::spawn(async move {
117            let max_attempts = 3u8;
118            let mut backoff = Duration::from_millis(200);
119            for attempt in 1..=max_attempts {
120                let remove_options = Some(RemoveContainerOptions {
121                    force: true,
122                    ..Default::default()
123                });
124                match docker.remove_container(&container_id, remove_options).await {
125                    Ok(()) => return,
126                    Err(e) if attempt < max_attempts => {
127                        tracing::warn!(
128                            attempt,
129                            max_attempts,
130                            "remove_container 失败,稍后重试: {:?}",
131                            e
132                        );
133                        tokio::time::sleep(backoff).await;
134                        backoff *= 2;
135                    }
136                    Err(e) => {
137                        tracing::error!(
138                            container_id = %container_id,
139                            "重试 {} 次后仍无法删除容器,可能泄漏;请手动执行 `docker rm -f {}`: {:?}",
140                            max_attempts,
141                            container_id,
142                            e
143                        );
144                        return;
145                    }
146                }
147            }
148        });
149    }
150}
151
152pub async fn run_in_container(
153    image_name: &str,
154    run_cmd: &str,
155    source: &str,
156    ext: &str,
157    limits: ResourceLimits,
158) -> Result<(Option<i64>, String, String, bool), bollard::errors::Error> {
159    let docker = get_docker()?;
160    let host_config = build_host_config(&limits);
161
162    // Source injection script: use sh -c to first receive stdin and write to file, then exec the actual command
163    let setup_cmd = format!("cat > /code/main.{} && exec {}", ext, run_cmd);
164    let cmd = vec!["sh".to_string(), "-c".to_string(), setup_cmd];
165
166    let config = ContainerCreateBody {
167        image: Some(image_name.to_string()),
168        cmd: Some(cmd),
169        host_config: Some(host_config),
170        attach_stdin: Some(true),
171        attach_stdout: Some(true),
172        attach_stderr: Some(true),
173        open_stdin: Some(true),
174        stdin_once: Some(true),
175        user: Some("1000:1000".to_string()), // non-root user
176        working_dir: Some("/code".to_string()),
177        ..Default::default()
178    };
179
180    let container = docker
181        .create_container(None::<CreateContainerOptions>, config)
182        .await?;
183
184    let container_id = container.id;
185    let _guard = ContainerGuard {
186        container_id: container_id.clone(),
187        docker: docker.clone(),
188    };
189
190    // Attach to container to stream stdin, stdout, and stderr
191    let attach_res = docker
192        .attach_container(
193            &container_id,
194            Some(AttachContainerOptions {
195                stdin: true,
196                stdout: true,
197                stderr: true,
198                stream: true,
199                logs: false,
200                ..Default::default()
201            }),
202        )
203        .await;
204
205    let (mut writer, mut stream) = match attach_res {
206        Ok(res) => (res.input, res.output),
207        Err(e) => return Err(e),
208    };
209
210    // Start container
211    docker
212        .start_container(&container_id, None::<StartContainerOptions>)
213        .await?;
214
215    // Write source code to stdin and drop/close the writer
216    use tokio::io::AsyncWriteExt;
217    let write_fut = async {
218        let _ = writer.write_all(source.as_bytes()).await;
219        let _ = writer.flush().await;
220        let _ = writer.shutdown().await;
221    };
222
223    if timeout(Duration::from_secs(5), write_fut).await.is_err() {
224        return Err(bollard::errors::Error::IOError {
225            err: std::io::Error::new(std::io::ErrorKind::TimedOut, "Writing to stdin timed out"),
226        });
227    }
228    drop(writer);
229
230    // Wait for execution with timeout control
231    let wait_future = async {
232        let mut wait_stream = docker.wait_container(&container_id, None::<WaitContainerOptions>);
233        wait_stream.next().await
234    };
235
236    let wait_res = timeout(Duration::from_secs(limits.timeout_secs), wait_future).await;
237
238    let mut timed_out = false;
239    let mut exit_code = None;
240
241    match wait_res {
242        Ok(Some(Ok(exit_status))) => {
243            exit_code = Some(exit_status.status_code);
244        }
245        Ok(_) => {} // wait error
246        Err(_) => {
247            // timeout, kill container
248            timed_out = true;
249            let _ = docker.kill_container(&container_id, None).await;
250        }
251    }
252
253    // Collect logs
254    let mut stdout_buf = Vec::new();
255    let mut stderr_buf = Vec::new();
256
257    while let Some(item) = stream.next().await {
258        match item {
259            Ok(chunk) => {
260                match chunk {
261                    LogOutput::StdOut { message } => {
262                        let remaining = (limits.output_bytes as usize)
263                            .saturating_sub(stdout_buf.len() + stderr_buf.len());
264                        if remaining > 0 {
265                            let to_add = message.len().min(remaining);
266                            stdout_buf.extend_from_slice(&message[..to_add]);
267                        }
268                    }
269                    LogOutput::StdErr { message } => {
270                        let remaining = (limits.output_bytes as usize)
271                            .saturating_sub(stdout_buf.len() + stderr_buf.len());
272                        if remaining > 0 {
273                            let to_add = message.len().min(remaining);
274                            stderr_buf.extend_from_slice(&message[..to_add]);
275                        }
276                    }
277                    _ => {}
278                }
279                if stdout_buf.len() + stderr_buf.len() >= limits.output_bytes as usize {
280                    break;
281                }
282            }
283            Err(e) => {
284                tracing::error!("Error reading container log stream: {:?}", e);
285                break;
286            }
287        }
288    }
289
290    // Check OOM status
291    let inspect = docker.inspect_container(&container_id, None).await;
292    let oom_killed = inspect
293        .ok()
294        .and_then(|info| info.state.and_then(|s| s.oom_killed))
295        .unwrap_or(false);
296
297    // Truncate output to limits.output_bytes
298    let limit_bytes = limits.output_bytes as usize;
299    let stdout_len = stdout_buf.len().min(limit_bytes);
300    let stderr_len = stderr_buf.len().min(limit_bytes);
301
302    let stdout = String::from_utf8_lossy(&stdout_buf[..stdout_len]).into_owned();
303    let stderr = String::from_utf8_lossy(&stderr_buf[..stderr_len]).into_owned();
304
305    if timed_out {
306        return Err(bollard::errors::Error::IOError {
307            err: std::io::Error::new(std::io::ErrorKind::TimedOut, "Execution timed out"),
308        });
309    }
310
311    Ok((exit_code, stdout, stderr, oom_killed))
312}
313
314/// 流式输出 chunk:run_in_container_stream 边读日志边推送给 SSE handler。
315///
316/// 序列化后作为 SSE event data;`Done` 同时携带终态信息(退出码 / OOM / 超时 / 耗时)。
317#[derive(Clone, Debug)]
318pub enum OutputChunk {
319    /// stdout 块(容器逐块产出)。
320    Stdout(String),
321    /// stderr 块(容器逐块产出)。
322    Stderr(String),
323    /// 终态:容器执行结束。exit_code=None 表示拿不到退出码(wait 出错)。
324    /// duration_ms = start_container 到 wait 完成的耗时。
325    Done {
326        exit_code: Option<i64>,
327        oom_killed: bool,
328        timed_out: bool,
329        duration_ms: u64,
330    },
331}
332
333/// 流式执行:与 [`run_in_container`] 相同的容器生命周期与清理(`ContainerGuard`),
334/// 但边读日志流边推 chunk 到 `tx`,同时保留完整 buffer 供调用方回填 EXEC_TASKS。
335///
336/// 与 `run_in_container` 的差异:
337/// 1. 日志循环里每块 chunk 既 `tx.send` 推流,也 append 到本地 buffer。
338/// 2. 用 `tokio::select!` 在日志读取中并发等待 `tx` 关闭——客户端断开(SSE 关闭)
339///    → `tx` 所有 Sender drop → `rx` 返回 None → 中止读取。
340/// 3. 终态推 `OutputChunk::Done` 后 return。
341///
342/// 返回完整 buffer(exit_code / stdout / stderr / oom / timed_out),供调用方写 EXEC_TASKS,
343/// 让轮询兜底路径(get_exec_result)也能拿到完整结果。
344pub async fn run_in_container_stream(
345    image_name: &str,
346    run_cmd: &str,
347    source: &str,
348    ext: &str,
349    limits: ResourceLimits,
350    tx: tokio::sync::mpsc::Sender<OutputChunk>,
351) -> Result<(Option<i64>, String, String, bool, bool), bollard::errors::Error> {
352    let docker = get_docker()?;
353    let host_config = build_host_config(&limits);
354
355    // 与 run_in_container 相同的 stdin 注入脚本。
356    let setup_cmd = format!("cat > /code/main.{} && exec {}", ext, run_cmd);
357    let cmd = vec!["sh".to_string(), "-c".to_string(), setup_cmd];
358
359    let config = ContainerCreateBody {
360        image: Some(image_name.to_string()),
361        cmd: Some(cmd),
362        host_config: Some(host_config),
363        attach_stdin: Some(true),
364        attach_stdout: Some(true),
365        attach_stderr: Some(true),
366        open_stdin: Some(true),
367        stdin_once: Some(true),
368        user: Some("1000:1000".to_string()),
369        working_dir: Some("/code".to_string()),
370        ..Default::default()
371    };
372
373    let container = docker
374        .create_container(None::<CreateContainerOptions>, config)
375        .await?;
376
377    let container_id = container.id;
378    let _guard = ContainerGuard {
379        container_id: container_id.clone(),
380        docker: docker.clone(),
381    };
382
383    // Attach 到容器的 stdin/stdout/stderr 流。
384    let attach_res = docker
385        .attach_container(
386            &container_id,
387            Some(AttachContainerOptions {
388                stdin: true,
389                stdout: true,
390                stderr: true,
391                stream: true,
392                logs: false,
393                ..Default::default()
394            }),
395        )
396        .await;
397
398    let (mut writer, mut stream) = match attach_res {
399        Ok(res) => (res.input, res.output),
400        Err(e) => return Err(e),
401    };
402
403    docker
404        .start_container(&container_id, None::<StartContainerOptions>)
405        .await?;
406
407    // 容器开始执行的时刻,用于计算 duration_ms(start_container 返回即视为起点)。
408    let start_time = std::time::Instant::now();
409
410    // 写入源码到 stdin 后关闭 writer。
411    use tokio::io::AsyncWriteExt;
412    let write_fut = async {
413        let _ = writer.write_all(source.as_bytes()).await;
414        let _ = writer.flush().await;
415        let _ = writer.shutdown().await;
416    };
417
418    if timeout(Duration::from_secs(5), write_fut).await.is_err() {
419        return Err(bollard::errors::Error::IOError {
420            err: std::io::Error::new(std::io::ErrorKind::TimedOut, "Writing to stdin timed out"),
421        });
422    }
423    drop(writer);
424
425    // —— 关键:wait_container 与日志读取必须并发,否则流式失效 ——
426    //
427    // 若先 await wait_container(等容器退出)再读日志流,wait 会阻塞到程序结束,
428    // 届时 attach stream 里已缓冲全部输出,stream.next() 一次性快速读完——
429    // 表现为"等完再一次性输出",流式名存实亡。
430    //
431    // 用 tokio::select! 让两条分支并发:
432    // - log_reader:持续读 attach stream,每块 chunk 立即 tx.send 推流 + 累积 buffer
433    // - wait_with_timeout:等容器退出(带超时),退出后日志流自然结束(stream 返回 None)
434    // 先完成的一方触发 select 返回;若 wait 超时则 kill 容器。
435    let mut stdout_buf = Vec::new();
436    let mut stderr_buf = Vec::new();
437    let limit_bytes = limits.output_bytes as usize;
438    let mut client_disconnected = false;
439    let mut timed_out = false;
440    let mut exit_code = None;
441
442    // 日志读取循环:逐块推流 + 累积 buffer。
443    // 循环正常退出条件:stream 返回 None(容器退出后 Docker 关闭 attach 流),
444    // 或输出超限 break,或 select 被另一分支抢先完成(log_reader 被 drop)。
445    let log_reader = async {
446        while let Some(item) = stream.next().await {
447            match item {
448                Ok(chunk) => match chunk {
449                    LogOutput::StdOut { message } => {
450                        let remaining =
451                            limit_bytes.saturating_sub(stdout_buf.len() + stderr_buf.len());
452                        if remaining > 0 {
453                            let to_add = message.len().min(remaining);
454                            let slice = &message[..to_add];
455                            stdout_buf.extend_from_slice(slice);
456                            if !client_disconnected {
457                                let text = String::from_utf8_lossy(slice).into_owned();
458                                if tx.send(OutputChunk::Stdout(text)).await.is_err() {
459                                    client_disconnected = true;
460                                }
461                            }
462                        }
463                    }
464                    LogOutput::StdErr { message } => {
465                        let remaining =
466                            limit_bytes.saturating_sub(stdout_buf.len() + stderr_buf.len());
467                        if remaining > 0 {
468                            let to_add = message.len().min(remaining);
469                            let slice = &message[..to_add];
470                            stderr_buf.extend_from_slice(slice);
471                            if !client_disconnected {
472                                let text = String::from_utf8_lossy(slice).into_owned();
473                                if tx.send(OutputChunk::Stderr(text)).await.is_err() {
474                                    client_disconnected = true;
475                                }
476                            }
477                        }
478                    }
479                    _ => {}
480                },
481                Err(e) => {
482                    tracing::error!("Error reading container log stream: {:?}", e);
483                    break;
484                }
485            }
486            if stdout_buf.len() + stderr_buf.len() >= limit_bytes {
487                break;
488            }
489        }
490    };
491
492    // 带超时地等待容器退出。
493    let wait_future = async {
494        let mut wait_stream = docker.wait_container(&container_id, None::<WaitContainerOptions>);
495        wait_stream.next().await
496    };
497    let wait_with_timeout = async {
498        match timeout(Duration::from_secs(limits.timeout_secs), wait_future).await {
499            Ok(Some(Ok(exit_status))) => Some(exit_status.status_code),
500            Ok(Some(Err(_))) => None, // wait error
501            Ok(None) => None,         // stream ended
502            Err(_) => {
503                // 超时,杀容器。kill 后 attach stream 会被 Docker 关闭,log_reader 自然结束。
504                timed_out = true;
505                let _ = docker.kill_container(&container_id, None).await;
506                None
507            }
508        }
509    };
510
511    // 并发:哪边先完成就先用其结果。
512    // 通常 wait 先完成(容器退出 → Docker 关闭 attach stream → log_reader 也很快结束),
513    // 但若日志流先因输出超限 break,wait 会被 select drop 掉(容器仍在跑,后续 _guard 清理)。
514    tokio::select! {
515        status = wait_with_timeout => {
516            exit_code = status;
517            // 容器已退出,但 attach stream 可能还有缓冲的尾部日志。
518            // 继续读完日志流(非阻塞:stream 即将返回 None)。
519            while let Some(item) = stream.next().await {
520                if let Ok(chunk) = item {
521                    match chunk {
522                        LogOutput::StdOut { message } => {
523                            let remaining = limit_bytes.saturating_sub(stdout_buf.len() + stderr_buf.len());
524                            let to_add = message.len().min(remaining);
525                            if to_add > 0 {
526                                let slice = &message[..to_add];
527                                stdout_buf.extend_from_slice(slice);
528                                if !client_disconnected {
529                                    let text = String::from_utf8_lossy(slice).into_owned();
530                                    let _ = tx.send(OutputChunk::Stdout(text)).await;
531                                }
532                            }
533                        }
534                        LogOutput::StdErr { message } => {
535                            let remaining = limit_bytes.saturating_sub(stdout_buf.len() + stderr_buf.len());
536                            let to_add = message.len().min(remaining);
537                            if to_add > 0 {
538                                let slice = &message[..to_add];
539                                stderr_buf.extend_from_slice(slice);
540                                if !client_disconnected {
541                                    let text = String::from_utf8_lossy(slice).into_owned();
542                                    let _ = tx.send(OutputChunk::Stderr(text)).await;
543                                }
544                            }
545                        }
546                        _ => {}
547                    }
548                }
549            }
550        }
551        _ = log_reader => {
552            // 日志流先结束(输出超限 break,或 attach 断开),容器可能仍在运行。
553            // C3 修复:旧代码 wait_container().next().await 无超时无 kill,注释还谎称
554            // "短超时"。`while True: print()` 式程序会永久挂起 → RUNNER_SEMAPHORE 许可
555            // 不释放(默认 4 并发 → 4 个即 DoS 全部代码执行器)+ ContainerGuard 不 drop
556            // → 容器永久运行烧 CPU。与 wait_with_timeout 分支对称:带超时地等退出,超时则 kill。
557            let mut wait_stream = docker.wait_container(&container_id, None::<WaitContainerOptions>);
558            match timeout(Duration::from_secs(limits.timeout_secs), wait_stream.next()).await {
559                Ok(Some(Ok(status))) => exit_code = Some(status.status_code),
560                Ok(Some(Err(_))) | Ok(None) => {}
561                Err(_) => {
562                    // 超时:kill 后再等一次回收 exit_code(kill 后 wait 立即返回)。
563                    timed_out = true;
564                    let _ = docker.kill_container(&container_id, None).await;
565                    if let Some(Ok(status)) = wait_stream.next().await {
566                        exit_code = Some(status.status_code);
567                    }
568                }
569            }
570        }
571    }
572
573    // 检查 OOM 状态。
574    let inspect = docker.inspect_container(&container_id, None).await;
575    let oom_killed = inspect
576        .ok()
577        .and_then(|info| info.state.and_then(|s| s.oom_killed))
578        .unwrap_or(false);
579
580    // 推送终态 chunk(客户端已断开则跳过,send 必然失败)。
581    let duration_ms = start_time.elapsed().as_millis() as u64;
582    if !client_disconnected {
583        let _ = tx
584            .send(OutputChunk::Done {
585                exit_code,
586                oom_killed,
587                timed_out,
588                duration_ms,
589            })
590            .await;
591    }
592
593    let stdout_len = stdout_buf.len().min(limit_bytes);
594    let stderr_len = stderr_buf.len().min(limit_bytes);
595    let stdout = String::from_utf8_lossy(&stdout_buf[..stdout_len]).into_owned();
596    let stderr = String::from_utf8_lossy(&stderr_buf[..stderr_len]).into_owned();
597
598    if timed_out {
599        return Err(bollard::errors::Error::IOError {
600            err: std::io::Error::new(std::io::ErrorKind::TimedOut, "Execution timed out"),
601        });
602    }
603
604    Ok((exit_code, stdout, stderr, oom_killed, timed_out))
605}
606
607#[cfg(test)]
608mod tests {
609    use super::*;
610    use crate::infra::runner_config::ResourceLimits;
611
612    #[test]
613    fn test_host_config_generation() {
614        let limits = ResourceLimits {
615            cpu_cores: 1.5,
616            memory_mb: 256,
617            timeout_secs: 5,
618            output_bytes: 1024,
619            allow_network: false,
620        };
621        let host_config = build_host_config(&limits);
622        assert_eq!(host_config.cpu_quota, Some(150_000));
623        assert_eq!(host_config.memory, Some(256 * 1024 * 1024));
624        assert_eq!(host_config.readonly_rootfs, Some(true));
625        assert_eq!(host_config.network_mode.as_deref(), Some("none"));
626
627        // 隔离不变量:这些配置若被误删,容器隔离会悄悄失效。
628        // 单独断言而非隐含在端到端测试里,确保回归可见。
629        assert_eq!(
630            host_config.cap_drop.as_deref(),
631            Some(&["ALL".to_string()][..])
632        );
633        assert_eq!(
634            host_config.security_opt.as_deref(),
635            Some(&["no-new-privileges".to_string()][..])
636        );
637        assert_eq!(host_config.pids_limit, Some(64));
638        // memory_swap == memory 才是真正禁用 swap;二者不等价于可换出到磁盘
639        assert_eq!(host_config.memory_swap, host_config.memory);
640        assert_eq!(host_config.memory_swap, Some(256 * 1024 * 1024));
641    }
642
643    #[test]
644    fn host_config_ulimits_drops_only_nofile() {
645        // nproc 在容器内对 non-root 用户有害(初始 exec /bin/sh 直接 EAGAIN),
646        // 只保留 nofile=64。若有人加回 nproc,这里会失败。
647        let limits = ResourceLimits {
648            cpu_cores: 1.0,
649            memory_mb: 128,
650            timeout_secs: 5,
651            output_bytes: 1024,
652            allow_network: false,
653        };
654        let host_config = build_host_config(&limits);
655        let ulimits = host_config.ulimits.expect("ulimits must be set");
656        assert_eq!(ulimits.len(), 1, "only nofile, no nproc");
657        let nf = &ulimits[0];
658        assert_eq!(nf.name.as_deref(), Some("nofile"));
659        assert_eq!(nf.soft, Some(64));
660        assert_eq!(nf.hard, Some(64));
661    }
662
663    #[test]
664    fn host_config_tmpfs_has_exec_on_tmp_only() {
665        // /tmp 必须 exec(编译型语言把产物落在 /tmp 后再 exec);
666        // /code、/run 不带 exec(默认 noexec)。若误给 /code 也加 exec,测试失败。
667        let limits = ResourceLimits {
668            cpu_cores: 1.0,
669            memory_mb: 128,
670            timeout_secs: 5,
671            output_bytes: 1024,
672            allow_network: false,
673        };
674        let host_config = build_host_config(&limits);
675        let tmpfs = host_config.tmpfs.expect("tmpfs must be set");
676        assert_eq!(tmpfs.len(), 3);
677        assert!(tmpfs["/tmp"].contains("exec"));
678        assert!(!tmpfs["/code"].contains("exec"));
679        assert!(!tmpfs["/run"].contains("exec"));
680    }
681
682    #[test]
683    fn host_config_network_mode_follows_allow_network() {
684        let base = ResourceLimits {
685            cpu_cores: 1.0,
686            memory_mb: 128,
687            timeout_secs: 5,
688            output_bytes: 1024,
689            allow_network: false,
690        };
691        assert_eq!(
692            build_host_config(&base).network_mode.as_deref(),
693            Some("none")
694        );
695        let mut net = base;
696        net.allow_network = true;
697        assert_eq!(
698            build_host_config(&net).network_mode.as_deref(),
699            Some("bridge")
700        );
701    }
702
703    #[test]
704    fn host_config_cpu_and_memory_scale_with_limits() {
705        // 资源钳制是安全边界,断言换算公式不漂移(cpu_cores * 100_000,memory_mb * 1MiB)。
706        let limits = ResourceLimits {
707            cpu_cores: 2.0,
708            memory_mb: 512,
709            timeout_secs: 5,
710            output_bytes: 1024,
711            allow_network: false,
712        };
713        let host_config = build_host_config(&limits);
714        assert_eq!(host_config.cpu_quota, Some(200_000));
715        assert_eq!(host_config.cpu_period, Some(100_000));
716        assert_eq!(host_config.memory, Some(512 * 1024 * 1024));
717        assert_eq!(host_config.memory_swap, Some(512 * 1024 * 1024));
718    }
719
720    /// 探测 Docker daemon 是否可用:socket 缺失(`DOCKER_CLIENT == None`)、
721    /// daemon 无响应、或所需镜像不在本地时返回 `None`。集成测试用它做动态
722    /// 守卫——daemon 与镜像都在则跑,否则显式跳过(eprintln + 提前 return),
723    /// 而非 panic 失败。
724    ///
725    /// 这比 `#[ignore]` 更合适:`#[ignore]` 会在所有环境(含有 Docker 的开发机)
726    /// 一律跳过、且需 `cargo test --ignored` 显式触发;探测守卫让这些测试在
727    /// 有 Docker 时自动运行、在无 Docker 或缺镜像的 CI 上静默放行。
728    ///
729    /// 镜像探测:GitHub runner 有 daemon 但默认不带 alpine:latest,创建容器
730    /// 时会 404。这里用 inspect_image 只读检查本地是否已有镜像,缺失即跳过,
731    /// 不在此 pull(CI 不应依赖外网拉镜像——慢且脆弱)。
732    async fn require_docker_with_image(image: &str) -> Option<&'static Docker> {
733        let docker = DOCKER_CLIENT.as_ref()?;
734        // socket 在但 daemon 挂了的情况靠这一步轻量只读调用兜住。
735        docker.version().await.ok()?;
736        // 镜像不在本地 → 视为环境不可用,跳过而非 panic。
737        docker.inspect_image(image).await.ok()?;
738        Some(docker)
739    }
740
741    #[tokio::test]
742    #[serial_test::serial]
743    async fn test_run_in_container_success() {
744        if require_docker_with_image("alpine:latest").await.is_none() {
745            eprintln!("skip: Docker daemon 不可用或缺少 alpine:latest");
746            return;
747        }
748        let limits = ResourceLimits {
749            cpu_cores: 1.0,
750            memory_mb: 128,
751            timeout_secs: 5,
752            output_bytes: 1024,
753            allow_network: false,
754        };
755        let (exit_code, stdout, stderr, oom_killed) = run_in_container(
756            "alpine:latest",
757            "cat /code/main.txt",
758            "hello world",
759            "txt",
760            limits,
761        )
762        .await
763        .unwrap();
764
765        assert_eq!(exit_code, Some(0));
766        assert_eq!(stdout, "hello world");
767        assert!(stderr.is_empty());
768        assert!(!oom_killed);
769    }
770
771    #[tokio::test]
772    #[serial_test::serial]
773    async fn test_run_in_container_output_truncation() {
774        if require_docker_with_image("alpine:latest").await.is_none() {
775            eprintln!("skip: Docker daemon 不可用或缺少 alpine:latest");
776            return;
777        }
778        let limits = ResourceLimits {
779            cpu_cores: 1.0,
780            memory_mb: 128,
781            timeout_secs: 5,
782            output_bytes: 5,
783            allow_network: false,
784        };
785        let (exit_code, stdout, stderr, oom_killed) = run_in_container(
786            "alpine:latest",
787            "cat /code/main.txt",
788            "hello world",
789            "txt",
790            limits,
791        )
792        .await
793        .unwrap();
794
795        assert_eq!(exit_code, Some(0));
796        assert_eq!(stdout, "hello");
797        assert!(stderr.is_empty());
798        assert!(!oom_killed);
799    }
800
801    #[tokio::test]
802    #[serial_test::serial]
803    async fn test_run_in_container_timeout() {
804        if require_docker_with_image("alpine:latest").await.is_none() {
805            eprintln!("skip: Docker daemon 不可用或缺少 alpine:latest");
806            return;
807        }
808        let limits = ResourceLimits {
809            cpu_cores: 1.0,
810            memory_mb: 128,
811            timeout_secs: 1,
812            output_bytes: 1024,
813            allow_network: false,
814        };
815        let res = run_in_container("alpine:latest", "sleep 10", "", "txt", limits).await;
816
817        assert!(res.is_err());
818        let err = res.unwrap_err();
819        match err {
820            bollard::errors::Error::IOError { err } => {
821                assert_eq!(err.kind(), std::io::ErrorKind::TimedOut);
822            }
823            _ => panic!("Expected IOError(TimedOut), got {:?}", err),
824        }
825    }
826
827    #[tokio::test]
828    #[serial_test::serial]
829    async fn test_run_in_container_cancellation() {
830        use bollard::query_parameters::ListContainersOptions;
831        let docker = match require_docker_with_image("alpine:latest").await {
832            Some(d) => d,
833            None => {
834                eprintln!("skip: Docker daemon 不可用或缺少 alpine:latest");
835                return;
836            }
837        };
838
839        let before = docker
840            .list_containers(Some(ListContainersOptions {
841                all: true,
842                ..Default::default()
843            }))
844            .await
845            .unwrap();
846        let before_ids: std::collections::HashSet<String> =
847            before.into_iter().map(|c| c.id.unwrap()).collect();
848
849        let limits = ResourceLimits {
850            cpu_cores: 1.0,
851            memory_mb: 128,
852            timeout_secs: 10,
853            output_bytes: 1024,
854            allow_network: false,
855        };
856
857        let run_fut = run_in_container("alpine:latest", "sleep 100", "", "txt", limits);
858
859        tokio::select! {
860            _ = run_fut => {
861                panic!("Should have been cancelled");
862            }
863            _ = tokio::time::sleep(Duration::from_secs(1)) => {
864                // Cancelled!
865            }
866        }
867
868        tokio::time::sleep(Duration::from_secs(2)).await;
869
870        let after = docker
871            .list_containers(Some(ListContainersOptions {
872                all: true,
873                ..Default::default()
874            }))
875            .await
876            .unwrap();
877
878        let mut leaked = Vec::new();
879        for c in after {
880            let id = c.id.unwrap();
881            if !before_ids.contains(&id) && c.image.as_deref() == Some("alpine:latest") {
882                leaked.push(id);
883            }
884        }
885
886        let leaked_count = leaked.len();
887        for id in leaked {
888            let _ = docker
889                .remove_container(
890                    &id,
891                    Some(RemoveContainerOptions {
892                        force: true,
893                        ..Default::default()
894                    }),
895                )
896                .await;
897        }
898
899        assert_eq!(leaked_count, 0, "Found {} leaked containers", leaked_count);
900    }
901}