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
14 changed files with 340 additions and 158 deletions

View file

@ -476,9 +476,11 @@ first start afterwards has hive-priv, as root, chown the agent's `state`,
builder reads `config.services.hyperhive.agent.user.privateUsers` from the builder reads `config.services.hyperhive.agent.user.privateUsers` from the
config it evaluates in its sandbox and puts it in the agent's closure record config it evaluates in its sandbox and puts it in the agent's closure record
(`private_users`). hive-c0re reads the record when it writes the container's (`private_users`). hive-c0re reads the record when it writes the container's
flags and sends the value to hive-priv with each start. A record or builder flags and sends the value to hive-priv with each start. An agent whose module
from before the field reads false; a record hive-c0re can't read, or none, doesn't declare the option reads false. When the builder can't read the
leaves the agent as it is. option (its eval fails or times out), the record carries no flag, and so does a
record or builder from before the field. A record without a flag, a record
hive-c0re can't read, or none, leaves the agent as it is.
- **On disk the data belongs to one host uid:gid**, - **On disk the data belongs to one host uid:gid**,
`services.hyperhive.deploy.hive-controller.userns.dataOwner` `services.hyperhive.deploy.hive-controller.userns.dataOwner`

View file

@ -282,6 +282,30 @@ The record's `cached` field says whether the builder pushed the closure to
[the binary cache](#binary-cache). Each hive reads its own agents' keys to [the binary cache](#binary-cache). Each hive reads its own agents' keys to
[report whether it can pull them and download them in a rebuild](#what-hives-do-with-the-cache). The closure stays unrooted in the builder host's store. [report whether it can pull them and download them in a rebuild](#what-hives-do-with-the-cache). The closure stays unrooted in the builder host's store.
The built reply and the record also carry two options of the agent's evaluated
config, `private_users` (`services.hyperhive.agent.user.privateUsers`) and
`nix_builds` (`services.hyperhive.agent.nixBuilds`). The builder reads each with
a `nix eval` in its sandbox, and an eval has three outcomes:
- **A value:** the field holds it.
- **The module doesn't declare the option** (`nix` says the flake doesn't
provide the attribute): the field holds the option's default, off for
`private_users` and `host` for `nix_builds`.
- **Any other failure, or a timeout:** the value is unknown, and the reply and
the record leave the field out. hive-c0re then sends nothing for that option,
and hive-priv keeps what the container's last write chose, as it does for an
agent with no record.
Mixed versions:
| builder | controller and hive-c0re | what happens |
| ------- | ------------------------ | -------------------------------------------------------------------------------------------------------------- |
| new | new | an unread option leaves the field out, and the last write stands |
| old | new | the old builder always sends a value, which the record carries |
| new | old | the old side reads a missing field as the default, so an unread option behaves as it did before this behaviour |
A record or reply from before the fields reads as unknown.
The controller gives the `builder` forge account read on `internal/meta` when it The controller gives the `builder` forge account read on `internal/meta` when it
updates the flake, and again before each build, since the repo is private. A updates the flake, and again before each build, since the repo is private. A
grant the forge refuses fails the build with no verdict. grant the forge refuses fails the build with no verdict.

View file

@ -211,12 +211,15 @@ the option from the evaluated config into the agent's closure record
flags and sends the mode to hive-priv, and the flags apply at the container's flags and sends the mode to hive-priv, and the flags apply at the container's
next start. next start.
- A record or builder from before the field reads `host`. - An agent whose module doesn't declare the option reads `host`. When the builder
can't read the option (its eval fails or times out), the record carries no
mode, and so does a record or builder from before the field. hive-c0re then
sends no mode, and hive-priv keeps what the container's last write chose.
- A mode this build doesn't know (a record written by a newer swarm) reads as - A mode this build doesn't know (a record written by a newer swarm) reads as
`none`, never `host`: an agent that asked for a mode its hive can't give gets `none`, never `host`: an agent that asked for a mode its hive can't give gets
no builds, not builds outside its limits. no builds, not builds outside its limits.
- A record hive-c0re can't read, or none, sends no mode, and hive-priv keeps - A record hive-c0re can't read, or none, sends no mode either, and hive-priv
what the container's last write chose. keeps what the container's last write chose.
- A hive-c0re or hive-priv from before the option ignores it, so the agent keeps - A hive-c0re or hive-priv from before the option ignores it, so the agent keeps
host builds until you upgrade both. host builds until you upgrade both.

View file

@ -173,8 +173,8 @@ mod tests {
system: "x86_64-linux".to_owned(), system: "x86_64-linux".to_owned(),
built_unix: 1, built_unix: 1,
cached, cached,
private_users: false, private_users: Some(false),
nix_builds: hive_types::NixBuilds::Host, nix_builds: Some(hive_types::NixBuilds::Host),
} }
} }

