Merge pull request 'add default image docker fallback' (#8) from 1.2.1 into main
ci/woodpecker/push/tests Pipeline was successful
ci/woodpecker/tag/release Pipeline was successful

Reviewed-on: qpismont/herald#8
This commit was merged in pull request #8.
This commit is contained in:
qpismont committed 2026-09-29 20:50:36 +02:00
commit c546707f72
36 files changed
+436 -234

No files matched your search

+3 -3
View File
@@ -1,5 +1,5 @@
{ {
"name": "herald", "name": "steep",
"build": { "build": {
"dockerfile": "Dockerfile", "dockerfile": "Dockerfile",
"args": { "args": {
@@ -17,8 +17,8 @@
"extensions": ["fill-labs.dependi", "rust-lang.rust-analyzer", "tamasfe.even-better-toml"] "extensions": ["fill-labs.dependi", "rust-lang.rust-analyzer", "tamasfe.even-better-toml"]
} }
}, },
"workspaceMount": "source=${localWorkspaceFolder},target=/workspaces/herald,type=bind", "workspaceMount": "source=${localWorkspaceFolder},target=/workspaces/steep,type=bind",
"workspaceFolder": "/workspaces/herald", "workspaceFolder": "/workspaces/steep",
"runArgs": ["--userns=keep-id", "--security-opt", "label=disable"], "runArgs": ["--userns=keep-id", "--security-opt", "label=disable"],
"appPort": [3000] "appPort": [3000]
} }
+1 -1
View File
@@ -26,4 +26,4 @@ steps:
volumes: volumes:
- /data/woodpecker-builds:/data - /data/woodpecker-builds:/data
commands: commands:
- buildah bud -f Containerfile -t herald-ci . - buildah bud -f Containerfile -t steep-ci .
Generated
+29 -28
View File
@@ -485,6 +485,7 @@ dependencies = [
"thiserror", "thiserror",
"tokio", "tokio",
"tokio-stream", "tokio-stream",
"tracing",
] ]
[[package]] [[package]]
@@ -765,34 +766,6 @@ version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "herald-server"
version = "1.2.0"
dependencies = [
"anyhow",
"axum",
"devcontainer-rs",
"dotenvy",
"futures-util",
"hex",
"metrics",
"metrics-exporter-prometheus",
"reqwest",
"ring",
"sentry",
"sentry-anyhow",
"serde",
"serde_json",
"tempfile",
"thiserror",
"tokio",
"tokio-util",
"tower",
"tower-http 0.7.1",
"tracing",
"tracing-subscriber",
]
[[package]] [[package]]
name = "hex" name = "hex"
version = "0.4.3" version = "0.4.3"
@@ -2235,6 +2208,34 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "steep-server"
version = "1.2.0"
dependencies = [
"anyhow",
"axum",
"devcontainer-rs",
"dotenvy",
"futures-util",
"hex",
"metrics",
"metrics-exporter-prometheus",
"reqwest",
"ring",
"sentry",
"sentry-anyhow",
"serde",
"serde_json",
"tempfile",
"thiserror",
"tokio",
"tokio-util",
"tower",
"tower-http 0.7.1",
"tracing",
"tracing-subscriber",
]
[[package]] [[package]]
name = "syn" name = "syn"
version = "2.0.119" version = "2.0.119"
+1 -1
View File
@@ -1,6 +1,6 @@
[workspace] [workspace]
members = [ members = [
"crates/herald-server", "crates/steep-server",
"crates/devcontainer-rs", "crates/devcontainer-rs",
] ]
resolver = "3" resolver = "3"
+4 -4
View File
@@ -5,14 +5,14 @@ WORKDIR /app
COPY Cargo.toml Cargo.lock ./ COPY Cargo.toml Cargo.lock ./
COPY crates/ crates/ COPY crates/ crates/
RUN cargo build --release --package herald-server RUN cargo build --release --package steep-server
FROM debian:trixie-slim FROM debian:trixie-slim
# git clones the pull request. ca-certificates is what every HTTPS call needs # git clones the pull request. ca-certificates is what every HTTPS call needs
# (Gitea, OpenRouter, git). The shared libraries are the non-base ones the binary # (Gitea, OpenRouter, git). The shared libraries are the non-base ones the binary
# links against, as reported by `ldd target/release/herald-server`; libc, libm # links against, as reported by `ldd target/release/steep-server`; libc, libm
# and libgcc_s come from the base image. # and libgcc_s come from the base image.
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \ ca-certificates \
@@ -24,8 +24,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
WORKDIR /app WORKDIR /app
COPY --from=builder /app/target/release/herald-server ./herald-server COPY --from=builder /app/target/release/steep-server ./steep-server
# Exec form, so the binary is PID 1 and receives the SIGTERM it handles to shut # Exec form, so the binary is PID 1 and receives the SIGTERM it handles to shut
# down gracefully. # down gracefully.
CMD ["./herald-server"] CMD ["./steep-server"]
+37 -31
View File
@@ -1,6 +1,10 @@
# Herald ![Steep — Let your code steep](banniere_readme.png)
Herald is a Gitea bot that performs automated AI-powered code reviews on pull requests using [OpenRouter](https://openrouter.ai/). # Steep
*Let your code steep.*
Steep is a Gitea bot that performs automated AI-powered code reviews on pull requests using [OpenRouter](https://openrouter.ai/).
## Features ## Features
@@ -10,18 +14,18 @@ Herald is a Gitea bot that performs automated AI-powered code reviews on pull re
- Graceful shutdown — in-progress reviews finish before the process exits - Graceful shutdown — in-progress reviews finish before the process exits
- Error tracking via Sentry - Error tracking via Sentry
- Prometheus metrics endpoint for monitoring - Prometheus metrics endpoint for monitoring
- Tiny memory footprint (~4MB) thanks to Rust - Lightweight, performant Rust service with Prometheus metrics and Sentry error tracking
## Installation ## Installation
**Requirements:** Rust toolchain ([rustup.rs](https://rustup.rs)) **Requirements:** Rust toolchain ([rustup.rs](https://rustup.rs))
```sh ```sh
cargo build --release cargo build --release -p steep-server
./target/release/herald ./target/release/steep-server
``` ```
Herald reads its configuration from environment variables (a `.env` file is supported): Steep reads its configuration from environment variables (a `.env` file is supported):
| Variable | Description | | Variable | Description |
|---|---| |---|---|
@@ -38,15 +42,16 @@ Herald reads its configuration from environment variables (a `.env` file is supp
| `SENTRY_DSN` | *(optional)* Sentry DSN for error tracking | | `SENTRY_DSN` | *(optional)* Sentry DSN for error tracking |
| `RUST_LOG` | *(optional)* Log level, defaults to `info` | | `RUST_LOG` | *(optional)* Log level, defaults to `info` |
| `SANDBOX_MAX_ITERATIONS` | *(optional)* Maximum number of tool-calling iterations per sandboxed review. Defaults to `8` | | `SANDBOX_MAX_ITERATIONS` | *(optional)* Maximum number of tool-calling iterations per sandboxed review. Defaults to `8` |
| `DOCKER_HOST` | *(optional)* Container daemon socket Herald drives, e.g. `unix:///run/user/1000/podman/podman.sock` for a rootless podman. Defaults to `unix:///var/run/docker.sock` | | `DOCKER_HOST` | *(optional)* Container daemon socket Steep drives, e.g. `unix:///run/user/1000/podman/podman.sock` for a rootless podman. Defaults to `unix:///var/run/docker.sock` |
## Sandboxed reviews ## Sandboxed reviews
Herald reviews pull requests inside an ephemeral Steep 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
@@ -65,12 +70,14 @@ Each comment is tagged with a severity — `bug`, `security`, `performance` or
`maintainability` — shown at the start of the comment, and the summary also lists `maintainability` — shown at the start of the comment, and the summary also lists
what the pull request does well. what the pull request does well.
Herald drives the container daemon through its socket: `DOCKER_HOST` (default Steep 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`), Steep 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 Steep 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
the socket, like the build context, instead of being bind-mounted from a host the socket, like the build context, instead of being bind-mounted from a host
path the daemon would have to see. This is the setup the `Containerfile` path the daemon would have to see. This is the setup the `Containerfile`
@@ -79,14 +86,13 @@ produces, e.g.:
```sh ```sh
podman run --env-file=.env -p 3001:3001 \ podman run --env-file=.env -p 3001:3001 \
-v /run/user/$(id -u)/podman/podman.sock:/var/run/docker.sock \ -v /run/user/$(id -u)/podman/podman.sock:/var/run/docker.sock \
herald:latest steep: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` /
@@ -105,28 +111,28 @@ Open the project and reopen it in the container — the Rust toolchain is pre-in
```sh ```sh
rustup toolchain install stable rustup toolchain install stable
cargo run cargo run -p steep-server
``` ```
Copy `.env.example` to `.env` and fill in your values before running. Copy `.env.example` to `.env` and fill in your values before running.
## Metrics ## Metrics
Herald optionally exposes a Prometheus metrics endpoint, useful for scraping with an [OpenTelemetry Collector](https://opentelemetry.io/docs/collector/) or any Prometheus-compatible scraper. Steep optionally exposes a Prometheus metrics endpoint, useful for scraping with an [OpenTelemetry Collector](https://opentelemetry.io/docs/collector/) or any Prometheus-compatible scraper.
Set `METRICS_BIND_ADDR` (e.g. `0.0.0.0:9100`) to enable it. The metrics are then available at `http://<host>:9100/metrics`. Set `METRICS_BIND_ADDR` (e.g. `0.0.0.0:9100`) to enable it. The metrics are then available at `http://<host>:9100/metrics`. The metric namespace changed from `herald_*` to `steep_*`; update any dashboards or alerts that use the old names.
### Exposed metrics ### Exposed metrics
| Metric | Type | Description | | Metric | Type | Description |
|---|---|---| |---|---|---|
| `herald_webhooks_received_total` | counter | Total webhooks received (label: `event_type`) | | `steep_webhooks_received_total` | counter | Total webhooks received (label: `event_type`) |
| `herald_webhooks_duplicate_total` | counter | Webhooks rejected as duplicates (label: `event_type`) | | `steep_webhooks_duplicate_total` | counter | Webhooks rejected as duplicates (label: `event_type`) |
| `herald_webhooks_channel_full_total` | counter | Webhooks dropped because the bot channel was full (label: `event_type`) | | `steep_webhooks_channel_full_total` | counter | Webhooks dropped because the bot channel was full (label: `event_type`) |
| `herald_bot_tasks_active` | gauge | Bot tasks currently in progress | | `steep_bot_tasks_active` | gauge | Bot tasks currently in progress |
| `herald_bot_tasks_completed_total` | counter | Bot tasks completed successfully (label: `event_type`) | | `steep_bot_tasks_completed_total` | counter | Bot tasks completed successfully (label: `event_type`) |
| `herald_bot_tasks_failed_total` | counter | Bot tasks that failed (label: `event_type`) | | `steep_bot_tasks_failed_total` | counter | Bot tasks that failed (label: `event_type`) |
| `herald_openrouter_cost_cents_total` | counter | Total OpenRouter cost in cents (divide by 100 for USD) | | `steep_openrouter_cost_cents_total` | counter | Total OpenRouter cost in cents (divide by 100 for USD) |
### OTel collector example ### OTel collector example
@@ -135,10 +141,10 @@ receivers:
prometheus: prometheus:
config: config:
scrape_configs: scrape_configs:
- job_name: herald - job_name: steep
scrape_interval: 15s scrape_interval: 15s
static_configs: static_configs:
- targets: ["herald:9100"] - targets: ["steep:9100"]
service: service:
pipelines: pipelines:
+37
View File
@@ -0,0 +1,37 @@
# Roadmap
## 1.2 — Sandboxed reviews — delivered
- Run each pull request review in an ephemeral Dev Container.
- Let the model explore the checked-out repository through read-only tools instead of sending the source code in the prompt.
- Anchor review comments to changed lines and classify findings by severity.
## 1.2.1 — Default Dev Container — delivered
- If a repository has no Dev Container configuration, run its review in a default container based on `debian:stable-slim`.
- Use `/workspace` as the workspace path for the default container and as the fallback when `workspaceFolder` is not specified.
## 1.3 — Typed action routing — planned
- Introduce a type-safe model output for the action Steep should take, such as `Review` or `Implementation`.
- Route review requests (for example, “@steep, review this PR”) to the existing review flow.
- Recognize implementation requests (for example, “@steep, rename method `XXX` to `YYY`”) and establish their dispatch path. Execution of implementation requests is planned for 2.0.
## 2.0 — Implementation requests — planned
- Use the typed action from 1.3 to route explicit implementation requests to an implementation workflow.
- For a request on an issue, clone the repository from its default branch and implement the requested features on a new branch.
- For a request on an existing pull request, clone its branch and implement the requested modifications there.
- When the agent's iteration loop ends, the orchestration layer automatically pushes the resulting changes: it opens a pull request for issue-based work, or updates the existing pull request branch. The agent does not receive or invoke a push tool.
- Keep implementation work isolated in a sandbox; do not modify the default branch directly.
## 2.1 — `AGENTS.md` support and multi models — planned
- Read applicable `AGENTS.md` files from the repository and use their project-specific instructions when carrying out implementation requests.
- Allow configuring a dedicated model for pull request reviews and another for implementation requests.
- Route each action type to its configured model, with a clear fallback when only one model is configured.
## 3.0 — Autonomous Sentry fixes — planned
- Automatically discover project errors reported by Sentry, without waiting for a user comment.
- Investigate the errors in an isolated sandbox, implement candidate fixes, and validate them before reporting the results.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1009 KiB

+1
View File
@@ -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 }
+2
View File
@@ -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";
+6 -5
View File
@@ -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;
} }
+134 -87
View File
@@ -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,47 +29,42 @@ pub struct DevContainer {
} }
impl DevContainer { impl DevContainer {
/// Nom de l'image de base dérivé du nom du devcontainer. /// Dossier de workspace dans le container, par défaut `/workspace`.
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 { pub fn workspace_folder(&self) -> String {
self.workspace_folder self.workspace_folder
.clone() .clone()
.unwrap_or_else(|| "/workspaces/workspace".to_string()) .unwrap_or_else(|| "/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,41 @@ 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 image = self
.image
.clone()
.unwrap_or_else(|| self.local_image_tag(&id));
let name = self.container_name(); let owns_image = self.image.is_none();
let network = format!("{name}-net"); let container_name = self.container_name(&id);
let network = format!("{container_name}-net");
let workspace_folder = self.workspace_folder();
// Réseau dédié afin de pouvoir couper la connectivité après les hooks. info!(image = %image, "Starting sandbox container");
if let Err(err) = runtime.create_network(&network).await {
let _ = runtime.remove_image(&image_tag).await; let setup = async {
return Err(err); if owns_image {
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?;
} else {
runtime.pull_image(&image).await?;
} }
let workspace_folder = self.workspace_folder(); runtime.create_network(&network).await?;
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(
@@ -118,51 +129,54 @@ impl DevContainer {
..Default::default() ..Default::default()
}; };
if let Err(err) = runtime.create_container(&name, body).await { runtime.create_container(&container_name, body).await?;
let _ = runtime.remove_network(&network).await; runtime.start_container(&container_name).await?;
let _ = runtime.remove_image(&image_tag).await; runtime
return Err(err); .upload_directory(&container_name, &workspace_folder, workspace_dir)
} .await?;
if let Err(err) = runtime.start_container(&name).await { Ok::<(), ContainerError>(())
let _ = runtime.remove_container(&name).await;
let _ = runtime.remove_network(&network).await;
let _ = runtime.remove_image(&image_tag).await;
return Err(err);
} }
.await;
// Le clone est copié dans le container par le socket, pas monté depuis un if let Err(err) = setup {
// chemin de l'hôte : le daemon n'a pas besoin de voir le clone pour le rendre cleanup_failed_startup(
// visible dans la sandbox, ce qui permet à Herald de tourner dans un container runtime,
// (avec le socket monté) sans partager de dossier avec l'hôte. &container_name,
if let Err(err) = runtime &network,
.upload_directory(&name, &workspace_folder, workspace_dir) owns_image.then_some(image.as_str()),
.await )
{ .await;
let _ = runtime.remove_container(&name).await;
let _ = runtime.remove_network(&network).await;
let _ = runtime.remove_image(&image_tag).await;
return Err(err); return Err(err);
} }
let container = Container::new( let container = Container::new(
runtime.clone(), runtime.clone(),
name, container_name.clone(),
self.workspace_folder(), workspace_folder,
self.remote_user.clone(), self.remote_user.clone(),
Some(network.clone()), Some(network.clone()),
Some(image_tag), owns_image.then_some(image.clone()),
); );
// Les hooks s'exécutent avec accès au réseau (installation de dépendances, etc.). let post_setup = async {
if let Err(err) = self.run_hooks(&container, runtime.timeout()).await { self.run_hooks(&container, runtime.timeout()).await?;
let _ = container.remove().await; runtime
return Err(err); .disconnect_network(&network, &container_name)
} .await?;
// Coupe l'accès réseau pour le reste de la durée de vie de la sandbox. Ok::<(), ContainerError>(())
if let Err(err) = runtime.disconnect_network(&network, container.name()).await { }
let _ = container.remove().await; .await;
if let Err(err) = post_setup {
cleanup_failed_startup(
runtime,
&container_name,
&network,
owns_image.then_some(image.as_str()),
)
.await;
return Err(err); return Err(err);
} }
@@ -195,6 +209,36 @@ impl DevContainer {
} }
} }
async fn cleanup_failed_startup(
runtime: &ContainerRuntime,
container: &str,
network: &str,
owned_image: Option<&str>,
) {
let _ = runtime.remove_container(container).await;
let _ = runtime.remove_network(network).await;
if let Some(image) = owned_image {
let _ = runtime.remove_image(image).await;
}
}
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 +260,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 +300,30 @@ 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);
assert_eq!(dc.workspace_folder(), "/workspace");
} }
} }
+3
View File
@@ -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),
} }
+8 -8
View File
@@ -1,12 +1,13 @@
//! 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.
//! //!
//! Le daemon est joint par son **socket** (celui de l'hôte, monté dans le //! Le daemon est joint par son **socket** (celui de l'hôte, monté dans le
//! container de Herald) : [`ContainerRuntime::connect`] suit `DOCKER_HOST` comme //! container de Steep) : [`ContainerRuntime::connect`] suit `DOCKER_HOST` comme
//! le fait le CLI `docker`, et retombe sur le socket local. Aucun binaire de //! 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 //! 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. //! façon dès lors qu'il expose son socket compatible Docker.
@@ -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;
+31 -2
View File
@@ -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,
+105 -33
View File
@@ -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;
let (image, container_file_path, build_args) = match (image, build) {
(Some(image), _) => (Some(substitute_local_env(&image)), None, HashMap::new()),
(None, Some(build)) => {
let container_file_path = base_dir.join(&build.dockerfile);
if !container_file_path.is_file() { if !container_file_path.is_file() {
return Err(ParseError::ContainerFileNotFound(container_file_path)); return Err(ParseError::ContainerFileNotFound(container_file_path));
} }
let build_args = schema let build_args = build
.build
.args .args
.into_iter() .into_iter()
.map(|(k, v)| (k, substitute_local_env(&v))) .map(|(k, v)| (k, substitute_local_env(&v)))
.collect(); .collect();
let container_env = schema (None, Some(container_file_path), build_args)
.container_env }
(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,
}) })
} }
} }
@@ -97,7 +112,7 @@ impl TryFrom<(DevContainerSchema, PathBuf)> for DevContainer {
/// La spécification veut que `${localEnv:VAR}` soit lu dans l'environnement du /// 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 /// 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 /// 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 /// `GITEA_TOKEN`, `OPEN_ROUTER_API_KEY` ou n'importe quel autre secret de Steep et
/// de l'exfiltrer depuis un `build.args`, un `containerEnv`, son Dockerfile ou un /// 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 /// 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. /// défaut est utilisée, et une référence sans défaut devient une chaîne vide.
@@ -188,19 +203,76 @@ 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") };
// Le `devcontainer.json` vient d'une pull request : résoudre la référence // Le `devcontainer.json` vient d'une pull request : résoudre la référence
// depuis l'environnement de Herald y exposerait ses jetons. // depuis l'environnement de Steep y exposerait ses jetons.
assert_eq!( assert_eq!(
substitute_local_env("${localEnv:DEVCONTAINER_TEST_SECRET}"), substitute_local_env("${localEnv:DEVCONTAINER_TEST_SECRET}"),
"" ""
@@ -1,5 +1,5 @@
[package] [package]
name = "herald-server" name = "steep-server"
version = "1.2.0" version = "1.2.0"
edition = "2024" edition = "2024"
@@ -47,7 +47,7 @@ pub async fn start(app_state: AppState, shutdown: CancellationToken) -> anyhow::
} }
async fn root() -> &'static str { async fn root() -> &'static str {
"Hi, i'm Herald :)" "Hi, I'm Steep :)"
} }
#[instrument(skip(app_state), fields(webhook_type), err)] #[instrument(skip(app_state), fields(webhook_type), err)]
File renamed without changes.
@@ -875,7 +875,7 @@ mod tests {
#[test] #[test]
fn generated_lockfiles_are_ignored() { fn generated_lockfiles_are_ignored() {
assert!(is_ignored("Cargo.lock")); assert!(is_ignored("Cargo.lock"));
assert!(is_ignored("crates/herald-server/Cargo.lock")); assert!(is_ignored("crates/steep-server/Cargo.lock"));
assert!(is_ignored("package-lock.json")); assert!(is_ignored("package-lock.json"));
assert!(is_ignored("web/yarn.lock")); assert!(is_ignored("web/yarn.lock"));
assert!(is_ignored("go.sum")); assert!(is_ignored("go.sum"));
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -76,7 +76,7 @@ async fn run() -> anyhow::Result<()> {
model = %config.open_router_model, model = %config.open_router_model,
gitea_url = %config.gitea_url, gitea_url = %config.gitea_url,
bot_name = %gitea_user.login, bot_name = %gitea_user.login,
"Starting Herald" "Starting Steep"
); );
let open_router_client = OpenRouterClient::new( let open_router_client = OpenRouterClient::new(
@@ -3,73 +3,72 @@ use std::{net::SocketAddr, str::FromStr};
use metrics::{Unit, counter, describe_counter, describe_gauge, gauge}; use metrics::{Unit, counter, describe_counter, describe_gauge, gauge};
pub fn webhook_received(event_type: &str) { pub fn webhook_received(event_type: &str) {
counter!("herald_webhooks_received_total", "event_type" => event_type.to_string()).increment(1); counter!("steep_webhooks_received_total", "event_type" => event_type.to_string()).increment(1);
} }
pub fn webhook_duplicate(event_type: &str) { pub fn webhook_duplicate(event_type: &str) {
counter!("herald_webhooks_duplicate_total", "event_type" => event_type.to_string()) counter!("steep_webhooks_duplicate_total", "event_type" => event_type.to_string()).increment(1);
.increment(1);
} }
pub fn webhook_channel_full(event_type: &str) { pub fn webhook_channel_full(event_type: &str) {
counter!("herald_webhooks_channel_full_total", "event_type" => event_type.to_string()) counter!("steep_webhooks_channel_full_total", "event_type" => event_type.to_string())
.increment(1); .increment(1);
} }
pub fn increment_task_active() { pub fn increment_task_active() {
gauge!("herald_bot_tasks_active").increment(1.0); gauge!("steep_bot_tasks_active").increment(1.0);
} }
pub fn decrement_task_active() { pub fn decrement_task_active() {
gauge!("herald_bot_tasks_active").decrement(1.0); gauge!("steep_bot_tasks_active").decrement(1.0);
} }
pub fn task_completed(event_type: &str) { pub fn task_completed(event_type: &str) {
counter!("herald_bot_tasks_completed_total", "event_type" => event_type.to_string()) counter!("steep_bot_tasks_completed_total", "event_type" => event_type.to_string())
.increment(1); .increment(1);
} }
pub fn task_failed(event_type: &str) { pub fn task_failed(event_type: &str) {
counter!("herald_bot_tasks_failed_total", "event_type" => event_type.to_string()).increment(1); counter!("steep_bot_tasks_failed_total", "event_type" => event_type.to_string()).increment(1);
} }
pub fn openrouter_cost_usd(cost: f64) { pub fn openrouter_cost_usd(cost: f64) {
counter!("herald_openrouter_cost_cents_total").increment((cost * 100.0).round() as u64); counter!("steep_openrouter_cost_cents_total").increment((cost * 100.0).round() as u64);
} }
pub fn describe() { pub fn describe() {
describe_counter!( describe_counter!(
"herald_webhooks_received_total", "steep_webhooks_received_total",
Unit::Count, Unit::Count,
"Total webhooks received" "Total webhooks received"
); );
describe_counter!( describe_counter!(
"herald_webhooks_duplicate_total", "steep_webhooks_duplicate_total",
Unit::Count, Unit::Count,
"Webhooks rejected as duplicates" "Webhooks rejected as duplicates"
); );
describe_counter!( describe_counter!(
"herald_webhooks_channel_full_total", "steep_webhooks_channel_full_total",
Unit::Count, Unit::Count,
"Webhooks dropped because the bot channel was full" "Webhooks dropped because the bot channel was full"
); );
describe_gauge!( describe_gauge!(
"herald_bot_tasks_active", "steep_bot_tasks_active",
Unit::Count, Unit::Count,
"Bot tasks currently in progress" "Bot tasks currently in progress"
); );
describe_counter!( describe_counter!(
"herald_bot_tasks_completed_total", "steep_bot_tasks_completed_total",
Unit::Count, Unit::Count,
"Bot tasks completed successfully" "Bot tasks completed successfully"
); );
describe_counter!( describe_counter!(
"herald_bot_tasks_failed_total", "steep_bot_tasks_failed_total",
Unit::Count, Unit::Count,
"Bot tasks that failed" "Bot tasks that failed"
); );
describe_counter!( describe_counter!(
"herald_openrouter_cost_cents_total", "steep_openrouter_cost_cents_total",
Unit::Count, Unit::Count,
"Total OpenRouter cost in cents (divide by 100 for USD)" "Total OpenRouter cost in cents (divide by 100 for USD)"
); );
@@ -1,10 +1,10 @@
//! Minimal OpenRouter chat-completions client. //! Minimal OpenRouter chat-completions client.
//! //!
//! Herald only needs a non-streaming `POST /chat/completions` with optional //! Steep only needs a non-streaming `POST /chat/completions` with optional
//! tool calling, so the wire types are implemented in-tree instead of pulling a //! tool calling, so the wire types are implemented in-tree instead of pulling a
//! third-party SDK (and its own `reqwest` version) into the workspace. //! third-party SDK (and its own `reqwest` version) into the workspace.
//! //!
//! Only the response fields Herald consumes are modelled: `content`, //! Only the response fields Steep consumes are modelled: `content`,
//! `tool_calls` and `usage.cost`. Unknown fields are ignored. //! `tool_calls` and `usage.cost`. Unknown fields are ignored.
use std::time::Duration; use std::time::Duration;
@@ -174,7 +174,7 @@ struct ChatRequest<'a> {
tool_choice: &'a str, tool_choice: &'a str,
} }
/// OpenRouter reasoning config; Herald always asks for reasoning. /// OpenRouter reasoning config; Steep always asks for reasoning.
#[derive(Serialize)] #[derive(Serialize)]
struct Reasoning { struct Reasoning {
enabled: bool, enabled: bool,
@@ -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 {
@@ -210,10 +214,10 @@ async fn clone_pull_request(
/// Rend le clone lisible par les utilisateurs du container sandbox. /// Rend le clone lisible par les utilisateurs du container sandbox.
/// ///
/// Le container peut ne pas avoir les mêmes uid que Herald (podman rootless /// Le container peut ne pas avoir les mêmes uid que Steep (podman rootless
/// mappe les uid à travers des plages subuid), et l'umask de l'opérateur peut être /// mappe les uid à travers des plages subuid), et l'umask de l'opérateur peut être
/// restrictif : sans cela, les outils de la sandbox échouent en `Permission /// restrictif : sans cela, les outils de la sandbox échouent en `Permission
/// denied` sur des fichiers que Herald vient de cloner lui-même. /// denied` sur des fichiers que Steep vient de cloner lui-même.
async fn make_readable(repo_dir: &Path) -> anyhow::Result<()> { async fn make_readable(repo_dir: &Path) -> anyhow::Result<()> {
let output = tokio::process::Command::new("chmod") let output = tokio::process::Command::new("chmod")
.args(["-R", "a+rX"]) .args(["-R", "a+rX"])
File renamed without changes.
File renamed without changes.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 843 KiB

+1 -1
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
IMAGE="tintounn/herald" IMAGE="tintounn/steep"
if [ -z "${CI_COMMIT_TAG:-}" ]; then if [ -z "${CI_COMMIT_TAG:-}" ]; then
echo "Error: CI_COMMIT_TAG is not set" >&2 echo "Error: CI_COMMIT_TAG is not set" >&2