diff --git a/crates/devcontainer-rs/src/consts.rs b/crates/devcontainer-rs/src/consts.rs new file mode 100644 index 0000000..b62ce35 --- /dev/null +++ b/crates/devcontainer-rs/src/consts.rs @@ -0,0 +1,24 @@ +//! Constantes de la crate : endpoint du daemon, timeouts et découpage du +//! contexte de build. + +use std::time::Duration; + +/// Endpoint affiché dans les logs quand `DOCKER_HOST` n'est pas défini. +pub(crate) const DEFAULT_ENDPOINT: &str = "unix:///var/run/docker.sock"; + +/// Timeout appliqué aux opérations de build/run/stop/remove. +pub(crate) const DEFAULT_COMMAND_TIMEOUT: Duration = Duration::from_secs(600); + +/// Timeout appliqué aux commandes exécutées dans un container en cours d'exécution. +pub(crate) const DEFAULT_EXEC_TIMEOUT: Duration = Duration::from_secs(60); + +/// Taille des morceaux du contexte de build envoyés au daemon. +pub(crate) const CONTEXT_CHUNK_SIZE: usize = 64 * 1024; + +/// Nombre de morceaux pouvant attendre dans le canal : c'est ce qui borne la +/// mémoire occupée par le contexte, quelle que soit sa taille. +pub(crate) const CONTEXT_CHUNKS: usize = 4; + +/// Lectures successives du code de sortie d'un exec, et attente entre elles. +pub(crate) const EXIT_CODE_ATTEMPTS: usize = 10; +pub(crate) const EXIT_CODE_DELAY: Duration = Duration::from_millis(20); diff --git a/crates/devcontainer-rs/src/container.rs b/crates/devcontainer-rs/src/container.rs index 224cf4d..e93d9e5 100644 --- a/crates/devcontainer-rs/src/container.rs +++ b/crates/devcontainer-rs/src/container.rs @@ -1,440 +1,12 @@ -//! Primitives de cycle de vie de container pour un [`DevContainer`] analysé. -//! -//! Ce module pilote l'API du daemon de containers 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. -//! -//! Le daemon est joint par son **socket** (celui de l'hôte, monté dans le -//! container de Herald) : [`ContainerRuntime::connect`] suit `DOCKER_HOST` comme -//! le fait le CLI `docker`, et retombe sur le socket local. Aucun binaire de -//! runtime n'est donc requis dans l'image, et `podman` fonctionne de la même -//! façon dès lors qu'il expose son socket compatible Docker. -//! -//! # Isolation -//! -//! 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. -//! -//! Les `runArgs` du `devcontainer.json` sont lus mais pas transmis au daemon : ils -//! viennent du dépôt, donc d'une pull request non fiable, et pourraient rattacher le -//! container à un autre réseau ou lui donner des privilèges qui annuleraient cette -//! isolation. +//! Un devcontainer en cours d'exécution : inspection et exécution de commandes. -use std::{ - collections::HashMap, - io::{BufWriter, Write}, - path::{Path, PathBuf}, - time::{Duration, SystemTime, UNIX_EPOCH}, +use std::time::Duration; + +use crate::{ + consts::DEFAULT_EXEC_TIMEOUT, errors::ContainerError, exec::ExecOutput, + runtime::ContainerRuntime, }; -use bollard::{ - Docker, body_try_stream, - container::LogOutput, - errors::Error as BollardError, - exec::{CreateExecOptions, StartExecOptions, StartExecResults}, - models::{ - BuildInfo, ContainerCreateBody, HostConfig, NetworkCreateRequest, NetworkDisconnectRequest, - }, - query_parameters::{ - BuildImageOptions, CreateContainerOptions, RemoveContainerOptions, RemoveImageOptions, - StartContainerOptions, StopContainerOptions, - }, -}; -use bytes::Bytes; -use futures_util::{Stream, StreamExt}; -use tokio::sync::mpsc; -use tokio_stream::wrappers::ReceiverStream; - -use crate::DevContainer; - -/// Endpoint affiché dans les logs quand `DOCKER_HOST` n'est pas défini. -const DEFAULT_ENDPOINT: &str = "unix:///var/run/docker.sock"; - -/// Timeout appliqué aux opérations de build/run/stop/remove. -const DEFAULT_COMMAND_TIMEOUT: Duration = Duration::from_secs(600); - -/// Timeout appliqué aux commandes exécutées dans un container en cours d'exécution. -const DEFAULT_EXEC_TIMEOUT: Duration = Duration::from_secs(60); - -/// Taille des morceaux du contexte de build envoyés au daemon. -const CONTEXT_CHUNK_SIZE: usize = 64 * 1024; - -/// Nombre de morceaux pouvant attendre dans le canal : c'est ce qui borne la -/// mémoire occupée par le contexte, quelle que soit sa taille. -const CONTEXT_CHUNKS: usize = 4; - -/// Lectures successives du code de sortie d'un exec, et attente entre elles. -const EXIT_CODE_ATTEMPTS: usize = 10; -const EXIT_CODE_DELAY: Duration = Duration::from_millis(20); - -/// Résultat d'une commande exécutée dans un container. -#[derive(Debug, Clone)] -pub struct ExecOutput { - /// Code de sortie, ou `-1` si le processus a été terminé par un signal. - pub status: i32, - pub stdout: String, - pub stderr: String, -} - -impl ExecOutput { - pub fn success(&self) -> bool { - self.status == 0 - } -} - -#[derive(Debug, thiserror::Error)] -pub enum ContainerError { - /// Le daemon est injoignable, ou a refusé une requête. - #[error("container daemon request failed: {source}")] - Request { - #[from] - source: BollardError, - }, - - /// Une opération a dépassé son timeout. - #[error("`{operation}` timed out after {timeout:?}")] - Timeout { - operation: String, - timeout: Duration, - }, - - /// La construction de l'image a échoué. - #[error("image build failed: {message}")] - Build { message: String }, - - /// Le daemon a répondu autre chose que ce qui était attendu. - #[error("{0}")] - Unexpected(String), -} - -/// Un client de l'API du daemon de containers. -#[derive(Debug, Clone)] -pub struct ContainerRuntime { - docker: Docker, - endpoint: String, - timeout: Duration, -} - -impl ContainerRuntime { - /// Se connecte au daemon désigné par `DOCKER_HOST`, ou au socket local par défaut. - pub fn connect() -> Result { - let endpoint = - std::env::var("DOCKER_HOST").unwrap_or_else(|_| String::from(DEFAULT_ENDPOINT)); - - Ok(Self { - docker: Docker::connect_with_defaults()?, - endpoint, - timeout: DEFAULT_COMMAND_TIMEOUT, - }) - } - - /// 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 - } - - /// Endpoint du daemon, pour les logs. - pub fn endpoint(&self) -> &str { - &self.endpoint - } - - pub fn timeout(&self) -> Duration { - self.timeout - } - - /// Vérifie que le daemon est joignable et répond. - pub async fn available(&self) -> bool { - self.docker.ping().await.is_ok() - } - - /// Exécute une requête du daemon en appliquant le timeout des commandes. - async fn request( - &self, - operation: &str, - request: impl Future>, - ) -> Result { - match tokio::time::timeout(self.timeout, request).await { - Ok(Ok(value)) => Ok(value), - Ok(Err(source)) => Err(ContainerError::Request { source }), - Err(_) => Err(ContainerError::Timeout { - operation: String::from(operation), - timeout: self.timeout, - }), - } - } - - /// Construit l'image `tag` depuis le contexte `context_dir`. - async fn build_image( - &self, - context_dir: &Path, - dockerfile: &str, - tag: &str, - build_args: &HashMap, - ) -> Result<(), ContainerError> { - let options = BuildImageOptions { - dockerfile: String::from(dockerfile), - t: Some(String::from(tag)), - buildargs: Some(build_args.clone()), - rm: true, - ..Default::default() - }; - - let context = tar_directory_stream(context_dir); - let mut stream = self - .docker - .build_image(options, None, Some(body_try_stream(context))); - - // Le flux porte la progression du build : la dernière erreur signalée par le - // daemon fait échouer l'opération. - let consume = async { - let mut failure = None; - - while let Some(info) = stream.next().await { - let info: BuildInfo = info?; - - if let Some(message) = info.error_detail.and_then(|detail| detail.message) { - failure = Some(message); - } - } - - Ok::<_, BollardError>(failure) - }; - - let failure = match tokio::time::timeout(self.timeout, consume).await { - Ok(Ok(failure)) => failure, - Ok(Err(source)) => return Err(ContainerError::Request { source }), - Err(_) => { - return Err(ContainerError::Timeout { - operation: format!("build image `{tag}`"), - timeout: self.timeout, - }); - } - }; - - match failure { - Some(message) => Err(ContainerError::Build { message }), - None => Ok(()), - } - } - - /// Crée un container, sans le démarrer. - async fn create_container( - &self, - name: &str, - body: ContainerCreateBody, - ) -> Result<(), ContainerError> { - let options = CreateContainerOptions { - name: Some(String::from(name)), - ..Default::default() - }; - - self.request( - "create container", - self.docker.create_container(Some(options), body), - ) - .await?; - - Ok(()) - } - - async fn start_container(&self, name: &str) -> Result<(), ContainerError> { - self.request( - "start container", - self.docker - .start_container(name, None::), - ) - .await?; - - Ok(()) - } - - /// Exécute une commande dans un container et renvoie sa sortie. - async fn exec( - &self, - container: &str, - cmd: &[&str], - user: Option<&str>, - timeout: Duration, - ) -> Result { - let config = CreateExecOptions { - cmd: Some(cmd.iter().map(|arg| String::from(*arg)).collect()), - user: user.map(String::from), - attach_stdout: Some(true), - attach_stderr: Some(true), - ..Default::default() - }; - - let created = self - .request("create exec", self.docker.create_exec(container, config)) - .await?; - - let started = self - .request( - "start exec", - self.docker - .start_exec(&created.id, None::), - ) - .await?; - - let StartExecResults::Attached { mut output, .. } = started else { - return Err(ContainerError::Unexpected(String::from( - "the daemon detached an exec that was not requested as detached", - ))); - }; - - let mut stdout = String::new(); - let mut stderr = String::new(); - - // Le timeout couvre l'exécution de la commande elle-même, pas seulement sa - // mise en place : le processus continue de tourner dans le container, il - // disparaît avec lui. - let collect = async { - while let Some(message) = output.next().await { - match message? { - LogOutput::StdOut { message } | LogOutput::Console { message } => { - stdout.push_str(&String::from_utf8_lossy(&message)); - } - LogOutput::StdErr { message } => { - stderr.push_str(&String::from_utf8_lossy(&message)); - } - LogOutput::StdIn { .. } => {} - } - } - - Ok::<_, BollardError>(()) - }; - - match tokio::time::timeout(timeout, collect).await { - Ok(Ok(())) => {} - Ok(Err(source)) => return Err(ContainerError::Request { source }), - Err(_) => { - return Err(ContainerError::Timeout { - operation: format!("exec {}", cmd.join(" ")), - timeout, - }); - } - } - - // Le code de sortie n'est pas forcément publié au moment où le flux se ferme : - // on laisse au daemon le temps de le renseigner, sans bloquer indéfiniment. - // Sans cela, une commande réussie peut être rapportée en échec (code -1) et - // l'outil renvoie une erreur au modèle à la place du contenu. - let mut inspected = self - .request("inspect exec", self.docker.inspect_exec(&created.id)) - .await?; - - for _ in 0..EXIT_CODE_ATTEMPTS { - if !inspected.running.unwrap_or(false) { - break; - } - - tokio::time::sleep(EXIT_CODE_DELAY).await; - - inspected = self - .request("inspect exec", self.docker.inspect_exec(&created.id)) - .await?; - } - - Ok(ExecOutput { - status: inspected.exit_code.unwrap_or(-1) as i32, - stdout, - stderr, - }) - } - - async fn stop_container(&self, name: &str) -> Result<(), ContainerError> { - let options = StopContainerOptions { - t: Some(1), - ..Default::default() - }; - - self.request( - "stop container", - self.docker.stop_container(name, Some(options)), - ) - .await?; - - Ok(()) - } - - /// Supprime un container, ses volumes anonymes et le processus qui y tourne. - async fn remove_container(&self, name: &str) -> Result<(), ContainerError> { - let options = RemoveContainerOptions { - force: true, - v: true, - ..Default::default() - }; - - self.request( - "remove container", - self.docker.remove_container(name, Some(options)), - ) - .await?; - - Ok(()) - } - - async fn remove_image(&self, tag: &str) -> Result<(), ContainerError> { - let options = RemoveImageOptions { - force: true, - ..Default::default() - }; - - self.request( - "remove image", - self.docker.remove_image(tag, Some(options), None), - ) - .await?; - - Ok(()) - } - - /// Crée le réseau dédié d'une sandbox. - async fn create_network(&self, name: &str) -> Result<(), ContainerError> { - let request = NetworkCreateRequest { - name: String::from(name), - ..Default::default() - }; - - self.request("create network", self.docker.create_network(request)) - .await?; - - Ok(()) - } - - /// Détache un container d'un réseau, ce qui coupe sa connectivité. - async fn disconnect_network( - &self, - network: &str, - container: &str, - ) -> Result<(), ContainerError> { - let request = NetworkDisconnectRequest { - container: String::from(container), - ..Default::default() - }; - - self.request( - "disconnect network", - self.docker.disconnect_network(network, request), - ) - .await?; - - Ok(()) - } - - async fn remove_network(&self, name: &str) -> Result<(), ContainerError> { - self.request("remove network", self.docker.remove_network(name)) - .await?; - - Ok(()) - } -} - /// Un devcontainer en cours d'exécution. #[derive(Debug, Clone)] pub struct Container { @@ -447,6 +19,25 @@ pub struct Container { } impl Container { + /// Assemble un container démarré, avec son réseau et son image à nettoyer. + pub(crate) fn new( + runtime: ContainerRuntime, + name: String, + workspace_folder: String, + remote_user: Option, + network: Option, + image: Option, + ) -> Self { + Self { + runtime, + name, + workspace_folder, + remote_user, + network, + image, + } + } + pub fn name(&self) -> &str { &self.name } @@ -463,7 +54,7 @@ impl Container { self.exec_with_timeout(cmd, DEFAULT_EXEC_TIMEOUT).await } - async fn exec_with_timeout( + pub(crate) async fn exec_with_timeout( &self, cmd: &[&str], timeout: Duration, @@ -500,434 +91,3 @@ impl Container { Ok(()) } } - -impl DevContainer { - /// 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)) - } - - /// Tag d'image unique pour une exécution de sandbox donnée. - /// - /// 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()) - } - - /// 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()) - } - - /// 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()) - } - - /// Construit l'image devcontainer sous `image_tag`. - pub async fn build( - &self, - runtime: &ContainerRuntime, - image_tag: &str, - ) -> Result<(), ContainerError> { - let context = self.container_file_path.parent().ok_or_else(|| { - ContainerError::Unexpected(String::from( - "the devcontainer file path has no parent directory", - )) - })?; - - let dockerfile = self - .container_file_path - .file_name() - .and_then(|name| name.to_str()) - .ok_or_else(|| { - ContainerError::Unexpected(String::from( - "the devcontainer file name is not valid UTF-8", - )) - })?; - - runtime - .build_image(context, dockerfile, image_tag, &self.build_args) - .await - } - - /// Construit l'image, démarre le container, exécute les hooks puis coupe le réseau. - /// - /// 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, - workspace_dir: &Path, - ) -> Result { - let image_tag = self.image_tag(); - self.build(runtime, &image_tag).await?; - - let name = self.container_name(); - let network = format!("{name}-net"); - - // Réseau dédié afin de pouvoir couper la connectivité après les hooks. - if let Err(err) = runtime.create_network(&network).await { - let _ = runtime.remove_image(&image_tag).await; - return Err(err); - } - - let workspace_folder = self.workspace_folder(); - let body = ContainerCreateBody { - image: Some(image_tag.clone()), - cmd: Some(vec![String::from("sleep"), String::from("infinity")]), - user: self.remote_user.clone(), - env: Some( - self.container_env - .iter() - .map(|(key, value)| format!("{key}={value}")) - .collect(), - ), - working_dir: Some(workspace_folder.clone()), - host_config: Some(HostConfig { - binds: Some(vec![format!( - "{}:{workspace_folder}", - workspace_dir.display() - )]), - network_mode: Some(network.clone()), - // Le clone est monté depuis un chemin de l'hôte. Sur une distribution - // à SELinux enforcing, ce chemin n'a pas le label attendu et l'accès - // est refusé (EACCES), ce qui fait échouer tous les outils de la - // sandbox. C'est le compromis inverse de l'alternative `:Z` sur le - // montage, qui re-labellise le clone et garde le confinement SELinux. - security_opt: Some(vec![String::from("label=disable")]), - ..Default::default() - }), - ..Default::default() - }; - - if let Err(err) = runtime.create_container(&name, body).await { - let _ = runtime.remove_network(&network).await; - let _ = runtime.remove_image(&image_tag).await; - return Err(err); - } - - if let Err(err) = runtime.start_container(&name).await { - let _ = runtime.remove_container(&name).await; - let _ = runtime.remove_network(&network).await; - let _ = runtime.remove_image(&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), - }; - - // 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); - } - - // Coupe l'accès réseau pour le reste de la durée de vie de la sandbox. - if let Err(err) = runtime.disconnect_network(&network, container.name()).await { - let _ = container.remove().await; - return Err(err); - } - - Ok(container) - } - - async fn run_hooks(&self, container: &Container) -> Result<(), ContainerError> { - // 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] - .into_iter() - .flatten() - { - let output = container - .exec_with_timeout(&["sh", "-c", command], timeout) - .await?; - - if !output.success() { - return Err(ContainerError::Unexpected(format!( - "hook `{command}` failed with status {}: {}", - output.status, - output.stderr.trim() - ))); - } - } - - Ok(()) - } -} - -/// Empaquette le contexte de build dans un tar, **en flux**. -/// -/// Le CLI `docker build` empaquette le contexte puis l'envoie ; le faire d'un coup -/// chargerait tout le dossier en mémoire, ce qui devient intenable dès que le -/// `.devcontainer` embarque des binaires. Ici l'archive est écrite par une tâche -/// bloquante et poussée morceau par morceau, avec la contre-pression du canal : -/// si le daemon lit lentement, l'empaquetage ralentit. -fn tar_directory_stream( - dir: &Path, -) -> impl Stream> + Send + 'static { - let (sender, receiver) = mpsc::channel(CONTEXT_CHUNKS); - let failures = sender.clone(); - let dir = dir.to_path_buf(); - - tokio::task::spawn_blocking(move || { - let writer = BufWriter::with_capacity(CONTEXT_CHUNK_SIZE, ChannelWriter { sender }); - let mut builder = tar::Builder::new(writer); - - let result = builder - .append_dir_all(".", &dir) - .and_then(|()| builder.finish()) - // `finish` écrit les blocs de fin d'archive ; il reste à vider le tampon - // pour que le daemon reçoive tout, y compris une archive vide. - .and_then(|()| builder.get_mut().flush()); - - if let Err(error) = result { - // L'échec est remonté au daemon par le flux : sinon il ne verrait qu'un - // tar tronqué et signalerait une erreur incompréhensible. - let _ = failures.blocking_send(Err(error)); - } - }); - - ReceiverStream::new(receiver) -} - -/// Écrit les morceaux du tar dans un canal, en attendant qu'il se vide. -/// -/// Utilisé depuis une tâche bloquante, seul contexte où `blocking_send` est autorisé. -struct ChannelWriter { - sender: mpsc::Sender>, -} - -impl Write for ChannelWriter { - fn write(&mut self, buf: &[u8]) -> std::io::Result { - self.sender - .blocking_send(Ok(Bytes::copy_from_slice(buf))) - .map_err(|_| { - std::io::Error::new( - std::io::ErrorKind::BrokenPipe, - "the daemon dropped the build context", - ) - })?; - - Ok(buf.len()) - } - - fn flush(&mut self) -> std::io::Result<()> { - Ok(()) - } -} - -/// Nettoie une chaîne pour qu'elle puisse servir de nom d'image/container docker. -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() - } -} - -/// 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) - .map(|duration| duration.as_nanos()) - .unwrap_or(0); - - format!("{}-{}", std::process::id(), nanos) -} - -/// 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 { - 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" - }"#, - ) - .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 container_name_is_sanitized() { - let dir = tempfile::tempdir().unwrap(); - let dc = devcontainer(dir.path()); - - assert!( - dc.container_name() - .starts_with("devcontainer-rs-my-project-") - ); - } - - #[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); - } - - /// Rassemble le flux du contexte en un tar complet, pour l'inspecter. - async fn context_tar(dir: &Path) -> Vec { - let mut tar = Vec::new(); - let mut chunks = tar_directory_stream(dir); - - while let Some(chunk) = chunks.next().await { - tar.extend_from_slice(&chunk.unwrap()); - } - - tar - } - - #[tokio::test] - async fn tar_context_holds_the_devcontainer_files() { - let dir = tempfile::tempdir().unwrap(); - let devcontainer_dir = dir.path().join(".devcontainer"); - fs::create_dir(&devcontainer_dir).unwrap(); - fs::write(devcontainer_dir.join("Dockerfile"), "FROM alpine\n").unwrap(); - fs::write(devcontainer_dir.join("devcontainer.json"), "{}").unwrap(); - - let tar = context_tar(&devcontainer_dir).await; - - let mut archive = tar::Archive::new(tar.as_slice()); - let names = archive - .entries() - .unwrap() - .map(|entry| entry.unwrap().path().unwrap().display().to_string()) - .collect::>(); - - assert!(names.iter().any(|name| name.ends_with("Dockerfile"))); - assert!(names.iter().any(|name| name.ends_with("devcontainer.json"))); - } - - #[tokio::test] - async fn tar_context_keeps_the_executable_bit() { - let dir = tempfile::tempdir().unwrap(); - let script = dir.path().join("setup.sh"); - fs::write(&script, "#!/bin/sh\n").unwrap(); - - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap(); - } - - let tar = context_tar(dir.path()).await; - - let mut archive = tar::Archive::new(tar.as_slice()); - let mode = archive - .entries() - .unwrap() - .map(|entry| entry.unwrap()) - .find(|entry| entry.path().unwrap().ends_with("setup.sh")) - .unwrap() - .header() - .mode() - .unwrap(); - - assert_eq!(mode & 0o111, 0o111); - } - - #[tokio::test] - async fn tar_context_is_sent_in_chunks() { - let dir = tempfile::tempdir().unwrap(); - fs::write( - dir.path().join("big.bin"), - vec![0_u8; CONTEXT_CHUNK_SIZE * 3], - ) - .unwrap(); - - let mut chunks = tar_directory_stream(dir.path()); - let mut count = 0; - - while let Some(chunk) = chunks.next().await { - assert!(chunk.unwrap().len() <= CONTEXT_CHUNK_SIZE); - count += 1; - } - - assert!( - count > 1, - "the context should be streamed, not buffered in one piece" - ); - } -} diff --git a/crates/devcontainer-rs/src/context.rs b/crates/devcontainer-rs/src/context.rs new file mode 100644 index 0000000..8c80d46 --- /dev/null +++ b/crates/devcontainer-rs/src/context.rs @@ -0,0 +1,166 @@ +//! Empaquetage en flux du contexte de build envoyé au daemon. + +use std::{ + io::{BufWriter, Write}, + path::Path, +}; + +use bytes::Bytes; +use futures_util::Stream; +use tokio::sync::mpsc; +use tokio_stream::wrappers::ReceiverStream; + +use crate::consts::{CONTEXT_CHUNK_SIZE, CONTEXT_CHUNKS}; + +/// Empaquette le contexte de build dans un tar, **en flux**. +/// +/// Le CLI `docker build` empaquette le contexte puis l'envoie ; le faire d'un coup +/// chargerait tout le dossier en mémoire, ce qui devient intenable dès que le +/// `.devcontainer` embarque des binaires. Ici l'archive est écrite par une tâche +/// bloquante et poussée morceau par morceau, avec la contre-pression du canal : +/// si le daemon lit lentement, l'empaquetage ralentit. +pub(crate) fn tar_directory_stream( + dir: &Path, +) -> impl Stream> + Send + 'static { + let (sender, receiver) = mpsc::channel(CONTEXT_CHUNKS); + let failures = sender.clone(); + let dir = dir.to_path_buf(); + + tokio::task::spawn_blocking(move || { + let writer = BufWriter::with_capacity(CONTEXT_CHUNK_SIZE, ChannelWriter { sender }); + let mut builder = tar::Builder::new(writer); + + let result = builder + .append_dir_all(".", &dir) + .and_then(|()| builder.finish()) + // `finish` écrit les blocs de fin d'archive ; il reste à vider le tampon + // pour que le daemon reçoive tout, y compris une archive vide. + .and_then(|()| builder.get_mut().flush()); + + if let Err(error) = result { + // L'échec est remonté au daemon par le flux : sinon il ne verrait qu'un + // tar tronqué et signalerait une erreur incompréhensible. + let _ = failures.blocking_send(Err(error)); + } + }); + + ReceiverStream::new(receiver) +} + +/// Écrit les morceaux du tar dans un canal, en attendant qu'il se vide. +/// +/// Utilisé depuis une tâche bloquante, seul contexte où `blocking_send` est autorisé. +struct ChannelWriter { + sender: mpsc::Sender>, +} + +impl Write for ChannelWriter { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.sender + .blocking_send(Ok(Bytes::copy_from_slice(buf))) + .map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::BrokenPipe, + "the daemon dropped the build context", + ) + })?; + + Ok(buf.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + use futures_util::StreamExt; + + /// Rassemble le flux du contexte en un tar complet, pour l'inspecter. + async fn context_tar(dir: &Path) -> Vec { + let mut tar = Vec::new(); + let mut chunks = tar_directory_stream(dir); + + while let Some(chunk) = chunks.next().await { + tar.extend_from_slice(&chunk.unwrap()); + } + + tar + } + + #[tokio::test] + async fn tar_context_holds_the_devcontainer_files() { + let dir = tempfile::tempdir().unwrap(); + let devcontainer_dir = dir.path().join(".devcontainer"); + fs::create_dir(&devcontainer_dir).unwrap(); + fs::write(devcontainer_dir.join("Dockerfile"), "FROM alpine\n").unwrap(); + fs::write(devcontainer_dir.join("devcontainer.json"), "{}").unwrap(); + + let tar = context_tar(&devcontainer_dir).await; + + let mut archive = tar::Archive::new(tar.as_slice()); + let names = archive + .entries() + .unwrap() + .map(|entry| entry.unwrap().path().unwrap().display().to_string()) + .collect::>(); + + assert!(names.iter().any(|name| name.ends_with("Dockerfile"))); + assert!(names.iter().any(|name| name.ends_with("devcontainer.json"))); + } + + #[tokio::test] + async fn tar_context_keeps_the_executable_bit() { + let dir = tempfile::tempdir().unwrap(); + let script = dir.path().join("setup.sh"); + fs::write(&script, "#!/bin/sh\n").unwrap(); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap(); + } + + let tar = context_tar(dir.path()).await; + + let mut archive = tar::Archive::new(tar.as_slice()); + let mode = archive + .entries() + .unwrap() + .map(|entry| entry.unwrap()) + .find(|entry| entry.path().unwrap().ends_with("setup.sh")) + .unwrap() + .header() + .mode() + .unwrap(); + + assert_eq!(mode & 0o111, 0o111); + } + + #[tokio::test] + async fn tar_context_is_sent_in_chunks() { + let dir = tempfile::tempdir().unwrap(); + fs::write( + dir.path().join("big.bin"), + vec![0_u8; CONTEXT_CHUNK_SIZE * 3], + ) + .unwrap(); + + let mut chunks = tar_directory_stream(dir.path()); + let mut count = 0; + + while let Some(chunk) = chunks.next().await { + assert!(chunk.unwrap().len() <= CONTEXT_CHUNK_SIZE); + count += 1; + } + + assert!( + count > 1, + "the context should be streamed, not buffered in one piece" + ); + } +} diff --git a/crates/devcontainer-rs/src/devcontainer.rs b/crates/devcontainer-rs/src/devcontainer.rs new file mode 100644 index 0000000..bd73690 --- /dev/null +++ b/crates/devcontainer-rs/src/devcontainer.rs @@ -0,0 +1,278 @@ +//! Représentation analysée d'un `devcontainer.json` et son cycle de vie : +//! nommage, build, démarrage et hooks. + +use std::{ + collections::HashMap, + path::{Path, PathBuf}, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +use bollard::models::{ContainerCreateBody, HostConfig}; + +use crate::{container::Container, errors::ContainerError, runtime::ContainerRuntime}; + +#[derive(Debug)] +pub struct DevContainer { + pub container_file_path: PathBuf, + pub name: Option, + pub build_args: HashMap, + pub container_env: HashMap, + pub workspace_folder: Option, + pub post_create_command: Option, + pub post_start_command: Option, + pub remote_user: Option, +} + +impl DevContainer { + /// 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)) + } + + /// Tag d'image unique pour une exécution de sandbox donnée. + /// + /// 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()) + } + + /// 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()) + } + + /// 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()) + } + + /// Construit l'image devcontainer sous `image_tag`. + pub async fn build( + &self, + runtime: &ContainerRuntime, + image_tag: &str, + ) -> Result<(), ContainerError> { + let context = self.container_file_path.parent().ok_or_else(|| { + ContainerError::Unexpected(String::from( + "the devcontainer file path has no parent directory", + )) + })?; + + let dockerfile = self + .container_file_path + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| { + ContainerError::Unexpected(String::from( + "the devcontainer file name is not valid UTF-8", + )) + })?; + + runtime + .build_image(context, dockerfile, image_tag, &self.build_args) + .await + } + + /// Construit l'image, démarre le container, exécute les hooks puis coupe le réseau. + /// + /// 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, + workspace_dir: &Path, + ) -> Result { + let image_tag = self.image_tag(); + self.build(runtime, &image_tag).await?; + + let name = self.container_name(); + let network = format!("{name}-net"); + + // Réseau dédié afin de pouvoir couper la connectivité après les hooks. + if let Err(err) = runtime.create_network(&network).await { + let _ = runtime.remove_image(&image_tag).await; + return Err(err); + } + + let workspace_folder = self.workspace_folder(); + let body = ContainerCreateBody { + image: Some(image_tag.clone()), + cmd: Some(vec![String::from("sleep"), String::from("infinity")]), + user: self.remote_user.clone(), + env: Some( + self.container_env + .iter() + .map(|(key, value)| format!("{key}={value}")) + .collect(), + ), + working_dir: Some(workspace_folder.clone()), + host_config: Some(HostConfig { + binds: Some(vec![format!( + "{}:{workspace_folder}", + workspace_dir.display() + )]), + network_mode: Some(network.clone()), + // Le clone est monté depuis un chemin de l'hôte. Sur une distribution + // à SELinux enforcing, ce chemin n'a pas le label attendu et l'accès + // est refusé (EACCES), ce qui fait échouer tous les outils de la + // sandbox. C'est le compromis inverse de l'alternative `:Z` sur le + // montage, qui re-labellise le clone et garde le confinement SELinux. + security_opt: Some(vec![String::from("label=disable")]), + ..Default::default() + }), + ..Default::default() + }; + + if let Err(err) = runtime.create_container(&name, body).await { + let _ = runtime.remove_network(&network).await; + let _ = runtime.remove_image(&image_tag).await; + return Err(err); + } + + if let Err(err) = runtime.start_container(&name).await { + let _ = runtime.remove_container(&name).await; + let _ = runtime.remove_network(&network).await; + let _ = runtime.remove_image(&image_tag).await; + return Err(err); + } + + let container = Container::new( + runtime.clone(), + name, + self.workspace_folder(), + self.remote_user.clone(), + Some(network.clone()), + Some(image_tag), + ); + + // Les hooks s'exécutent avec accès au réseau (installation de dépendances, etc.). + if let Err(err) = self.run_hooks(&container, runtime.timeout()).await { + let _ = container.remove().await; + return Err(err); + } + + // Coupe l'accès réseau pour le reste de la durée de vie de la sandbox. + if let Err(err) = runtime.disconnect_network(&network, container.name()).await { + let _ = container.remove().await; + return Err(err); + } + + Ok(container) + } + + async fn run_hooks( + &self, + container: &Container, + timeout: Duration, + ) -> Result<(), ContainerError> { + 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?; + + if !output.success() { + return Err(ContainerError::Unexpected(format!( + "hook `{command}` failed with status {}: {}", + output.status, + output.stderr.trim() + ))); + } + } + + Ok(()) + } +} + +/// Nettoie une chaîne pour qu'elle puisse servir de nom d'image/container docker. +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() + } +} + +/// 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) + .map(|duration| duration.as_nanos()) + .unwrap_or(0); + + format!("{}-{}", std::process::id(), nanos) +} + +#[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" + }"#, + ) + .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 container_name_is_sanitized() { + let dir = tempfile::tempdir().unwrap(); + let dc = devcontainer(dir.path()); + + assert!( + dc.container_name() + .starts_with("devcontainer-rs-my-project-") + ); + } +} diff --git a/crates/devcontainer-rs/src/errors.rs b/crates/devcontainer-rs/src/errors.rs new file mode 100644 index 0000000..ac0b400 --- /dev/null +++ b/crates/devcontainer-rs/src/errors.rs @@ -0,0 +1,52 @@ +//! Erreurs de la crate : échecs du daemon de containers et échecs d'analyse du +//! `devcontainer.json`. + +use std::{path::PathBuf, time::Duration}; + +use bollard::errors::Error as BollardError; + +#[derive(Debug, thiserror::Error)] +pub enum ContainerError { + /// Le daemon est injoignable, ou a refusé une requête. + #[error("container daemon request failed: {source}")] + Request { + #[from] + source: BollardError, + }, + + /// Une opération a dépassé son timeout. + #[error("`{operation}` timed out after {timeout:?}")] + Timeout { + operation: String, + timeout: Duration, + }, + + /// La construction de l'image a échoué. + #[error("image build failed: {message}")] + Build { message: String }, + + /// Le daemon a répondu autre chose que ce qui était attendu. + #[error("{0}")] + Unexpected(String), +} + +#[derive(Debug, thiserror::Error)] +pub enum ParseError { + #[error("failed to read devcontainer file `{path}`: {source}")] + Read { + path: PathBuf, + source: std::io::Error, + }, + + #[error("invalid devcontainer JSON in `{path}`: {source}")] + Json { + path: PathBuf, + source: serde_json::Error, + }, + + #[error("container file `{0}` does not exist or is not a regular file")] + ContainerFileNotFound(PathBuf), + + #[error("the devcontainer file path has no parent directory: `{0}`")] + InvalidDevContainerPath(PathBuf), +} diff --git a/crates/devcontainer-rs/src/exec.rs b/crates/devcontainer-rs/src/exec.rs new file mode 100644 index 0000000..58d2615 --- /dev/null +++ b/crates/devcontainer-rs/src/exec.rs @@ -0,0 +1,16 @@ +//! Résultat d'une commande exécutée dans un container. + +/// Résultat d'une commande exécutée dans un container. +#[derive(Debug, Clone)] +pub struct ExecOutput { + /// Code de sortie, ou `-1` si le processus a été terminé par un signal. + pub status: i32, + pub stdout: String, + pub stderr: String, +} + +impl ExecOutput { + pub fn success(&self) -> bool { + self.status == 0 + } +} diff --git a/crates/devcontainer-rs/src/lib.rs b/crates/devcontainer-rs/src/lib.rs index c8f39bc..0bdb717 100644 --- a/crates/devcontainer-rs/src/lib.rs +++ b/crates/devcontainer-rs/src/lib.rs @@ -1,250 +1,43 @@ -use std::{ - collections::HashMap, - path::{Path, PathBuf}, -}; - -use serde::Deserialize; +//! Primitives de cycle de vie de container pour un [`DevContainer`] analysé. +//! +//! Cette crate pilote l'API du daemon de containers 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. +//! +//! Le daemon est joint par son **socket** (celui de l'hôte, monté dans le +//! container de Herald) : [`ContainerRuntime::connect`] suit `DOCKER_HOST` comme +//! le fait le CLI `docker`, et retombe sur le socket local. Aucun binaire de +//! runtime n'est donc requis dans l'image, et `podman` fonctionne de la même +//! façon dès lors qu'il expose son socket compatible Docker. +//! +//! # Isolation +//! +//! 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. +//! +//! Les `runArgs` du `devcontainer.json` sont lus mais pas transmis au daemon : ils +//! viennent du dépôt, donc d'une pull request non fiable, et pourraient rattacher le +//! container à un autre réseau ou lui donner des privilèges qui annuleraient cette +//! isolation. +mod consts; mod container; +mod context; +mod devcontainer; +mod errors; +mod exec; +mod path; +mod runtime; +mod schema; -pub use container::{Container, ContainerError, ContainerRuntime, ExecOutput, normalize}; - -#[derive(Debug, Deserialize)] -pub struct DevContainerBuildSchema { - #[serde(default)] - pub dockerfile: String, - #[serde(default)] - pub args: HashMap, -} - -#[derive(Debug, Deserialize)] -pub struct DevContainerSchema { - #[serde(default)] - pub name: Option, - pub build: DevContainerBuildSchema, - - #[serde(rename = "workspaceFolder", default)] - pub workspace_folder: Option, - - #[serde(rename = "containerEnv", default)] - pub container_env: HashMap, - - #[serde(rename = "postCreateCommand", default)] - pub post_create_command: Option, - - #[serde(rename = "postStartCommand", default)] - pub post_start_command: Option, - - #[serde(rename = "remoteUser", default)] - pub remote_user: Option, - - /// Arguments passés à `docker run`. - /// - /// Lus pour rester fidèle au format `devcontainer.json`, mais - /// **délibérément pas transmis** au runtime : ils viennent d'une pull - /// request non fiable et pourraient casser l'isolation de la sandbox (voir - /// [`DevContainer::run_args`]). - #[serde(rename = "runArgs", default)] - pub run_args: Vec, -} - -#[derive(Debug)] -pub struct DevContainer { - pub container_file_path: PathBuf, - pub name: Option, - pub build_args: HashMap, - pub container_env: HashMap, - pub workspace_folder: Option, - pub post_create_command: Option, - pub post_start_command: Option, - pub remote_user: Option, -} - -#[derive(Debug, thiserror::Error)] -pub enum ParseError { - #[error("failed to read devcontainer file `{path}`: {source}")] - Read { - path: PathBuf, - source: std::io::Error, - }, - - #[error("invalid devcontainer JSON in `{path}`: {source}")] - Json { - path: PathBuf, - source: serde_json::Error, - }, - - #[error("container file `{0}` does not exist or is not a regular file")] - ContainerFileNotFound(PathBuf), - - #[error("the devcontainer file path has no parent directory: `{0}`")] - InvalidDevContainerPath(PathBuf), -} - -impl TryFrom<(DevContainerSchema, PathBuf)> for DevContainer { - type Error = ParseError; - - fn try_from( - (schema, devcontainer_path): (DevContainerSchema, PathBuf), - ) -> Result { - let base_dir = devcontainer_path - .parent() - .ok_or_else(|| ParseError::InvalidDevContainerPath(devcontainer_path.clone()))?; - - let container_file_path = base_dir.join(schema.build.dockerfile); - - if !container_file_path.is_file() { - 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, - 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, - }) - } -} - -/// 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:"; - - 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) -> Result { - let path = path.as_ref().to_path_buf(); - let contents = tokio::fs::read_to_string(&path) - .await - .map_err(|source| ParseError::Read { - path: path.clone(), - source, - })?; - - let schema = serde_json::from_str::(&contents).map_err(|source| { - ParseError::Json { - path: path.clone(), - source, - } - })?; - - DevContainer::try_from((schema, path)) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::fs; - - #[tokio::test] - async fn parses_devcontainer_file() { - let dir = tempfile::tempdir().unwrap(); - let devcontainer_path = dir.path().join("devcontainer.json"); - let dockerfile_path = dir.path().join("Dockerfile"); - - fs::write(&dockerfile_path, "FROM alpine\n").unwrap(); - fs::write( - &devcontainer_path, - r#"{ - "name": "test", - "build": { - "dockerfile": "Dockerfile", - "args": { - "VERSION": "1" - } - }, - "workspaceFolder": "/workspace", - "containerEnv": { - "RUST_LOG": "debug" - }, - "remoteUser": "dev" - }"#, - ) - .unwrap(); - - let config = parse(&devcontainer_path).await.unwrap(); - - assert_eq!(config.name.as_deref(), Some("test")); - assert_eq!(config.container_file_path, dockerfile_path); - 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")); - } - - #[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" - ); - } -} +pub use container::Container; +pub use devcontainer::DevContainer; +pub use errors::{ContainerError, ParseError}; +pub use exec::ExecOutput; +pub use path::normalize; +pub use runtime::ContainerRuntime; +pub use schema::{DevContainerBuildSchema, DevContainerSchema, parse}; diff --git a/crates/devcontainer-rs/src/path.rs b/crates/devcontainer-rs/src/path.rs new file mode 100644 index 0000000..4524c0e --- /dev/null +++ b/crates/devcontainer-rs/src/path.rs @@ -0,0 +1,40 @@ +//! Normalisation lexicale de chemins, sans accès au système de fichiers. + +use std::path::{Path, PathBuf}; + +/// 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 { + 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::*; + + #[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); + } +} diff --git a/crates/devcontainer-rs/src/runtime.rs b/crates/devcontainer-rs/src/runtime.rs new file mode 100644 index 0000000..43b238d --- /dev/null +++ b/crates/devcontainer-rs/src/runtime.rs @@ -0,0 +1,352 @@ +//! Client de l'API du daemon de containers et opérations de cycle de vie. + +use std::{collections::HashMap, path::Path, time::Duration}; + +use bollard::{ + Docker, body_try_stream, + container::LogOutput, + errors::Error as BollardError, + exec::{CreateExecOptions, StartExecOptions, StartExecResults}, + models::{BuildInfo, ContainerCreateBody, NetworkCreateRequest, NetworkDisconnectRequest}, + query_parameters::{ + BuildImageOptions, CreateContainerOptions, RemoveContainerOptions, RemoveImageOptions, + StartContainerOptions, StopContainerOptions, + }, +}; +use futures_util::StreamExt; + +use crate::{ + consts::{DEFAULT_COMMAND_TIMEOUT, DEFAULT_ENDPOINT, EXIT_CODE_ATTEMPTS, EXIT_CODE_DELAY}, + context::tar_directory_stream, + errors::ContainerError, + exec::ExecOutput, +}; + +/// Un client de l'API du daemon de containers. +#[derive(Debug, Clone)] +pub struct ContainerRuntime { + docker: Docker, + endpoint: String, + timeout: Duration, +} + +impl ContainerRuntime { + /// Se connecte au daemon désigné par `DOCKER_HOST`, ou au socket local par défaut. + pub fn connect() -> Result { + let endpoint = + std::env::var("DOCKER_HOST").unwrap_or_else(|_| String::from(DEFAULT_ENDPOINT)); + + Ok(Self { + docker: Docker::connect_with_defaults()?, + endpoint, + timeout: DEFAULT_COMMAND_TIMEOUT, + }) + } + + /// 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 + } + + /// Endpoint du daemon, pour les logs. + pub fn endpoint(&self) -> &str { + &self.endpoint + } + + pub fn timeout(&self) -> Duration { + self.timeout + } + + /// Vérifie que le daemon est joignable et répond. + pub async fn available(&self) -> bool { + self.docker.ping().await.is_ok() + } + + /// Exécute une requête du daemon en appliquant le timeout des commandes. + async fn request( + &self, + operation: &str, + request: impl Future>, + ) -> Result { + match tokio::time::timeout(self.timeout, request).await { + Ok(Ok(value)) => Ok(value), + Ok(Err(source)) => Err(ContainerError::Request { source }), + Err(_) => Err(ContainerError::Timeout { + operation: String::from(operation), + timeout: self.timeout, + }), + } + } + + /// Construit l'image `tag` depuis le contexte `context_dir`. + pub(crate) async fn build_image( + &self, + context_dir: &Path, + dockerfile: &str, + tag: &str, + build_args: &HashMap, + ) -> Result<(), ContainerError> { + let options = BuildImageOptions { + dockerfile: String::from(dockerfile), + t: Some(String::from(tag)), + buildargs: Some(build_args.clone()), + rm: true, + ..Default::default() + }; + + let context = tar_directory_stream(context_dir); + let mut stream = self + .docker + .build_image(options, None, Some(body_try_stream(context))); + + // Le flux porte la progression du build : la dernière erreur signalée par le + // daemon fait échouer l'opération. + let consume = async { + let mut failure = None; + + while let Some(info) = stream.next().await { + let info: BuildInfo = info?; + + if let Some(message) = info.error_detail.and_then(|detail| detail.message) { + failure = Some(message); + } + } + + Ok::<_, BollardError>(failure) + }; + + let failure = match tokio::time::timeout(self.timeout, consume).await { + Ok(Ok(failure)) => failure, + Ok(Err(source)) => return Err(ContainerError::Request { source }), + Err(_) => { + return Err(ContainerError::Timeout { + operation: format!("build image `{tag}`"), + timeout: self.timeout, + }); + } + }; + + match failure { + Some(message) => Err(ContainerError::Build { message }), + None => Ok(()), + } + } + + /// Crée un container, sans le démarrer. + pub(crate) async fn create_container( + &self, + name: &str, + body: ContainerCreateBody, + ) -> Result<(), ContainerError> { + let options = CreateContainerOptions { + name: Some(String::from(name)), + ..Default::default() + }; + + self.request( + "create container", + self.docker.create_container(Some(options), body), + ) + .await?; + + Ok(()) + } + + pub(crate) async fn start_container(&self, name: &str) -> Result<(), ContainerError> { + self.request( + "start container", + self.docker + .start_container(name, None::), + ) + .await?; + + Ok(()) + } + + /// Exécute une commande dans un container et renvoie sa sortie. + pub(crate) async fn exec( + &self, + container: &str, + cmd: &[&str], + user: Option<&str>, + timeout: Duration, + ) -> Result { + let config = CreateExecOptions { + cmd: Some(cmd.iter().map(|arg| String::from(*arg)).collect()), + user: user.map(String::from), + attach_stdout: Some(true), + attach_stderr: Some(true), + ..Default::default() + }; + + let created = self + .request("create exec", self.docker.create_exec(container, config)) + .await?; + + let started = self + .request( + "start exec", + self.docker + .start_exec(&created.id, None::), + ) + .await?; + + let StartExecResults::Attached { mut output, .. } = started else { + return Err(ContainerError::Unexpected(String::from( + "the daemon detached an exec that was not requested as detached", + ))); + }; + + let mut stdout = String::new(); + let mut stderr = String::new(); + + // Le timeout couvre l'exécution de la commande elle-même, pas seulement sa + // mise en place. + let collect = async { + while let Some(message) = output.next().await { + match message? { + LogOutput::StdOut { message } | LogOutput::Console { message } => { + stdout.push_str(&String::from_utf8_lossy(&message)); + } + LogOutput::StdErr { message } => { + stderr.push_str(&String::from_utf8_lossy(&message)); + } + LogOutput::StdIn { .. } => {} + } + } + + Ok::<_, BollardError>(()) + }; + + match tokio::time::timeout(timeout, collect).await { + Ok(Ok(())) => {} + Ok(Err(source)) => return Err(ContainerError::Request { source }), + Err(_) => { + // Le processus continue de tourner dans le container : sans arrêt, il + // consommerait CPU et mémoire et pourrait encore modifier le workspace + // pendant toute la durée de vie de la sandbox. L'API n'offre pas de + // moyen de tuer un exec, on arrête donc le container qui le porte. + let _ = self.stop_container(container).await; + + return Err(ContainerError::Timeout { + operation: format!("exec {}", cmd.join(" ")), + timeout, + }); + } + } + + // Le code de sortie n'est pas forcément publié au moment où le flux se ferme : + // on laisse au daemon le temps de le renseigner, sans bloquer indéfiniment. + // Sans cela, une commande réussie peut être rapportée en échec (code -1) et + // l'outil renvoie une erreur au modèle à la place du contenu. + let mut inspected = self + .request("inspect exec", self.docker.inspect_exec(&created.id)) + .await?; + + for _ in 0..EXIT_CODE_ATTEMPTS { + if !inspected.running.unwrap_or(false) { + break; + } + + tokio::time::sleep(EXIT_CODE_DELAY).await; + + inspected = self + .request("inspect exec", self.docker.inspect_exec(&created.id)) + .await?; + } + + Ok(ExecOutput { + status: inspected.exit_code.unwrap_or(-1) as i32, + stdout, + stderr, + }) + } + + pub(crate) async fn stop_container(&self, name: &str) -> Result<(), ContainerError> { + let options = StopContainerOptions { + t: Some(1), + ..Default::default() + }; + + self.request( + "stop container", + self.docker.stop_container(name, Some(options)), + ) + .await?; + + Ok(()) + } + + /// Supprime un container, ses volumes anonymes et le processus qui y tourne. + pub(crate) async fn remove_container(&self, name: &str) -> Result<(), ContainerError> { + let options = RemoveContainerOptions { + force: true, + v: true, + ..Default::default() + }; + + self.request( + "remove container", + self.docker.remove_container(name, Some(options)), + ) + .await?; + + Ok(()) + } + + pub(crate) async fn remove_image(&self, tag: &str) -> Result<(), ContainerError> { + let options = RemoveImageOptions { + force: true, + ..Default::default() + }; + + self.request( + "remove image", + self.docker.remove_image(tag, Some(options), None), + ) + .await?; + + Ok(()) + } + + /// Crée le réseau dédié d'une sandbox. + pub(crate) async fn create_network(&self, name: &str) -> Result<(), ContainerError> { + let request = NetworkCreateRequest { + name: String::from(name), + ..Default::default() + }; + + self.request("create network", self.docker.create_network(request)) + .await?; + + Ok(()) + } + + /// Détache un container d'un réseau, ce qui coupe sa connectivité. + pub(crate) async fn disconnect_network( + &self, + network: &str, + container: &str, + ) -> Result<(), ContainerError> { + let request = NetworkDisconnectRequest { + container: String::from(container), + ..Default::default() + }; + + self.request( + "disconnect network", + self.docker.disconnect_network(network, request), + ) + .await?; + + Ok(()) + } + + pub(crate) async fn remove_network(&self, name: &str) -> Result<(), ContainerError> { + self.request("remove network", self.docker.remove_network(name)) + .await?; + + Ok(()) + } +} diff --git a/crates/devcontainer-rs/src/schema.rs b/crates/devcontainer-rs/src/schema.rs new file mode 100644 index 0000000..033337a --- /dev/null +++ b/crates/devcontainer-rs/src/schema.rs @@ -0,0 +1,230 @@ +//! Schéma du `devcontainer.json`, analyse du fichier et résolution des +//! références `${localEnv:…}`. + +use std::{ + collections::HashMap, + path::{Path, PathBuf}, +}; + +use serde::Deserialize; + +use crate::{devcontainer::DevContainer, errors::ParseError}; + +#[derive(Debug, Deserialize)] +pub struct DevContainerBuildSchema { + #[serde(default)] + pub dockerfile: String, + #[serde(default)] + pub args: HashMap, +} + +#[derive(Debug, Deserialize)] +pub struct DevContainerSchema { + #[serde(default)] + pub name: Option, + pub build: DevContainerBuildSchema, + + #[serde(rename = "workspaceFolder", default)] + pub workspace_folder: Option, + + #[serde(rename = "containerEnv", default)] + pub container_env: HashMap, + + #[serde(rename = "postCreateCommand", default)] + pub post_create_command: Option, + + #[serde(rename = "postStartCommand", default)] + pub post_start_command: Option, + + #[serde(rename = "remoteUser", default)] + pub remote_user: Option, + + /// Arguments passés à `docker run`. + /// + /// Lus pour rester fidèle au format `devcontainer.json`, mais + /// **délibérément pas transmis** au runtime : ils viennent d'une pull + /// request non fiable et pourraient casser l'isolation de la sandbox, décrite + /// dans le doc de la crate. + #[serde(rename = "runArgs", default)] + pub run_args: Vec, +} + +impl TryFrom<(DevContainerSchema, PathBuf)> for DevContainer { + type Error = ParseError; + + fn try_from( + (schema, devcontainer_path): (DevContainerSchema, PathBuf), + ) -> Result { + let base_dir = devcontainer_path + .parent() + .ok_or_else(|| ParseError::InvalidDevContainerPath(devcontainer_path.clone()))?; + + let container_file_path = base_dir.join(schema.build.dockerfile); + + if !container_file_path.is_file() { + 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, + 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, + }) + } +} + +/// Résout les références `${localEnv:VAR}` et `${localEnv:VAR:default}` d'un +/// `devcontainer.json`. +/// +/// La spécification veut que `${localEnv:VAR}` soit lu dans l'environnement du +/// client ; ici le `devcontainer.json` vient d'une **pull request**, donc de code +/// non fiable : lire l'environnement du processus lui permettrait de récupérer +/// `GITEA_TOKEN`, `OPEN_ROUTER_API_KEY` ou n'importe quel autre secret de Herald et +/// de l'exfiltrer depuis un `build.args`, un `containerEnv`, son Dockerfile ou un +/// hook. L'environnement n'est donc **jamais** consulté : seule la valeur par +/// défaut est utilisée, et une référence sans défaut devient une chaîne vide. +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]; + // La clé est lue pour délimiter la référence, pas pour la résoudre. + let (_key, default) = match inner.split_once(':') { + Some((key, default)) => (key, Some(default)), + None => (inner, None), + }; + + 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) -> Result { + let path = path.as_ref().to_path_buf(); + let contents = tokio::fs::read_to_string(&path) + .await + .map_err(|source| ParseError::Read { + path: path.clone(), + source, + })?; + + let schema = serde_json::from_str::(&contents).map_err(|source| { + ParseError::Json { + path: path.clone(), + source, + } + })?; + + DevContainer::try_from((schema, path)) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + #[tokio::test] + async fn parses_devcontainer_file() { + let dir = tempfile::tempdir().unwrap(); + let devcontainer_path = dir.path().join("devcontainer.json"); + let dockerfile_path = dir.path().join("Dockerfile"); + + fs::write(&dockerfile_path, "FROM alpine\n").unwrap(); + fs::write( + &devcontainer_path, + r#"{ + "name": "test", + "build": { + "dockerfile": "Dockerfile", + "args": { + "VERSION": "1" + } + }, + "workspaceFolder": "/workspace", + "containerEnv": { + "RUST_LOG": "debug" + }, + "remoteUser": "dev" + }"#, + ) + .unwrap(); + + let config = parse(&devcontainer_path).await.unwrap(); + + assert_eq!(config.name.as_deref(), Some("test")); + assert_eq!(config.container_file_path, dockerfile_path); + 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")); + } + + #[test] + fn local_env_references_are_never_read_from_the_environment() { + unsafe { std::env::set_var("DEVCONTAINER_TEST_SECRET", "s3cret") }; + + // Le `devcontainer.json` vient d'une pull request : résoudre la référence + // depuis l'environnement de Herald y exposerait ses jetons. + assert_eq!( + substitute_local_env("${localEnv:DEVCONTAINER_TEST_SECRET}"), + "" + ); + assert_eq!( + substitute_local_env("token=${localEnv:DEVCONTAINER_TEST_SECRET}"), + "token=" + ); + } + + #[test] + fn local_env_defaults_are_used() { + 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("uid=${localEnv:UID:1000}"), "uid=1000"); + assert_eq!( + substitute_local_env("no variables here"), + "no variables here" + ); + } +} diff --git a/crates/herald-server/src/sandbox/tools.rs b/crates/herald-server/src/sandbox/tools.rs index bf318e6..0e7d916 100644 --- a/crates/herald-server/src/sandbox/tools.rs +++ b/crates/herald-server/src/sandbox/tools.rs @@ -43,7 +43,8 @@ fn review_tools() -> Vec { ), Tool::new( "read_file", - "Read the content of a text file inside the repository.", + "Read the content of a text file inside the repository. Every line is \ + prefixed with its absolute line number, even when only a range is read.", json!({ "type": "object", "properties": { @@ -128,18 +129,36 @@ async fn read_file(sandbox: &Sandbox, args: &Value) -> anyhow::Result { let start = args.get("start_line").and_then(Value::as_u64); let end = args.get("end_line").and_then(Value::as_u64); - let output = if start.is_none() && end.is_none() { - sandbox.exec(&["cat", "--", &path]).await? + let (first_line, output) = if start.is_none() && end.is_none() { + (1, sandbox.exec(&["cat", "--", &path]).await?) } else { let start = start.unwrap_or(1); let end = end .map(|line| line.to_string()) .unwrap_or_else(|| "$".to_string()); let range = format!("{start},{end}p"); - sandbox.exec(&["sed", "-n", &range, "--", &path]).await? + + ( + start, + sandbox.exec(&["sed", "-n", &range, "--", &path]).await?, + ) }; - into_stdout(output) + Ok(number_lines(&into_stdout(output)?, first_line)) +} + +/// Préfixe chaque ligne par son numéro. +/// +/// Le modèle doit citer une ligne précise pour ancrer son commentaire : sans +/// numéros, il les compte lui-même et se décale de quelques lignes, ce qui place le +/// commentaire à côté du code visé. +fn number_lines(content: &str, first_line: u64) -> String { + content + .lines() + .enumerate() + .map(|(offset, line)| format!("{}:{line}", first_line + offset as u64)) + .collect::>() + .join("\n") } async fn grep(sandbox: &Sandbox, args: &Value) -> anyhow::Result { @@ -243,4 +262,21 @@ mod tests { let err = required_str(&json!({}), "path").unwrap_err(); assert!(err.to_string().contains("path")); } + + #[test] + fn lines_are_numbered_from_the_first_one() { + assert_eq!(number_lines("a\nb\n", 1), "1:a\n2:b"); + } + + #[test] + fn a_range_keeps_the_absolute_line_numbers() { + // Un extrait lu à partir de la ligne 12 doit garder la numérotation du + // fichier : sinon le modèle citerait des lignes décalées. + assert_eq!(number_lines("x\ny", 12), "12:x\n13:y"); + } + + #[test] + fn an_empty_read_stays_empty() { + assert_eq!(number_lines("", 1), ""); + } }