From 3ca660e558aa3319c14026c9d6ee8de95bfdf56f Mon Sep 17 00:00:00 2001 From: atlas Date: Sun, 1 Nov 2026 16:05:36 +0100 Subject: [PATCH] record whether an agent's config sets privateUsers in its closure record --- hive-c0re/src/attic/probe.rs | 1 + hive-c0re/src/job_queue/exec/tests.rs | 1 + swarm-builder/src/main.rs | 4 ++ swarm-builder/src/nix_build.rs | 92 ++++++++++++++++++++++++++- swarm-builder/src/push.rs | 7 +- swarm-controller/src/cache_miss.rs | 34 +++++++++- swarm-controller/src/meta_build.rs | 24 +++++++ swarm-wire/src/agent_closures.rs | 11 +++- swarm-wire/src/builder.rs | 10 ++- 9 files changed, 178 insertions(+), 6 deletions(-) diff --git a/hive-c0re/src/attic/probe.rs b/hive-c0re/src/attic/probe.rs index b7d7389b..726e23cb 100644 --- a/hive-c0re/src/attic/probe.rs +++ b/hive-c0re/src/attic/probe.rs @@ -317,6 +317,7 @@ mod tests { system: "x86_64-linux".to_owned(), built_unix: 1, cached: true, + private_users: false, } } diff --git a/hive-c0re/src/job_queue/exec/tests.rs b/hive-c0re/src/job_queue/exec/tests.rs index bf694a30..ad997c4d 100644 --- a/hive-c0re/src/job_queue/exec/tests.rs +++ b/hive-c0re/src/job_queue/exec/tests.rs @@ -285,6 +285,7 @@ fn closure(meta_rev: &str, cached: bool) -> AgentClosure { system: "x86_64-linux".to_owned(), built_unix: 1, cached, + private_users: false, } } diff --git a/swarm-builder/src/main.rs b/swarm-builder/src/main.rs index aecc2ff5..9d1cde65 100644 --- a/swarm-builder/src/main.rs +++ b/swarm-builder/src/main.rs @@ -422,12 +422,14 @@ async fn handle_meta( out_path, system, cached, + private_users, } => tracing::info!( agent = %job.agent, meta_rev = %job.meta_rev, %out_path, %system, cached, + private_users, "agent meta build built" ), FromMetaReply::Failed { error_tail } => tracing::warn!( @@ -641,6 +643,7 @@ mod tests { out_path: "/nix/store/x".to_owned(), system: "s".to_owned(), cached: false, + private_users: false, } }) .await; @@ -656,6 +659,7 @@ mod tests { out_path: "/nix/store/x".to_owned(), system: "s".to_owned(), cached: false, + private_users: false, } }) .await; diff --git a/swarm-builder/src/nix_build.rs b/swarm-builder/src/nix_build.rs index e20124e7..f79a094c 100644 --- a/swarm-builder/src/nix_build.rs +++ b/swarm-builder/src/nix_build.rs @@ -92,6 +92,7 @@ impl Builder { out_path, system: self.system.clone(), cached: false, + private_users: self.private_users(job, token).await, }, Reply::Failed { error_tail } => FromMetaReply::Failed { error_tail }, }, @@ -170,12 +171,39 @@ impl Builder { /// the hives build. No input is overridden: the committed `flake.lock` /// decides all of them. fn meta_attr(&self, job: &BuildFromMeta) -> String { + format!("{}.system.build.toplevel", self.meta_config(job)) + } + + /// The agent's evaluated NixOS config in `internal/meta` at the commit. + fn meta_config(&self, job: &BuildFromMeta) -> String { format!( - "git+{}/{META_REPO_PATH}.git?rev={}#nixosConfigurations.{}.config.system.build.toplevel", + "git+{}/{META_REPO_PATH}.git?rev={}#nixosConfigurations.{}.config", self.forge_url, job.meta_rev, job.agent ) } + /// 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 { + let mut cmd = self.nix_command(token); + cmd.args(EVAL_FLAGS) + .arg(format!("{}.{PRIVATE_USERS_OPTION}", self.meta_config(job))); + match run_raw(cmd, self.deadline).await { + Ok((status, out, _)) if status.success() => parse_flag(&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 + } + Err(why) => { + tracing::warn!(agent = %job.agent, %why, "privateUsers not read; the agent stays out of a user namespace"); + false + } + } + } + /// The config commit as a flake reference, fetched through the PR's head /// ref. fn agent_url(&self, job: &BuildAgent) -> String { @@ -199,6 +227,26 @@ const BUILD_FLAGS: [&str; 8] = [ "--print-build-logs", ]; +/// What every `nix eval` of an option starts with: JSON out, nothing written to +/// the flake's lock, the flake's own `nix.conf` settings refused. +const EVAL_FLAGS: [&str; 7] = [ + "eval", + "--json", + "--no-write-lock-file", + "--option", + "accept-flake-config", + "false", + "--quiet", +]; + +/// The agent option the controller records, relative to the evaluated config. +const PRIVATE_USERS_OPTION: &str = "services.hyperhive.agent.user.privateUsers"; + +/// `nix eval --json` of a bool: true only for exactly `true`. +fn parse_flag(out: &[u8]) -> bool { + out.trim_ascii() == b"true" +} + /// `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"]; @@ -484,6 +532,47 @@ 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. + #[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), + ] + .into_iter() + .enumerate() + { + let nix = fake_nix( + &format!("private-users-{i}"), + &format!("if [ \"$1\" = eval ]; then {answer}; else echo /nix/store/x; fi\n"), + ); + let b = Builder { nix, ..builder() }; + let reply = b.build_from_meta(&meta_job(), "t").await; + assert!( + matches!(&reply, FromMetaReply::Built { private_users, .. } if *private_users == want), + "{answer}: {reply:?}" + ); + } + } + + #[test] + fn the_option_is_read_from_the_evaluated_config_not_the_toplevel() { + assert_eq!( + format!( + "{}.{PRIVATE_USERS_OPTION}", + builder().meta_config(&meta_job()) + ), + "git+https://forge.example/internal/meta.git\ + ?rev=0123456789abcdef0123456789abcdef01234567\ + #nixosConfigurations.atlas.config.services.hyperhive.agent.user.privateUsers" + ); + assert!(parse_flag(b"true\n") && !parse_flag(b"false\n") && !parse_flag(b"")); + } #[test] fn a_meta_job_that_would_bend_the_reference_is_refused() { for bad in [ @@ -520,6 +609,7 @@ mod tests { out_path: format!("{} {}", BUILD_FLAGS.join(" "), b.meta_attr(&meta_job())), system: "x86_64-linux".to_owned(), cached: false, + private_users: false, } ); } diff --git a/swarm-builder/src/push.rs b/swarm-builder/src/push.rs index 39c09eac..29becee3 100644 --- a/swarm-builder/src/push.rs +++ b/swarm-builder/src/push.rs @@ -208,7 +208,10 @@ pub async fn cache_built( let ( Some(cache), FromMetaReply::Built { - out_path, system, .. + out_path, + system, + private_users, + .. }, ) = (cache, &reply) else { @@ -234,6 +237,7 @@ pub async fn cache_built( out_path: out_path.clone(), system: system.clone(), cached: true, + private_users: *private_users, } } Err(reason) => { @@ -258,6 +262,7 @@ mod tests { out_path: OUT.to_owned(), system: "x86_64-linux".to_owned(), cached: false, + private_users: false, } } diff --git a/swarm-controller/src/cache_miss.rs b/swarm-controller/src/cache_miss.rs index 0de080ea..f28efef4 100644 --- a/swarm-controller/src/cache_miss.rs +++ b/swarm-controller/src/cache_miss.rs @@ -288,12 +288,13 @@ async fn rebuild(world: &impl World, agent: &str, stored: Stored) -> Outcome { return Outcome::Unavailable; } }; - let (out_path, system, cached) = match reply { + let (out_path, system, cached, private_users) = match reply { FromMetaReply::Built { out_path, system, cached, - } => (out_path, system, cached), + private_users, + } => (out_path, system, cached, private_users), FromMetaReply::Failed { error_tail } => { tracing::warn!(%agent, %meta_rev, %error_tail, "cache miss: the rebuild failed"); return Outcome::Failed; @@ -321,6 +322,7 @@ async fn rebuild(world: &impl World, agent: &str, stored: Stored) -> Outcome { system, built_unix: unix_now(), cached: true, + private_users, }; match replace_if_current(world, agent, &now).await { Ok(true) => Outcome::PathChanged, @@ -558,6 +560,7 @@ mod tests { system: "x86_64-linux".to_owned(), built_unix: 7, cached, + private_users: false, } } @@ -644,6 +647,7 @@ mod tests { out_path: out_path.to_owned(), system: "x86_64-linux".to_owned(), cached, + private_users: false, } } @@ -888,6 +892,32 @@ mod tests { ); } + /// The record a rebuild at another path writes carries what the builder + /// answered about the user namespace, not what the old record held. + #[tokio::test] + async fn the_replacement_record_carries_the_rebuilds_private_users_answer() { + let other = path("iris-other"); + let FromMetaReply::Built { + out_path, + system, + cached, + .. + } = built(&other, true) + else { + unreachable!() + }; + let answer = FromMetaReply::Built { + out_path, + system, + cached, + private_users: true, + }; + let world = Fake::missing(&["iris"]).answering("iris", answer); + pass_at(&world, &mut Backoff::default(), Instant::now()).await; + let writes = world.writes(); + assert_eq!(writes.len(), 1); + assert!(writes[0].1.private_users); + } #[tokio::test] async fn another_path_leaves_a_record_that_moved_on() { let world = Fake::missing(&["iris"]).answering("iris", built(&path("iris-other"), true)); diff --git a/swarm-controller/src/meta_build.rs b/swarm-controller/src/meta_build.rs index a13e64ec..a6d09c08 100644 --- a/swarm-controller/src/meta_build.rs +++ b/swarm-controller/src/meta_build.rs @@ -370,6 +370,7 @@ pub(crate) async fn run( out_path, system, cached, + private_users, }) => { let built_rev = meta_rev.clone(); let closure = AgentClosure { @@ -378,6 +379,7 @@ pub(crate) async fn run( system, built_unix: unix_now(), cached, + private_users, }; match queue.record(&agent, &closure).await { Ok(()) => { @@ -486,10 +488,15 @@ mod tests { } fn built_cached(cached: bool) -> Self { + Self::built_with(cached, false) + } + + fn built_with(cached: bool, private_users: bool) -> Self { Self::answering(Ok(FromMetaReply::Built { out_path: "/nix/store/x-toplevel".to_owned(), system: "x86_64-linux".to_owned(), cached, + private_users, })) } } @@ -617,6 +624,22 @@ mod tests { } } + /// The record says what the builder said about the agent's user namespace, + /// both ways; a builder that said nothing leaves it off. + #[tokio::test] + async fn the_record_carries_the_builders_private_users_answer() { + for private_users in [true, false] { + let queue = FakeQueue::built_with(true, private_users); + let outcome = run_with(&FakeHead::reading(Ok(Some(REV))), &queue).await; + assert_eq!(outcome, Outcome::Done); + let recorded = queue.recorded.lock().unwrap(); + let [(_, closure)] = recorded.as_slice() else { + panic!("one record: {recorded:?}") + }; + assert_eq!(closure.private_users, private_users); + } + } + /// Any `nix` failure, a timeout included, fails the node at once with what /// the builder said: not a no-verdict failure, and nothing is recorded. The /// green test above is the control that a record is written when the build @@ -898,6 +921,7 @@ mod tests { system: "x86_64-linux".to_owned(), built_unix: 1, cached: false, + private_users: true, }; client.record("atlas", &closure).await.expect("written"); closure.store_path = "/nix/store/b".to_owned(); diff --git a/swarm-wire/src/agent_closures.rs b/swarm-wire/src/agent_closures.rs index 68aea9c8..0e86f66e 100644 --- a/swarm-wire/src/agent_closures.rs +++ b/swarm-wire/src/agent_closures.rs @@ -36,6 +36,12 @@ pub struct AgentClosure { /// written before the field existed reads as false. #[serde(default)] pub cached: bool, + /// Whether the agent's evaluated config sets + /// `services.hyperhive.agent.user.privateUsers`: the agent runs in its own + /// user namespace. A record written before the field existed reads as + /// false, which keeps the agent out of one. + #[serde(default)] + pub private_users: bool, } #[cfg(test)] @@ -56,6 +62,7 @@ mod tests { system: "x86_64-linux".to_owned(), built_unix: 7, cached: true, + private_users: true, }; let wire = serde_json::json!({ "meta_rev": "abc", @@ -63,6 +70,7 @@ mod tests { "system": "x86_64-linux", "built_unix": 7, "cached": true, + "private_users": true, }); assert_eq!(serde_json::to_value(&value).expect("encodes"), wire); assert_eq!( @@ -72,7 +80,7 @@ mod tests { } #[test] - fn a_record_without_the_field_reads_as_not_cached() { + fn a_record_without_the_fields_reads_as_not_cached_and_not_namespaced() { let old = serde_json::json!({ "meta_rev": "abc", "store_path": "/nix/store/x", @@ -81,5 +89,6 @@ mod tests { }); let record = serde_json::from_value::(old).expect("decodes"); assert!(!record.cached); + assert!(!record.private_users); } } diff --git a/swarm-wire/src/builder.rs b/swarm-wire/src/builder.rs index 2a5d67b3..649eaf0d 100644 --- a/swarm-wire/src/builder.rs +++ b/swarm-wire/src/builder.rs @@ -149,6 +149,11 @@ pub enum FromMetaReply { system: String, #[serde(default)] cached: bool, + /// Whether the agent's evaluated config sets + /// `services.hyperhive.agent.user.privateUsers`. A builder that + /// predates the field answers without it, which reads as false. + #[serde(default)] + private_users: bool, }, /// 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 @@ -262,12 +267,14 @@ mod tests { out_path: "/nix/store/x".to_owned(), system: "x86_64-linux".to_owned(), cached: true, + private_users: true, }, json!({ "state": "built", "out_path": "/nix/store/x", "system": "x86_64-linux", "cached": true, + "private_users": true, }), ), ( @@ -292,7 +299,7 @@ mod tests { } #[test] - fn a_built_reply_from_a_builder_without_the_field_reads_as_not_cached() { + fn a_built_reply_from_a_builder_without_the_fields_reads_as_not_cached_and_not_namespaced() { let old = json!({"state": "built", "out_path": "/nix/store/x", "system": "s"}); assert_eq!( serde_json::from_value::(old).expect("decodes"), @@ -300,6 +307,7 @@ mod tests { out_path: "/nix/store/x".to_owned(), system: "s".to_owned(), cached: false, + private_users: false, } ); }