View file

@ -245,8 +245,8 @@ mod tests {
system: "x86_64-linux".to_owned(), system: "x86_64-linux".to_owned(),
built_unix: 1, built_unix: 1,
cached: true, cached: true,
private_users: false, private_users: Some(false),
nix_builds: hive_types::NixBuilds::Host, nix_builds: Some(hive_types::NixBuilds::Host),
} }
} }

View file

@ -274,8 +274,8 @@ fn closure(cached: bool) -> AgentClosure {
system: "x86_64-linux".to_owned(), system: "x86_64-linux".to_owned(),
built_unix: 1, built_unix: 1,
cached, cached,
private_users: false, private_users: Some(false),
nix_builds: hive_types::NixBuilds::Host, nix_builds: Some(hive_types::NixBuilds::Host),
} }
} }

View file

@ -518,13 +518,14 @@ fn socket_bind(agent_name: &str, socket_dir: &Path, private_users: Option<bool>)
} }
/// What hive-priv is told about the agent's user namespace: the record's flag, /// What hive-priv is told about the agent's user namespace: the record's flag,
/// or `None` (leave the agent as it is) when there is no record or it cannot be /// or `None` (leave the agent as it is) when there is no record, it cannot be
/// read, so a swarm outage never turns a namespaced agent off or on. /// read, or it carries no flag because the builder could not read the option,
/// so neither a swarm outage nor a failed eval turns a namespaced agent off or on.
fn private_users_of( fn private_users_of(
record: &Result<Option<swarm_wire::agent_closures::AgentClosure>>, record: &Result<Option<swarm_wire::agent_closures::AgentClosure>>,
) -> Option<bool> { ) -> Option<bool> {
match record { match record {
Ok(record) => record.as_ref().map(|r| r.private_users), Ok(record) => record.as_ref().and_then(|r| r.private_users),
Err(e) => { Err(e) => {
tracing::warn!(error = %format!("{e:#}"), "closure record unreadable; the agent's user namespace is left as it is"); tracing::warn!(error = %format!("{e:#}"), "closure record unreadable; the agent's user namespace is left as it is");
None None
@ -533,13 +534,15 @@ fn private_users_of(
} }
/// What hive-priv is told about the agent's nix builds: the record's mode, or /// What hive-priv is told about the agent's nix builds: the record's mode, or
/// `None` (leave the agent as the last write set it) when there is no record or /// `None` (leave the agent as the last write set it) when there is no record,
/// it cannot be read, so a swarm outage never turns an agent's host builds on. /// it cannot be read, or it carries no mode because the builder could not read
/// the option, so neither a swarm outage nor a failed eval turns an agent's
/// host builds on.
fn nix_builds_of( fn nix_builds_of(
record: &Result<Option<swarm_wire::agent_closures::AgentClosure>>, record: &Result<Option<swarm_wire::agent_closures::AgentClosure>>,
) -> Option<NixBuilds> { ) -> Option<NixBuilds> {
match record { match record {
Ok(record) => record.as_ref().map(|r| r.nix_builds), Ok(record) => record.as_ref().and_then(|r| r.nix_builds),
Err(e) => { Err(e) => {
tracing::warn!(error = %format!("{e:#}"), "closure record unreadable; the agent's nix builds are left as they are"); tracing::warn!(error = %format!("{e:#}"), "closure record unreadable; the agent's nix builds are left as they are");
None None
@ -579,13 +582,13 @@ mod tests {
use crate::coordinator::HiveEnv; use crate::coordinator::HiveEnv;
use hive_types::NixBuilds; use hive_types::NixBuilds;
fn record_with(private_users: bool) -> swarm_wire::agent_closures::AgentClosure { fn record_with(private_users: Option<bool>) -> swarm_wire::agent_closures::AgentClosure {
record_with_builds(private_users, NixBuilds::Host) record_with_builds(private_users, Some(NixBuilds::Host))
} }
fn record_with_builds( fn record_with_builds(
private_users: bool, private_users: Option<bool>,
nix_builds: NixBuilds, nix_builds: Option<NixBuilds>,
) -> swarm_wire::agent_closures::AgentClosure { ) -> swarm_wire::agent_closures::AgentClosure {
swarm_wire::agent_closures::AgentClosure { swarm_wire::agent_closures::AgentClosure {
meta_rev: "a".repeat(40), meta_rev: "a".repeat(40),
@ -613,48 +616,58 @@ mod tests {
assert!(!b.read_only); assert!(!b.read_only);
} }
/// The flag in the record is what hive-priv is told, both ways. No record /// The flag in the record is what hive-priv is told, both ways. No record,
/// and an unreadable one say nothing, so a swarm outage changes no agent's /// an unreadable one, and a record whose builder could not read the option
/// namespace; a record from before the field reads false. /// (or that predates the field) say nothing, so neither a swarm outage nor
/// a failed eval changes an agent's namespace.
#[test] #[test]
fn the_records_flag_is_what_the_start_path_passes() { fn the_records_flag_is_what_the_start_path_passes() {
assert_eq!( for flag in [true, false] {
super::private_users_of(&Ok(Some(record_with(true)))), assert_eq!(
Some(true) super::private_users_of(&Ok(Some(record_with(Some(flag))))),
); Some(flag)
assert_eq!( );
super::private_users_of(&Ok(Some(record_with(false)))), }
Some(false)
);
assert_eq!(super::private_users_of(&Ok(None)), None); assert_eq!(super::private_users_of(&Ok(None)), None);
assert_eq!(super::private_users_of(&Err(anyhow::anyhow!("down"))), None); assert_eq!(super::private_users_of(&Err(anyhow::anyhow!("down"))), None);
assert_eq!(
super::private_users_of(&Ok(Some(record_with(None)))),
None,
"an unread flag keeps the last write"
);
let old = serde_json::json!({ let old = serde_json::json!({
"meta_rev": "a", "store_path": "/nix/store/x", "meta_rev": "a", "store_path": "/nix/store/x",
"system": "s", "built_unix": 1, "system": "s", "built_unix": 1,
}); });
let old = serde_json::from_value(old).expect("decodes"); let old = serde_json::from_value(old).expect("decodes");
assert_eq!(super::private_users_of(&Ok(Some(old))), Some(false)); assert_eq!(super::private_users_of(&Ok(Some(old))), None);
} }
/// The record's mode is what hive-priv is told, both ways. No record and an /// The record's mode is what hive-priv is told, both ways. No record, an
/// unreadable one say nothing, so a swarm outage changes no agent; a record /// unreadable one, and a record whose builder could not read the option (or
/// from before the field reads as host builds. /// that predates the field) say nothing, so neither a swarm outage nor a
/// failed eval changes an agent.
#[test] #[test]
fn the_records_nix_builds_is_what_the_start_path_passes() { fn the_records_nix_builds_is_what_the_start_path_passes() {
for mode in [NixBuilds::Host, NixBuilds::Disabled] { for mode in [NixBuilds::Host, NixBuilds::Disabled] {
assert_eq!( assert_eq!(
super::nix_builds_of(&Ok(Some(record_with_builds(false, mode)))), super::nix_builds_of(&Ok(Some(record_with_builds(None, Some(mode))))),
Some(mode) Some(mode)
); );
} }
assert_eq!(super::nix_builds_of(&Ok(None)), None); assert_eq!(super::nix_builds_of(&Ok(None)), None);
assert_eq!(super::nix_builds_of(&Err(anyhow::anyhow!("down"))), None); assert_eq!(super::nix_builds_of(&Err(anyhow::anyhow!("down"))), None);
assert_eq!(
super::nix_builds_of(&Ok(Some(record_with_builds(None, None)))),
None,
"an unread mode keeps the last write"
);
let old = serde_json::json!({ let old = serde_json::json!({
"meta_rev": "a", "store_path": "/nix/store/x", "meta_rev": "a", "store_path": "/nix/store/x",
"system": "s", "built_unix": 1, "system": "s", "built_unix": 1,
}); });
let old = serde_json::from_value(old).expect("decodes"); let old = serde_json::from_value(old).expect("decodes");
assert_eq!(super::nix_builds_of(&Ok(Some(old))), Some(NixBuilds::Host)); assert_eq!(super::nix_builds_of(&Ok(Some(old))), None);
// A mode a newer swarm wrote, which this build does not know, decodes // A mode a newer swarm wrote, which this build does not know, decodes
// and builds nothing. // and builds nothing.
let newer = serde_json::json!({ let newer = serde_json::json!({

View file

@ -862,8 +862,8 @@ mod tests {
out_path: "/nix/store/x".to_owned(), out_path: "/nix/store/x".to_owned(),
system: "s".to_owned(), system: "s".to_owned(),
cached: false, cached: false,
private_users: false, private_users: Some(false),
nix_builds: hive_types::NixBuilds::Host, nix_builds: Some(hive_types::NixBuilds::Host),
} }
}, },
) )
@ -885,8 +885,8 @@ mod tests {
out_path: "/nix/store/x".to_owned(), out_path: "/nix/store/x".to_owned(),
system: "s".to_owned(), system: "s".to_owned(),
cached: false, cached: false,
private_users: false, private_users: Some(false),
nix_builds: hive_types::NixBuilds::Host, nix_builds: Some(hive_types::NixBuilds::Host),
} }
}, },
) )
@ -1454,7 +1454,9 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn a_meta_build_is_answered_with_the_path_the_system_and_the_private_users_flag() { 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 (env, mut sent) = env();
let request = msg(Some("_INBOX.5"), &meta_json("atlas", REV)); let request = msg(Some("_INBOX.5"), &meta_json("atlas", REV));
serve_meta(fake.builder(), env, vec![request]).await; 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] #[tokio::test]
async fn private_users_is_false_unless_nix_eval_printed_true() { async fn private_users_is_left_out_unless_nix_eval_printed_a_bool_or_the_option_is_missing() {
for eval_says in [ let missing = "echo \"error: flake 'f' does not provide attribute 'a'\" >&2; exit 1";
"echo false", for (eval_says, want) in [
"echo null", ("echo false", Some(false)),
"echo 'error: no such option' >&2; exit 1", (missing, Some(false)),
("echo null", None),
("echo 'error: no such option' >&2; exit 1", None),
] { ] {
let fake = meta_nix(eval_says); let fake = meta_nix(eval_says);
let (env, mut sent) = env(); let (env, mut sent) = env();
@ -1503,7 +1510,12 @@ mod tests {
serve_meta(fake.builder(), env, vec![request]).await; serve_meta(fake.builder(), env, vec![request]).await;
let (_, body) = next(&mut sent).await; let (_, body) = next(&mut sent).await;
assert_eq!(body["state"], "built", "{eval_says}"); 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 /// `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 /// sandbox against the flake the build just evaluated. Logs why when the
/// fails or times out, and logs why along with what the caller falls back to. /// answer is not a value.
async fn eval_option( async fn eval_option(&self, job: &BuildFromMeta, token: &str, option: &str) -> Eval {
&self,
job: &BuildFromMeta,
token: &str,
option: &str,
fallback: &str,
) -> Option<Vec<u8>> {
let mut cmd = self.nix_command(token); let mut cmd = self.nix_command(token);
cmd.args(EVAL_FLAGS) cmd.args(EVAL_FLAGS)
.arg(format!("{}.{option}", self.meta_config(job))); .arg(format!("{}.{option}", self.meta_config(job)));
match run_raw(cmd, self.deadline).await { 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)) => { Ok((status, _, err)) => {
tracing::info!(agent = %job.agent, %status, error = %tail(&String::from_utf8_lossy(&err), status), "{option} not read; {fallback}"); tracing::info!(agent = %job.agent, %status, error = %tail(&String::from_utf8_lossy(&err), status), "{option} not read; the reply carries no value for it");
None Eval::Unknown
} }
Err(why) => { Err(why) => {
tracing::warn!(agent = %job.agent, %why, "{option} not read; {fallback}"); tracing::warn!(agent = %job.agent, %why, "{option} not read; the reply carries no value for it");
None Eval::Unknown
} }
} }
} }
/// Whether the agent's config sets `services.hyperhive.agent.user.privateUsers`. /// Whether the agent's config sets `services.hyperhive.agent.user.privateUsers`.
/// Anything but a clean `true` is false: an agent whose module predates the /// An agent whose module predates the option is out of a user namespace;
/// option, a failed eval and a timeout all keep the agent out of a user /// a failed eval, a timeout and an output that is no bool are `None`.
/// namespace. async fn private_users(&self, job: &BuildFromMeta, token: &str) -> Option<bool> {
async fn private_users(&self, job: &BuildFromMeta, token: &str) -> bool { match self.eval_option(job, token, PRIVATE_USERS_OPTION).await {
self.eval_option( Eval::Value(out) => parse_flag(&out),
job, Eval::Missing => Some(false),
token, Eval::Unknown => None,
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 /// The agent's `services.hyperhive.agent.nixBuilds`. An agent whose module
/// predates the option, a failed eval and a timeout all read as /// predates the option reads as [`NixBuilds::Host`], which is what every
/// [`NixBuilds::Host`], which is what every agent did before the option. /// agent did before the option; a failed eval, a timeout and an output that
async fn nix_builds(&self, job: &BuildFromMeta, token: &str) -> NixBuilds { /// is no string are `None`.
self.eval_option( async fn nix_builds(&self, job: &BuildFromMeta, token: &str) -> Option<NixBuilds> {
job, match self.eval_option(job, token, NIX_BUILDS_OPTION).await {
token, Eval::Value(out) => parse_nix_builds(&out),
NIX_BUILDS_OPTION, Eval::Missing => Some(NixBuilds::default()),
"the agent builds through the host daemon", Eval::Unknown => None,
) }
.await
.map_or_else(NixBuilds::default, |out| parse_nix_builds(&out))
} }
/// The config commit as a flake reference, fetched through the PR's head /// 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. /// The agent option holding where its nix builds run.
const NIX_BUILDS_OPTION: &str = "services.hyperhive.agent.nixBuilds"; const NIX_BUILDS_OPTION: &str = "services.hyperhive.agent.nixBuilds";
/// `nix eval --json` of a bool: true only for exactly `true`. /// What `nix eval --json` of an agent option came to.
fn parse_flag(out: &[u8]) -> bool { enum Eval {
out.trim_ascii() == b"true" /// 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. /// output is not a string.
fn parse_nix_builds(out: &[u8]) -> NixBuilds { fn parse_nix_builds(out: &[u8]) -> Option<NixBuilds> {
serde_json::from_slice(out).unwrap_or_default() serde_json::from_slice(out).ok()
} }
/// `nix flake lock` of the flake in the working directory. The flake's config /// `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 /// The shell for a `nix eval` of an option the module does not declare, as
/// anything but a clean `true` reads as false. The `true` arm is the /// `nix` 2.34 words it.
/// control for the others. 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] #[tokio::test]
async fn a_meta_build_reports_the_agents_private_users_option() { async fn a_meta_build_reports_the_agents_private_users_option() {
for (i, (answer, want)) in [ for (i, (answer, want)) in [
("echo true", true), ("echo true", Some(true)),
("echo false", false), ("echo false", Some(false)),
("echo null", false), (ATTRIBUTE_MISSING, Some(false)),
("echo true; exit 1", false), ("echo null", None),
("echo error: attribute missing >&2; exit 1", false), ("echo true; exit 1", None),
("echo error: attribute missing >&2; exit 1", None),
(
"echo 'error: infinite recursion encountered' >&2; exit 1",
None,
),
] ]
.into_iter() .into_iter()
.enumerate() .enumerate()
@ -606,17 +628,19 @@ mod tests {
} }
/// The reply carries the option's spelling. `host` is the control for /// 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 /// `none`; a mode this build does not know reads as `none`, an agent whose
/// that is no spelling at all (a failed eval, an older module) as `host`. /// module predates the option as `host`, and a failed eval, or output that
/// is no spelling at all, leaves the field out.
#[tokio::test] #[tokio::test]
async fn a_meta_build_reports_the_agents_nix_builds_option() { async fn a_meta_build_reports_the_agents_nix_builds_option() {
for (i, (answer, want)) in [ for (i, (answer, want)) in [
("echo '\"host\"'", NixBuilds::Host), ("echo '\"host\"'", Some(NixBuilds::Host)),
("echo '\"none\"'", NixBuilds::Disabled), ("echo '\"none\"'", Some(NixBuilds::Disabled)),
("echo '\"container\"'", NixBuilds::Disabled), ("echo '\"container\"'", Some(NixBuilds::Disabled)),
("echo null", NixBuilds::Host), (ATTRIBUTE_MISSING, Some(NixBuilds::Host)),
("echo '\"none\"'; exit 1", NixBuilds::Host), ("echo null", None),
("echo error: attribute missing >&2; exit 1", NixBuilds::Host), ("echo '\"none\"'; exit 1", None),
("echo error: attribute missing >&2; exit 1", None),
] ]
.into_iter() .into_iter()
.enumerate() .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] #[test]
fn the_option_is_read_from_the_evaluated_config_not_the_toplevel() { fn the_option_is_read_from_the_evaluated_config_not_the_toplevel() {
assert_eq!( assert_eq!(
@ -647,8 +698,11 @@ mod tests {
?rev=0123456789abcdef0123456789abcdef01234567\ ?rev=0123456789abcdef0123456789abcdef01234567\
#nixosConfigurations.atlas.config.services.hyperhive.agent.user.privateUsers" #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] #[test]
fn a_meta_job_that_would_bend_the_reference_is_refused() { fn a_meta_job_that_would_bend_the_reference_is_refused() {
for bad in [ for bad in [
@ -685,8 +739,8 @@ mod tests {
out_path: format!("{} {}", BUILD_FLAGS.join(" "), b.meta_attr(&meta_job())), out_path: format!("{} {}", BUILD_FLAGS.join(" "), b.meta_attr(&meta_job())),
system: "x86_64-linux".to_owned(), system: "x86_64-linux".to_owned(),
cached: false, cached: false,
private_users: false, private_users: None,
nix_builds: NixBuilds::Host, nix_builds: None,
} }
); );
} }

View file

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

View file

@ -564,8 +564,8 @@ mod tests {
system: "x86_64-linux".to_owned(), system: "x86_64-linux".to_owned(),
built_unix: 7, built_unix: 7,
cached, cached,
private_users: false, private_users: Some(false),
nix_builds: NixBuilds::Host, nix_builds: Some(NixBuilds::Host),
} }
} }
@ -652,8 +652,8 @@ mod tests {
out_path: out_path.to_owned(), out_path: out_path.to_owned(),
system: "x86_64-linux".to_owned(), system: "x86_64-linux".to_owned(),
cached, cached,
private_users: false, private_users: Some(false),
nix_builds: NixBuilds::Host, nix_builds: Some(NixBuilds::Host),
} }
} }
@ -916,15 +916,15 @@ mod tests {
out_path, out_path,
system, system,
cached, cached,
private_users: true, private_users: Some(true),
nix_builds: NixBuilds::Disabled, nix_builds: Some(NixBuilds::Disabled),
}; };
let world = Fake::missing(&["iris"]).answering("iris", answer); let world = Fake::missing(&["iris"]).answering("iris", answer);
pass_at(&world, &mut Backoff::default(), Instant::now()).await; pass_at(&world, &mut Backoff::default(), Instant::now()).await;
let writes = world.writes(); let writes = world.writes();
assert_eq!(writes.len(), 1); assert_eq!(writes.len(), 1);
assert!(writes[0].1.private_users); assert_eq!(writes[0].1.private_users, Some(true));
assert_eq!(writes[0].1.nix_builds, NixBuilds::Disabled); assert_eq!(writes[0].1.nix_builds, Some(NixBuilds::Disabled));
} }
#[tokio::test] #[tokio::test]
async fn another_path_leaves_a_record_that_moved_on() { async fn another_path_leaves_a_record_that_moved_on() {

View file

@ -492,10 +492,14 @@ mod tests {
} }
fn built_cached(cached: bool) -> Self { fn built_cached(cached: bool) -> Self {
Self::built_with(cached, false, NixBuilds::Host) Self::built_with(cached, Some(false), Some(NixBuilds::Host))
} }
fn built_with(cached: bool, private_users: bool, nix_builds: NixBuilds) -> Self { fn built_with(
cached: bool,
private_users: Option<bool>,
nix_builds: Option<NixBuilds>,
) -> Self {
Self::answering(Ok(FromMetaReply::Built { Self::answering(Ok(FromMetaReply::Built {
out_path: "/nix/store/x-toplevel".to_owned(), out_path: "/nix/store/x-toplevel".to_owned(),
system: "x86_64-linux".to_owned(), system: "x86_64-linux".to_owned(),
@ -629,11 +633,15 @@ mod tests {
} }
} }
/// The record says what the builder said about the agent's user namespace, /// The record says what the builder said about the agent's user namespace
/// both ways; a builder that said nothing leaves it off. /// and nix builds, both ways; an option the builder left unread stays unread.
#[tokio::test] #[tokio::test]
async fn the_record_carries_the_builders_user_namespace_and_nix_builds_answers() { async fn the_record_carries_the_builders_user_namespace_and_nix_builds_answers() {
for (private_users, nix_builds) in [(true, NixBuilds::Disabled), (false, NixBuilds::Host)] { for (private_users, nix_builds) in [
(Some(true), Some(NixBuilds::Disabled)),
(Some(false), Some(NixBuilds::Host)),
(None, None),
] {
let queue = FakeQueue::built_with(true, private_users, nix_builds); let queue = FakeQueue::built_with(true, private_users, nix_builds);
let outcome = run_with(&FakeHead::reading(Ok(Some(REV))), &queue).await; let outcome = run_with(&FakeHead::reading(Ok(Some(REV))), &queue).await;
assert_eq!(outcome, Outcome::Done); assert_eq!(outcome, Outcome::Done);
@ -927,8 +935,8 @@ mod tests {
system: "x86_64-linux".to_owned(), system: "x86_64-linux".to_owned(),
built_unix: 1, built_unix: 1,
cached: false, cached: false,
private_users: true, private_users: Some(true),
nix_builds: NixBuilds::Disabled, nix_builds: Some(NixBuilds::Disabled),
}; };
client.record("atlas", &closure).await.expect("written"); client.record("atlas", &closure).await.expect("written");
closure.store_path = "/nix/store/b".to_owned(); closure.store_path = "/nix/store/b".to_owned();

View file

@ -40,15 +40,15 @@ pub struct AgentClosure {
pub cached: bool, pub cached: bool,
/// Whether the agent's evaluated config sets /// Whether the agent's evaluated config sets
/// `services.hyperhive.agent.user.privateUsers`: the agent runs in its own /// `services.hyperhive.agent.user.privateUsers`: the agent runs in its own
/// user namespace. A record written before the field existed reads as /// user namespace. `None` when the builder could not read it, and for a
/// false, which keeps the agent out of one. /// record written before the field existed: the record says nothing, and
#[serde(default)] /// the reader leaves the agent as its last start set it.
pub private_users: bool, #[serde(default, skip_serializing_if = "Option::is_none")]
pub private_users: Option<bool>,
/// The agent's `services.hyperhive.agent.nixBuilds`: where its nix builds /// The agent's `services.hyperhive.agent.nixBuilds`: where its nix builds
/// run. A record written before the field existed reads as /// run. `None` as for `private_users`.
/// [`NixBuilds::Host`], which is what every agent did before it. #[serde(default, skip_serializing_if = "Option::is_none")]
#[serde(default)] pub nix_builds: Option<NixBuilds>,
pub nix_builds: NixBuilds,
} }
#[cfg(test)] #[cfg(test)]
@ -69,8 +69,8 @@ mod tests {
system: "x86_64-linux".to_owned(), system: "x86_64-linux".to_owned(),
built_unix: 7, built_unix: 7,
cached: true, cached: true,
private_users: true, private_users: Some(true),
nix_builds: NixBuilds::Disabled, nix_builds: Some(NixBuilds::Disabled),
}; };
let wire = serde_json::json!({ let wire = serde_json::json!({
"meta_rev": "abc", "meta_rev": "abc",
@ -89,7 +89,7 @@ mod tests {
} }
#[test] #[test]
fn a_record_without_the_fields_reads_as_not_cached_not_namespaced_and_host_builds() { fn a_record_without_the_fields_reads_as_not_cached_and_says_nothing_of_the_options() {
let old = serde_json::json!({ let old = serde_json::json!({
"meta_rev": "abc", "meta_rev": "abc",
"store_path": "/nix/store/x", "store_path": "/nix/store/x",
@ -98,7 +98,41 @@ mod tests {
}); });
let record = serde_json::from_value::<AgentClosure>(old).expect("decodes"); let record = serde_json::from_value::<AgentClosure>(old).expect("decodes");
assert!(!record.cached); assert!(!record.cached);
assert!(!record.private_users); assert_eq!(record.private_users, None);
assert_eq!(record.nix_builds, NixBuilds::Host); assert_eq!(record.nix_builds, None);
}
/// An option the builder could not read is left out of the record, not
/// written as `null`: a reader that predates the option still decodes it.
#[test]
fn an_unread_option_is_left_out_of_the_record() {
// The shape a reader before the option had: a bool that defaults to false.
#[derive(serde::Deserialize)]
struct Before {
#[serde(default)]
private_users: bool,
}
let record = AgentClosure {
meta_rev: "abc".to_owned(),
store_path: "/nix/store/x".to_owned(),
system: "x86_64-linux".to_owned(),
built_unix: 7,
cached: true,
private_users: None,
nix_builds: Some(NixBuilds::Host),
};
let wire = serde_json::to_value(&record).expect("encodes");
let fields = wire.as_object().expect("an object");
assert!(!fields.contains_key("private_users"), "{wire}");
assert_eq!(fields["nix_builds"], "host");
assert!(
!serde_json::from_value::<Before>(wire.clone())
.expect("decodes")
.private_users
);
assert_eq!(
serde_json::from_value::<AgentClosure>(wire).expect("decodes"),
record
);
} }
} }

View file

@ -151,15 +151,15 @@ pub enum FromMetaReply {
#[serde(default)] #[serde(default)]
cached: bool, cached: bool,
/// Whether the agent's evaluated config sets /// Whether the agent's evaluated config sets
/// `services.hyperhive.agent.user.privateUsers`. A builder that /// `services.hyperhive.agent.user.privateUsers`. Absent (`None`) when
/// predates the field answers without it, which reads as false. /// the builder could not read it: its eval failed or timed out. A
#[serde(default)] /// builder that predates the field answers without it too.
private_users: bool, #[serde(default, skip_serializing_if = "Option::is_none")]
/// The agent's `services.hyperhive.agent.nixBuilds`. A builder that private_users: Option<bool>,
/// predates the field answers without it, which reads as /// The agent's `services.hyperhive.agent.nixBuilds`. Absent (`None`)
/// [`NixBuilds::Host`]. /// when the builder could not read it, as for `private_users`.
#[serde(default)] #[serde(default, skip_serializing_if = "Option::is_none")]
nix_builds: NixBuilds, nix_builds: Option<NixBuilds>,
}, },
/// The build did not succeed: the last lines `nix` printed, or why it /// The build did not succeed: the last lines `nix` printed, or why it
/// stopped (it timed out, or could not start). The controller holds the /// stopped (it timed out, or could not start). The controller holds the
@ -273,8 +273,8 @@ mod tests {
out_path: "/nix/store/x".to_owned(), out_path: "/nix/store/x".to_owned(),
system: "x86_64-linux".to_owned(), system: "x86_64-linux".to_owned(),
cached: true, cached: true,
private_users: true, private_users: Some(true),
nix_builds: NixBuilds::Disabled, nix_builds: Some(NixBuilds::Disabled),
}, },
json!({ json!({
"state": "built", "state": "built",
@ -307,8 +307,7 @@ mod tests {
} }
#[test] #[test]
fn a_built_reply_from_a_builder_without_the_fields_reads_as_not_cached_not_namespaced_and_host_builds() fn a_built_reply_from_a_builder_without_the_fields_reads_as_not_cached_and_unknown() {
{
let old = json!({"state": "built", "out_path": "/nix/store/x", "system": "s"}); let old = json!({"state": "built", "out_path": "/nix/store/x", "system": "s"});
assert_eq!( assert_eq!(
serde_json::from_value::<FromMetaReply>(old).expect("decodes"), serde_json::from_value::<FromMetaReply>(old).expect("decodes"),
@ -316,12 +315,44 @@ mod tests {
out_path: "/nix/store/x".to_owned(), out_path: "/nix/store/x".to_owned(),
system: "s".to_owned(), system: "s".to_owned(),
cached: false, cached: false,
private_users: false, private_users: None,
nix_builds: NixBuilds::Host, nix_builds: None,
} }
); );
} }
/// A value the builder could not read is left out of the reply, not sent as
/// `null`: a controller that predates the option still decodes it.
#[test]
fn an_unread_option_is_left_out_of_the_built_reply() {
// The shape a reader before the option had: a bool that defaults to false.
#[derive(serde::Deserialize)]
struct Before {
#[serde(default)]
private_users: bool,
}
let reply = FromMetaReply::Built {
out_path: "/nix/store/x".to_owned(),
system: "s".to_owned(),
cached: false,
private_users: None,
nix_builds: Some(NixBuilds::Host),
};
let wire = serde_json::to_value(&reply).expect("encodes");
let fields = wire.as_object().expect("an object");
assert!(!fields.contains_key("private_users"), "{wire}");
assert_eq!(fields["nix_builds"], "host");
assert!(
!serde_json::from_value::<Before>(wire.clone())
.expect("decodes")
.private_users
);
assert_eq!(
serde_json::from_value::<FromMetaReply>(wire).expect("decodes"),
reply
);
}
#[test] #[test]
fn the_subjects_are_pinned() { fn the_subjects_are_pinned() {
assert_eq!(SUBJECT, "$SWARM.builder.build"); assert_eq!(SUBJECT, "$SWARM.builder.build");