Watch
0
0
Fork
You've already forked hyperhive
0

add a per-agent nix builds mode, host or none, carried like privateUsers

This commit is contained in:
atlas 2026-11-03 00:30:55 +01:00 • committed by mara
commit 5485151ba6

View file

@ -18,6 +18,7 @@ use std::path::PathBuf;
use std::process::Stdio;
use std::time::Duration;
use hive_types::NixBuilds;
use swarm_wire::builder::{
BuildAgent, BuildFromMeta, FromMetaReply, LockMeta, LockReply, META_REPO_PATH, Reply,
};
@ -93,6 +94,7 @@ impl Builder {
system: self.system.clone(),
cached: false,
private_users: self.private_users(job, token).await,
nix_builds: self.nix_builds(job, token).await,
},
Reply::Failed { error_tail } => FromMetaReply::Failed { error_tail },
},
@ -182,28 +184,61 @@ impl Builder {
)
}
/// Whether the agent's config sets `services.hyperhive.agent.user.privateUsers`,
/// read by `nix eval` of the flake the build just evaluated, in this
/// sandbox. Anything but a clean `true` is false: an agent whose module
/// predates the option, a failed eval and a timeout all keep the agent out
/// of a user namespace.
async fn private_users(&self, job: &BuildFromMeta, token: &str) -> bool {
/// `nix eval --json` of `option` in the agent's evaluated config, run in this
/// sandbox against the flake the build just evaluated. `None` when the eval
/// fails or times out, and logs why along with what the caller falls back to.
async fn eval_option(
&self,
job: &BuildFromMeta,
token: &str,
option: &str,
fallback: &str,
) -> Option<Vec<u8>> {
let mut cmd = self.nix_command(token);
cmd.args(EVAL_FLAGS)
.arg(format!("{}.{PRIVATE_USERS_OPTION}", self.meta_config(job)));
.arg(format!("{}.{option}", self.meta_config(job)));
match run_raw(cmd, self.deadline).await {
Ok((status, out, _)) if status.success() => parse_flag(&out),
Ok((status, out, _)) if status.success() => Some(out),
Ok((status, _, err)) => {
tracing::info!(agent = %job.agent, %status, error = %tail(&String::from_utf8_lossy(&err), status), "privateUsers not read; the agent stays out of a user namespace");
false
tracing::info!(agent = %job.agent, %status, error = %tail(&String::from_utf8_lossy(&err), status), "{option} not read; {fallback}");
None
}
Err(why) => {
tracing::warn!(agent = %job.agent, %why, "privateUsers not read; the agent stays out of a user namespace");
false
tracing::warn!(agent = %job.agent, %why, "{option} not read; {fallback}");
None
}
}
}
/// Whether the agent's config sets `services.hyperhive.agent.user.privateUsers`.
/// Anything but a clean `true` is false: an agent whose module predates the
/// option, a failed eval and a timeout all keep the agent out of a user
/// namespace.
async fn private_users(&self, job: &BuildFromMeta, token: &str) -> bool {
self.eval_option(
job,
token,
PRIVATE_USERS_OPTION,
"the agent stays out of a user namespace",
)
.await
.is_some_and(|out| parse_flag(&out))
}
/// The agent's `services.hyperhive.agent.nixBuilds`. An agent whose module
/// predates the option, a failed eval and a timeout all read as
/// [`NixBuilds::Host`], which is what every agent did before the option.
async fn nix_builds(&self, job: &BuildFromMeta, token: &str) -> NixBuilds {
self.eval_option(
job,
token,
NIX_BUILDS_OPTION,
"the agent builds through the host daemon",
)
.await
.map_or_else(NixBuilds::default, |out| parse_nix_builds(&out))
}
/// The config commit as a flake reference, fetched through the PR's head
/// ref.
fn agent_url(&self, job: &BuildAgent) -> String {
@ -242,11 +277,20 @@ const EVAL_FLAGS: [&str; 7] = [
/// The agent option the controller records, relative to the evaluated config.
const PRIVATE_USERS_OPTION: &str = "services.hyperhive.agent.user.privateUsers";
/// The agent option holding where its nix builds run.
const NIX_BUILDS_OPTION: &str = "services.hyperhive.agent.nixBuilds";
/// `nix eval --json` of a bool: true only for exactly `true`.
fn parse_flag(out: &[u8]) -> bool {
out.trim_ascii() == b"true"
}
/// `nix eval --json` of a [`NixBuilds`]: the spelling, or the default when the
/// output is not a string.
fn parse_nix_builds(out: &[u8]) -> NixBuilds {
serde_json::from_slice(out).unwrap_or_default()
}
/// `nix flake lock` of the flake in the working directory. The flake's config
/// is not trusted, and nothing is built.
const LOCK_ARGS: &[&str] = &["flake", "lock", "--option", "accept-flake-config", "false"];
@ -560,6 +604,37 @@ mod tests {
}
}
/// The reply carries the option's spelling. `host` is the control for
/// `none`; a mode this build does not know reads as `none`, and anything
/// that is no spelling at all (a failed eval, an older module) as `host`.
#[tokio::test]
async fn a_meta_build_reports_the_agents_nix_builds_option() {
for (i, (answer, want)) in [
("echo '\"host\"'", NixBuilds::Host),
("echo '\"none\"'", NixBuilds::Disabled),
("echo '\"container\"'", NixBuilds::Disabled),
("echo null", NixBuilds::Host),
("echo '\"none\"'; exit 1", NixBuilds::Host),
("echo error: attribute missing >&2; exit 1", NixBuilds::Host),
]
.into_iter()
.enumerate()
{
let nix = fake_nix(
&format!("nix-builds-{i}"),
&format!(
"for a in \"$@\"; do last=$a; done; case \"$last\" in *nixBuilds) {answer};; *) if [ \"$1\" = eval ]; then echo false; else echo /nix/store/x; fi;; esac\n"
),
);
let b = Builder { nix, ..builder() };
let reply = b.build_from_meta(&meta_job(), "t").await;
assert!(
matches!(&reply, FromMetaReply::Built { nix_builds, .. } if *nix_builds == want),
"{answer}: {reply:?}"
);
}
}
#[test]
fn the_option_is_read_from_the_evaluated_config_not_the_toplevel() {
assert_eq!(
@ -610,6 +685,7 @@ mod tests {
system: "x86_64-linux".to_owned(),
cached: false,
private_users: false,
nix_builds: NixBuilds::Host,
}
);
}