Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/swarm-review-classifier/src/main.rs
atlas af1495731e swarm-review-classifier: claude's environment is an allowlist, not the daemon's
hive-claude adds its variables on top of the parent's environment, so
claude inherited the daemon's store identity paths, CREDENTIALS_DIRECTORY
and queue coordinates. claude now starts as this same binary with
SWARM_REVIEW_CLASSIFIER_EXEC_CLAUDE naming the real one; main execs it
before the runtime starts, with the environment cleared down to the API
key, PATH, HOME, CLAUDE_CONFIG_DIR and claude's telemetry settings. The
exec keeps the pid, so hive-claude's cancel and idle timeout still reach
it. An integration test runs the built binary as the trampoline and
checks that a BAO_* variable does not reach the program it execs.
2026-10-12 13:11:21 +02:00

173 lines
6.3 KiB
Rust

//! The review classifier's try-out service.
//!
//! Connects to the swarm queue as its own OIDC client, takes try-out requests
//! from swarm-controller on
//! [`swarm_queue_client::review_classifier::SUBJECT`], answers each one on its
//! reply subject, and logs one line per run. It holds no forge credential:
//! the controller reads the PR and sends it along.
//!
//! The store identity comes from the `BAO_*` environment. The queue client
//! secret is read once at start and the API key once per run, both from the
//! store under that identity.
use std::path::PathBuf;
use std::time::Duration;
use anyhow::{Context, Result, bail};
use futures_util::StreamExt as _;
use swarm_queue_client::review_classifier::{QUEUE_GROUP, Reply, SUBJECT, TryOut};
use swarm_queue_client::{ClientSecret, QueueConfig};
mod aggregate;
mod answer;
mod child_env;
mod prompt;
mod run;
/// The cert-auth role this service logs in to the store as. `swarm-bao.nix`
/// writes it, matching the leaf's subject.
const CERT_ROLE: &str = "swarm-review-classifier";
/// The environment prefix `swarm-review-classifier.nix` sets.
const PREFIX: &str = "SWARM_REVIEW_CLASSIFIER";
/// Seconds to wait after each failed read of the queue client secret. A
/// longer outage fails the start, and the unit's `Restart=` tries again.
const RETRY_DELAYS_S: [u64; 6] = [1, 2, 4, 8, 16, 30];
fn main() -> Result<()> {
// Before anything else: as the trampoline, this process becomes `claude`.
child_env::exec_if_requested();
tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.context("starting the async runtime")?
.block_on(serve())
}
async fn serve() -> Result<()> {
hive_log::init();
swarm_queue_client::install_crypto_provider();
let var = |name: &str| {
let full = format!("{PREFIX}_{name}");
std::env::var(&full).with_context(|| format!("{full} is unset"))
};
let client_id = var("OIDC_CLIENT_ID")?;
let claude = run::Claude {
program: PathBuf::from(var("CLAUDE")?),
trampoline: std::env::current_exe()
.context("finding this binary for the claude trampoline")?,
};
let secret = client_secret_with_retry(&client_id).await?;
let queue = swarm_queue_client::connect(QueueConfig {
url: var("NATS_URL")?,
token_endpoint: var("OIDC_TOKEN_ENDPOINT")?,
client_id,
client_secret: ClientSecret::Value(secret),
ca_file: std::env::var(format!("{PREFIX}_OIDC_CA_FILE"))
.ok()
.map(PathBuf::from),
})
.await?;
let mut requests = queue
.queue_subscribe(SUBJECT, QUEUE_GROUP.to_owned())
.await
.with_context(|| format!("subscribing to {SUBJECT}"))?;
tracing::info!(subject = SUBJECT, "review classifier: awaiting try-outs");
let claude = std::sync::Arc::new(claude);
while let Some(msg) = requests.next().await {
let Some(reply_to) = msg.reply.clone() else {
tracing::warn!("a try-out arrived with no reply subject; dropping it");
continue;
};
let queue = queue.clone();
let claude = std::sync::Arc::clone(&claude);
tokio::spawn(async move {
let reply = handle(&msg.payload, &run::Store, &*claude).await;
match serde_json::to_vec(&reply) {
Ok(body) => {
if let Err(e) = queue.publish(reply_to, body.into()).await {
tracing::warn!(error = %e, "publishing a try-out's reply failed");
}
}
Err(e) => tracing::warn!(error = %e, "encoding a try-out's reply failed"),
}
});
}
bail!("the subscription to {SUBJECT} ended")
}
/// Decode one request, answer it and log the run.
async fn handle(payload: &[u8], keys: &impl run::KeySource, model: &impl run::Model) -> Reply {
let run: TryOut = match serde_json::from_slice(payload) {
Ok(run) => run,
Err(e) => {
tracing::warn!(error = %e, "an undecodable try-out; answering with the error");
return Reply::Failed {
error: format!("the classifier could not decode the request: {e}"),
};
}
};
let reply = run::answer(keys, model, &run).await;
log_run(&run, &reply);
reply
}
/// The one journal line a run leaves.
fn log_run(run: &TryOut, reply: &Reply) {
match reply {
Reply::Done { rows, overall } => tracing::info!(
operator = %run.operator,
repo = %run.repo,
pr = run.pr,
head_sha = %run.head_sha,
rows = %serde_json::to_string(rows).unwrap_or_default(),
overall = ?overall,
"review classification run"
),
Reply::Failed { error } => tracing::warn!(
operator = %run.operator,
repo = %run.repo,
pr = run.pr,
head_sha = %run.head_sha,
%error,
"review classification run failed"
),
}
}
/// The queue client secret, read from the store, retried through a store that
/// is restarting alongside this service.
async fn client_secret_with_retry(client_id: &str) -> Result<String> {
for delay in RETRY_DELAYS_S.map(Duration::from_secs) {
match client_secret(client_id).await {
Ok(secret) => return Ok(secret),
Err(e) => {
tracing::warn!(error = %format!("{e:#}"), retry_in = ?delay, "reading the queue client secret failed");
tokio::time::sleep(delay).await;
}
}
}
client_secret(client_id).await
}
async fn client_secret(client_id: &str) -> Result<String> {
use swarm_secret_client::review_classifier::{ClientSecret, client_secret_path};
let path = client_secret_path(client_id)?;
let store = swarm_secret_client::SecretStore::from_env(CERT_ROLE)
.await
.context("logging in to the secret store")?;
let stored: Option<ClientSecret> = store
.read_optional(&path)
.await
.with_context(|| format!("reading {path}"))?;
match stored {
Some(s) if !s.value.trim().is_empty() => Ok(s.value.trim().to_owned()),
_ => bail!(
"nothing at {path}: swarm-secret-publish, on the host that runs authelia, has not \
published this service's client secret"
),
}
}