Watch
0
0
Fork
You've already forked hyperhive
0

swarm-builder: build each agent config PR head and post a commit status

A new `swarm-builder` service, in its own container on the host that sets
`deploy.builder.enable` (defaulted on by `allSwarmServices` where the host
has the builder's store leaf, like the review classifier).

When an `agent-configs/<agent>` PR is opened, reopened or synchronized,
swarm-controller queues a `build_agent` jobq node. The node sets a
`swarm-builder` commit status to pending on the PR head, sends
`{agent, rev, pr}` to the builder over NATS request-reply
(`$SWARM.builder.build`), and writes success (store path), failure (last
line nix printed) or error (no builder / no reply) on the head. The node
fails when the build does, so the Jobs page matches the PR.

The builder builds nix/builder/check-flake's `check` with every input
overridden: hyperhive and nixpkgs to the host's own
`c0re.hyperhiveFlake`/`c0re.nixpkgsFlake` (what a hive there renders into
its meta flake), and the agent to the PR head on the forge. Evaluation is
pure and runs in the container; builds go to the host nix daemon as an
untrusted client. The private config repo is fetched with the token of a
new read-only `builder` forge account (in the `hives` team), which the
controller mints into `swarm/services/builder/forge/token`; git gets it
from a credential helper reading the child's environment, so it is in no
argument or URL.

Credentials: a `builder` store leaf minted by glue-bao-tls on the store
host, a `swarm-builder` reader policy (forge token + its own OIDC client
secret), an authelia machine client, and a `--builder-client` grant in the
queue's auth-callout responder. The controller gains a grant on the token
path and the secret publisher a deny on it.

This is the agent-config build check atlas scoped on #5081 (slice 3):
nothing is pushed to a cache, nothing is written to KV, and a red status
gates no deploy. The check composes agent-base + the config without the
values a hive adds (CA, docs, claude-code path, ports, input follows), so
it can disagree with a hive's own build.
This commit is contained in:
atlas 2026-10-12 22:04:51 +02:00
commit b68b74d687
43 changed files with 2452 additions and 42 deletions

View file

@ -162,6 +162,11 @@ struct Args {
#[arg(long = "review-classifier-client")]
review_classifier_clients: Vec<String>,
/// Client ids that answer the swarm builder's build requests.
/// Repeatable, empty by default, which denies such a client.
#[arg(long = "builder-client")]
builder_clients: Vec<String>,
/// Role on the secret store's `cert` auth mount this responder logs in
/// as to read the callout keys, its own OIDC client secret and agent
/// credentials. The store is found
@ -226,7 +231,8 @@ async fn serve(args: &Args) -> anyhow::Result<()> {
args.hive_publish_subjects.clone(),
)?
.with_agent_token_subjects(args.agent_token_publish_subjects.clone())?
.with_review_classifiers(args.review_classifier_clients.clone());
.with_review_classifiers(args.review_classifier_clients.clone())
.with_builders(args.builder_clients.clone());
let http = reqwest::Client::new();
let settings = swarm_secret_client::client::Settings::from_env()
.context("reading the secret store's address and this responder's identity")?;

View file

@ -50,6 +50,7 @@ pub struct Policy {
extra_hive_subjects: Vec<String>,
agent_token_subjects: Vec<String>,
review_classifiers: Vec<String>,
builders: Vec<String>,
}
/// Placeholder replaced with the hive's own name in `extra_hive_subjects`.
@ -120,6 +121,7 @@ impl Policy {
extra_hive_subjects,
agent_token_subjects: Vec::new(),
review_classifiers: Vec::new(),
builders: Vec::new(),
})
}
@ -148,6 +150,13 @@ impl Policy {
self
}
/// Set the client ids that answer the swarm builder's build requests.
#[must_use]
pub fn with_builders(mut self, clients: Vec<String>) -> Self {
self.builders = clients;
self
}
/// The permissions for an agent whose own credential was verified, or
/// `None` when none are configured.
///
@ -209,6 +218,9 @@ impl Policy {
if self.review_classifiers.iter().any(|c| c == client_id) {
return Some(Self::review_classifier_permissions(client_id));
}
if self.builders.iter().any(|c| c == client_id) {
return Some(Self::builder_permissions(client_id));
}
None
}
@ -229,6 +241,20 @@ impl Policy {
}
}
/// What the builder may do: hear build requests and answer each on its
/// reply subject, for as long as a build may take. Its publish list is
/// its own inbox, for [`Self::review_classifier_permissions`]' reason.
fn builder_permissions(client_id: &str) -> Permissions {
Permissions {
publish: vec![Self::inbox(client_id)],
subscribe: Some(vec![
swarm_queue_client::builder::SUBJECT.to_owned(),
Self::inbox(client_id),
]),
responses: Some(swarm_queue_client::builder::REPLY_DEADLINE),
}
}
/// What a hive may subscribe to: the two events the controller sends it,
/// and its own reply inbox.
///
@ -562,6 +588,8 @@ impl Policy {
// A review classifier try-out, sent as a request: the reply comes
// back on this client's inbox, which it already subscribes to.
swarm_queue_client::review_classifier::SUBJECT.to_owned(),
// A build of an agent config, sent as a request the same way.
swarm_queue_client::builder::SUBJECT.to_owned(),
// The wanted-state buckets — one per hive, all created and written
// by this single client.
//
@ -1261,6 +1289,60 @@ mod tests {
}
}
const BUILDER: &str = "swarm-builder";
#[test]
fn the_builder_hears_its_subject_and_inbox_only_and_answers_by_reply() {
use swarm_queue_client::builder::{REPLY_DEADLINE, SUBJECT};
use swarm_queue_client::client_inbox_prefix;
let p = policy()
.with_builders(vec![BUILDER.to_owned()])
.permissions(BUILDER)
.expect("a configured builder is admitted");
let inbox = client_inbox_prefix(BUILDER);
assert!(may_subscribe(&p, SUBJECT));
assert!(may_subscribe(&p, &reply(&inbox)));
for refused in [
">".to_owned(),
"$SWARM.>".to_owned(),
swarm_queue_client::review_classifier::SUBJECT.to_owned(),
reply(&client_inbox_prefix("swarm-controller")),
] {
assert!(
!may_subscribe(&p, &refused),
"the builder may subscribe to {refused}"
);
}
assert_eq!(p.publish, vec![format!("{inbox}.>")]);
assert_eq!(p.responses, Some(REPLY_DEADLINE));
// The builder's id is nothing until it is configured.
assert_eq!(policy().permissions(BUILDER), None);
}
#[test]
fn only_the_reader_may_send_a_build() {
use swarm_queue_client::builder::SUBJECT;
let p = with_classifier().with_builders(vec![BUILDER.to_owned()]);
let reader = p.permissions("swarm-controller").expect("the reader");
assert!(reader.publish.iter().any(|g| covers(g, SUBJECT)));
for (who, grant) in [
("a hive", p.permissions("hive-alpha").expect("a hive")),
("the builder", p.permissions(BUILDER).expect("admitted")),
(
"the classifier",
p.permissions(CLASSIFIER).expect("admitted"),
),
] {
assert!(
!grant.publish.iter().any(|g| covers(g, SUBJECT)),
"{who} may send a build: {:?}",
grant.publish
);
}
}
#[test]
fn the_prefix_alone_names_no_hive() {
// `hive-` would otherwise produce `$KV.hive-status.`, a subject nobody