Add sandbox
ci/woodpecker/push/tests Pipeline was canceled

This commit is contained in:
2026-09-17 13:16:15 +00:00
parent 536c55f27b
commit 99d1c2feef
16 changed files with 1537 additions and 12 deletions
+649
View File
@@ -0,0 +1,649 @@
//! Container lifecycle primitives for a parsed [`DevContainer`].
//!
//! This module shells out to a container runtime (`docker` or `podman`) to build
//! the devcontainer image, start a container with the workspace mounted, run the
//! `postCreateCommand` / `postStartCommand` hooks and execute commands inside the
//! running container.
//!
//! It is intentionally runtime-agnostic: any binary exposing the `docker` CLI
//! surface (including `podman`) can be used via [`ContainerRuntime::new`].
//!
//! # Isolation
//!
//! Each sandbox gets its own image tag, its own container and its own network.
//! The container starts attached to that network so the `postCreateCommand` /
//! `postStartCommand` hooks can fetch dependencies (e.g. `npm install`); once the
//! hooks have run, the container is disconnected from the network for the rest of
//! its lifetime. Every command is bounded by a timeout.
use std::{
path::{Path, PathBuf},
process::Stdio,
time::{Duration, SystemTime, UNIX_EPOCH},
};
use tokio::process::Command;
use crate::DevContainer;
/// Timeout applied to build/run/stop/remove operations.
const DEFAULT_COMMAND_TIMEOUT: Duration = Duration::from_secs(600);
/// Timeout applied to commands executed inside a running container.
const DEFAULT_EXEC_TIMEOUT: Duration = Duration::from_secs(60);
/// Result of a command executed inside a container.
#[derive(Debug, Clone)]
pub struct ExecOutput {
/// Exit code, or `-1` if the process was terminated by a signal.
pub status: i32,
pub stdout: String,
pub stderr: String,
}
impl ExecOutput {
pub fn success(&self) -> bool {
self.status == 0
}
/// Turns a non-zero exit code into a [`ContainerError::Command`].
pub fn ensure_success(self, program: &str, args: &[String]) -> Result<Self, ContainerError> {
if self.success() {
return Ok(self);
}
Err(ContainerError::Command {
program: program.to_string(),
args: args.join(" "),
status: self.status,
stderr: self.stderr.trim().to_string(),
})
}
}
#[derive(Debug, thiserror::Error)]
pub enum ContainerError {
#[error("failed to run `{program}`: {source}")]
Spawn {
program: String,
source: std::io::Error,
},
#[error("`{program} {args}` failed with status {status}: {stderr}")]
Command {
program: String,
args: String,
status: i32,
stderr: String,
},
#[error("`{program} {args}` timed out after {timeout:?}")]
Timeout {
program: String,
args: String,
timeout: Duration,
},
}
/// A container runtime binary exposing the `docker` CLI surface.
#[derive(Debug, Clone)]
pub struct ContainerRuntime {
program: String,
timeout: Duration,
}
impl ContainerRuntime {
pub fn new(program: impl Into<String>) -> Self {
Self {
program: program.into(),
timeout: DEFAULT_COMMAND_TIMEOUT,
}
}
pub fn docker() -> Self {
Self::new("docker")
}
pub fn podman() -> Self {
Self::new("podman")
}
/// Overrides the timeout applied to build/run/stop/remove operations.
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
pub fn program(&self) -> &str {
&self.program
}
pub fn timeout(&self) -> Duration {
self.timeout
}
/// Checks that the runtime binary is present and responsive.
pub async fn available(&self) -> bool {
Command::new(&self.program)
.arg("version")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.await
.map(|status| status.success())
.unwrap_or(false)
}
/// Runs the runtime with the given arguments, capturing stdout/stderr.
///
/// Only spawn failures and timeouts are reported as errors; a non-zero exit
/// code is returned in the [`ExecOutput`] so callers can decide how to react.
pub async fn run(&self, args: &[String]) -> Result<ExecOutput, ContainerError> {
self.run_with_timeout(args, self.timeout).await
}
/// Like [`run`](Self::run) with an explicit timeout.
pub async fn run_with_timeout(
&self,
args: &[String],
timeout: Duration,
) -> Result<ExecOutput, ContainerError> {
let output = Command::new(&self.program)
.args(args)
.stdin(Stdio::null())
.kill_on_drop(true)
.output();
match tokio::time::timeout(timeout, output).await {
Ok(Ok(output)) => Ok(ExecOutput {
status: output.status.code().unwrap_or(-1),
stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
}),
Ok(Err(source)) => Err(ContainerError::Spawn {
program: self.program.clone(),
source,
}),
Err(_) => Err(ContainerError::Timeout {
program: self.program.clone(),
args: args.join(" "),
timeout,
}),
}
}
}
/// A running devcontainer.
#[derive(Debug, Clone)]
pub struct Container {
runtime: ContainerRuntime,
name: String,
workspace_folder: String,
remote_user: Option<String>,
network: Option<String>,
image: Option<String>,
}
impl Container {
pub fn name(&self) -> &str {
&self.name
}
pub fn workspace_folder(&self) -> &str {
&self.workspace_folder
}
/// Executes a command inside the container, returning its output.
///
/// The command is passed as an argv vector (no shell), so no quoting or
/// interpolation is performed.
pub async fn exec(&self, cmd: &[&str]) -> Result<ExecOutput, ContainerError> {
self.exec_with_timeout(cmd, DEFAULT_EXEC_TIMEOUT).await
}
async fn exec_with_timeout(
&self,
cmd: &[&str],
timeout: Duration,
) -> Result<ExecOutput, ContainerError> {
let mut args = vec!["exec".to_string()];
if let Some(user) = &self.remote_user {
args.push("--user".to_string());
args.push(user.clone());
}
args.push(self.name.clone());
args.extend(cmd.iter().map(|arg| arg.to_string()));
self.runtime.run_with_timeout(&args, timeout).await
}
/// Executes a shell script inside the container via `sh -c`.
pub async fn exec_shell(&self, script: &str) -> Result<ExecOutput, ContainerError> {
self.exec(&["sh", "-c", script]).await
}
/// Stops the container.
pub async fn stop(&self) -> Result<(), ContainerError> {
let args = vec!["stop".to_string(), self.name.clone()];
self.runtime
.run(&args)
.await?
.ensure_success(self.runtime.program(), &args)?;
Ok(())
}
/// Removes the container, its network and its image.
///
/// Network and image removal are best-effort: they may already be gone.
pub async fn remove(&self) -> Result<(), ContainerError> {
let args = vec![
"rm".to_string(),
"-f".to_string(),
"-v".to_string(),
self.name.clone(),
];
self.runtime
.run(&args)
.await?
.ensure_success(self.runtime.program(), &args)?;
if let Some(network) = &self.network {
let args = vec!["network".to_string(), "rm".to_string(), network.clone()];
let _ = self.runtime.run(&args).await;
}
if let Some(image) = &self.image {
let args = vec!["rmi".to_string(), image.clone()];
let _ = self.runtime.run(&args).await;
}
Ok(())
}
}
impl DevContainer {
/// Base image name derived from the devcontainer name.
pub fn image_name(&self) -> String {
let base = self.name.as_deref().unwrap_or("devcontainer");
format!("devcontainer-rs/{}", sanitize(base))
}
/// Unique image tag for a single sandbox run.
///
/// Uniqueness matters: two concurrent sandboxes (possibly for different
/// repositories sharing a devcontainer name) must not race on a shared tag.
pub fn image_tag(&self) -> String {
format!("{}:{}", self.image_name(), unique_suffix())
}
/// Arguments passed to `docker build` (everything after the `build` verb).
pub fn build_args(&self, image_tag: &str) -> Vec<String> {
let context = self
.container_file_path
.parent()
.unwrap_or_else(|| Path::new("."));
let mut args = vec![
"-f".to_string(),
self.container_file_path.display().to_string(),
"-t".to_string(),
image_tag.to_string(),
];
for (key, value) in &self.build_args {
args.push("--build-arg".to_string());
args.push(format!("{key}={value}"));
}
args.push(context.display().to_string());
args
}
/// Arguments passed to `docker run` (everything after the `run` verb).
pub fn run_args(
&self,
workspace_dir: &Path,
container_name: &str,
image_tag: &str,
network: Option<&str>,
) -> Vec<String> {
let workspace_folder = self.workspace_folder();
let mut args = vec![
"-d".to_string(),
"--name".to_string(),
container_name.to_string(),
"-v".to_string(),
format!("{}:{}", workspace_dir.display(), workspace_folder),
"-w".to_string(),
workspace_folder.to_string(),
];
if let Some(network) = network {
args.push("--network".to_string());
args.push(network.to_string());
}
if let Some(user) = &self.remote_user {
args.push("--user".to_string());
args.push(user.clone());
}
for (key, value) in &self.container_env {
args.push("-e".to_string());
args.push(format!("{key}={value}"));
}
args.extend(self.run_args.iter().cloned());
args.push(image_tag.to_string());
// Keep the container alive so we can `exec` into it.
args.push("sleep".to_string());
args.push("infinity".to_string());
args
}
/// Workspace folder inside the container, defaulting to `/workspaces/workspace`.
pub fn workspace_folder(&self) -> String {
self.workspace_folder
.clone()
.unwrap_or_else(|| "/workspaces/workspace".to_string())
}
/// Unique container name for this run.
pub fn container_name(&self) -> String {
let base = self.name.as_deref().unwrap_or("devcontainer");
format!("devcontainer-rs-{}-{}", sanitize(base), unique_suffix())
}
/// Builds the devcontainer image under `image_tag`.
pub async fn build(
&self,
runtime: &ContainerRuntime,
image_tag: &str,
) -> Result<(), ContainerError> {
let mut args = vec!["build".to_string()];
args.extend(self.build_args(image_tag));
runtime
.run(&args)
.await?
.ensure_success(runtime.program(), &args)?;
Ok(())
}
/// Builds the image, starts the container with the workspace mounted, runs
/// the `postCreateCommand` / `postStartCommand` hooks with network access,
/// then disconnects the container from the network.
///
/// On any failure the container, network and image are cleaned up before
/// returning, so no resource is leaked.
pub async fn up(
&self,
runtime: &ContainerRuntime,
workspace_dir: &Path,
) -> Result<Container, ContainerError> {
let image_tag = self.image_tag();
self.build(runtime, &image_tag).await?;
let name = self.container_name();
let network = format!("{name}-net");
// Dedicated network so connectivity can be cut after the hooks.
let args = vec!["network".to_string(), "create".to_string(), network.clone()];
if let Err(err) = runtime
.run(&args)
.await
.and_then(|out| out.ensure_success(runtime.program(), &args))
{
let _ = runtime.run(&["rmi".to_string(), image_tag]).await;
return Err(err);
}
let mut args = vec!["run".to_string()];
args.extend(self.run_args(workspace_dir, &name, &image_tag, Some(&network)));
if let Err(err) = runtime
.run(&args)
.await
.and_then(|out| out.ensure_success(runtime.program(), &args))
{
let _ = runtime
.run(&["network".to_string(), "rm".to_string(), network])
.await;
let _ = runtime.run(&["rmi".to_string(), image_tag]).await;
return Err(err);
}
let container = Container {
runtime: runtime.clone(),
name,
workspace_folder: self.workspace_folder(),
remote_user: self.remote_user.clone(),
network: Some(network.clone()),
image: Some(image_tag),
};
// Hooks run with network access (dependency installation, etc.).
if let Err(err) = self.run_hooks(&container).await {
let _ = container.remove().await;
return Err(err);
}
// Cut network access for the rest of the sandbox lifetime.
let args = vec![
"network".to_string(),
"disconnect".to_string(),
network,
container.name.clone(),
];
if let Err(err) = runtime
.run(&args)
.await
.and_then(|out| out.ensure_success(runtime.program(), &args))
{
let _ = container.remove().await;
return Err(err);
}
Ok(container)
}
async fn run_hooks(&self, container: &Container) -> Result<(), ContainerError> {
// Hooks may install dependencies, so they get the long command timeout
// rather than the short one used for tool execution.
let timeout = container.runtime.timeout();
for command in [&self.post_create_command, &self.post_start_command]
.into_iter()
.flatten()
{
let output = container
.exec_with_timeout(&["sh", "-c", command], timeout)
.await?;
output.ensure_success(
container.runtime.program(),
&["exec".to_string(), command.clone()],
)?;
}
Ok(())
}
}
/// Sanitizes a string so it can be used as a docker image/container name.
fn sanitize(input: &str) -> String {
let sanitized: String = input
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
c.to_ascii_lowercase()
} else {
'-'
}
})
.collect();
let trimmed = sanitized.trim_matches(|c| c == '-' || c == '.' || c == '_');
if trimmed.is_empty() {
"devcontainer".to_string()
} else {
trimmed.to_string()
}
}
/// Suffix unique to a sandbox run, combining the process id and a timestamp.
fn unique_suffix() -> String {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_nanos())
.unwrap_or(0);
format!("{}-{}", std::process::id(), nanos)
}
/// Lexically normalizes a path, resolving `.` and `..` without touching the
/// filesystem. Returns `None` if the path escapes its root.
pub fn normalize(path: &Path) -> Option<PathBuf> {
use std::path::Component;
let mut out = PathBuf::new();
for component in path.components() {
match component {
Component::RootDir => out.push("/"),
Component::CurDir => {}
Component::ParentDir => {
if !out.pop() {
return None;
}
}
Component::Normal(part) => out.push(part),
Component::Prefix(_) => return None,
}
}
Some(out)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn devcontainer(dir: &Path) -> DevContainer {
let devcontainer_path = dir.join("devcontainer.json");
let dockerfile_path = dir.join("Dockerfile");
fs::write(&dockerfile_path, "FROM alpine\n").unwrap();
fs::write(
&devcontainer_path,
r#"{
"name": "My Project",
"build": {
"dockerfile": "Dockerfile",
"args": { "VERSION": "1" }
},
"workspaceFolder": "/workspaces/my-project",
"containerEnv": { "RUST_LOG": "debug" },
"remoteUser": "dev",
"runArgs": ["--userns=keep-id"]
}"#,
)
.unwrap();
let runtime = tokio::runtime::Runtime::new().unwrap();
runtime.block_on(crate::parse(&devcontainer_path)).unwrap()
}
#[test]
fn image_name_is_sanitized() {
let dir = tempfile::tempdir().unwrap();
let dc = devcontainer(dir.path());
assert_eq!(dc.image_name(), "devcontainer-rs/my-project");
}
#[test]
fn image_tags_are_unique() {
let dir = tempfile::tempdir().unwrap();
let dc = devcontainer(dir.path());
assert_ne!(dc.image_tag(), dc.image_tag());
}
#[test]
fn build_args_include_dockerfile_tag_build_args_and_context() {
let dir = tempfile::tempdir().unwrap();
let dc = devcontainer(dir.path());
let args = dc.build_args("devcontainer-rs/my-project:test");
assert_eq!(args[0], "-f");
assert!(args[1].ends_with("Dockerfile"));
assert_eq!(args[2], "-t");
assert_eq!(args[3], "devcontainer-rs/my-project:test");
assert!(args.contains(&"--build-arg".to_string()));
assert!(args.contains(&"VERSION=1".to_string()));
assert_eq!(args.last().unwrap(), &dir.path().display().to_string());
}
#[test]
fn run_args_mount_workspace_and_keep_alive() {
let dir = tempfile::tempdir().unwrap();
let dc = devcontainer(dir.path());
let workspace = Path::new("/tmp/clone");
let args = dc.run_args(
workspace,
"devcontainer-rs-my-project-42",
"devcontainer-rs/my-project:test",
Some("sandbox-net"),
);
assert!(args.contains(&"-d".to_string()));
assert!(args.contains(&"--name".to_string()));
assert!(args.contains(&"devcontainer-rs-my-project-42".to_string()));
assert!(args.contains(&"/tmp/clone:/workspaces/my-project".to_string()));
assert!(args.contains(&"--network".to_string()));
assert!(args.contains(&"sandbox-net".to_string()));
assert!(args.contains(&"--user".to_string()));
assert!(args.contains(&"dev".to_string()));
assert!(args.contains(&"RUST_LOG=debug".to_string()));
assert!(args.contains(&"--userns=keep-id".to_string()));
assert!(args.contains(&"devcontainer-rs/my-project:test".to_string()));
assert_eq!(
&args[args.len() - 2..],
&["sleep".to_string(), "infinity".to_string()]
);
}
#[test]
fn normalize_rejects_escaping_paths() {
assert_eq!(
normalize(Path::new("/workspaces/project/src/../main.rs")),
Some(PathBuf::from("/workspaces/project/main.rs"))
);
assert_eq!(normalize(Path::new("/workspaces/../../etc/passwd")), None);
}
#[tokio::test]
async fn run_captures_output() {
let runtime = ContainerRuntime::new("echo");
let output = runtime.run(&["hello".to_string()]).await.unwrap();
assert!(output.success());
assert_eq!(output.stdout.trim(), "hello");
}
#[tokio::test]
async fn run_times_out_and_kills_the_process() {
let runtime = ContainerRuntime::new("sleep");
let err = runtime
.run_with_timeout(&["10".to_string()], Duration::from_millis(50))
.await
.unwrap_err();
assert!(matches!(err, ContainerError::Timeout { .. }));
}
}
+99 -3
View File
@@ -5,6 +5,10 @@ use std::{
use serde::Deserialize;
mod container;
pub use container::{Container, ContainerError, ContainerRuntime, ExecOutput, normalize};
#[derive(Debug, Deserialize)]
pub struct DevContainerBuildSchema {
#[serde(default)]
@@ -30,6 +34,12 @@ pub struct DevContainerSchema {
#[serde(rename = "postStartCommand", default)]
pub post_start_command: Option<String>,
#[serde(rename = "remoteUser", default)]
pub remote_user: Option<String>,
#[serde(rename = "runArgs", default)]
pub run_args: Vec<String>,
}
#[derive(Debug)]
@@ -41,6 +51,8 @@ pub struct DevContainer {
pub workspace_folder: Option<String>,
pub post_create_command: Option<String>,
pub post_start_command: Option<String>,
pub remote_user: Option<String>,
pub run_args: Vec<String>,
}
#[derive(Debug, thiserror::Error)]
@@ -80,18 +92,72 @@ impl TryFrom<(DevContainerSchema, PathBuf)> for DevContainer {
return Err(ParseError::ContainerFileNotFound(container_file_path));
}
let build_args = schema
.build
.args
.into_iter()
.map(|(k, v)| (k, substitute_local_env(&v)))
.collect();
let container_env = schema
.container_env
.into_iter()
.map(|(k, v)| (k, substitute_local_env(&v)))
.collect();
Ok(Self {
container_file_path,
name: schema.name,
build_args: schema.build.args,
container_env: schema.container_env,
build_args,
container_env,
workspace_folder: schema.workspace_folder,
post_create_command: schema.post_create_command,
post_start_command: schema.post_start_command,
remote_user: schema.remote_user,
run_args: schema.run_args,
})
}
}
/// Resolves `${localEnv:VAR}` and `${localEnv:VAR:default}` references using the
/// current process environment, as described by the devcontainer specification.
/// Unresolved variables without a default expand to an empty string.
fn substitute_local_env(input: &str) -> String {
const PREFIX: &str = "${localEnv:";
let mut out = String::with_capacity(input.len());
let mut rest = input;
while let Some(start) = rest.find(PREFIX) {
out.push_str(&rest[..start]);
let after = &rest[start + PREFIX.len()..];
match after.find('}') {
Some(end) => {
let inner = &after[..end];
let (key, default) = match inner.split_once(':') {
Some((key, default)) => (key, Some(default)),
None => (inner, None),
};
match std::env::var(key) {
Ok(value) => out.push_str(&value),
Err(_) => out.push_str(default.unwrap_or("")),
}
rest = &after[end + 1..];
}
None => {
out.push_str(PREFIX);
rest = after;
}
}
}
out.push_str(rest);
out
}
pub async fn parse(path: impl AsRef<Path>) -> Result<DevContainer, ParseError> {
let path = path.as_ref().to_path_buf();
let contents = tokio::fs::read_to_string(&path)
@@ -136,7 +202,9 @@ mod tests {
"workspaceFolder": "/workspace",
"containerEnv": {
"RUST_LOG": "debug"
}
},
"remoteUser": "dev",
"runArgs": ["--userns=keep-id"]
}"#,
)
.unwrap();
@@ -148,5 +216,33 @@ mod tests {
assert_eq!(config.build_args.get("VERSION").unwrap(), "1");
assert_eq!(config.container_env.get("RUST_LOG").unwrap(), "debug");
assert_eq!(config.workspace_folder.as_deref(), Some("/workspace"));
assert_eq!(config.remote_user.as_deref(), Some("dev"));
assert_eq!(config.run_args, vec!["--userns=keep-id"]);
}
#[test]
fn substitutes_local_env_with_default() {
unsafe { std::env::set_var("DEVCONTAINER_TEST_UID", "1000") };
assert_eq!(
substitute_local_env("${localEnv:DEVCONTAINER_TEST_UID}"),
"1000"
);
assert_eq!(
substitute_local_env("uid=${localEnv:DEVCONTAINER_TEST_UID}"),
"uid=1000"
);
assert_eq!(
substitute_local_env("${localEnv:DEVCONTAINER_TEST_MISSING:fallback}"),
"fallback"
);
assert_eq!(
substitute_local_env("${localEnv:DEVCONTAINER_TEST_MISSING}"),
""
);
assert_eq!(
substitute_local_env("no variables here"),
"no variables here"
);
}
}