This commit is contained in:
@@ -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);
|
||||||
@@ -1,440 +1,12 @@
|
|||||||
//! Primitives de cycle de vie de container pour un [`DevContainer`] analysé.
|
//! Un devcontainer en cours d'exécution : inspection et exécution de commandes.
|
||||||
//!
|
|
||||||
//! 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.
|
|
||||||
|
|
||||||
use std::{
|
use std::time::Duration;
|
||||||
collections::HashMap,
|
|
||||||
io::{BufWriter, Write},
|
use crate::{
|
||||||
path::{Path, PathBuf},
|
consts::DEFAULT_EXEC_TIMEOUT, errors::ContainerError, exec::ExecOutput,
|
||||||
time::{Duration, SystemTime, UNIX_EPOCH},
|
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<Self, ContainerError> {
|
|
||||||
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<T>(
|
|
||||||
&self,
|
|
||||||
operation: &str,
|
|
||||||
request: impl Future<Output = Result<T, BollardError>>,
|
|
||||||
) -> Result<T, ContainerError> {
|
|
||||||
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<String, String>,
|
|
||||||
) -> 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::<StartContainerOptions>),
|
|
||||||
)
|
|
||||||
.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<ExecOutput, ContainerError> {
|
|
||||||
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::<StartExecOptions>),
|
|
||||||
)
|
|
||||||
.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.
|
/// Un devcontainer en cours d'exécution.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Container {
|
pub struct Container {
|
||||||
@@ -447,6 +19,25 @@ pub struct Container {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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<String>,
|
||||||
|
network: Option<String>,
|
||||||
|
image: Option<String>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
runtime,
|
||||||
|
name,
|
||||||
|
workspace_folder,
|
||||||
|
remote_user,
|
||||||
|
network,
|
||||||
|
image,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn name(&self) -> &str {
|
pub fn name(&self) -> &str {
|
||||||
&self.name
|
&self.name
|
||||||
}
|
}
|
||||||
@@ -463,7 +54,7 @@ impl Container {
|
|||||||
self.exec_with_timeout(cmd, DEFAULT_EXEC_TIMEOUT).await
|
self.exec_with_timeout(cmd, DEFAULT_EXEC_TIMEOUT).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn exec_with_timeout(
|
pub(crate) async fn exec_with_timeout(
|
||||||
&self,
|
&self,
|
||||||
cmd: &[&str],
|
cmd: &[&str],
|
||||||
timeout: Duration,
|
timeout: Duration,
|
||||||
@@ -500,434 +91,3 @@ impl Container {
|
|||||||
Ok(())
|
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<Container, ContainerError> {
|
|
||||||
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<Item = Result<Bytes, std::io::Error>> + 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<Result<Bytes, std::io::Error>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Write for ChannelWriter {
|
|
||||||
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
|
||||||
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<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"
|
|
||||||
}"#,
|
|
||||||
)
|
|
||||||
.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<u8> {
|
|
||||||
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::<Vec<_>>();
|
|
||||||
|
|
||||||
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"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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<Item = Result<Bytes, std::io::Error>> + 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<Result<Bytes, std::io::Error>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Write for ChannelWriter {
|
||||||
|
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
||||||
|
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<u8> {
|
||||||
|
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::<Vec<_>>();
|
||||||
|
|
||||||
|
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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String>,
|
||||||
|
pub build_args: HashMap<String, String>,
|
||||||
|
pub container_env: HashMap<String, String>,
|
||||||
|
pub workspace_folder: Option<String>,
|
||||||
|
pub post_create_command: Option<String>,
|
||||||
|
pub post_start_command: Option<String>,
|
||||||
|
pub remote_user: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Container, ContainerError> {
|
||||||
|
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-")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,250 +1,43 @@
|
|||||||
use std::{
|
//! Primitives de cycle de vie de container pour un [`DevContainer`] analysé.
|
||||||
collections::HashMap,
|
//!
|
||||||
path::{Path, PathBuf},
|
//! 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 à
|
||||||
use serde::Deserialize;
|
//! 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 container;
|
||||||
|
mod context;
|
||||||
|
mod devcontainer;
|
||||||
|
mod errors;
|
||||||
|
mod exec;
|
||||||
|
mod path;
|
||||||
|
mod runtime;
|
||||||
|
mod schema;
|
||||||
|
|
||||||
pub use container::{Container, ContainerError, ContainerRuntime, ExecOutput, normalize};
|
pub use container::Container;
|
||||||
|
pub use devcontainer::DevContainer;
|
||||||
#[derive(Debug, Deserialize)]
|
pub use errors::{ContainerError, ParseError};
|
||||||
pub struct DevContainerBuildSchema {
|
pub use exec::ExecOutput;
|
||||||
#[serde(default)]
|
pub use path::normalize;
|
||||||
pub dockerfile: String,
|
pub use runtime::ContainerRuntime;
|
||||||
#[serde(default)]
|
pub use schema::{DevContainerBuildSchema, DevContainerSchema, parse};
|
||||||
pub args: HashMap<String, String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
pub struct DevContainerSchema {
|
|
||||||
#[serde(default)]
|
|
||||||
pub name: Option<String>,
|
|
||||||
pub build: DevContainerBuildSchema,
|
|
||||||
|
|
||||||
#[serde(rename = "workspaceFolder", default)]
|
|
||||||
pub workspace_folder: Option<String>,
|
|
||||||
|
|
||||||
#[serde(rename = "containerEnv", default)]
|
|
||||||
pub container_env: HashMap<String, String>,
|
|
||||||
|
|
||||||
#[serde(rename = "postCreateCommand", default)]
|
|
||||||
pub post_create_command: Option<String>,
|
|
||||||
|
|
||||||
#[serde(rename = "postStartCommand", default)]
|
|
||||||
pub post_start_command: Option<String>,
|
|
||||||
|
|
||||||
#[serde(rename = "remoteUser", default)]
|
|
||||||
pub remote_user: Option<String>,
|
|
||||||
|
|
||||||
/// 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<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct DevContainer {
|
|
||||||
pub container_file_path: PathBuf,
|
|
||||||
pub name: Option<String>,
|
|
||||||
pub build_args: HashMap<String, String>,
|
|
||||||
pub container_env: HashMap<String, String>,
|
|
||||||
pub workspace_folder: Option<String>,
|
|
||||||
pub post_create_command: Option<String>,
|
|
||||||
pub post_start_command: Option<String>,
|
|
||||||
pub remote_user: Option<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),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TryFrom<(DevContainerSchema, PathBuf)> for DevContainer {
|
|
||||||
type Error = ParseError;
|
|
||||||
|
|
||||||
fn try_from(
|
|
||||||
(schema, devcontainer_path): (DevContainerSchema, PathBuf),
|
|
||||||
) -> Result<Self, Self::Error> {
|
|
||||||
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<Path>) -> Result<DevContainer, ParseError> {
|
|
||||||
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::<DevContainerSchema>(&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"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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<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::*;
|
||||||
|
|
||||||
|
#[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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Self, ContainerError> {
|
||||||
|
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<T>(
|
||||||
|
&self,
|
||||||
|
operation: &str,
|
||||||
|
request: impl Future<Output = Result<T, BollardError>>,
|
||||||
|
) -> Result<T, ContainerError> {
|
||||||
|
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<String, String>,
|
||||||
|
) -> 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::<StartContainerOptions>),
|
||||||
|
)
|
||||||
|
.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<ExecOutput, ContainerError> {
|
||||||
|
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::<StartExecOptions>),
|
||||||
|
)
|
||||||
|
.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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct DevContainerSchema {
|
||||||
|
#[serde(default)]
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub build: DevContainerBuildSchema,
|
||||||
|
|
||||||
|
#[serde(rename = "workspaceFolder", default)]
|
||||||
|
pub workspace_folder: Option<String>,
|
||||||
|
|
||||||
|
#[serde(rename = "containerEnv", default)]
|
||||||
|
pub container_env: HashMap<String, String>,
|
||||||
|
|
||||||
|
#[serde(rename = "postCreateCommand", default)]
|
||||||
|
pub post_create_command: Option<String>,
|
||||||
|
|
||||||
|
#[serde(rename = "postStartCommand", default)]
|
||||||
|
pub post_start_command: Option<String>,
|
||||||
|
|
||||||
|
#[serde(rename = "remoteUser", default)]
|
||||||
|
pub remote_user: Option<String>,
|
||||||
|
|
||||||
|
/// 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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<(DevContainerSchema, PathBuf)> for DevContainer {
|
||||||
|
type Error = ParseError;
|
||||||
|
|
||||||
|
fn try_from(
|
||||||
|
(schema, devcontainer_path): (DevContainerSchema, PathBuf),
|
||||||
|
) -> Result<Self, Self::Error> {
|
||||||
|
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<Path>) -> Result<DevContainer, ParseError> {
|
||||||
|
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::<DevContainerSchema>(&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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,7 +43,8 @@ fn review_tools() -> Vec<Tool> {
|
|||||||
),
|
),
|
||||||
Tool::new(
|
Tool::new(
|
||||||
"read_file",
|
"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!({
|
json!({
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -128,18 +129,36 @@ async fn read_file(sandbox: &Sandbox, args: &Value) -> anyhow::Result<String> {
|
|||||||
let start = args.get("start_line").and_then(Value::as_u64);
|
let start = args.get("start_line").and_then(Value::as_u64);
|
||||||
let end = args.get("end_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() {
|
let (first_line, output) = if start.is_none() && end.is_none() {
|
||||||
sandbox.exec(&["cat", "--", &path]).await?
|
(1, sandbox.exec(&["cat", "--", &path]).await?)
|
||||||
} else {
|
} else {
|
||||||
let start = start.unwrap_or(1);
|
let start = start.unwrap_or(1);
|
||||||
let end = end
|
let end = end
|
||||||
.map(|line| line.to_string())
|
.map(|line| line.to_string())
|
||||||
.unwrap_or_else(|| "$".to_string());
|
.unwrap_or_else(|| "$".to_string());
|
||||||
let range = format!("{start},{end}p");
|
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::<Vec<_>>()
|
||||||
|
.join("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn grep(sandbox: &Sandbox, args: &Value) -> anyhow::Result<String> {
|
async fn grep(sandbox: &Sandbox, args: &Value) -> anyhow::Result<String> {
|
||||||
@@ -243,4 +262,21 @@ mod tests {
|
|||||||
let err = required_str(&json!({}), "path").unwrap_err();
|
let err = required_str(&json!({}), "path").unwrap_err();
|
||||||
assert!(err.to_string().contains("path"));
|
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), "");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user