clean devcontainer-rs architecture
ci/woodpecker/push/tests Pipeline was successful

This commit is contained in:
qpismont committed 2026-09-17 20:37:54 +00:00
1 parent 624bc1e028
commit 620ec6727e
11 files changed
+1265 -1118

No files matched your search

+40 -247
View File
@@ -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<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"
);
}
}
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};