1.2: Sandboxing #7
@@ -1,20 +1,20 @@
|
||||
//! Container lifecycle primitives for a parsed [`DevContainer`].
|
||||
//! Primitives de cycle de vie de container pour un [`DevContainer`] analysé.
|
||||
//!
|
||||
//! 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.
|
||||
//! Ce module invoque un runtime de containers (`docker` ou `podman`) pour construire
|
||||
//! l'image devcontainer, démarrer un container avec le workspace monté, exécuter les
|
||||
//! hooks `postCreateCommand` / `postStartCommand` et lancer des commandes à l'intérieur
|
||||
//! du container en cours d'exécution.
|
||||
//!
|
||||
//! It is intentionally runtime-agnostic: any binary exposing the `docker` CLI
|
||||
//! surface (including `podman`) can be used via [`ContainerRuntime::new`].
|
||||
//! Il est volontairement agnostique du runtime : tout binaire exposant l'interface
|
||||
//! CLI `docker` (y compris `podman`) peut être utilisé 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.
|
||||
//! Chaque sandbox dispose de son propre tag d'image, de son propre container et de
|
||||
//! son propre réseau. Le container démarre attaché à ce réseau afin que les hooks
|
||||
//! `postCreateCommand` / `postStartCommand` puissent récupérer des dépendances
|
||||
//! (par ex. `npm install`) ; une fois les hooks exécutés, le container est déconnecté
|
||||
//! du réseau pour le reste de sa durée de vie. Chaque commande est bornée par un timeout.
|
||||
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
@@ -26,16 +26,16 @@ use tokio::process::Command;
|
||||
|
||||
use crate::DevContainer;
|
||||
|
||||
/// Timeout applied to build/run/stop/remove operations.
|
||||
/// Timeout appliqué aux opérations de build/run/stop/remove.
|
||||
const DEFAULT_COMMAND_TIMEOUT: Duration = Duration::from_secs(600);
|
||||
|
||||
/// Timeout applied to commands executed inside a running container.
|
||||
/// Timeout appliqué aux commandes exécutées dans un container en cours d'exécution.
|
||||
const DEFAULT_EXEC_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
|
||||
/// Result of a command executed inside a container.
|
||||
/// Résultat d'une commande exécutée dans un container.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ExecOutput {
|
||||
/// Exit code, or `-1` if the process was terminated by a signal.
|
||||
/// Code de sortie, ou `-1` si le processus a été terminé par un signal.
|
||||
pub status: i32,
|
||||
pub stdout: String,
|
||||
pub stderr: String,
|
||||
@@ -46,7 +46,7 @@ impl ExecOutput {
|
||||
self.status == 0
|
||||
}
|
||||
|
||||
/// Turns a non-zero exit code into a [`ContainerError::Command`].
|
||||
/// Transforme un code de sortie non nul en [`ContainerError::Command`].
|
||||
pub fn ensure_success(self, program: &str, args: &[String]) -> Result<Self, ContainerError> {
|
||||
if self.success() {
|
||||
return Ok(self);
|
||||
@@ -85,7 +85,7 @@ pub enum ContainerError {
|
||||
},
|
||||
}
|
||||
|
||||
/// A container runtime binary exposing the `docker` CLI surface.
|
||||
/// Un binaire de runtime de containers exposant l'interface CLI `docker`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ContainerRuntime {
|
||||
program: String,
|
||||
@@ -108,7 +108,7 @@ impl ContainerRuntime {
|
||||
Self::new("podman")
|
||||
}
|
||||
|
||||
/// Overrides the timeout applied to build/run/stop/remove operations.
|
||||
/// Remplace le timeout appliqué aux opérations de build/run/stop/remove.
|
||||
pub fn with_timeout(mut self, timeout: Duration) -> Self {
|
||||
self.timeout = timeout;
|
||||
self
|
||||
@@ -122,7 +122,7 @@ impl ContainerRuntime {
|
||||
self.timeout
|
||||
}
|
||||
|
||||
/// Checks that the runtime binary is present and responsive.
|
||||
/// Vérifie que le binaire du runtime est présent et répond.
|
||||
pub async fn available(&self) -> bool {
|
||||
Command::new(&self.program)
|
||||
.arg("version")
|
||||
@@ -135,15 +135,15 @@ impl ContainerRuntime {
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Runs the runtime with the given arguments, capturing stdout/stderr.
|
||||
/// Exécute le runtime avec les arguments donnés, en capturant 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.
|
||||
/// Seuls les échecs de lancement (spawn) et les timeouts sont des erreurs ; un
|
||||
/// code non nul est renvoyé dans [`ExecOutput`] afin que les appelants réagissent.
|
||||
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.
|
||||
/// Comme [`run`](Self::run), mais avec un timeout explicite.
|
||||
pub async fn run_with_timeout(
|
||||
&self,
|
||||
args: &[String],
|
||||
@@ -174,7 +174,7 @@ impl ContainerRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
/// A running devcontainer.
|
||||
/// Un devcontainer en cours d'exécution.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Container {
|
||||
runtime: ContainerRuntime,
|
||||
@@ -194,10 +194,10 @@ impl Container {
|
||||
&self.workspace_folder
|
||||
}
|
||||
|
||||
/// Executes a command inside the container, returning its output.
|
||||
/// Exécute une commande dans le container et renvoie sa sortie.
|
||||
///
|
||||
/// The command is passed as an argv vector (no shell), so no quoting or
|
||||
/// interpolation is performed.
|
||||
/// La commande est transmise sous forme de vecteur d'arguments (argv, sans shell),
|
||||
/// donc aucun échappement ni interpolation n'est effectué.
|
||||
pub async fn exec(&self, cmd: &[&str]) -> Result<ExecOutput, ContainerError> {
|
||||
self.exec_with_timeout(cmd, DEFAULT_EXEC_TIMEOUT).await
|
||||
}
|
||||
@@ -220,12 +220,12 @@ impl Container {
|
||||
self.runtime.run_with_timeout(&args, timeout).await
|
||||
}
|
||||
|
||||
/// Executes a shell script inside the container via `sh -c`.
|
||||
/// Exécute un script shell dans le container via `sh -c`.
|
||||
pub async fn exec_shell(&self, script: &str) -> Result<ExecOutput, ContainerError> {
|
||||
self.exec(&["sh", "-c", script]).await
|
||||
}
|
||||
|
||||
/// Stops the container.
|
||||
/// Arrête le container.
|
||||
pub async fn stop(&self) -> Result<(), ContainerError> {
|
||||
let args = vec!["stop".to_string(), self.name.clone()];
|
||||
self.runtime
|
||||
@@ -235,9 +235,9 @@ impl Container {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Removes the container, its network and its image.
|
||||
/// Supprime le container, son réseau et son image.
|
||||
///
|
||||
/// Network and image removal are best-effort: they may already be gone.
|
||||
/// La suppression du réseau et de l'image est best-effort : ils peuvent déjà être absents.
|
||||
pub async fn remove(&self) -> Result<(), ContainerError> {
|
||||
let args = vec![
|
||||
"rm".to_string(),
|
||||
@@ -265,21 +265,21 @@ impl Container {
|
||||
}
|
||||
|
||||
impl DevContainer {
|
||||
/// Base image name derived from the devcontainer name.
|
||||
/// Nom de l'image de base dérivé du nom du devcontainer.
|
||||
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.
|
||||
/// Tag d'image unique pour une exécution de sandbox donnée.
|
||||
///
|
||||
/// Uniqueness matters: two concurrent sandboxes (possibly for different
|
||||
/// repositories sharing a devcontainer name) must not race on a shared tag.
|
||||
/// L'unicité est importante : deux sandboxes concurrentes (éventuellement pour des
|
||||
/// dépôts différents partageant un nom de devcontainer) ne doivent pas se disputer le même tag.
|
||||
pub fn image_tag(&self) -> String {
|
||||
format!("{}:{}", self.image_name(), unique_suffix())
|
||||
}
|
||||
|
||||
/// Arguments passed to `docker build` (everything after the `build` verb).
|
||||
/// Arguments passés à `docker build` (tout ce qui suit le verbe `build`).
|
||||
pub fn build_args(&self, image_tag: &str) -> Vec<String> {
|
||||
let context = self
|
||||
.container_file_path
|
||||
@@ -302,7 +302,7 @@ impl DevContainer {
|
||||
args
|
||||
}
|
||||
|
||||
/// Arguments passed to `docker run` (everything after the `run` verb).
|
||||
/// Arguments passés à `docker run` (tout ce qui suit le verbe `run`).
|
||||
pub fn run_args(
|
||||
&self,
|
||||
workspace_dir: &Path,
|
||||
@@ -340,27 +340,27 @@ impl DevContainer {
|
||||
args.extend(self.run_args.iter().cloned());
|
||||
|
||||
args.push(image_tag.to_string());
|
||||
// Keep the container alive so we can `exec` into it.
|
||||
// Maintient le container en vie pour pouvoir y exécuter `exec`.
|
||||
args.push("sleep".to_string());
|
||||
args.push("infinity".to_string());
|
||||
|
||||
args
|
||||
}
|
||||
|
||||
/// Workspace folder inside the container, defaulting to `/workspaces/workspace`.
|
||||
/// Dossier de workspace dans le container, par défaut `/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.
|
||||
/// Nom de container unique pour cette exécution.
|
||||
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`.
|
||||
/// Construit l'image devcontainer sous `image_tag`.
|
||||
pub async fn build(
|
||||
&self,
|
||||
runtime: &ContainerRuntime,
|
||||
@@ -377,12 +377,12 @@ impl DevContainer {
|
||||
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.
|
||||
/// Construit l'image, démarre le container avec le workspace monté, exécute les
|
||||
/// hooks `postCreateCommand` / `postStartCommand` avec accès au réseau, puis
|
||||
/// déconnecte le container du réseau.
|
||||
///
|
||||
/// On any failure the container, network and image are cleaned up before
|
||||
/// returning, so no resource is leaked.
|
||||
/// En cas d'échec, le container, le réseau et l'image sont nettoyés avant de
|
||||
/// renvoyer l'erreur, afin qu'aucune ressource ne soit laissée en place.
|
||||
pub async fn up(
|
||||
&self,
|
||||
runtime: &ContainerRuntime,
|
||||
@@ -394,7 +394,7 @@ impl DevContainer {
|
||||
let name = self.container_name();
|
||||
let network = format!("{name}-net");
|
||||
|
||||
// Dedicated network so connectivity can be cut after the hooks.
|
||||
// Réseau dédié afin de pouvoir couper la connectivité après les hooks.
|
||||
let args = vec!["network".to_string(), "create".to_string(), network.clone()];
|
||||
if let Err(err) = runtime
|
||||
.run(&args)
|
||||
@@ -429,13 +429,13 @@ impl DevContainer {
|
||||
image: Some(image_tag),
|
||||
};
|
||||
|
||||
// Hooks run with network access (dependency installation, etc.).
|
||||
// Les hooks s'exécutent avec accès au réseau (installation de dépendances, 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.
|
||||
// Coupe l'accès réseau pour le reste de la durée de vie de la sandbox.
|
||||
let args = vec![
|
||||
"network".to_string(),
|
||||
"disconnect".to_string(),
|
||||
@@ -455,8 +455,8 @@ impl DevContainer {
|
||||
}
|
||||
|
||||
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.
|
||||
// Les hooks peuvent installer des dépendances, ils utilisent donc le timeout
|
||||
// long des commandes plutôt que le court réservé à l'exécution des outils.
|
||||
let timeout = container.runtime.timeout();
|
||||
|
||||
for command in [&self.post_create_command, &self.post_start_command]
|
||||
@@ -476,7 +476,7 @@ impl DevContainer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Sanitizes a string so it can be used as a docker image/container name.
|
||||
/// Nettoie une chaîne pour qu'elle puisse servir de nom d'image/container docker.
|
||||
fn sanitize(input: &str) -> String {
|
||||
|
qpismont marked this conversation as resolved
|
||||
let sanitized: String = input
|
||||
.chars()
|
||||
@@ -497,7 +497,7 @@ fn sanitize(input: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Suffix unique to a sandbox run, combining the process id and a timestamp.
|
||||
/// Suffixe unique à une exécution de sandbox, combinant l'identifiant de processus et un horodatage.
|
||||
fn unique_suffix() -> String {
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
@@ -507,8 +507,8 @@ fn unique_suffix() -> String {
|
||||
format!("{}-{}", std::process::id(), nanos)
|
||||
}
|
||||
|
||||
/// Lexically normalizes a path, resolving `.` and `..` without touching the
|
||||
/// filesystem. Returns `None` if the path escapes its root.
|
||||
/// Normalise lexicalement un chemin, en résolvant `.` et `..` sans toucher au
|
||||
/// système de fichiers. Renvoie `None` si le chemin sort de sa racine.
|
||||
pub fn normalize(path: &Path) -> Option<PathBuf> {
|
||||
use std::path::Component;
|
||||
|
||||
|
||||
@@ -119,9 +119,9 @@ impl TryFrom<(DevContainerSchema, PathBuf)> for DevContainer {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// Résout les références `${localEnv:VAR}` et `${localEnv:VAR:default}` à l'aide de
|
||||
/// l'environnement du processus courant, comme décrit par la spécification devcontainer.
|
||||
/// Les variables non résolues sans valeur par défaut sont remplacées par une chaîne vide.
|
||||
fn substitute_local_env(input: &str) -> String {
|
||||
const PREFIX: &str = "${localEnv:";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user
La fonction
sanitizetronque les tirets et points en début et fin, mais elle pourrait laisser passer des séquences de deux tirets consécutifs qui sont autorisées par Docker. Ce n’est pas critique, mais une validation plus stricte (limiter la longueur, interdire les séquences interdites) améliorerait la robustesse.