@@ -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 { .. }));
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user