Compare commits
1
Commits
589d337057
...
1.2.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7b8048992 |
Generated
+1
@@ -485,6 +485,7 @@ dependencies = [
|
|||||||
"thiserror",
|
"thiserror",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-stream",
|
"tokio-stream",
|
||||||
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -46,7 +46,8 @@ Herald reviews pull requests inside an ephemeral
|
|||||||
[Dev Container](https://containers.dev/). For each review it:
|
[Dev Container](https://containers.dev/). For each review it:
|
||||||
|
|
||||||
1. clones the pull request head into a temporary directory,
|
1. clones the pull request head into a temporary directory,
|
||||||
2. builds and starts the repository's devcontainer (`devcontainer-rs`),
|
2. builds and starts the repository's devcontainer (`devcontainer-rs`) — or, when
|
||||||
|
the repository has none, a default one based on `debian:stable-slim`,
|
||||||
3. reads the pull request diff and file list from the Gitea API with
|
3. reads the pull request diff and file list from the Gitea API with
|
||||||
`GITEA_TOKEN` (so private repositories work), tells the model which files and
|
`GITEA_TOKEN` (so private repositories work), tells the model which files and
|
||||||
lines changed — additions and deletions, with the line numbers of the new and
|
lines changed — additions and deletions, with the line numbers of the new and
|
||||||
@@ -67,8 +68,10 @@ what the pull request does well.
|
|||||||
|
|
||||||
Herald drives the container daemon through its socket: `DOCKER_HOST` (default
|
Herald drives the container daemon through its socket: `DOCKER_HOST` (default
|
||||||
`unix:///var/run/docker.sock`), which covers both docker and podman's
|
`unix:///var/run/docker.sock`), which covers both docker and podman's
|
||||||
Docker-compatible socket. The repository must contain a
|
Docker-compatible socket. When the repository contains a
|
||||||
`.devcontainer/devcontainer.json`.
|
`.devcontainer/devcontainer.json` (or `.devcontainer.json`), Herald uses it;
|
||||||
|
otherwise it falls back to a default devcontainer that pulls `debian:stable-slim`
|
||||||
|
and runs the review in `/workspace`.
|
||||||
|
|
||||||
Herald can therefore run inside a container with only that socket mounted (no
|
Herald can therefore run inside a container with only that socket mounted (no
|
||||||
shared workspace directory is required): the clone is streamed to the daemon over
|
shared workspace directory is required): the clone is streamed to the daemon over
|
||||||
@@ -82,11 +85,10 @@ podman run --env-file=.env -p 3001:3001 \
|
|||||||
herald:latest
|
herald:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
The `runArgs` of that file are read but deliberately **not** passed to the daemon:
|
The `runArgs` of that file are ignored: they come from an untrusted pull request,
|
||||||
they come from an untrusted pull request, and one of them (`--network host`) would
|
and one of them (`--network host`) would attach the container to another network
|
||||||
attach the container to another network and quietly
|
and quietly defeat the network cut described below. A devcontainer that relies on
|
||||||
defeat the network cut described below. A devcontainer that relies on them
|
them (`--gpus all`, `--cap-add`, `--shm-size`…) will not get them.
|
||||||
(`--gpus all`, `--cap-add`, `--shm-size`…) will not get them.
|
|
||||||
|
|
||||||
Each sandbox is isolated: it gets its own image tag, container and network. The
|
Each sandbox is isolated: it gets its own image tag, container and network. The
|
||||||
container starts with network access so the `postCreateCommand` /
|
container starts with network access so the `postCreateCommand` /
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ futures-util = { workspace = true }
|
|||||||
tar = "0.4"
|
tar = "0.4"
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
tokio-stream = { workspace = true }
|
tokio-stream = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
|
|||||||
@@ -22,3 +22,5 @@ pub(crate) const CONTEXT_CHUNKS: usize = 4;
|
|||||||
/// Lectures successives du code de sortie d'un exec, et attente entre elles.
|
/// 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_ATTEMPTS: usize = 10;
|
||||||
pub(crate) const EXIT_CODE_DELAY: Duration = Duration::from_millis(20);
|
pub(crate) const EXIT_CODE_DELAY: Duration = Duration::from_millis(20);
|
||||||
|
|
||||||
|
pub(crate) const DEFAULT_IMAGE_NAME: &str = "debian:stable-slim";
|
||||||
|
|||||||
@@ -15,18 +15,19 @@ pub struct Container {
|
|||||||
workspace_folder: String,
|
workspace_folder: String,
|
||||||
remote_user: Option<String>,
|
remote_user: Option<String>,
|
||||||
network: Option<String>,
|
network: Option<String>,
|
||||||
image: Option<String>,
|
owned_image: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Container {
|
impl Container {
|
||||||
/// Assemble un container démarré, avec son réseau et son image à nettoyer.
|
/// Assemble un container démarré, avec son réseau et, le cas échéant, l'image
|
||||||
|
/// construite pour lui et à nettoyer.
|
||||||
pub(crate) fn new(
|
pub(crate) fn new(
|
||||||
runtime: ContainerRuntime,
|
runtime: ContainerRuntime,
|
||||||
name: String,
|
name: String,
|
||||||
workspace_folder: String,
|
workspace_folder: String,
|
||||||
remote_user: Option<String>,
|
remote_user: Option<String>,
|
||||||
network: Option<String>,
|
network: Option<String>,
|
||||||
image: Option<String>,
|
owned_image: Option<String>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
runtime,
|
runtime,
|
||||||
@@ -34,7 +35,7 @@ impl Container {
|
|||||||
workspace_folder,
|
workspace_folder,
|
||||||
remote_user,
|
remote_user,
|
||||||
network,
|
network,
|
||||||
image,
|
owned_image,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,7 +85,7 @@ impl Container {
|
|||||||
let _ = self.runtime.remove_network(network).await;
|
let _ = self.runtime.remove_network(network).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(image) = &self.image {
|
if let Some(image) = &self.owned_image {
|
||||||
let _ = self.runtime.remove_image(image).await;
|
let _ = self.runtime.remove_image(image).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,12 +8,17 @@ use std::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use bollard::models::{ContainerCreateBody, HostConfig};
|
use bollard::models::{ContainerCreateBody, HostConfig};
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
use crate::{container::Container, errors::ContainerError, runtime::ContainerRuntime};
|
use crate::{
|
||||||
|
consts::DEFAULT_IMAGE_NAME, container::Container, errors::ContainerError,
|
||||||
|
runtime::ContainerRuntime,
|
||||||
|
};
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct DevContainer {
|
pub struct DevContainer {
|
||||||
pub container_file_path: PathBuf,
|
pub image: Option<String>,
|
||||||
|
pub container_file_path: Option<PathBuf>,
|
||||||
pub name: Option<String>,
|
pub name: Option<String>,
|
||||||
pub build_args: HashMap<String, String>,
|
pub build_args: HashMap<String, String>,
|
||||||
pub container_env: HashMap<String, String>,
|
pub container_env: HashMap<String, String>,
|
||||||
@@ -24,20 +29,6 @@ pub struct DevContainer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl DevContainer {
|
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`.
|
/// Dossier de workspace dans le container, par défaut `/workspaces/workspace`.
|
||||||
pub fn workspace_folder(&self) -> String {
|
pub fn workspace_folder(&self) -> String {
|
||||||
self.workspace_folder
|
self.workspace_folder
|
||||||
@@ -45,26 +36,35 @@ impl DevContainer {
|
|||||||
.unwrap_or_else(|| "/workspaces/workspace".to_string())
|
.unwrap_or_else(|| "/workspaces/workspace".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Nom de container unique pour cette exécution.
|
/// Tag de l'image construite localement à partir du Dockerfile.
|
||||||
pub fn container_name(&self) -> String {
|
fn local_image_tag(&self, id: &str) -> String {
|
||||||
let base = self.name.as_deref().unwrap_or("devcontainer");
|
format!("devcontainer-rs/{}:{}", self.sanitized_name(), id)
|
||||||
format!("devcontainer-rs-{}-{}", sanitize(base), unique_suffix())
|
}
|
||||||
|
|
||||||
|
/// Nom du container de cette sandbox.
|
||||||
|
fn container_name(&self, id: &str) -> String {
|
||||||
|
format!("devcontainer-rs-{}-{}", self.sanitized_name(), id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nom du devcontainer nettoyé pour servir de nom d'image/container.
|
||||||
|
fn sanitized_name(&self) -> String {
|
||||||
|
sanitize(self.name.as_deref().unwrap_or("devcontainer"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Construit l'image devcontainer sous `image_tag`.
|
/// Construit l'image devcontainer sous `image_tag`.
|
||||||
pub async fn build(
|
async fn build(
|
||||||
&self,
|
&self,
|
||||||
runtime: &ContainerRuntime,
|
runtime: &ContainerRuntime,
|
||||||
|
container_file_path: &Path,
|
||||||
image_tag: &str,
|
image_tag: &str,
|
||||||
) -> Result<(), ContainerError> {
|
) -> Result<(), ContainerError> {
|
||||||
let context = self.container_file_path.parent().ok_or_else(|| {
|
let context = container_file_path.parent().ok_or_else(|| {
|
||||||
ContainerError::Unexpected(String::from(
|
ContainerError::Unexpected(String::from(
|
||||||
"the devcontainer file path has no parent directory",
|
"the devcontainer file path has no parent directory",
|
||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let dockerfile = self
|
let dockerfile = container_file_path
|
||||||
.container_file_path
|
|
||||||
.file_name()
|
.file_name()
|
||||||
.and_then(|name| name.to_str())
|
.and_then(|name| name.to_str())
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
@@ -78,30 +78,45 @@ impl DevContainer {
|
|||||||
.await
|
.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(
|
pub async fn up(
|
||||||
&self,
|
&self,
|
||||||
runtime: &ContainerRuntime,
|
runtime: &ContainerRuntime,
|
||||||
workspace_dir: &Path,
|
workspace_dir: &Path,
|
||||||
) -> Result<Container, ContainerError> {
|
) -> Result<Container, ContainerError> {
|
||||||
let image_tag = self.image_tag();
|
let id = unique_id();
|
||||||
self.build(runtime, &image_tag).await?;
|
|
||||||
|
|
||||||
let name = self.container_name();
|
let image = self
|
||||||
|
.image
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| self.local_image_tag(&id));
|
||||||
|
|
||||||
|
let owns_image = self.image.is_none();
|
||||||
|
|
||||||
|
info!(image = %image, "Starting sandbox container");
|
||||||
|
|
||||||
|
match owns_image {
|
||||||
|
true => {
|
||||||
|
let container_file_path = self.container_file_path.as_ref().ok_or_else(|| {
|
||||||
|
ContainerError::Unexpected(String::from(
|
||||||
|
"the devcontainer has neither an image nor a container file",
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
self.build(runtime, container_file_path, &image).await?;
|
||||||
|
}
|
||||||
|
false => {
|
||||||
|
runtime.pull_image(&image).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let name = self.container_name(&id);
|
||||||
let network = format!("{name}-net");
|
let network = format!("{name}-net");
|
||||||
|
|
||||||
// Réseau dédié afin de pouvoir couper la connectivité après les hooks.
|
runtime.create_network(&network).await?;
|
||||||
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 workspace_folder = self.workspace_folder();
|
||||||
let body = ContainerCreateBody {
|
let body = ContainerCreateBody {
|
||||||
image: Some(image_tag.clone()),
|
image: Some(image.clone()),
|
||||||
cmd: Some(vec![String::from("sleep"), String::from("infinity")]),
|
cmd: Some(vec![String::from("sleep"), String::from("infinity")]),
|
||||||
user: self.remote_user.clone(),
|
user: self.remote_user.clone(),
|
||||||
env: Some(
|
env: Some(
|
||||||
@@ -120,28 +135,21 @@ impl DevContainer {
|
|||||||
|
|
||||||
if let Err(err) = runtime.create_container(&name, body).await {
|
if let Err(err) = runtime.create_container(&name, body).await {
|
||||||
let _ = runtime.remove_network(&network).await;
|
let _ = runtime.remove_network(&network).await;
|
||||||
let _ = runtime.remove_image(&image_tag).await;
|
|
||||||
return Err(err);
|
return Err(err);
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Err(err) = runtime.start_container(&name).await {
|
if let Err(err) = runtime.start_container(&name).await {
|
||||||
let _ = runtime.remove_container(&name).await;
|
let _ = runtime.remove_container(&name).await;
|
||||||
let _ = runtime.remove_network(&network).await;
|
let _ = runtime.remove_network(&network).await;
|
||||||
let _ = runtime.remove_image(&image_tag).await;
|
|
||||||
return Err(err);
|
return Err(err);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Le clone est copié dans le container par le socket, pas monté depuis un
|
|
||||||
// chemin de l'hôte : le daemon n'a pas besoin de voir le clone pour le rendre
|
|
||||||
// visible dans la sandbox, ce qui permet à Herald de tourner dans un container
|
|
||||||
// (avec le socket monté) sans partager de dossier avec l'hôte.
|
|
||||||
if let Err(err) = runtime
|
if let Err(err) = runtime
|
||||||
.upload_directory(&name, &workspace_folder, workspace_dir)
|
.upload_directory(&name, &workspace_folder, workspace_dir)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
let _ = runtime.remove_container(&name).await;
|
let _ = runtime.remove_container(&name).await;
|
||||||
let _ = runtime.remove_network(&network).await;
|
let _ = runtime.remove_network(&network).await;
|
||||||
let _ = runtime.remove_image(&image_tag).await;
|
|
||||||
return Err(err);
|
return Err(err);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,7 +159,7 @@ impl DevContainer {
|
|||||||
self.workspace_folder(),
|
self.workspace_folder(),
|
||||||
self.remote_user.clone(),
|
self.remote_user.clone(),
|
||||||
Some(network.clone()),
|
Some(network.clone()),
|
||||||
Some(image_tag),
|
owns_image.then_some(image),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Les hooks s'exécutent avec accès au réseau (installation de dépendances, etc.).
|
// Les hooks s'exécutent avec accès au réseau (installation de dépendances, etc.).
|
||||||
@@ -195,6 +203,22 @@ impl DevContainer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Default for DevContainer {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
name: Some("generic-devcontainer".to_string()),
|
||||||
|
image: Some(DEFAULT_IMAGE_NAME.to_string()),
|
||||||
|
post_create_command: None,
|
||||||
|
post_start_command: None,
|
||||||
|
build_args: HashMap::new(),
|
||||||
|
container_env: HashMap::new(),
|
||||||
|
container_file_path: None,
|
||||||
|
remote_user: None,
|
||||||
|
workspace_folder: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Nettoie une chaîne pour qu'elle puisse servir de nom d'image/container docker.
|
/// Nettoie une chaîne pour qu'elle puisse servir de nom d'image/container docker.
|
||||||
fn sanitize(input: &str) -> String {
|
fn sanitize(input: &str) -> String {
|
||||||
let sanitized: String = input
|
let sanitized: String = input
|
||||||
@@ -216,8 +240,8 @@ fn sanitize(input: &str) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Suffixe unique à une exécution de sandbox, combinant l'identifiant de processus et un horodatage.
|
/// Identifiant unique à une exécution de sandbox, combinant l'identifiant de processus et un horodatage.
|
||||||
fn unique_suffix() -> String {
|
fn unique_id() -> String {
|
||||||
let nanos = SystemTime::now()
|
let nanos = SystemTime::now()
|
||||||
.duration_since(UNIX_EPOCH)
|
.duration_since(UNIX_EPOCH)
|
||||||
.map(|duration| duration.as_nanos())
|
.map(|duration| duration.as_nanos())
|
||||||
@@ -256,27 +280,29 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn image_name_is_sanitized() {
|
fn local_image_tag_is_sanitized() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let dc = devcontainer(dir.path());
|
let dc = devcontainer(dir.path());
|
||||||
assert_eq!(dc.image_name(), "devcontainer-rs/my-project");
|
assert_eq!(dc.local_image_tag("abc"), "devcontainer-rs/my-project:abc");
|
||||||
}
|
|
||||||
|
|
||||||
#[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]
|
#[test]
|
||||||
fn container_name_is_sanitized() {
|
fn container_name_is_sanitized() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let dc = devcontainer(dir.path());
|
let dc = devcontainer(dir.path());
|
||||||
|
assert_eq!(dc.container_name("abc"), "devcontainer-rs-my-project-abc");
|
||||||
|
}
|
||||||
|
|
||||||
assert!(
|
#[test]
|
||||||
dc.container_name()
|
fn unique_ids_differ() {
|
||||||
.starts_with("devcontainer-rs-my-project-")
|
assert_ne!(unique_id(), unique_id());
|
||||||
);
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_devcontainer_pulls_a_base_image() {
|
||||||
|
let dc = DevContainer::default();
|
||||||
|
|
||||||
|
assert_eq!(dc.image.as_deref(), Some("debian:stable-slim"));
|
||||||
|
assert_eq!(dc.container_file_path, None);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,9 @@ pub enum ParseError {
|
|||||||
#[error("container file `{0}` does not exist or is not a regular file")]
|
#[error("container file `{0}` does not exist or is not a regular file")]
|
||||||
ContainerFileNotFound(PathBuf),
|
ContainerFileNotFound(PathBuf),
|
||||||
|
|
||||||
|
#[error("devcontainer file `{0}` declares neither `image` nor `build`")]
|
||||||
|
MissingImageOrBuild(PathBuf),
|
||||||
|
|
||||||
#[error("the devcontainer file path has no parent directory: `{0}`")]
|
#[error("the devcontainer file path has no parent directory: `{0}`")]
|
||||||
InvalidDevContainerPath(PathBuf),
|
InvalidDevContainerPath(PathBuf),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
//! Primitives de cycle de vie de container pour un [`DevContainer`] analysé.
|
//! Primitives de cycle de vie de container pour un [`DevContainer`] analysé.
|
||||||
//!
|
//!
|
||||||
//! Cette crate pilote l'API du daemon de containers pour construire l'image
|
//! Cette crate pilote l'API du daemon de containers pour préparer l'image
|
||||||
//! devcontainer, démarrer un container et y copier le workspace, exécuter les
|
//! devcontainer (construite depuis un Dockerfile, ou tirée d'un registre),
|
||||||
|
//! démarrer un container et y copier le workspace, exécuter les
|
||||||
//! hooks `postCreateCommand` / `postStartCommand` et lancer des commandes à
|
//! hooks `postCreateCommand` / `postStartCommand` et lancer des commandes à
|
||||||
//! l'intérieur du container en cours d'exécution.
|
//! l'intérieur du container en cours d'exécution.
|
||||||
//!
|
//!
|
||||||
@@ -19,10 +20,9 @@
|
|||||||
//! (par ex. `npm install`) ; une fois les hooks exécutés, le container est déconnecté
|
//! (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.
|
//! 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
|
//! Les `runArgs` du `devcontainer.json` sont ignorés : ils viennent du dépôt, donc
|
||||||
//! viennent du dépôt, donc d'une pull request non fiable, et pourraient rattacher le
|
//! d'une pull request non fiable, et pourraient rattacher le container à un autre
|
||||||
//! container à un autre réseau ou lui donner des privilèges qui annuleraient cette
|
//! réseau ou lui donner des privilèges qui annuleraient cette isolation.
|
||||||
//! isolation.
|
|
||||||
|
|
||||||
mod consts;
|
mod consts;
|
||||||
mod container;
|
mod container;
|
||||||
@@ -40,4 +40,4 @@ pub use errors::{ContainerError, ParseError};
|
|||||||
pub use exec::ExecOutput;
|
pub use exec::ExecOutput;
|
||||||
pub use path::normalize;
|
pub use path::normalize;
|
||||||
pub use runtime::ContainerRuntime;
|
pub use runtime::ContainerRuntime;
|
||||||
pub use schema::{DevContainerBuildSchema, DevContainerSchema, parse};
|
pub use schema::parse;
|
||||||
|
|||||||
@@ -9,11 +9,13 @@ use bollard::{
|
|||||||
exec::{CreateExecOptions, StartExecOptions, StartExecResults},
|
exec::{CreateExecOptions, StartExecOptions, StartExecResults},
|
||||||
models::{BuildInfo, ContainerCreateBody, NetworkCreateRequest, NetworkDisconnectRequest},
|
models::{BuildInfo, ContainerCreateBody, NetworkCreateRequest, NetworkDisconnectRequest},
|
||||||
query_parameters::{
|
query_parameters::{
|
||||||
BuildImageOptions, CreateContainerOptions, RemoveContainerOptions, RemoveImageOptions,
|
BuildImageOptions, CreateContainerOptions, CreateImageOptionsBuilder,
|
||||||
StartContainerOptions, StopContainerOptions, UploadToContainerOptions,
|
RemoveContainerOptions, RemoveImageOptions, StartContainerOptions, StopContainerOptions,
|
||||||
|
UploadToContainerOptions,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
use futures_util::StreamExt;
|
use futures_util::StreamExt;
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
consts::{DEFAULT_COMMAND_TIMEOUT, DEFAULT_ENDPOINT, EXIT_CODE_ATTEMPTS, EXIT_CODE_DELAY},
|
consts::{DEFAULT_COMMAND_TIMEOUT, DEFAULT_ENDPOINT, EXIT_CODE_ATTEMPTS, EXIT_CODE_DELAY},
|
||||||
@@ -133,6 +135,33 @@ impl ContainerRuntime {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn pull_image(&self, image: &str) -> Result<(), ContainerError> {
|
||||||
|
info!("Pulling image `{image}`");
|
||||||
|
|
||||||
|
let options = CreateImageOptionsBuilder::default()
|
||||||
|
.from_image(image)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let mut stream = self.docker.create_image(Some(options), None, None);
|
||||||
|
|
||||||
|
let consume = async {
|
||||||
|
while let Some(info) = stream.next().await {
|
||||||
|
info?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok::<_, BollardError>(())
|
||||||
|
};
|
||||||
|
|
||||||
|
match tokio::time::timeout(self.timeout, consume).await {
|
||||||
|
Ok(Ok(())) => Ok(()),
|
||||||
|
Ok(Err(source)) => Err(ContainerError::Request { source }),
|
||||||
|
Err(_) => Err(ContainerError::Timeout {
|
||||||
|
operation: format!("pull image `{image}`"),
|
||||||
|
timeout: self.timeout,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Crée un container, sans le démarrer.
|
/// Crée un container, sans le démarrer.
|
||||||
pub(crate) async fn create_container(
|
pub(crate) async fn create_container(
|
||||||
&self,
|
&self,
|
||||||
|
|||||||
@@ -11,42 +11,39 @@ use serde::Deserialize;
|
|||||||
use crate::{devcontainer::DevContainer, errors::ParseError};
|
use crate::{devcontainer::DevContainer, errors::ParseError};
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct DevContainerBuildSchema {
|
struct DevContainerBuildSchema {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub dockerfile: String,
|
dockerfile: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub args: HashMap<String, String>,
|
args: HashMap<String, String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct DevContainerSchema {
|
struct DevContainerSchema {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub name: Option<String>,
|
name: Option<String>,
|
||||||
pub build: DevContainerBuildSchema,
|
|
||||||
|
/// Image de base tirée du registre, alternative à `build`.
|
||||||
|
#[serde(default)]
|
||||||
|
image: Option<String>,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
build: Option<DevContainerBuildSchema>,
|
||||||
|
|
||||||
#[serde(rename = "workspaceFolder", default)]
|
#[serde(rename = "workspaceFolder", default)]
|
||||||
pub workspace_folder: Option<String>,
|
workspace_folder: Option<String>,
|
||||||
|
|
||||||
#[serde(rename = "containerEnv", default)]
|
#[serde(rename = "containerEnv", default)]
|
||||||
pub container_env: HashMap<String, String>,
|
container_env: HashMap<String, String>,
|
||||||
|
|
||||||
#[serde(rename = "postCreateCommand", default)]
|
#[serde(rename = "postCreateCommand", default)]
|
||||||
pub post_create_command: Option<String>,
|
post_create_command: Option<String>,
|
||||||
|
|
||||||
#[serde(rename = "postStartCommand", default)]
|
#[serde(rename = "postStartCommand", default)]
|
||||||
pub post_start_command: Option<String>,
|
post_start_command: Option<String>,
|
||||||
|
|
||||||
#[serde(rename = "remoteUser", default)]
|
#[serde(rename = "remoteUser", default)]
|
||||||
pub remote_user: Option<String>,
|
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 {
|
impl TryFrom<(DevContainerSchema, PathBuf)> for DevContainer {
|
||||||
@@ -59,34 +56,52 @@ impl TryFrom<(DevContainerSchema, PathBuf)> for DevContainer {
|
|||||||
.parent()
|
.parent()
|
||||||
.ok_or_else(|| ParseError::InvalidDevContainerPath(devcontainer_path.clone()))?;
|
.ok_or_else(|| ParseError::InvalidDevContainerPath(devcontainer_path.clone()))?;
|
||||||
|
|
||||||
let container_file_path = base_dir.join(schema.build.dockerfile);
|
let DevContainerSchema {
|
||||||
|
name,
|
||||||
|
image,
|
||||||
|
build,
|
||||||
|
workspace_folder,
|
||||||
|
container_env,
|
||||||
|
post_create_command,
|
||||||
|
post_start_command,
|
||||||
|
remote_user,
|
||||||
|
} = schema;
|
||||||
|
|
||||||
if !container_file_path.is_file() {
|
let (image, container_file_path, build_args) = match (image, build) {
|
||||||
return Err(ParseError::ContainerFileNotFound(container_file_path));
|
(Some(image), _) => (Some(substitute_local_env(&image)), None, HashMap::new()),
|
||||||
}
|
(None, Some(build)) => {
|
||||||
|
let container_file_path = base_dir.join(&build.dockerfile);
|
||||||
|
|
||||||
let build_args = schema
|
if !container_file_path.is_file() {
|
||||||
.build
|
return Err(ParseError::ContainerFileNotFound(container_file_path));
|
||||||
.args
|
}
|
||||||
.into_iter()
|
|
||||||
.map(|(k, v)| (k, substitute_local_env(&v)))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let container_env = schema
|
let build_args = build
|
||||||
.container_env
|
.args
|
||||||
|
.into_iter()
|
||||||
|
.map(|(k, v)| (k, substitute_local_env(&v)))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
(None, Some(container_file_path), build_args)
|
||||||
|
}
|
||||||
|
(None, None) => return Err(ParseError::MissingImageOrBuild(devcontainer_path)),
|
||||||
|
};
|
||||||
|
|
||||||
|
let container_env = container_env
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(k, v)| (k, substitute_local_env(&v)))
|
.map(|(k, v)| (k, substitute_local_env(&v)))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
|
image,
|
||||||
container_file_path,
|
container_file_path,
|
||||||
name: schema.name,
|
name,
|
||||||
build_args,
|
build_args,
|
||||||
container_env,
|
container_env,
|
||||||
workspace_folder: schema.workspace_folder,
|
workspace_folder,
|
||||||
post_create_command: schema.post_create_command,
|
post_create_command,
|
||||||
post_start_command: schema.post_start_command,
|
post_start_command,
|
||||||
remote_user: schema.remote_user,
|
remote_user,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -188,13 +203,70 @@ mod tests {
|
|||||||
let config = parse(&devcontainer_path).await.unwrap();
|
let config = parse(&devcontainer_path).await.unwrap();
|
||||||
|
|
||||||
assert_eq!(config.name.as_deref(), Some("test"));
|
assert_eq!(config.name.as_deref(), Some("test"));
|
||||||
assert_eq!(config.container_file_path, dockerfile_path);
|
assert_eq!(config.image, None);
|
||||||
|
assert_eq!(config.container_file_path, Some(dockerfile_path));
|
||||||
assert_eq!(config.build_args.get("VERSION").unwrap(), "1");
|
assert_eq!(config.build_args.get("VERSION").unwrap(), "1");
|
||||||
assert_eq!(config.container_env.get("RUST_LOG").unwrap(), "debug");
|
assert_eq!(config.container_env.get("RUST_LOG").unwrap(), "debug");
|
||||||
assert_eq!(config.workspace_folder.as_deref(), Some("/workspace"));
|
assert_eq!(config.workspace_folder.as_deref(), Some("/workspace"));
|
||||||
assert_eq!(config.remote_user.as_deref(), Some("dev"));
|
assert_eq!(config.remote_user.as_deref(), Some("dev"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn parses_image_based_devcontainer() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let devcontainer_path = dir.path().join("devcontainer.json");
|
||||||
|
|
||||||
|
fs::write(
|
||||||
|
&devcontainer_path,
|
||||||
|
r#"{
|
||||||
|
"name": "image-based",
|
||||||
|
"image": "mcr.microsoft.com/devcontainers/base:debian"
|
||||||
|
}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let config = parse(&devcontainer_path).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
config.image.as_deref(),
|
||||||
|
Some("mcr.microsoft.com/devcontainers/base:debian")
|
||||||
|
);
|
||||||
|
assert_eq!(config.container_file_path, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn ignores_run_args() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let devcontainer_path = dir.path().join("devcontainer.json");
|
||||||
|
|
||||||
|
// `runArgs` vient d'une pull request non fiable : il doit être accepté par
|
||||||
|
// l'analyse mais jamais repris dans le devcontainer.
|
||||||
|
fs::write(
|
||||||
|
&devcontainer_path,
|
||||||
|
r#"{
|
||||||
|
"image": "alpine",
|
||||||
|
"runArgs": ["--network", "host"]
|
||||||
|
}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let config = parse(&devcontainer_path).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(config.image.as_deref(), Some("alpine"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rejects_devcontainer_without_image_or_build() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let devcontainer_path = dir.path().join("devcontainer.json");
|
||||||
|
|
||||||
|
fs::write(&devcontainer_path, r#"{ "name": "empty" }"#).unwrap();
|
||||||
|
|
||||||
|
let err = parse(&devcontainer_path).await.unwrap_err();
|
||||||
|
|
||||||
|
assert!(matches!(err, ParseError::MissingImageOrBuild(_)));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn local_env_references_are_never_read_from_the_environment() {
|
fn local_env_references_are_never_read_from_the_environment() {
|
||||||
unsafe { std::env::set_var("DEVCONTAINER_TEST_SECRET", "s3cret") };
|
unsafe { std::env::set_var("DEVCONTAINER_TEST_SECRET", "s3cret") };
|
||||||
|
|||||||
@@ -50,12 +50,16 @@ impl Sandbox {
|
|||||||
clone_pull_request(clone_url, token, pull_request_number, &repo_dir).await?;
|
clone_pull_request(clone_url, token, pull_request_number, &repo_dir).await?;
|
||||||
make_readable(&repo_dir).await?;
|
make_readable(&repo_dir).await?;
|
||||||
|
|
||||||
let devcontainer_path = find_devcontainer(&repo_dir)
|
let devcontainer = match find_devcontainer(&repo_dir) {
|
||||||
.with_context(|| format!("no devcontainer found in `{clone_url}`"))?;
|
Some(path) => devcontainer_rs::parse(&path).await?,
|
||||||
|
None => {
|
||||||
|
info!(
|
||||||
|
"no devcontainer found in the pull request, falling back to the default image"
|
||||||
|
);
|
||||||
|
devcontainer_rs::DevContainer::default()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let devcontainer = devcontainer_rs::parse(&devcontainer_path).await?;
|
|
||||||
|
|
||||||
info!(image = %devcontainer.image_tag(), "Building and starting sandbox container");
|
|
||||||
let container = devcontainer.up(runtime, &repo_dir).await?;
|
let container = devcontainer.up(runtime, &repo_dir).await?;
|
||||||
|
|
||||||
let sandbox = Self {
|
let sandbox = Self {
|
||||||
|
|||||||
Reference in New Issue
Block a user