Docker sandboxes run `sleep infinity` as PID 1. `sleep` never wait()s on re-parented children, so Chromium's short-lived helper/crashpad processes accumulate as zombies in long-lived browser sandboxes. With `pids_limit` set, this eventually exhausts the slot and the container can no longer fork — the browser tool starts failing before it looks "unhealthy" anywhere else. Setting HostConfig.init = Some(true) makes Docker inject tini, which reaps re-parented children. Note: existing sandbox containers keep their old spec (init is set at create time). Cycle them post-deploy to pick up tini.
351 lines
13 KiB
Rust
351 lines
13 KiB
Rust
//! Docker implementation of the sandbox driver (bollard). Serves local
|
|
//! development and the air-gapped compose target; podman works through the
|
|
//! same API via `DOCKER_HOST`.
|
|
|
|
use bollard::exec::{CreateExecOptions, ResizeExecOptions, StartExecResults};
|
|
use bollard::models::{ContainerCreateBody, HostConfig, Mount, MountTypeEnum, MountVolumeOptions};
|
|
use bollard::query_parameters::{
|
|
CreateContainerOptions, InspectContainerOptions, RemoveContainerOptions, StartContainerOptions,
|
|
};
|
|
use bollard::Docker;
|
|
use futures::StreamExt;
|
|
|
|
use crate::spec::{ExecResult, ManagedSandbox, PtySession, SandboxHandle, SandboxSpec};
|
|
use crate::{SandboxDriver, SandboxError};
|
|
|
|
/// The seccomp deny profile, embedded so the Docker path needs no file
|
|
/// distribution (single source of truth: images/seccomp/agent-profile.json).
|
|
const SECCOMP_PROFILE: &str = include_str!("../../../images/seccomp/agent-profile.json");
|
|
|
|
pub struct DockerDriver {
|
|
docker: Docker,
|
|
}
|
|
|
|
impl DockerDriver {
|
|
pub fn connect() -> Result<DockerDriver, SandboxError> {
|
|
// DOCKER_HOST (set in compose to the allow-listed socket proxy)
|
|
// wins; otherwise the local socket.
|
|
if let Ok(host) = std::env::var("DOCKER_HOST") {
|
|
return DockerDriver::connect_to(&host);
|
|
}
|
|
let docker = Docker::connect_with_local_defaults()
|
|
.map_err(|e| SandboxError::Engine(e.to_string()))?;
|
|
Ok(DockerDriver { docker })
|
|
}
|
|
|
|
/// Connects to a specific engine endpoint, e.g. the compose
|
|
/// deployment's allow-listed socket proxy (`tcp://socket-proxy:2375`).
|
|
pub fn connect_to(host: &str) -> Result<DockerDriver, SandboxError> {
|
|
let docker = Docker::connect_with_http(host, 30, bollard::API_DEFAULT_VERSION)
|
|
.map_err(|e| SandboxError::Engine(e.to_string()))?;
|
|
Ok(DockerDriver { docker })
|
|
}
|
|
|
|
/// Removes a container by name if it exists (test/restart hygiene).
|
|
pub async fn destroy_by_name(&self, name: &str) -> Result<(), SandboxError> {
|
|
self.docker
|
|
.remove_container(
|
|
name,
|
|
Some(RemoveContainerOptions {
|
|
force: true,
|
|
..Default::default()
|
|
}),
|
|
)
|
|
.await
|
|
.map_err(|e| SandboxError::Engine(e.to_string()))
|
|
}
|
|
}
|
|
|
|
#[async_trait::async_trait]
|
|
impl SandboxDriver for DockerDriver {
|
|
async fn provision(&self, spec: &SandboxSpec) -> Result<SandboxHandle, SandboxError> {
|
|
// Hardened tool sandboxes are read-only with a tmpfs home; the Terminal
|
|
// flavour keeps a writable rootfs + home so its baked zsh/oh-my-zsh/p10k
|
|
// config and shell history survive the session. Everything else (non-root,
|
|
// cap-drop ALL, seccomp, no-new-privileges, limits) stays identical.
|
|
let writable = spec.kind.writable_home();
|
|
let tmpfs = if writable {
|
|
None
|
|
} else {
|
|
Some(
|
|
[
|
|
(
|
|
"/tmp".to_owned(),
|
|
"rw,noexec,nosuid,size=67108864".to_owned(),
|
|
),
|
|
// Writable workspace; everything else is read-only.
|
|
(
|
|
"/home/agent".to_owned(),
|
|
"rw,nosuid,size=268435456,uid=10001,gid=10001".to_owned(),
|
|
),
|
|
]
|
|
.into_iter()
|
|
.collect(),
|
|
)
|
|
};
|
|
// Per-agent volume-subpath mounts (the Terminal's Files drives).
|
|
let mounts: Option<Vec<Mount>> = if spec.mounts.is_empty() {
|
|
None
|
|
} else {
|
|
Some(
|
|
spec.mounts
|
|
.iter()
|
|
.map(|m| Mount {
|
|
target: Some(m.target.clone()),
|
|
source: Some(m.volume.clone()),
|
|
typ: Some(MountTypeEnum::VOLUME),
|
|
read_only: Some(m.read_only),
|
|
// An empty subpath ⇒ mount the whole volume at the target
|
|
// (the per-agent node-local drive volume, auto-created by
|
|
// Docker). A non-empty subpath is per-agent isolation within
|
|
// the shared gateway volume.
|
|
volume_options: if m.subpath.is_empty() {
|
|
None
|
|
} else {
|
|
Some(MountVolumeOptions {
|
|
subpath: Some(m.subpath.clone()),
|
|
..Default::default()
|
|
})
|
|
},
|
|
..Default::default()
|
|
})
|
|
.collect(),
|
|
)
|
|
};
|
|
// The §15 controls, enforced unconditionally:
|
|
let host_config = HostConfig {
|
|
cap_drop: Some(vec!["ALL".into()]),
|
|
security_opt: Some(vec![
|
|
"no-new-privileges:true".into(),
|
|
format!("seccomp={SECCOMP_PROFILE}"),
|
|
]),
|
|
readonly_rootfs: Some(!writable),
|
|
tmpfs,
|
|
mounts,
|
|
network_mode: Some(if spec.egress { "bridge" } else { "none" }.into()),
|
|
// Lets browser containers reach host-published test pages on
|
|
// Linux engines; Docker Desktop resolves this name natively.
|
|
extra_hosts: if spec.egress {
|
|
Some(vec!["host.docker.internal:host-gateway".into()])
|
|
} else {
|
|
None
|
|
},
|
|
memory: Some(spec.memory_bytes),
|
|
nano_cpus: Some(spec.nano_cpus),
|
|
pids_limit: Some(spec.pids_limit),
|
|
// Run tini as PID 1 so it reaps re-parented children (Chromium
|
|
// spawns short-lived helper/crashpad processes). `sleep infinity`
|
|
// never wait()s, so without this they accumulate as zombies and
|
|
// eventually exhaust `pids_limit` in long-lived browser sandboxes.
|
|
init: Some(true),
|
|
..Default::default()
|
|
};
|
|
let body = ContainerCreateBody {
|
|
image: Some(spec.image.clone()),
|
|
user: Some(spec.kind.run_user().into()),
|
|
cmd: Some(vec!["sleep".into(), "infinity".into()]),
|
|
host_config: Some(host_config),
|
|
// Mark every sandbox so the reaper can find orphans after a crash.
|
|
labels: Some(std::collections::HashMap::from([(
|
|
crate::SANDBOX_LABEL.to_string(),
|
|
spec.kind.label().to_string(),
|
|
)])),
|
|
..Default::default()
|
|
};
|
|
|
|
let created = self
|
|
.docker
|
|
.create_container(
|
|
Some(CreateContainerOptions {
|
|
name: Some(spec.name.clone()),
|
|
..Default::default()
|
|
}),
|
|
body,
|
|
)
|
|
.await
|
|
.map_err(|e| SandboxError::Engine(e.to_string()))?;
|
|
self.docker
|
|
.start_container(&created.id, None::<StartContainerOptions>)
|
|
.await
|
|
.map_err(|e| SandboxError::Engine(e.to_string()))?;
|
|
Ok(SandboxHandle {
|
|
id: created.id,
|
|
name: spec.name.clone(),
|
|
})
|
|
}
|
|
|
|
async fn exec(&self, handle: &SandboxHandle, cmd: &[&str]) -> Result<ExecResult, SandboxError> {
|
|
let exec = self
|
|
.docker
|
|
.create_exec(
|
|
&handle.id,
|
|
CreateExecOptions {
|
|
cmd: Some(cmd.iter().map(|s| s.to_string()).collect()),
|
|
attach_stdout: Some(true),
|
|
attach_stderr: Some(true),
|
|
..Default::default()
|
|
},
|
|
)
|
|
.await
|
|
.map_err(|e| SandboxError::Engine(e.to_string()))?;
|
|
|
|
let mut stdout = String::new();
|
|
let mut stderr = String::new();
|
|
match self
|
|
.docker
|
|
.start_exec(&exec.id, None)
|
|
.await
|
|
.map_err(|e| SandboxError::Engine(e.to_string()))?
|
|
{
|
|
StartExecResults::Attached { mut output, .. } => {
|
|
while let Some(chunk) = output.next().await {
|
|
match chunk.map_err(|e| SandboxError::Engine(e.to_string()))? {
|
|
bollard::container::LogOutput::StdOut { message } => {
|
|
stdout.push_str(&String::from_utf8_lossy(&message));
|
|
}
|
|
bollard::container::LogOutput::StdErr { message } => {
|
|
stderr.push_str(&String::from_utf8_lossy(&message));
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
StartExecResults::Detached => {}
|
|
}
|
|
|
|
let inspect = self
|
|
.docker
|
|
.inspect_exec(&exec.id)
|
|
.await
|
|
.map_err(|e| SandboxError::Engine(e.to_string()))?;
|
|
Ok(ExecResult {
|
|
exit_code: inspect.exit_code.unwrap_or(-1),
|
|
stdout,
|
|
stderr,
|
|
})
|
|
}
|
|
|
|
async fn attach_pty(
|
|
&self,
|
|
handle: &SandboxHandle,
|
|
cmd: &[&str],
|
|
cols: u16,
|
|
rows: u16,
|
|
env: &[String],
|
|
) -> Result<PtySession, SandboxError> {
|
|
let exec = self
|
|
.docker
|
|
.create_exec(
|
|
&handle.id,
|
|
CreateExecOptions {
|
|
cmd: Some(cmd.iter().map(|s| s.to_string()).collect()),
|
|
env: if env.is_empty() {
|
|
None
|
|
} else {
|
|
Some(env.to_vec())
|
|
},
|
|
attach_stdin: Some(true),
|
|
attach_stdout: Some(true),
|
|
attach_stderr: Some(true),
|
|
tty: Some(true),
|
|
..Default::default()
|
|
},
|
|
)
|
|
.await
|
|
.map_err(|e| SandboxError::Engine(e.to_string()))?;
|
|
|
|
match self
|
|
.docker
|
|
.start_exec(&exec.id, None)
|
|
.await
|
|
.map_err(|e| SandboxError::Engine(e.to_string()))?
|
|
{
|
|
StartExecResults::Attached { output, input } => {
|
|
// Best-effort initial window size; resize_pty handles later changes.
|
|
let _ = self
|
|
.docker
|
|
.resize_exec(
|
|
&exec.id,
|
|
ResizeExecOptions {
|
|
height: rows,
|
|
width: cols,
|
|
},
|
|
)
|
|
.await;
|
|
let bytes = output.map(|chunk| {
|
|
chunk
|
|
.map(|log| log.into_bytes().to_vec())
|
|
.map_err(|e| SandboxError::Engine(e.to_string()))
|
|
});
|
|
Ok(PtySession {
|
|
exec_id: exec.id,
|
|
output: Box::pin(bytes),
|
|
input,
|
|
})
|
|
}
|
|
StartExecResults::Detached => Err(SandboxError::Engine(
|
|
"pty exec detached unexpectedly".into(),
|
|
)),
|
|
}
|
|
}
|
|
|
|
async fn resize_pty(&self, exec_id: &str, cols: u16, rows: u16) -> Result<(), SandboxError> {
|
|
self.docker
|
|
.resize_exec(
|
|
exec_id,
|
|
ResizeExecOptions {
|
|
height: rows,
|
|
width: cols,
|
|
},
|
|
)
|
|
.await
|
|
.map_err(|e| SandboxError::Engine(e.to_string()))
|
|
}
|
|
|
|
async fn destroy(&self, handle: &SandboxHandle) -> Result<(), SandboxError> {
|
|
self.destroy_by_name(&handle.id).await
|
|
}
|
|
|
|
async fn health(&self, handle: &SandboxHandle) -> Result<bool, SandboxError> {
|
|
match self
|
|
.docker
|
|
.inspect_container(&handle.id, None::<InspectContainerOptions>)
|
|
.await
|
|
{
|
|
Ok(info) => Ok(info.state.and_then(|s| s.running).unwrap_or(false)),
|
|
Err(bollard::errors::Error::DockerResponseServerError {
|
|
status_code: 404, ..
|
|
}) => Ok(false),
|
|
Err(e) => Err(SandboxError::Engine(e.to_string())),
|
|
}
|
|
}
|
|
|
|
async fn list_managed(&self, kind: &str) -> Result<Vec<ManagedSandbox>, SandboxError> {
|
|
use bollard::query_parameters::ListContainersOptions;
|
|
let mut filters = std::collections::HashMap::new();
|
|
filters.insert(
|
|
"label".to_string(),
|
|
vec![format!("{}={}", crate::SANDBOX_LABEL, kind)],
|
|
);
|
|
let opts = ListContainersOptions {
|
|
all: true, // include stopped/exited orphans too
|
|
filters: Some(filters),
|
|
..Default::default()
|
|
};
|
|
let list = self
|
|
.docker
|
|
.list_containers(Some(opts))
|
|
.await
|
|
.map_err(|e| SandboxError::Engine(e.to_string()))?;
|
|
Ok(list
|
|
.into_iter()
|
|
.filter_map(|c| {
|
|
c.id.map(|id| ManagedSandbox {
|
|
id,
|
|
created_unix: c.created.unwrap_or(0),
|
|
})
|
|
})
|
|
.collect())
|
|
}
|
|
}
|