This commit is contained in:
1 parent
624bc1e028
commit
620ec6727e
11 files changed
+1265
-1118
No files matched your search
@@ -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};
|
||||
Reference in new issue
Block a user