1.2: Sandboxing #7
@@ -1,4 +1,4 @@
|
||||
FROM debian:trixie
|
||||
FROM rust:1.97-trixie
|
||||
|
||||
ARG USERNAME=dev
|
||||
ARG USER_UID=1000
|
||||
@@ -18,11 +18,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN groupadd --gid ${USER_GID:-1000} $USERNAME \
|
||||
&& useradd --uid ${USER_UID:-1000} --gid ${USER_GID:-1000} -m $USERNAME
|
||||
&& useradd --uid ${USER_UID:-1000} --gid ${USER_GID:-1000} -m $USERNAME \
|
||||
&& rustup component add clippy
|
||||
|
||||
|
||||
|
||||
USER $USERNAME
|
||||
WORKDIR /home/$USERNAME
|
||||
|
||||
ENV PATH="/home/${USERNAME}/.cargo/bin:${PATH}"
|
||||
|
||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
|
||||
|
||||
@@ -19,5 +19,6 @@
|
||||
},
|
||||
"workspaceMount": "source=${localWorkspaceFolder},target=/workspaces/herald,type=bind",
|
||||
"workspaceFolder": "/workspaces/herald",
|
||||
"runArgs": ["--userns=keep-id", "--security-opt", "label=disable"],
|
||||
|
qpismont marked this conversation as resolved
|
||||
"appPort": [3000]
|
||||
}
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
target/
|
||||
.env
|
||||
.devcontainer/
|
||||
docs/
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"rust-analyzer.check.command": "clippy"
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"lsp": {
|
||||
"rust-analyzer": {
|
||||
"initialization_options": {
|
||||
"check": {
|
||||
"command": "clippy"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -490,6 +490,18 @@ dependencies = [
|
||||
"unicode-xid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "devcontainer-rs"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dispatch2"
|
||||
version = "0.3.1"
|
||||
@@ -787,12 +799,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
|
||||
[[package]]
|
||||
name = "herald"
|
||||
version = "1.1.0"
|
||||
name = "herald-server"
|
||||
version = "1.2.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
"bytes",
|
||||
"devcontainer-rs",
|
||||
"dotenvy",
|
||||
"futures-util",
|
||||
"hex",
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
[package]
|
||||
name = "herald"
|
||||
version = "1.1.0"
|
||||
edition = "2024"
|
||||
[workspace]
|
||||
members = [
|
||||
"crates/herald-server",
|
||||
"crates/devcontainer-rs",
|
||||
]
|
||||
resolver = "3"
|
||||
|
||||
[profile.release]
|
||||
debug = 1
|
||||
|
||||
[dependencies]
|
||||
[workspace.dependencies]
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
tokio = { version = "1.53", features = ["full"] }
|
||||
tokio-stream = "0.1"
|
||||
@@ -30,3 +29,6 @@ hex = "0.4"
|
||||
bytes = "1.1"
|
||||
metrics = "0.24"
|
||||
metrics-exporter-prometheus = { version = "0.18", default-features = false, features = ["http-listener"] }
|
||||
|
||||
[profile.release]
|
||||
debug = 1
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
FROM rust:1.96 as builder
|
||||
FROM rust:1.97-trixie as builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
RUN cargo build --release
|
||||
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY crates/ crates/
|
||||
|
||||
RUN cargo build --release --package herald-server
|
||||
|
||||
|
||||
FROM debian:trixie-slim
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app/target/release/herald .
|
||||
CMD [ "./herald" ]
|
||||
COPY --from=builder /app/target/release/herald-server .
|
||||
CMD [ "./herald-server" ]
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "devcontainer-rs"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
tokio = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
@@ -0,0 +1,175 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DevContainerBuildSchema {
|
||||
#[serde(default)]
|
||||
pub dockerfile: Option<String>,
|
||||
#[serde(default)]
|
||||
pub args: HashMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DevContainerSchema {
|
||||
|
qpismont marked this conversation as resolved
Herald
commented
Le champ Le champ `build` est obligatoire, mais le schéma standard devcontainer.json permet également d'utiliser `image` à la place de `build`. Si quelqu'un utilise `image`, la désérialisation échouera. Envisagez de rendre `build` optionnel avec `#[serde(default)]` et de gérer le cas où ni `build` ni `image` ne sont présents.
|
||||
#[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>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct DevContainer {
|
||||
/// Absolute or relative path to the Dockerfile/Containerfile to build.
|
||||
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>,
|
||||
}
|
||||
|
||||
#[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 = match schema.build.dockerfile.as_deref() {
|
||||
Some(file) => base_dir.join(file),
|
||||
None => first_existing_container_file(base_dir),
|
||||
};
|
||||
|
||||
if !container_file_path.is_file() {
|
||||
return Err(ParseError::ContainerFileNotFound(container_file_path));
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
container_file_path,
|
||||
name: schema.name,
|
||||
build_args: schema.build.args,
|
||||
container_env: schema.container_env,
|
||||
workspace_folder: schema.workspace_folder,
|
||||
post_create_command: schema.post_create_command,
|
||||
post_start_command: schema.post_start_command,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn first_existing_container_file(base_dir: &Path) -> PathBuf {
|
||||
|
qpismont marked this conversation as resolved
Herald
commented
Si aucun fichier Si aucun fichier `Dockerfile` ou `Containerfile` n'existe, la fonction retourne `base_dir.join("Dockerfile")`, ce qui échouera plus tard avec `ContainerFileNotFound`. Cela pourrait être plus explicite en retournant une `Option<PathBuf>` et en laissant l'appelant gérer l'absence de fichier. Sinon, le message d'erreur actuel est suffisant.
|
||||
["Dockerfile", "Containerfile"]
|
||||
.iter()
|
||||
.map(|filename| base_dir.join(filename))
|
||||
.find(|path| path.is_file())
|
||||
.unwrap_or_else(|| base_dir.join("Dockerfile"))
|
||||
}
|
||||
|
||||
pub async fn parse(path: impl AsRef<Path>) -> Result<DevContainer, ParseError> {
|
||||
|
qpismont marked this conversation as resolved
Herald
commented
La fonction La fonction `parse` est asynchrone, mais les tests unitaires ne la testent pas directement (ils testent `TryFrom`). Il serait bon d'ajouter un test asynchrone pour `parse` afin de couvrir le flux complet (lecture du fichier, désérialisation, conversion).
|
||||
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;
|
||||
|
||||
#[test]
|
||||
fn resolves_configured_containerfile_relative_to_devcontainer_file() {
|
||||
|
qpismont marked this conversation as resolved
Outdated
Herald
commented
Les tests sont synchrones et utilisent Les tests sont synchrones et utilisent `std::fs` plutôt que `tokio::fs`. C'est acceptable pour l'instant, mais si l'implémentation venait à dépendre de fonctionnalités asynchrones (comme `tokio::fs`), les tests pourraient ne plus être représentatifs.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let devcontainer_path = dir.path().join("devcontainer.json");
|
||||
let containerfile_path = dir.path().join("Containerfile");
|
||||
fs::write(&containerfile_path, "FROM alpine\n").unwrap();
|
||||
|
||||
let schema = DevContainerSchema {
|
||||
name: Some("test".into()),
|
||||
build: DevContainerBuildSchema {
|
||||
dockerfile: Some("Containerfile".into()),
|
||||
args: HashMap::new(),
|
||||
},
|
||||
workspace_folder: None,
|
||||
container_env: HashMap::new(),
|
||||
post_create_command: None,
|
||||
post_start_command: None,
|
||||
};
|
||||
|
||||
let config = DevContainer::try_from((schema, devcontainer_path)).unwrap();
|
||||
assert_eq!(config.container_file_path, containerfile_path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_back_to_dockerfile_before_containerfile() {
|
||||
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(dir.path().join("Containerfile"), "FROM busybox\n").unwrap();
|
||||
|
||||
let schema = DevContainerSchema {
|
||||
name: None,
|
||||
build: DevContainerBuildSchema {
|
||||
dockerfile: None,
|
||||
args: HashMap::new(),
|
||||
},
|
||||
workspace_folder: None,
|
||||
container_env: HashMap::new(),
|
||||
post_create_command: None,
|
||||
post_start_command: None,
|
||||
};
|
||||
|
||||
let config = DevContainer::try_from((schema, devcontainer_path)).unwrap();
|
||||
assert_eq!(config.container_file_path, dockerfile_path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
[package]
|
||||
name = "herald-server"
|
||||
version = "1.2.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
reqwest = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tokio-stream = { workspace = true }
|
||||
tokio-util = { workspace = true }
|
||||
futures-util = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
sentry = { workspace = true }
|
||||
sentry-anyhow = { workspace = true }
|
||||
openrouter-rs = { workspace = true }
|
||||
dotenvy = { workspace = true }
|
||||
tower = { workspace = true }
|
||||
tower-http = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
axum = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
ring = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
bytes = { workspace = true }
|
||||
metrics = { workspace = true }
|
||||
metrics-exporter-prometheus = { workspace = true }
|
||||
devcontainer-rs = { path = "../devcontainer-rs" }
|
||||
L'option
--security-opt label=disabledésactive la sécurité SELinux, ce qui réduit l'isolation du conteneur. Pour un environnement de développement, cela peut être acceptable, mais il faut être conscient des implications de sécurité. Envisagez d'utiliser--security-opt label=type:container_runtime_tou d'autres options moins permissives si possible.