The module is a rename (97% similar); everything else in the diff follows from the move: - stored_secret.rs: private `chain()` helper, the 8-line copy of the queue crate's `chain` the module called as `crate::chain`; the queue crate's public `chain` stays. - stored_secret.rs: `Duration::from_mins(5)` in `MAX_REREAD_DELAY` is unchanged moved code, not a new use. - swarm-secret-client Cargo.toml: gains futures-util, tokio and tracing as dependencies and tokio `test-util` plus tracing-subscriber as dev dependencies; the module and its tests use them. - swarm-queue-client Cargo.toml: drops futures-util and the tracing-subscriber dev dependency, which only the moved module used; its tokio dev comment is reworded to the tests that remain. - Cargo.lock: the same dependency moves, nothing else. - swarm-queue-client lib.rs: imports `swarm_secret_client::stored_secret`, and its tests use a small local `Store` in place of the moved module's test-only `Fake`, which is no longer reachable. - callers (hive-c0re, swarm-builder, swarm-ci-metrics, swarm-claude-login, swarm-controller, swarm-nats-auth, swarm-review-classifier): import path only; no re-export is left behind.
176 lines
6.2 KiB
Rust
176 lines
6.2 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 at start and again when authelia refuses it, the run's key
|
|
//! once per run, both from the store under that identity.
|
|
|
|
use std::path::PathBuf;
|
|
|
|
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};
|
|
use swarm_secret_client::stored_secret::StoredSecret;
|
|
|
|
mod acp;
|
|
mod aggregate;
|
|
mod answer;
|
|
mod child_env;
|
|
mod cost;
|
|
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";
|
|
|
|
fn main() -> Result<()> {
|
|
// Before anything else: as the trampoline, this process becomes the
|
|
// model child.
|
|
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_secret_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 trampoline =
|
|
std::env::current_exe().context("finding this binary for the child trampoline")?;
|
|
let models = run::Runtimes {
|
|
claude: run::Claude {
|
|
program: PathBuf::from(var("CLAUDE")?),
|
|
trampoline: trampoline.clone(),
|
|
},
|
|
acp: acp::Acp {
|
|
program: PathBuf::from(var("OPENCODE")?),
|
|
trampoline,
|
|
cancel_wait: acp::CANCEL_WAIT,
|
|
cost: cost::Otel,
|
|
cwd: std::env::temp_dir().join("review-classifier-acp-cwd"),
|
|
},
|
|
};
|
|
let secret =
|
|
swarm_queue_client::read_with_startup_retry("reading the queue client secret", || {
|
|
client_secret(&client_id)
|
|
})
|
|
.await?;
|
|
let reread_id = client_id.clone();
|
|
let secret = StoredSecret::new(client_id.clone(), secret, move || {
|
|
let client_id = reread_id.clone();
|
|
async move { client_secret(&client_id).await.map_err(Into::into) }
|
|
});
|
|
let queue = swarm_queue_client::connect(QueueConfig {
|
|
url: var("NATS_URL")?,
|
|
token_endpoint: var("OIDC_TOKEN_ENDPOINT")?,
|
|
client_id,
|
|
client_secret: ClientSecret::Stored(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 models = std::sync::Arc::new(models);
|
|
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 models = std::sync::Arc::clone(&models);
|
|
tokio::spawn(async move {
|
|
let reply = handle(&msg.payload, &run::Store, &*models).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"
|
|
),
|
|
}
|
|
}
|
|
|
|
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, in authelia's container, has not \
|
|
published this service's client secret"
|
|
),
|
|
}
|
|
}
|