Compare commits
6
Commits
1.2
...
c546707f72
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c546707f72 | ||
|
|
f5f0d3999e | ||
|
|
9f03384bc9 | ||
|
|
ece8f8d29c | ||
|
|
4999091f32 | ||
|
|
e7b8048992 |
No files matched your search
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "herald",
|
||||
"name": "steep",
|
||||
"build": {
|
||||
"dockerfile": "Dockerfile",
|
||||
"args": {
|
||||
@@ -17,8 +17,8 @@
|
||||
"extensions": ["fill-labs.dependi", "rust-lang.rust-analyzer", "tamasfe.even-better-toml"]
|
||||
}
|
||||
},
|
||||
"workspaceMount": "source=${localWorkspaceFolder},target=/workspaces/herald,type=bind",
|
||||
"workspaceFolder": "/workspaces/herald",
|
||||
"workspaceMount": "source=${localWorkspaceFolder},target=/workspaces/steep,type=bind",
|
||||
"workspaceFolder": "/workspaces/steep",
|
||||
"runArgs": ["--userns=keep-id", "--security-opt", "label=disable"],
|
||||
"appPort": [3000]
|
||||
}
|
||||
@@ -26,4 +26,4 @@ steps:
|
||||
volumes:
|
||||
- /data/woodpecker-builds:/data
|
||||
commands:
|
||||
- buildah bud -f Containerfile -t herald-ci .
|
||||
- buildah bud -f Containerfile -t steep-ci .
|
||||
Generated
+29
-28
@@ -485,6 +485,7 @@ dependencies = [
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -765,34 +766,6 @@ version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "hex"
|
||||
version = "0.4.3"
|
||||
@@ -2235,6 +2208,34 @@ version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "syn"
|
||||
version = "2.0.119"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[workspace]
|
||||
members = [
|
||||
"crates/herald-server",
|
||||
"crates/steep-server",
|
||||
"crates/devcontainer-rs",
|
||||
]
|
||||
resolver = "3"
|
||||
|
||||
+4
-4
@@ -5,14 +5,14 @@ WORKDIR /app
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY crates/ crates/
|
||||
|
||||
RUN cargo build --release --package herald-server
|
||||
RUN cargo build --release --package steep-server
|
||||
|
||||
|
||||
FROM debian:trixie-slim
|
||||
|
||||
# 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
|
||||
# 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.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
@@ -24,8 +24,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
|
||||
|
||||
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
|
||||
# down gracefully.
|
||||
CMD ["./herald-server"]
|
||||
CMD ["./steep-server"]
|
||||
@@ -1,6 +1,10 @@
|
||||
# Herald
|
||||

|
||||
|
||||
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
|
||||
|
||||
@@ -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
|
||||
- Error tracking via Sentry
|
||||
- Prometheus metrics endpoint for monitoring
|
||||
- Tiny memory footprint (~4MB) thanks to Rust
|
||||
- Lightweight, performant Rust service with Prometheus metrics and Sentry error tracking
|
||||
|
||||
## Installation
|
||||
|
||||
**Requirements:** Rust toolchain ([rustup.rs](https://rustup.rs))
|
||||
|
||||
```sh
|
||||
cargo build --release
|
||||
./target/release/herald
|
||||
cargo build --release -p steep-server
|
||||
./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 |
|
||||
|---|---|
|
||||
@@ -38,15 +42,16 @@ Herald reads its configuration from environment variables (a `.env` file is supp
|
||||
| `SENTRY_DSN` | *(optional)* Sentry DSN for error tracking |
|
||||
| `RUST_LOG` | *(optional)* Log level, defaults to `info` |
|
||||
| `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
|
||||
|
||||
Herald reviews pull requests inside an ephemeral
|
||||
Steep reviews pull requests inside an ephemeral
|
||||
[Dev Container](https://containers.dev/). For each review it:
|
||||
|
||||
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
|
||||
`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
|
||||
@@ -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
|
||||
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
|
||||
Docker-compatible socket. The repository must contain a
|
||||
`.devcontainer/devcontainer.json`.
|
||||
Docker-compatible socket. When the repository contains a
|
||||
`.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
|
||||
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`
|
||||
@@ -79,14 +86,13 @@ produces, e.g.:
|
||||
```sh
|
||||
podman run --env-file=.env -p 3001:3001 \
|
||||
-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:
|
||||
they come from an untrusted pull request, and one of them (`--network host`) would
|
||||
attach the container to another network and quietly
|
||||
defeat the network cut described below. A devcontainer that relies on them
|
||||
(`--gpus all`, `--cap-add`, `--shm-size`…) will not get them.
|
||||
The `runArgs` of that file are ignored: they come from an untrusted pull request,
|
||||
and one of them (`--network host`) would attach the container to another network
|
||||
and quietly defeat the network cut described below. A devcontainer that relies on
|
||||
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
|
||||
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
|
||||
rustup toolchain install stable
|
||||
cargo run
|
||||
cargo run -p steep-server
|
||||
```
|
||||
|
||||
Copy `.env.example` to `.env` and fill in your values before running.
|
||||
|
||||
## 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
|
||||
|
||||
| Metric | Type | Description |
|
||||
|---|---|---|
|
||||
| `herald_webhooks_received_total` | counter | Total webhooks received (label: `event_type`) |
|
||||
| `herald_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`) |
|
||||
| `herald_bot_tasks_active` | gauge | Bot tasks currently in progress |
|
||||
| `herald_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`) |
|
||||
| `herald_openrouter_cost_cents_total` | counter | Total OpenRouter cost in cents (divide by 100 for USD) |
|
||||
| `steep_webhooks_received_total` | counter | Total webhooks received (label: `event_type`) |
|
||||
| `steep_webhooks_duplicate_total` | counter | Webhooks rejected as duplicates (label: `event_type`) |
|
||||
| `steep_webhooks_channel_full_total` | counter | Webhooks dropped because the bot channel was full (label: `event_type`) |
|
||||
| `steep_bot_tasks_active` | gauge | Bot tasks currently in progress |
|
||||
| `steep_bot_tasks_completed_total` | counter | Bot tasks completed successfully (label: `event_type`) |
|
||||
| `steep_bot_tasks_failed_total` | counter | Bot tasks that failed (label: `event_type`) |
|
||||
| `steep_openrouter_cost_cents_total` | counter | Total OpenRouter cost in cents (divide by 100 for USD) |
|
||||
|
||||
### OTel collector example
|
||||
|
||||
@@ -135,10 +141,10 @@ receivers:
|
||||
prometheus:
|
||||
config:
|
||||
scrape_configs:
|
||||
- job_name: herald
|
||||
- job_name: steep
|
||||
scrape_interval: 15s
|
||||
static_configs:
|
||||
- targets: ["herald:9100"]
|
||||
- targets: ["steep:9100"]
|
||||
|
||||
service:
|
||||
pipelines:
|
||||
|
||||
+37
@@ -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 |
@@ -10,6 +10,7 @@ futures-util = { workspace = true }
|
||||
tar = "0.4"
|
||||
tokio = { workspace = true }
|
||||
tokio-stream = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { 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.
|
||||
pub(crate) const EXIT_CODE_ATTEMPTS: usize = 10;
|
||||
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,
|
||||
remote_user: Option<String>,
|
||||
network: Option<String>,
|
||||
image: Option<String>,
|
||||
owned_image: Option<String>,
|
||||
}
|
||||
|
||||
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(
|
||||
runtime: ContainerRuntime,
|
||||
name: String,
|
||||
workspace_folder: String,
|
||||
remote_user: Option<String>,
|
||||
network: Option<String>,
|
||||
image: Option<String>,
|
||||
owned_image: Option<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
runtime,
|
||||
@@ -34,7 +35,7 @@ impl Container {
|
||||
workspace_folder,
|
||||
remote_user,
|
||||
network,
|
||||
image,
|
||||
owned_image,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,7 +85,7 @@ impl Container {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,12 +8,17 @@ use std::{
|
||||
};
|
||||
|
||||
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)]
|
||||
pub struct DevContainer {
|
||||
pub container_file_path: PathBuf,
|
||||
pub image: Option<String>,
|
||||
pub container_file_path: Option<PathBuf>,
|
||||
pub name: Option<String>,
|
||||
pub build_args: HashMap<String, String>,
|
||||
pub container_env: HashMap<String, String>,
|
||||
@@ -24,47 +29,42 @@ pub struct 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 `/workspace`.
|
||||
pub fn workspace_folder(&self) -> String {
|
||||
self.workspace_folder
|
||||
.clone()
|
||||
.unwrap_or_else(|| "/workspaces/workspace".to_string())
|
||||
.unwrap_or_else(|| "/workspace".to_string())
|
||||
}
|
||||
|
||||
/// Nom de container unique pour cette exécution.
|
||||
pub fn container_name(&self) -> String {
|
||||
let base = self.name.as_deref().unwrap_or("devcontainer");
|
||||
format!("devcontainer-rs-{}-{}", sanitize(base), unique_suffix())
|
||||
/// Tag de l'image construite localement à partir du Dockerfile.
|
||||
fn local_image_tag(&self, id: &str) -> String {
|
||||
format!("devcontainer-rs/{}:{}", self.sanitized_name(), id)
|
||||
}
|
||||
|
||||
/// 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`.
|
||||
pub async fn build(
|
||||
async fn build(
|
||||
&self,
|
||||
runtime: &ContainerRuntime,
|
||||
container_file_path: &Path,
|
||||
image_tag: &str,
|
||||
) -> 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(
|
||||
"the devcontainer file path has no parent directory",
|
||||
))
|
||||
})?;
|
||||
|
||||
let dockerfile = self
|
||||
.container_file_path
|
||||
let dockerfile = container_file_path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.ok_or_else(|| {
|
||||
@@ -78,30 +78,41 @@ impl DevContainer {
|
||||
.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(
|
||||
&self,
|
||||
runtime: &ContainerRuntime,
|
||||
workspace_dir: &Path,
|
||||
) -> Result<Container, ContainerError> {
|
||||
let image_tag = self.image_tag();
|
||||
self.build(runtime, &image_tag).await?;
|
||||
let id = unique_id();
|
||||
let image = self
|
||||
.image
|
||||
.clone()
|
||||
.unwrap_or_else(|| self.local_image_tag(&id));
|
||||
|
||||
let name = self.container_name();
|
||||
let network = format!("{name}-net");
|
||||
let owns_image = self.image.is_none();
|
||||
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.
|
||||
if let Err(err) = runtime.create_network(&network).await {
|
||||
let _ = runtime.remove_image(&image_tag).await;
|
||||
return Err(err);
|
||||
info!(image = %image, "Starting sandbox container");
|
||||
|
||||
let setup = async {
|
||||
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 {
|
||||
image: Some(image_tag.clone()),
|
||||
image: Some(image.clone()),
|
||||
cmd: Some(vec![String::from("sleep"), String::from("infinity")]),
|
||||
user: self.remote_user.clone(),
|
||||
env: Some(
|
||||
@@ -118,51 +129,54 @@ impl DevContainer {
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
if let Err(err) = runtime.create_container(&name, body).await {
|
||||
let _ = runtime.remove_network(&network).await;
|
||||
let _ = runtime.remove_image(&image_tag).await;
|
||||
return Err(err);
|
||||
}
|
||||
runtime.create_container(&container_name, body).await?;
|
||||
runtime.start_container(&container_name).await?;
|
||||
runtime
|
||||
.upload_directory(&container_name, &workspace_folder, workspace_dir)
|
||||
.await?;
|
||||
|
||||
if let Err(err) = runtime.start_container(&name).await {
|
||||
let _ = runtime.remove_container(&name).await;
|
||||
let _ = runtime.remove_network(&network).await;
|
||||
let _ = runtime.remove_image(&image_tag).await;
|
||||
return Err(err);
|
||||
Ok::<(), ContainerError>(())
|
||||
}
|
||||
.await;
|
||||
|
||||
// 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
|
||||
.upload_directory(&name, &workspace_folder, workspace_dir)
|
||||
.await
|
||||
{
|
||||
let _ = runtime.remove_container(&name).await;
|
||||
let _ = runtime.remove_network(&network).await;
|
||||
let _ = runtime.remove_image(&image_tag).await;
|
||||
if let Err(err) = setup {
|
||||
cleanup_failed_startup(
|
||||
runtime,
|
||||
&container_name,
|
||||
&network,
|
||||
owns_image.then_some(image.as_str()),
|
||||
)
|
||||
.await;
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
let container = Container::new(
|
||||
runtime.clone(),
|
||||
name,
|
||||
self.workspace_folder(),
|
||||
container_name.clone(),
|
||||
workspace_folder,
|
||||
self.remote_user.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.).
|
||||
if let Err(err) = self.run_hooks(&container, runtime.timeout()).await {
|
||||
let _ = container.remove().await;
|
||||
return Err(err);
|
||||
}
|
||||
let post_setup = async {
|
||||
self.run_hooks(&container, runtime.timeout()).await?;
|
||||
runtime
|
||||
.disconnect_network(&network, &container_name)
|
||||
.await?;
|
||||
|
||||
// Coupe l'accès réseau pour le reste de la durée de vie de la sandbox.
|
||||
if let Err(err) = runtime.disconnect_network(&network, container.name()).await {
|
||||
let _ = container.remove().await;
|
||||
Ok::<(), ContainerError>(())
|
||||
}
|
||||
.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);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
fn sanitize(input: &str) -> String {
|
||||
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.
|
||||
fn unique_suffix() -> String {
|
||||
/// Identifiant unique à une exécution de sandbox, combinant l'identifiant de processus et un horodatage.
|
||||
fn unique_id() -> String {
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_nanos())
|
||||
@@ -256,27 +300,30 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_name_is_sanitized() {
|
||||
fn local_image_tag_is_sanitized() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let dc = devcontainer(dir.path());
|
||||
assert_eq!(dc.image_name(), "devcontainer-rs/my-project");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_tags_are_unique() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let dc = devcontainer(dir.path());
|
||||
assert_ne!(dc.image_tag(), dc.image_tag());
|
||||
assert_eq!(dc.local_image_tag("abc"), "devcontainer-rs/my-project:abc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn container_name_is_sanitized() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let dc = devcontainer(dir.path());
|
||||
assert_eq!(dc.container_name("abc"), "devcontainer-rs-my-project-abc");
|
||||
}
|
||||
|
||||
assert!(
|
||||
dc.container_name()
|
||||
.starts_with("devcontainer-rs-my-project-")
|
||||
);
|
||||
#[test]
|
||||
fn unique_ids_differ() {
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,9 @@ pub enum ParseError {
|
||||
#[error("container file `{0}` does not exist or is not a regular file")]
|
||||
ContainerFileNotFound(PathBuf),
|
||||
|
||||
#[error("devcontainer file `{0}` declares neither `image` nor `build`")]
|
||||
MissingImageOrBuild(PathBuf),
|
||||
|
||||
#[error("the devcontainer file path has no parent directory: `{0}`")]
|
||||
InvalidDevContainerPath(PathBuf),
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
//! Primitives de cycle de vie de container pour un [`DevContainer`] analysé.
|
||||
//!
|
||||
//! Cette crate pilote l'API du daemon de containers pour construire l'image
|
||||
//! devcontainer, démarrer un container et y copier le workspace, exécuter les
|
||||
//! Cette crate pilote l'API du daemon de containers pour préparer l'image
|
||||
//! 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 à
|
||||
//! l'intérieur du container en cours d'exécution.
|
||||
//!
|
||||
//! Le daemon est joint par son **socket** (celui de l'hôte, monté dans le
|
||||
//! container de Herald) : [`ContainerRuntime::connect`] suit `DOCKER_HOST` comme
|
||||
//! container de Steep) : [`ContainerRuntime::connect`] suit `DOCKER_HOST` comme
|
||||
//! le fait le CLI `docker`, et retombe sur le socket local. Aucun binaire de
|
||||
//! runtime n'est donc requis dans l'image, et `podman` fonctionne de la même
|
||||
//! façon dès lors qu'il expose son socket compatible Docker.
|
||||
@@ -19,10 +20,9 @@
|
||||
//! (par ex. `npm install`) ; une fois les hooks exécutés, le container est déconnecté
|
||||
//! du réseau pour le reste de sa durée de vie. Chaque commande est bornée par un timeout.
|
||||
//!
|
||||
//! Les `runArgs` du `devcontainer.json` sont lus mais pas transmis au daemon : ils
|
||||
//! viennent du dépôt, donc d'une pull request non fiable, et pourraient rattacher le
|
||||
//! container à un autre réseau ou lui donner des privilèges qui annuleraient cette
|
||||
//! isolation.
|
||||
//! Les `runArgs` du `devcontainer.json` sont ignorés : ils viennent du dépôt, donc
|
||||
//! d'une pull request non fiable, et pourraient rattacher le container à un autre
|
||||
//! réseau ou lui donner des privilèges qui annuleraient cette isolation.
|
||||
|
||||
mod consts;
|
||||
mod container;
|
||||
@@ -40,4 +40,4 @@ pub use errors::{ContainerError, ParseError};
|
||||
pub use exec::ExecOutput;
|
||||
pub use path::normalize;
|
||||
pub use runtime::ContainerRuntime;
|
||||
pub use schema::{DevContainerBuildSchema, DevContainerSchema, parse};
|
||||
pub use schema::parse;
|
||||
@@ -9,11 +9,13 @@ use bollard::{
|
||||
exec::{CreateExecOptions, StartExecOptions, StartExecResults},
|
||||
models::{BuildInfo, ContainerCreateBody, NetworkCreateRequest, NetworkDisconnectRequest},
|
||||
query_parameters::{
|
||||
BuildImageOptions, CreateContainerOptions, RemoveContainerOptions, RemoveImageOptions,
|
||||
StartContainerOptions, StopContainerOptions, UploadToContainerOptions,
|
||||
BuildImageOptions, CreateContainerOptions, CreateImageOptionsBuilder,
|
||||
RemoveContainerOptions, RemoveImageOptions, StartContainerOptions, StopContainerOptions,
|
||||
UploadToContainerOptions,
|
||||
},
|
||||
};
|
||||
use futures_util::StreamExt;
|
||||
use tracing::info;
|
||||
|
||||
use crate::{
|
||||
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.
|
||||
pub(crate) async fn create_container(
|
||||
&self,
|
||||
|
||||
@@ -11,42 +11,39 @@ use serde::Deserialize;
|
||||
use crate::{devcontainer::DevContainer, errors::ParseError};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DevContainerBuildSchema {
|
||||
struct DevContainerBuildSchema {
|
||||
#[serde(default)]
|
||||
pub dockerfile: String,
|
||||
dockerfile: String,
|
||||
#[serde(default)]
|
||||
pub args: HashMap<String, String>,
|
||||
args: HashMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DevContainerSchema {
|
||||
struct DevContainerSchema {
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
pub build: DevContainerBuildSchema,
|
||||
name: Option<String>,
|
||||
|
||||
/// Image de base tirée du registre, alternative à `build`.
|
||||
#[serde(default)]
|
||||
image: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
build: Option<DevContainerBuildSchema>,
|
||||
|
||||
#[serde(rename = "workspaceFolder", default)]
|
||||
pub workspace_folder: Option<String>,
|
||||
workspace_folder: Option<String>,
|
||||
|
||||
#[serde(rename = "containerEnv", default)]
|
||||
pub container_env: HashMap<String, String>,
|
||||
container_env: HashMap<String, String>,
|
||||
|
||||
#[serde(rename = "postCreateCommand", default)]
|
||||
pub post_create_command: Option<String>,
|
||||
post_create_command: Option<String>,
|
||||
|
||||
#[serde(rename = "postStartCommand", default)]
|
||||
pub post_start_command: Option<String>,
|
||||
post_start_command: Option<String>,
|
||||
|
||||
#[serde(rename = "remoteUser", default)]
|
||||
pub remote_user: Option<String>,
|
||||
|
||||
/// Arguments passés à `docker run`.
|
||||
///
|
||||
/// Lus pour rester fidèle au format `devcontainer.json`, mais
|
||||
/// **délibérément pas transmis** au runtime : ils viennent d'une pull
|
||||
/// request non fiable et pourraient casser l'isolation de la sandbox, décrite
|
||||
/// dans le doc de la crate.
|
||||
#[serde(rename = "runArgs", default)]
|
||||
pub run_args: Vec<String>,
|
||||
remote_user: Option<String>,
|
||||
}
|
||||
|
||||
impl TryFrom<(DevContainerSchema, PathBuf)> for DevContainer {
|
||||
@@ -59,34 +56,52 @@ impl TryFrom<(DevContainerSchema, PathBuf)> for DevContainer {
|
||||
.parent()
|
||||
.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() {
|
||||
return Err(ParseError::ContainerFileNotFound(container_file_path));
|
||||
}
|
||||
|
||||
let build_args = schema
|
||||
.build
|
||||
let build_args = build
|
||||
.args
|
||||
.into_iter()
|
||||
.map(|(k, v)| (k, substitute_local_env(&v)))
|
||||
.collect();
|
||||
|
||||
let container_env = schema
|
||||
.container_env
|
||||
(None, Some(container_file_path), build_args)
|
||||
}
|
||||
(None, None) => return Err(ParseError::MissingImageOrBuild(devcontainer_path)),
|
||||
};
|
||||
|
||||
let container_env = container_env
|
||||
.into_iter()
|
||||
.map(|(k, v)| (k, substitute_local_env(&v)))
|
||||
.collect();
|
||||
|
||||
Ok(Self {
|
||||
image,
|
||||
container_file_path,
|
||||
name: schema.name,
|
||||
name,
|
||||
build_args,
|
||||
container_env,
|
||||
workspace_folder: schema.workspace_folder,
|
||||
post_create_command: schema.post_create_command,
|
||||
post_start_command: schema.post_start_command,
|
||||
remote_user: schema.remote_user,
|
||||
workspace_folder,
|
||||
post_create_command,
|
||||
post_start_command,
|
||||
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
|
||||
/// 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
|
||||
/// `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
|
||||
/// 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.
|
||||
@@ -188,19 +203,76 @@ mod tests {
|
||||
let config = parse(&devcontainer_path).await.unwrap();
|
||||
|
||||
assert_eq!(config.name.as_deref(), Some("test"));
|
||||
assert_eq!(config.container_file_path, dockerfile_path);
|
||||
assert_eq!(config.image, None);
|
||||
assert_eq!(config.container_file_path, Some(dockerfile_path));
|
||||
assert_eq!(config.build_args.get("VERSION").unwrap(), "1");
|
||||
assert_eq!(config.container_env.get("RUST_LOG").unwrap(), "debug");
|
||||
assert_eq!(config.workspace_folder.as_deref(), Some("/workspace"));
|
||||
assert_eq!(config.remote_user.as_deref(), Some("dev"));
|
||||
}
|
||||
|
||||
#[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]
|
||||
fn local_env_references_are_never_read_from_the_environment() {
|
||||
unsafe { std::env::set_var("DEVCONTAINER_TEST_SECRET", "s3cret") };
|
||||
|
||||
// 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!(
|
||||
substitute_local_env("${localEnv:DEVCONTAINER_TEST_SECRET}"),
|
||||
""
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[package]
|
||||
name = "herald-server"
|
||||
name = "steep-server"
|
||||
version = "1.2.0"
|
||||
edition = "2024"
|
||||
|
||||
@@ -47,7 +47,7 @@ pub async fn start(app_state: AppState, shutdown: CancellationToken) -> anyhow::
|
||||
}
|
||||
|
||||
async fn root() -> &'static str {
|
||||
"Hi, i'm Herald :)"
|
||||
"Hi, I'm Steep :)"
|
||||
}
|
||||
|
||||
#[instrument(skip(app_state), fields(webhook_type), err)]
|
||||
File renamed without changes.
File renamed without changes.
+1
-1
@@ -875,7 +875,7 @@ mod tests {
|
||||
#[test]
|
||||
fn generated_lockfiles_are_ignored() {
|
||||
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("web/yarn.lock"));
|
||||
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,
|
||||
gitea_url = %config.gitea_url,
|
||||
bot_name = %gitea_user.login,
|
||||
"Starting Herald"
|
||||
"Starting Steep"
|
||||
);
|
||||
|
||||
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};
|
||||
|
||||
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) {
|
||||
counter!("herald_webhooks_duplicate_total", "event_type" => event_type.to_string())
|
||||
.increment(1);
|
||||
counter!("steep_webhooks_duplicate_total", "event_type" => event_type.to_string()).increment(1);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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() {
|
||||
gauge!("herald_bot_tasks_active").decrement(1.0);
|
||||
gauge!("steep_bot_tasks_active").decrement(1.0);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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) {
|
||||
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() {
|
||||
describe_counter!(
|
||||
"herald_webhooks_received_total",
|
||||
"steep_webhooks_received_total",
|
||||
Unit::Count,
|
||||
"Total webhooks received"
|
||||
);
|
||||
describe_counter!(
|
||||
"herald_webhooks_duplicate_total",
|
||||
"steep_webhooks_duplicate_total",
|
||||
Unit::Count,
|
||||
"Webhooks rejected as duplicates"
|
||||
);
|
||||
describe_counter!(
|
||||
"herald_webhooks_channel_full_total",
|
||||
"steep_webhooks_channel_full_total",
|
||||
Unit::Count,
|
||||
"Webhooks dropped because the bot channel was full"
|
||||
);
|
||||
describe_gauge!(
|
||||
"herald_bot_tasks_active",
|
||||
"steep_bot_tasks_active",
|
||||
Unit::Count,
|
||||
"Bot tasks currently in progress"
|
||||
);
|
||||
describe_counter!(
|
||||
"herald_bot_tasks_completed_total",
|
||||
"steep_bot_tasks_completed_total",
|
||||
Unit::Count,
|
||||
"Bot tasks completed successfully"
|
||||
);
|
||||
describe_counter!(
|
||||
"herald_bot_tasks_failed_total",
|
||||
"steep_bot_tasks_failed_total",
|
||||
Unit::Count,
|
||||
"Bot tasks that failed"
|
||||
);
|
||||
describe_counter!(
|
||||
"herald_openrouter_cost_cents_total",
|
||||
"steep_openrouter_cost_cents_total",
|
||||
Unit::Count,
|
||||
"Total OpenRouter cost in cents (divide by 100 for USD)"
|
||||
);
|
||||
@@ -1,10 +1,10 @@
|
||||
//! 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
|
||||
//! 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.
|
||||
|
||||
use std::time::Duration;
|
||||
@@ -174,7 +174,7 @@ struct ChatRequest<'a> {
|
||||
tool_choice: &'a str,
|
||||
}
|
||||
|
||||
/// OpenRouter reasoning config; Herald always asks for reasoning.
|
||||
/// OpenRouter reasoning config; Steep always asks for reasoning.
|
||||
#[derive(Serialize)]
|
||||
struct Reasoning {
|
||||
enabled: bool,
|
||||
File renamed without changes.
+11
-7
@@ -50,12 +50,16 @@ impl Sandbox {
|
||||
clone_pull_request(clone_url, token, pull_request_number, &repo_dir).await?;
|
||||
make_readable(&repo_dir).await?;
|
||||
|
||||
let devcontainer_path = find_devcontainer(&repo_dir)
|
||||
.with_context(|| format!("no devcontainer found in `{clone_url}`"))?;
|
||||
let devcontainer = match find_devcontainer(&repo_dir) {
|
||||
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 sandbox = Self {
|
||||
@@ -210,10 +214,10 @@ async fn clone_pull_request(
|
||||
|
||||
/// 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
|
||||
/// 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<()> {
|
||||
let output = tokio::process::Command::new("chmod")
|
||||
.args(["-R", "a+rX"])
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
Binary file not shown.
|
After Width: | Height: | Size: 843 KiB |
+1
-1
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
IMAGE="tintounn/herald"
|
||||
IMAGE="tintounn/steep"
|
||||
|
||||
if [ -z "${CI_COMMIT_TAG:-}" ]; then
|
||||
echo "Error: CI_COMMIT_TAG is not set" >&2
|
||||
|
||||
Reference in new issue
Block a user