Watch
0
0
Fork
You've already forked hyperhive
0

swarm-builder: an agent option whose eval failed is left out of the reply, not read as its default

This commit is contained in:
atlas 2026-11-06 10:03:18 +01:00 • committed by mara
commit 5d2b0c9a54
3 changed files with 340 additions and 158 deletions

View file

@ -862,8 +862,8 @@ mod tests {
out_path: "/nix/store/x".to_owned(),
system: "s".to_owned(),
cached: false,
private_users: false,
nix_builds: hive_types::NixBuilds::Host,
private_users: Some(false),
nix_builds: Some(hive_types::NixBuilds::Host),
}
},
)
@ -885,8 +885,8 @@ mod tests {
out_path: "/nix/store/x".to_owned(),
system: "s".to_owned(),
cached: false,
private_users: false,
nix_builds: hive_types::NixBuilds::Host,
private_users: Some(false),
nix_builds: Some(hive_types::NixBuilds::Host),
}
},
)
@ -1454,7 +1454,9 @@ mod tests {
#[tokio::test]
async fn a_meta_build_is_answered_with_the_path_the_system_and_the_private_users_flag() {
let fake = meta_nix("echo true");
let fake = meta_nix(
"for a in \"$@\"; do last=$a; done; case \"$last\" in *nixBuilds) echo '\"host\"';; *) echo true;; esac",
);
let (env, mut sent) = env();
let request = msg(Some("_INBOX.5"), &meta_json("atlas", REV));
serve_meta(fake.builder(), env, vec![request]).await;
@ -1490,12 +1492,17 @@ mod tests {
);
}
/// An option the module does not declare is false; one whose eval failed, or
/// printed no bool, is left out of the reply, for the controller to record as
/// unknown. `echo false` is the control that the field is read at all.
#[tokio::test]
async fn private_users_is_false_unless_nix_eval_printed_true() {
for eval_says in [
"echo false",
"echo null",
"echo 'error: no such option' >&2; exit 1",
async fn private_users_is_left_out_unless_nix_eval_printed_a_bool_or_the_option_is_missing() {
let missing = "echo \"error: flake 'f' does not provide attribute 'a'\" >&2; exit 1";
for (eval_says, want) in [
("echo false", Some(false)),
(missing, Some(false)),
("echo null", None),
("echo 'error: no such option' >&2; exit 1", None),
] {
let fake = meta_nix(eval_says);
let (env, mut sent) = env();
@ -1503,7 +1510,12 @@ mod tests {
serve_meta(fake.builder(), env, vec![request]).await;
let (_, body) = next(&mut sent).await;
assert_eq!(body["state"], "built", "{eval_says}");
assert_eq!(body["private_users"], false, "{eval_says}");
assert_eq!(
body.get("private_users")
.and_then(serde_json::Value::as_bool),
want,
"{eval_says}: {body}"
);
}
}

View file

@ -186,58 +186,50 @@ impl Builder {
}
/// `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>> {
/// sandbox against the flake the build just evaluated. Logs why when the
/// answer is not a value.
async fn eval_option(&self, job: &BuildFromMeta, token: &str, option: &str) -> Eval {
let mut cmd = self.nix_command(token);
cmd.args(EVAL_FLAGS)
.arg(format!("{}.{option}", self.meta_config(job)));
match run_raw(cmd, self.deadline).await {
Ok((status, out, _)) if status.success() => Some(out),
Ok((status, out, _)) if status.success() => Eval::Value(out),
Ok((_, _, err)) if attribute_missing(&String::from_utf8_lossy(&err)) => {
tracing::info!(agent = %job.agent, "{option} is not declared by the agent's module; its default applies");
Eval::Missing
}
Ok((status, _, err)) => {
tracing::info!(agent = %job.agent, %status, error = %tail(&String::from_utf8_lossy(&err), status), "{option} not read; {fallback}");
None
tracing::info!(agent = %job.agent, %status, error = %tail(&String::from_utf8_lossy(&err), status), "{option} not read; the reply carries no value for it");
Eval::Unknown
}
Err(why) => {
tracing::warn!(agent = %job.agent, %why, "{option} not read; {fallback}");
None
tracing::warn!(agent = %job.agent, %why, "{option} not read; the reply carries no value for it");
Eval::Unknown
}
}
}
/// 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))
/// An agent whose module predates the option is out of a user namespace;
/// a failed eval, a timeout and an output that is no bool are `None`.
async fn private_users(&self, job: &BuildFromMeta, token: &str) -> Option<bool> {
match self.eval_option(job, token, PRIVATE_USERS_OPTION).await {
Eval::Value(out) => parse_flag(&out),
Eval::Missing => Some(false),
Eval::Unknown => None,
}
}
/// 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))
/// predates the option reads as [`NixBuilds::Host`], which is what every
/// agent did before the option; a failed eval, a timeout and an output that
/// is no string are `None`.
async fn nix_builds(&self, job: &BuildFromMeta, token: &str) -> Option<NixBuilds> {
match self.eval_option(job, token, NIX_BUILDS_OPTION).await {
Eval::Value(out) => parse_nix_builds(&out),
Eval::Missing => Some(NixBuilds::default()),
Eval::Unknown => None,
}
}
/// The config commit as a flake reference, fetched through the PR's head
@ -281,15 +273,35 @@ 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"
/// What `nix eval --json` of an agent option came to.
enum Eval {
/// It printed a value.
Value(Vec<u8>),
/// The config has no such attribute: the agent's module predates the option.
Missing,
/// It failed another way or timed out, so the value is not known.
Unknown,
}
/// `nix eval --json` of a [`NixBuilds`]: the spelling, or the default when the
/// Whether `stderr` is `nix` saying the attribute path is not in the flake's
/// outputs, which for an option path means the module does not declare it.
fn attribute_missing(stderr: &str) -> bool {
stderr.contains("does not provide attribute")
}
/// `nix eval --json` of a bool: the bool, or `None` for anything else.
fn parse_flag(out: &[u8]) -> Option<bool> {
match out.trim_ascii() {
b"true" => Some(true),
b"false" => Some(false),
_ => None,
}
}
/// `nix eval --json` of a [`NixBuilds`]: the spelling, or `None` when the
/// output is not a string.
fn parse_nix_builds(out: &[u8]) -> NixBuilds {
serde_json::from_slice(out).unwrap_or_default()
fn parse_nix_builds(out: &[u8]) -> Option<NixBuilds> {
serde_json::from_slice(out).ok()
}
/// `nix flake lock` of the flake in the working directory. The flake's config
@ -577,17 +589,27 @@ mod tests {
);
}
/// The reply carries what the evaluated config says about the option, and
/// anything but a clean `true` reads as false. The `true` arm is the
/// control for the others.
/// The shell for a `nix eval` of an option the module does not declare, as
/// `nix` 2.34 words it.
const ATTRIBUTE_MISSING: &str = "echo \"error: flake 'git+https://forge.example/internal/meta.git' does not provide attribute 'packages.x86_64-linux.nixosConfigurations.atlas.config.services.hyperhive.agent.user.privateUsers'\" >&2; exit 1";
/// The reply carries what the evaluated config says about the option. An
/// agent whose module predates the option is false; an eval that fails
/// another way, or prints no bool, leaves the field out. The `true` arm is
/// the control for the others.
#[tokio::test]
async fn a_meta_build_reports_the_agents_private_users_option() {
for (i, (answer, want)) in [
("echo true", true),
("echo false", false),
("echo null", false),
("echo true; exit 1", false),
("echo error: attribute missing >&2; exit 1", false),
("echo true", Some(true)),
("echo false", Some(false)),
(ATTRIBUTE_MISSING, Some(false)),
("echo null", None),
("echo true; exit 1", None),
("echo error: attribute missing >&2; exit 1", None),
(
"echo 'error: infinite recursion encountered' >&2; exit 1",
None,
),
]
.into_iter()
.enumerate()
@ -606,17 +628,19 @@ 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`.
/// `none`; a mode this build does not know reads as `none`, an agent whose
/// module predates the option as `host`, and a failed eval, or output that
/// is no spelling at all, leaves the field out.
#[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),
("echo '\"host\"'", Some(NixBuilds::Host)),
("echo '\"none\"'", Some(NixBuilds::Disabled)),
("echo '\"container\"'", Some(NixBuilds::Disabled)),
(ATTRIBUTE_MISSING, Some(NixBuilds::Host)),
("echo null", None),
("echo '\"none\"'; exit 1", None),
("echo error: attribute missing >&2; exit 1", None),
]
.into_iter()
.enumerate()
@ -636,6 +660,33 @@ mod tests {
}
}
/// An eval that does not finish in time is unknown for both options, not
/// their defaults. The build itself finishes, which is the control.
#[tokio::test]
async fn an_option_eval_that_times_out_leaves_both_options_out_of_the_reply() {
let nix = fake_nix(
"eval-timeout",
"if [ \"$1\" = eval ]; then exec sleep 30; else echo /nix/store/x; fi\n",
);
let b = Builder {
nix,
deadline: Duration::from_secs(1),
..builder()
};
let reply = b.build_from_meta(&meta_job(), "t").await;
assert!(
matches!(
&reply,
FromMetaReply::Built {
private_users: None,
nix_builds: None,
..
}
),
"{reply:?}"
);
}
#[test]
fn the_option_is_read_from_the_evaluated_config_not_the_toplevel() {
assert_eq!(
@ -647,8 +698,11 @@ mod tests {
?rev=0123456789abcdef0123456789abcdef01234567\
#nixosConfigurations.atlas.config.services.hyperhive.agent.user.privateUsers"
);
assert!(parse_flag(b"true\n") && !parse_flag(b"false\n") && !parse_flag(b""));
assert_eq!(parse_flag(b"true\n"), Some(true));
assert_eq!(parse_flag(b"false\n"), Some(false));
assert_eq!(parse_flag(b""), None);
}
#[test]
fn a_meta_job_that_would_bend_the_reference_is_refused() {
for bad in [
@ -685,8 +739,8 @@ mod tests {
out_path: format!("{} {}", BUILD_FLAGS.join(" "), b.meta_attr(&meta_job())),
system: "x86_64-linux".to_owned(),
cached: false,
private_users: false,
nix_builds: NixBuilds::Host,
private_users: None,
nix_builds: None,
}
);
}

View file

@ -266,8 +266,8 @@ mod tests {
out_path: OUT.to_owned(),
system: "x86_64-linux".to_owned(),
cached: false,
private_users: false,
nix_builds: NixBuilds::Disabled,
private_users: None,
nix_builds: Some(NixBuilds::Disabled),
}
}
@ -304,7 +304,8 @@ mod tests {
matches!(
reply,
FromMetaReply::Built {
nix_builds: NixBuilds::Disabled,
private_users: None,
nix_builds: Some(NixBuilds::Disabled),
..
}
),