archetypes: prefill the create form from flake-declared agent archetypes
A flake declares agent archetypes in its `hyperhiveArchetypes` output,
`<name> = { module; meta; }`. The hyperhive flake ships `reviewer`
(default permissions without `execution`).
swarm-builder answers `$SWARM.builder.archetypes` with the `meta` of
every archetype its own hyperhive source declares. swarm-controller asks
once the queue connects and every 10 minutes, checks each archetype as
`POST /api/agents` checks its settings, and stores the result in the
`agent-archetypes` KV bucket when it changed. `GET /api/archetypes`
serves it, and the create form's archetype select prefills permissions,
resource caps and inference from it.
The agent created is an ordinary agent with its own config repo; the
archetype's module is not applied yet.
Refs #5386
This commit is contained in:
parent
eb92e83c95
commit
51035eac1d
22 changed files with 1244 additions and 30 deletions
|
|
@ -31,3 +31,11 @@ read-only forge token at every build.
|
|||
environment, never in a URL or an argument, and the helper answers only an
|
||||
`https` request for the forge's host. Its store role reads exactly
|
||||
those two paths.
|
||||
|
||||
## Archetypes
|
||||
|
||||
The service also answers archetype evaluations: `nix eval` of the `meta` of
|
||||
every `hyperhiveArchetypes` entry in this host's hyperhive source, with
|
||||
nixpkgs pointed at this host's. It fetches nothing and uses no token, and it
|
||||
doesn't wait for a running build. See
|
||||
[`docs/swarm/archetypes.md`](../docs/swarm/archetypes.md).
|
||||
|
|
|
|||
189
swarm-builder/src/archetypes.rs
Normal file
189
swarm-builder/src/archetypes.rs
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
//! One archetype evaluation: `nix eval` of the `meta` of every entry in this
|
||||
//! host's hyperhive source's `hyperhiveArchetypes` output, as JSON.
|
||||
//!
|
||||
//! Only `meta` is evaluated, never `module`: `meta` is plain data, so the
|
||||
//! evaluation builds nothing. Both inputs are store paths, so it fetches
|
||||
//! nothing and needs no forge token.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::process::Stdio;
|
||||
use std::time::Duration;
|
||||
|
||||
use swarm_queue_client::builder::ArchetypesReply;
|
||||
|
||||
/// What every evaluation is run with, fixed at start.
|
||||
pub struct Evaluator {
|
||||
/// The `nix` binary.
|
||||
pub nix: PathBuf,
|
||||
/// The hyperhive source this host was deployed from.
|
||||
pub hyperhive: String,
|
||||
/// The nixpkgs source this host was deployed from, which the hyperhive
|
||||
/// source's own `nixpkgs` input is pointed at, as a hive's meta flake does.
|
||||
pub nixpkgs: String,
|
||||
/// How long one evaluation may run.
|
||||
pub deadline: Duration,
|
||||
}
|
||||
|
||||
impl Evaluator {
|
||||
/// Evaluate the archetypes' `meta`.
|
||||
pub async fn eval(&self) -> ArchetypesReply {
|
||||
let mut cmd = tokio::process::Command::new(&self.nix);
|
||||
cmd.args(self.args())
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true);
|
||||
run(cmd, &self.hyperhive, self.deadline).await
|
||||
}
|
||||
|
||||
/// The `nix eval` arguments.
|
||||
fn args(&self) -> Vec<String> {
|
||||
[
|
||||
"eval",
|
||||
"--json",
|
||||
"--no-write-lock-file",
|
||||
"--option",
|
||||
"accept-flake-config",
|
||||
"false",
|
||||
]
|
||||
.into_iter()
|
||||
.map(str::to_owned)
|
||||
.chain([
|
||||
format!("{}#hyperhiveArchetypes", self.hyperhive),
|
||||
"--override-input".to_owned(),
|
||||
"nixpkgs".to_owned(),
|
||||
self.nixpkgs.clone(),
|
||||
"--apply".to_owned(),
|
||||
"builtins.mapAttrs (_: a: a.meta)".to_owned(),
|
||||
])
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `cmd` to its end or `deadline`, and read the JSON object it printed or
|
||||
/// the tail of what it said.
|
||||
async fn run(
|
||||
mut cmd: tokio::process::Command,
|
||||
source: &str,
|
||||
deadline: Duration,
|
||||
) -> ArchetypesReply {
|
||||
let failed = |error_tail: String| ArchetypesReply::Failed { error_tail };
|
||||
let output = match tokio::time::timeout(deadline, cmd.output()).await {
|
||||
Err(_) => {
|
||||
return failed(format!(
|
||||
"the evaluation did not finish within {} seconds",
|
||||
deadline.as_secs()
|
||||
));
|
||||
}
|
||||
Ok(Err(e)) => return failed(format!("starting nix: {e}")),
|
||||
Ok(Ok(output)) => output,
|
||||
};
|
||||
if !output.status.success() {
|
||||
return failed(crate::nix_build::tail(
|
||||
&String::from_utf8_lossy(&output.stderr),
|
||||
output.status,
|
||||
));
|
||||
}
|
||||
match serde_json::from_slice::<serde_json::Value>(&output.stdout) {
|
||||
Ok(serde_json::Value::Object(archetypes)) => ArchetypesReply::Evaluated {
|
||||
source: source.to_owned(),
|
||||
archetypes,
|
||||
},
|
||||
Ok(other) => failed(format!(
|
||||
"hyperhiveArchetypes is not an attribute set of archetypes: {other}"
|
||||
)),
|
||||
Err(e) => failed(format!("nix printed no JSON: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn evaluator() -> Evaluator {
|
||||
Evaluator {
|
||||
nix: PathBuf::from("nix"),
|
||||
hyperhive: "/nix/store/h-source".to_owned(),
|
||||
nixpkgs: "/nix/store/n-source".to_owned(),
|
||||
deadline: swarm_queue_client::builder::ARCHETYPES_DEADLINE,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_meta_is_evaluated_on_this_hosts_sources() {
|
||||
let args = evaluator().args();
|
||||
assert!(
|
||||
args.iter()
|
||||
.any(|a| a == "/nix/store/h-source#hyperhiveArchetypes")
|
||||
);
|
||||
let overridden: Vec<&str> = args
|
||||
.windows(2)
|
||||
.filter(|w| w[0] == "--override-input")
|
||||
.map(|w| w[1].as_str())
|
||||
.collect();
|
||||
assert_eq!(overridden, ["nixpkgs"]);
|
||||
let apply = args
|
||||
.windows(2)
|
||||
.find(|w| w[0] == "--apply")
|
||||
.map(|w| w[1].as_str());
|
||||
assert_eq!(apply, Some("builtins.mapAttrs (_: a: a.meta)"));
|
||||
assert!(args.iter().any(|a| a == "--no-write-lock-file"));
|
||||
}
|
||||
|
||||
fn sh(script: &str, deadline: Duration) -> impl Future<Output = ArchetypesReply> {
|
||||
let mut cmd = tokio::process::Command::new("sh");
|
||||
cmd.args(["-c", script])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true);
|
||||
run(cmd, "/nix/store/h-source", deadline)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_evaluation_replies_with_each_archetypes_meta() {
|
||||
let reply = sh(
|
||||
r#"echo '{"reviewer":{"description":"reads"}}'"#,
|
||||
Duration::from_secs(30),
|
||||
)
|
||||
.await;
|
||||
let ArchetypesReply::Evaluated { source, archetypes } = reply else {
|
||||
panic!("{reply:?}")
|
||||
};
|
||||
assert_eq!(source, "/nix/store/h-source");
|
||||
assert_eq!(archetypes["reviewer"]["description"], "reads");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_failure_replies_with_what_nix_said_last() {
|
||||
let reply = sh(
|
||||
"echo 'error: attribute hyperhiveArchetypes missing' >&2; exit 1",
|
||||
Duration::from_secs(30),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
matches!(&reply, ArchetypesReply::Failed { error_tail } if error_tail.ends_with("missing")),
|
||||
"{reply:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn output_that_is_not_an_attribute_set_is_refused() {
|
||||
for script in ["echo '[1]'", "echo 'not json'"] {
|
||||
let reply = sh(script, Duration::from_secs(30)).await;
|
||||
assert!(
|
||||
matches!(reply, ArchetypesReply::Failed { .. }),
|
||||
"{script}: {reply:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_evaluation_past_its_deadline_is_stopped() {
|
||||
let reply = sh("sleep 30", Duration::from_millis(200)).await;
|
||||
assert!(
|
||||
matches!(&reply, ArchetypesReply::Failed { error_tail } if error_tail.contains("did not finish")),
|
||||
"{reply:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -4,7 +4,9 @@
|
|||
//! from swarm-controller on [`swarm_queue_client::builder::SUBJECT`], builds
|
||||
//! each agent config at its commit, answers on the request's reply subject,
|
||||
//! and logs one line per build. It writes nothing to the forge: the
|
||||
//! controller turns the reply into a commit status.
|
||||
//! controller turns the reply into a commit status. It also answers
|
||||
//! [`swarm_queue_client::builder::ARCHETYPES_SUBJECT`] with the agent
|
||||
//! archetypes its hyperhive source declares, which the controller stores.
|
||||
//!
|
||||
//! The store identity comes from the `BAO_*` environment. The queue client
|
||||
//! secret is read at start and again when authelia refuses it, the forge token
|
||||
|
|
@ -15,11 +17,15 @@ use std::sync::Arc;
|
|||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use futures_util::StreamExt as _;
|
||||
use swarm_queue_client::builder::{BUILD_DEADLINE, BuildAgent, QUEUE_GROUP, Reply, SUBJECT};
|
||||
use swarm_queue_client::builder::{
|
||||
ARCHETYPES_DEADLINE, ARCHETYPES_SUBJECT, BUILD_DEADLINE, BuildAgent, QUEUE_GROUP, Reply,
|
||||
SUBJECT,
|
||||
};
|
||||
use swarm_queue_client::stored_secret::StoredSecret;
|
||||
use swarm_queue_client::{ClientSecret, QueueConfig};
|
||||
use swarm_secret_client::forge::Credential;
|
||||
|
||||
mod archetypes;
|
||||
mod nix_build;
|
||||
|
||||
/// The cert-auth role this service logs in to the store as. `swarm-bao.nix`
|
||||
|
|
@ -52,6 +58,12 @@ async fn main() -> Result<()> {
|
|||
client_secret(&client_id)
|
||||
})
|
||||
.await?;
|
||||
let evaluator = archetypes::Evaluator {
|
||||
nix: builder.nix.clone(),
|
||||
hyperhive: builder.hyperhive.clone(),
|
||||
nixpkgs: builder.nixpkgs.clone(),
|
||||
deadline: ARCHETYPES_DEADLINE,
|
||||
};
|
||||
let reread_id = client_id.clone();
|
||||
let secret = StoredSecret::new(client_id.clone(), secret, move || {
|
||||
let client_id = reread_id.clone();
|
||||
|
|
@ -72,6 +84,12 @@ async fn main() -> Result<()> {
|
|||
.await
|
||||
.with_context(|| format!("subscribing to {SUBJECT}"))?;
|
||||
tracing::info!(subject = SUBJECT, "builder: awaiting builds");
|
||||
let evaluations = queue
|
||||
.queue_subscribe(ARCHETYPES_SUBJECT, QUEUE_GROUP.to_owned())
|
||||
.await
|
||||
.with_context(|| format!("subscribing to {ARCHETYPES_SUBJECT}"))?;
|
||||
// Outside the build slot below: a list refresh does not wait on a build.
|
||||
tokio::spawn(answer_evaluations(evaluations, queue.clone(), evaluator));
|
||||
|
||||
// One build at a time. swarm-controller already sends one at a time; this
|
||||
// holds the bound for any request it did not serialise.
|
||||
|
|
@ -103,6 +121,39 @@ async fn main() -> Result<()> {
|
|||
bail!("the subscription to {SUBJECT} ended")
|
||||
}
|
||||
|
||||
/// Answer every archetype evaluation request, one at a time, with the
|
||||
/// evaluation's result.
|
||||
async fn answer_evaluations(
|
||||
mut requests: async_nats::Subscriber,
|
||||
queue: async_nats::Client,
|
||||
evaluator: archetypes::Evaluator,
|
||||
) {
|
||||
while let Some(msg) = requests.next().await {
|
||||
let Some(reply_to) = msg.reply.clone() else {
|
||||
tracing::warn!("an archetype evaluation arrived with no reply subject; dropping it");
|
||||
continue;
|
||||
};
|
||||
let reply = evaluator.eval().await;
|
||||
match &reply {
|
||||
swarm_queue_client::builder::ArchetypesReply::Evaluated { archetypes, .. } => {
|
||||
tracing::info!(count = archetypes.len(), "archetypes evaluated");
|
||||
}
|
||||
swarm_queue_client::builder::ArchetypesReply::Failed { error_tail } => {
|
||||
tracing::warn!(%error_tail, "archetype evaluation failed");
|
||||
}
|
||||
}
|
||||
match serde_json::to_vec(&reply) {
|
||||
Ok(body) => {
|
||||
if let Err(e) = queue.publish(reply_to, body.into()).await {
|
||||
tracing::warn!(error = %e, "publishing an evaluation's reply failed");
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::warn!(error = %e, "encoding an evaluation's reply failed"),
|
||||
}
|
||||
}
|
||||
tracing::error!("the subscription to {ARCHETYPES_SUBJECT} ended");
|
||||
}
|
||||
|
||||
/// Decode one request, build it and log the build.
|
||||
async fn handle(payload: &[u8], builder: &nix_build::Builder) -> Reply {
|
||||
let job: BuildAgent = match serde_json::from_slice(payload) {
|
||||
|
|
|
|||
|
|
@ -271,7 +271,7 @@ async fn read_tail(
|
|||
|
||||
/// The last [`TAIL_LINES`] lines of `stderr`, at most [`TAIL_BYTES`] bytes,
|
||||
/// or the exit status when it said nothing.
|
||||
fn tail(stderr: &str, status: std::process::ExitStatus) -> String {
|
||||
pub(crate) fn tail(stderr: &str, status: std::process::ExitStatus) -> String {
|
||||
let lines: Vec<&str> = stderr.lines().filter(|l| !l.trim().is_empty()).collect();
|
||||
if lines.is_empty() {
|
||||
return format!("nix exited with {status} and printed nothing");
|
||||
|
|
|
|||
Loading…
Reference in a new issue